@instockng/storefront-ui 1.0.105 → 1.0.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/dist/components/AssistantDrawer.d.ts +24 -0
  2. package/dist/components/AssistantDrawer.d.ts.map +1 -0
  3. package/dist/components/Checkout.d.ts.map +1 -1
  4. package/dist/components/ProductAssistantChips.d.ts +13 -0
  5. package/dist/components/ProductAssistantChips.d.ts.map +1 -0
  6. package/dist/components/SearchAssistantTrigger.d.ts +12 -0
  7. package/dist/components/SearchAssistantTrigger.d.ts.map +1 -0
  8. package/dist/hooks/usePaystackPayment.d.ts +1 -0
  9. package/dist/hooks/usePaystackPayment.d.ts.map +1 -1
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.mjs +127 -121
  13. package/dist/index10.mjs +3 -3
  14. package/dist/index100.mjs +8 -3
  15. package/dist/index101.mjs +75 -2
  16. package/dist/index102.mjs +3 -82
  17. package/dist/index103.mjs +2 -54
  18. package/dist/index104.mjs +82 -5
  19. package/dist/index105.mjs +53 -4
  20. package/dist/index106.mjs +5 -178
  21. package/dist/index107.mjs +5 -53
  22. package/dist/index108.mjs +178 -68
  23. package/dist/index109.mjs +50 -34
  24. package/dist/index11.mjs +5 -5
  25. package/dist/index110.mjs +69 -43
  26. package/dist/index111.mjs +2 -2
  27. package/dist/index112.mjs +35 -26
  28. package/dist/index113.mjs +42 -17
  29. package/dist/index114.mjs +2 -215
  30. package/dist/index115.mjs +26 -178
  31. package/dist/index116.mjs +10 -14
  32. package/dist/index117.mjs +209 -17
  33. package/dist/index118.mjs +173 -26
  34. package/dist/index119.mjs +17 -151
  35. package/dist/index12.mjs +4 -4
  36. package/dist/index120.mjs +13 -10
  37. package/dist/index121.mjs +24 -22
  38. package/dist/index122.mjs +148 -76
  39. package/dist/index123.mjs +13 -31
  40. package/dist/index124.mjs +24 -138
  41. package/dist/index125.mjs +78 -49
  42. package/dist/index126.mjs +32 -17
  43. package/dist/index127.mjs +139 -21
  44. package/dist/index128.mjs +51 -19
  45. package/dist/index129.mjs +16 -18
  46. package/dist/index13.mjs +3 -3
  47. package/dist/index130.mjs +18 -12
  48. package/dist/index131.mjs +15 -14
  49. package/dist/index132.mjs +17 -13
  50. package/dist/index133.mjs +14 -58
  51. package/dist/index134.mjs +15 -11
  52. package/dist/index135.mjs +14 -32
  53. package/dist/index136.mjs +57 -16
  54. package/dist/index137.mjs +11 -27
  55. package/dist/index138.mjs +31 -19
  56. package/dist/index139.mjs +17 -12
  57. package/dist/index14.mjs +1 -1
  58. package/dist/index140.mjs +27 -14
  59. package/dist/index141.mjs +20 -40
  60. package/dist/index142.mjs +11 -15
  61. package/dist/index143.mjs +17 -264
  62. package/dist/index144.mjs +40 -63
  63. package/dist/index145.mjs +22 -7
  64. package/dist/index146.mjs +268 -2
  65. package/dist/index147.mjs +70 -2
  66. package/dist/index148.mjs +7 -32
  67. package/dist/index149.mjs +2 -2
  68. package/dist/index15.mjs +1 -1
  69. package/dist/index150.mjs +2 -2
  70. package/dist/index151.mjs +33 -2
  71. package/dist/index152.mjs +2 -2
  72. package/dist/index153.mjs +21 -2
  73. package/dist/index154.mjs +54 -16
  74. package/dist/index155.mjs +28 -31
  75. package/dist/index156.mjs +6 -37
  76. package/dist/index157.mjs +50 -16
  77. package/dist/index158.mjs +6 -18
  78. package/dist/index159.mjs +12 -2
  79. package/dist/index16.mjs +6 -6
  80. package/dist/index160.mjs +7 -20
  81. package/dist/index161.mjs +28 -46
  82. package/dist/index162.mjs +2 -2
  83. package/dist/index163.mjs +69 -29
  84. package/dist/index164.mjs +164 -15
  85. package/dist/index165.mjs +2 -2
  86. package/dist/index166.mjs +2 -2
  87. package/dist/index167.mjs +2 -2
  88. package/dist/index168.mjs +18 -2
  89. package/dist/index169.mjs +26 -66
  90. package/dist/index17.mjs +4 -4
  91. package/dist/index170.mjs +38 -2
  92. package/dist/index171.mjs +13 -48
  93. package/dist/index172.mjs +18 -2
  94. package/dist/index173.mjs +2 -36
  95. package/dist/index174.mjs +16 -148
  96. package/dist/index175.mjs +46 -2
  97. package/dist/index176.mjs +2 -2
  98. package/dist/index177.mjs +26 -15
  99. package/dist/index178.mjs +18 -2
  100. package/dist/index179.mjs +2 -2
  101. package/dist/index18.mjs +5 -5
  102. package/dist/index180.mjs +2 -26
  103. package/dist/index181.mjs +2 -2
  104. package/dist/index182.mjs +2 -2
  105. package/dist/index183.mjs +67 -19
  106. package/dist/index184.mjs +2 -23
  107. package/dist/index185.mjs +53 -2
  108. package/dist/index186.mjs +2 -2
  109. package/dist/index187.mjs +36 -2
  110. package/dist/index188.mjs +146 -17
  111. package/dist/index189.mjs +2 -2
  112. package/dist/index19.mjs +3 -3
  113. package/dist/index190.mjs +2 -23
  114. package/dist/index191.mjs +19 -2
  115. package/dist/index192.mjs +2 -2
  116. package/dist/index193.mjs +2 -2
  117. package/dist/index194.mjs +18 -15
  118. package/dist/index196.mjs +2 -23
  119. package/dist/index197.mjs +24 -2
  120. package/dist/index198.mjs +23 -2
  121. package/dist/index199.mjs +2 -2
  122. package/dist/index2.mjs +2 -2
  123. package/dist/index20.mjs +91 -90
  124. package/dist/index200.mjs +2 -2
  125. package/dist/index201.mjs +2 -2
  126. package/dist/index202.mjs +23 -2
  127. package/dist/index203.mjs +2 -2
  128. package/dist/index204.mjs +23 -2
  129. package/dist/index205.mjs +2 -127
  130. package/dist/index206.mjs +2 -2
  131. package/dist/index207.mjs +2 -74
  132. package/dist/index208.mjs +20 -71
  133. package/dist/index209.mjs +2 -21
  134. package/dist/index21.mjs +256 -51
  135. package/dist/index210.mjs +21 -54
  136. package/dist/index211.mjs +2 -29
  137. package/dist/index212.mjs +2 -7
  138. package/dist/index213.mjs +2 -52
  139. package/dist/index214.mjs +2 -6
  140. package/dist/index215.mjs +2 -12
  141. package/dist/index216.mjs +2 -7
  142. package/dist/index217.mjs +2 -28
  143. package/dist/index218.mjs +2 -2
  144. package/dist/index219.mjs +125 -68
  145. package/dist/index22.mjs +45 -62
  146. package/dist/index220.mjs +2 -167
  147. package/dist/index221.mjs +74 -2
  148. package/dist/index222.mjs +74 -2
  149. package/dist/index223.mjs +2 -2
  150. package/dist/index224.mjs +13 -2
  151. package/dist/index225.mjs +7 -2
  152. package/dist/index226.mjs +12 -2
  153. package/dist/index227.mjs +4 -107
  154. package/dist/index228.mjs +33 -2
  155. package/dist/index229.mjs +31 -2
  156. package/dist/index23.mjs +22 -22
  157. package/dist/index230.mjs +28 -2
  158. package/dist/index231.mjs +61 -2
  159. package/dist/index232.mjs +30 -36
  160. package/dist/index233.mjs +11 -2
  161. package/dist/index234.mjs +3 -243
  162. package/dist/index235.mjs +4 -2
  163. package/dist/index236.mjs +2 -33
  164. package/dist/index237.mjs +2 -65
  165. package/dist/index238.mjs +2 -25
  166. package/dist/index239.mjs +2 -2
  167. package/dist/index24.mjs +55 -104
  168. package/dist/index241.mjs +2 -2
  169. package/dist/index242.mjs +2 -2
  170. package/dist/index243.mjs +2 -2
  171. package/dist/index244.mjs +108 -2
  172. package/dist/index246.mjs +2 -2
  173. package/dist/index247.mjs +37 -2
  174. package/dist/index248.mjs +2 -2
  175. package/dist/index249.mjs +2 -4
  176. package/dist/index25.mjs +62 -42
  177. package/dist/index250.mjs +244 -2
  178. package/dist/index252.mjs +32 -30
  179. package/dist/index253.mjs +64 -10
  180. package/dist/index254.mjs +24 -3
  181. package/dist/index255.mjs +2 -4
  182. package/dist/index256.mjs +2 -13
  183. package/dist/index257.mjs +2 -7
  184. package/dist/index258.mjs +2 -12
  185. package/dist/index259.mjs +2 -5
  186. package/dist/index26.mjs +22 -40
  187. package/dist/index260.mjs +2 -33
  188. package/dist/index261.mjs +2 -31
  189. package/dist/index262.mjs +2 -28
  190. package/dist/index263.mjs +4 -61
  191. package/dist/index264.mjs +2 -2
  192. package/dist/index265.mjs +2 -2
  193. package/dist/index266.mjs +2 -18
  194. package/dist/index267.mjs +2 -47
  195. package/dist/index268.mjs +2 -2
  196. package/dist/index269.mjs +18 -2
  197. package/dist/index27.mjs +107 -87
  198. package/dist/index270.mjs +47 -2
  199. package/dist/index271.mjs +2 -2
  200. package/dist/index272.mjs +2 -91
  201. package/dist/index273.mjs +2 -2
  202. package/dist/index274.mjs +2 -3
  203. package/dist/index275.mjs +91 -2
  204. package/dist/index276.mjs +2 -2
  205. package/dist/index277.mjs +3 -17
  206. package/dist/index278.mjs +2 -13
  207. package/dist/index279.mjs +2 -6
  208. package/dist/index28.mjs +42 -32
  209. package/dist/index280.mjs +17 -30
  210. package/dist/index281.mjs +13 -2
  211. package/dist/index282.mjs +6 -2
  212. package/dist/index283.mjs +30 -2
  213. package/dist/index284.mjs +5 -0
  214. package/dist/index285.mjs +5 -0
  215. package/dist/index286.mjs +5 -0
  216. package/dist/index29.mjs +42 -9
  217. package/dist/index3.mjs +4 -4
  218. package/dist/index30.mjs +84 -17
  219. package/dist/index31.mjs +29 -35
  220. package/dist/index32.mjs +8 -39
  221. package/dist/index33.mjs +21 -125
  222. package/dist/index34.mjs +35 -46
  223. package/dist/index35.mjs +38 -9
  224. package/dist/index36.mjs +121 -6
  225. package/dist/index37.mjs +49 -123
  226. package/dist/index38.mjs +11 -28
  227. package/dist/index39.mjs +11 -91
  228. package/dist/index4.mjs +1 -1
  229. package/dist/index40.mjs +121 -123
  230. package/dist/index41.mjs +28 -11
  231. package/dist/index42.mjs +91 -35
  232. package/dist/index43.mjs +116 -37
  233. package/dist/index44.mjs +9 -9
  234. package/dist/index45.mjs +33 -121
  235. package/dist/index46.mjs +42 -385
  236. package/dist/index47.mjs +10 -24
  237. package/dist/index48.mjs +122 -31
  238. package/dist/index49.mjs +388 -27
  239. package/dist/index5.mjs +1 -1
  240. package/dist/index50.mjs +24 -6
  241. package/dist/index51.mjs +30 -1431
  242. package/dist/index52.mjs +26 -68
  243. package/dist/index53.mjs +7 -2
  244. package/dist/index54.mjs +1425 -52
  245. package/dist/index55.mjs +69 -50
  246. package/dist/index56.mjs +2 -33
  247. package/dist/index57.mjs +59 -14
  248. package/dist/index58.mjs +47 -2259
  249. package/dist/index59.mjs +33 -36
  250. package/dist/index6.mjs +1 -1
  251. package/dist/index60.mjs +14 -43
  252. package/dist/index61.mjs +2256 -96
  253. package/dist/index62.mjs +36 -81
  254. package/dist/index63.mjs +43 -18
  255. package/dist/index64.mjs +102 -128
  256. package/dist/index65.mjs +45 -89
  257. package/dist/index66.mjs +15 -75
  258. package/dist/index67.mjs +84 -130
  259. package/dist/index68.mjs +84 -66
  260. package/dist/index69.mjs +69 -26
  261. package/dist/index7.mjs +6 -6
  262. package/dist/index70.mjs +138 -58
  263. package/dist/index71.mjs +82 -56
  264. package/dist/index72.mjs +28 -55
  265. package/dist/index73.mjs +79 -46
  266. package/dist/index74.mjs +58 -112
  267. package/dist/index75.mjs +47 -54
  268. package/dist/index76.mjs +60 -22
  269. package/dist/index77.mjs +135 -2
  270. package/dist/index78.mjs +66 -149
  271. package/dist/index79.mjs +21 -20
  272. package/dist/index8.mjs +6 -6
  273. package/dist/index80.mjs +2 -75
  274. package/dist/index81.mjs +153 -15
  275. package/dist/index82.mjs +21 -61
  276. package/dist/index83.mjs +74 -4
  277. package/dist/index84.mjs +15 -2
  278. package/dist/index85.mjs +62 -5
  279. package/dist/index86.mjs +4 -1133
  280. package/dist/index87.mjs +2 -20
  281. package/dist/index88.mjs +5 -54
  282. package/dist/index89.mjs +1126 -25
  283. package/dist/index9.mjs +4 -4
  284. package/dist/index90.mjs +20 -2
  285. package/dist/index91.mjs +55 -2
  286. package/dist/index92.mjs +29 -231
  287. package/dist/index93.mjs +2 -6
  288. package/dist/index94.mjs +228 -127
  289. package/dist/index95.mjs +4 -66
  290. package/dist/index96.mjs +124 -77
  291. package/dist/index97.mjs +65 -26
  292. package/dist/index98.mjs +84 -6
  293. package/dist/index99.mjs +26 -72
  294. package/dist/styles.css +1 -1
  295. package/package.json +2 -1
  296. package/src/components/AssistantDrawer.stories.tsx +140 -0
  297. package/src/components/AssistantDrawer.tsx +430 -0
  298. package/src/components/Checkout.tsx +1 -0
  299. package/src/components/ProductAssistantChips.stories.tsx +42 -0
  300. package/src/components/ProductAssistantChips.tsx +64 -0
  301. package/src/components/SearchAssistantTrigger.stories.tsx +35 -0
  302. package/src/components/SearchAssistantTrigger.tsx +41 -0
  303. package/src/hooks/usePaystackPayment.ts +1 -0
  304. package/src/index.ts +10 -0
@@ -0,0 +1,140 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState, useCallback } from 'react';
3
+ import { AssistantDrawer } from './AssistantDrawer';
4
+ import { ProductAssistantChips } from './ProductAssistantChips';
5
+ import { SearchAssistantTrigger } from './SearchAssistantTrigger';
6
+
7
+ const meta = {
8
+ title: 'Components/AssistantDrawer',
9
+ component: AssistantDrawer,
10
+ parameters: {
11
+ layout: 'fullscreen',
12
+ },
13
+ decorators: [
14
+ // Override the default p-8 decorator — drawer needs full viewport
15
+ (Story) => (
16
+ <div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}>
17
+ <Story />
18
+ </div>
19
+ ),
20
+ ],
21
+ tags: ['autodocs'],
22
+ } satisfies Meta<typeof AssistantDrawer>;
23
+
24
+ export default meta;
25
+ type Story = StoryObj<typeof meta>;
26
+
27
+ const mockFaqs = [
28
+ { question: 'Are these leggings stretchy?', answer: 'Yes, made with 4-way stretch fabric.' },
29
+ { question: 'Do they have pockets?', answer: 'Yes, two side pockets.' },
30
+ { question: 'Can they be worn during workouts?', answer: 'Designed for high-intensity workouts.' },
31
+ ];
32
+
33
+ function ProductChipsDemo() {
34
+ const [isOpen, setIsOpen] = useState(false);
35
+ const [question, setQuestion] = useState('');
36
+
37
+ const handleAsk = useCallback((q: string) => {
38
+ setQuestion(q);
39
+ setIsOpen(true);
40
+ }, []);
41
+
42
+ const handleClose = useCallback(() => {
43
+ setIsOpen(false);
44
+ }, []);
45
+
46
+ return (
47
+ <div className="p-8">
48
+ <h3 className="text-lg font-semibold mb-4">Product Page</h3>
49
+ <div className="max-w-md">
50
+ <div className="aspect-[4/3] bg-gray-100 rounded-lg mb-4 flex items-center justify-center text-gray-400">
51
+ Product Image
52
+ </div>
53
+ <ProductAssistantChips faqs={mockFaqs} onAsk={handleAsk} />
54
+ </div>
55
+
56
+ <AssistantDrawer
57
+ isOpen={isOpen}
58
+ onClose={handleClose}
59
+ apiUrl="https://oms-api.instock.ng"
60
+ brandSlug="demo-brand"
61
+ productSlug="maternity-leggings"
62
+ initialQuestion={question}
63
+ assistantName="AI Assistant"
64
+ onProductClick={(slug) => console.log('Navigate to:', slug)}
65
+ disablePortal
66
+ />
67
+ </div>
68
+ );
69
+ }
70
+
71
+ function SearchTriggerDemo() {
72
+ const [isOpen, setIsOpen] = useState(false);
73
+ const [question, setQuestion] = useState('');
74
+
75
+ const handleAsk = useCallback((q: string) => {
76
+ setQuestion(q);
77
+ setIsOpen(true);
78
+ }, []);
79
+
80
+ const handleClose = useCallback(() => {
81
+ setIsOpen(false);
82
+ }, []);
83
+
84
+ return (
85
+ <div className="p-8">
86
+ <h3 className="text-lg font-semibold mb-4">Search Results</h3>
87
+ <div className="max-w-md space-y-4">
88
+ <p className="text-gray-500 text-sm">No results found for &quot;toys for babies&quot;</p>
89
+ <SearchAssistantTrigger
90
+ searchQuery="toys for babies"
91
+ onAsk={handleAsk}
92
+ label='Ask AI about "toys for babies"'
93
+ />
94
+ </div>
95
+
96
+ <AssistantDrawer
97
+ isOpen={isOpen}
98
+ onClose={handleClose}
99
+ apiUrl="https://oms-api.instock.ng"
100
+ brandSlug="demo-brand"
101
+ initialQuestion={question}
102
+ assistantName="AI Assistant"
103
+ onProductClick={(slug) => console.log('Navigate to:', slug)}
104
+ disablePortal
105
+ />
106
+ </div>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Interactive demo: FAQ chips → click → drawer opens with streaming answer
112
+ */
113
+ export const WithProductChips: Story = {
114
+ render: () => <ProductChipsDemo />,
115
+ args: {} as any,
116
+ };
117
+
118
+ /**
119
+ * Interactive demo: search "Ask AI" button → drawer opens with streaming answer
120
+ */
121
+ export const WithSearchTrigger: Story = {
122
+ render: () => <SearchTriggerDemo />,
123
+ args: {} as any,
124
+ };
125
+
126
+ /**
127
+ * Drawer opened directly
128
+ */
129
+ export const OpenDrawer: Story = {
130
+ args: {
131
+ isOpen: true,
132
+ onClose: () => console.log('Close'),
133
+ apiUrl: 'https://oms-api.instock.ng',
134
+ brandSlug: 'demo-brand',
135
+ productSlug: 'maternity-leggings',
136
+ assistantName: 'AI Assistant',
137
+ onProductClick: (slug: string) => console.log('Navigate to:', slug),
138
+ disablePortal: true,
139
+ },
140
+ };
@@ -0,0 +1,430 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * AssistantDrawer Component
5
+ *
6
+ * Chat drawer for the AI shopping assistant.
7
+ * - Mobile: bottom sheet
8
+ * - Desktop: side drawer from the right
9
+ *
10
+ * Streams responses via SSE, renders ProductCards for recommendations,
11
+ * shows follow-up suggestion chips.
12
+ */
13
+
14
+ import { useState, useEffect, useRef, useCallback } from 'react';
15
+ import { createPortal } from 'react-dom';
16
+ import { X, Send, Sparkles, Loader2 } from 'lucide-react';
17
+ import { cn, formatCurrency } from '../lib/utils';
18
+ import { useHideBodyOverflow } from '../hooks/useHideBodyOverflow';
19
+
20
+ type ChatMessage = {
21
+ role: 'user' | 'assistant';
22
+ content: string;
23
+ products?: RecommendedProduct[];
24
+ };
25
+
26
+ type RecommendedProduct = {
27
+ slug: string;
28
+ name: string;
29
+ thumbnailUrl: string | null;
30
+ price: number;
31
+ };
32
+
33
+ export interface AssistantDrawerProps {
34
+ /** Controls drawer visibility */
35
+ isOpen: boolean;
36
+ /** Callback when drawer should close */
37
+ onClose: () => void;
38
+ /** API base URL */
39
+ apiUrl: string;
40
+ /** Brand slug for context */
41
+ brandSlug: string;
42
+ /** Product slug if opened from a product page */
43
+ productSlug?: string;
44
+ /** Initial question to send immediately when opened */
45
+ initialQuestion?: string;
46
+ /** Assistant display name */
47
+ assistantName?: string;
48
+ /** Callback when a product card is clicked */
49
+ onProductClick?: (slug: string) => void;
50
+ /** Custom class name */
51
+ className?: string;
52
+ /** Render inline instead of using a portal (useful for Storybook) */
53
+ disablePortal?: boolean;
54
+ }
55
+
56
+ export function AssistantDrawer({
57
+ isOpen,
58
+ onClose,
59
+ apiUrl,
60
+ brandSlug,
61
+ productSlug,
62
+ initialQuestion,
63
+ assistantName = 'AI Assistant',
64
+ onProductClick,
65
+ className,
66
+ disablePortal = false,
67
+ }: AssistantDrawerProps) {
68
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
69
+ const [input, setInput] = useState('');
70
+ const [isStreaming, setIsStreaming] = useState(false);
71
+ const [error, setError] = useState<string | null>(null);
72
+ const [isAnimating, setIsAnimating] = useState(false);
73
+ const [shouldRender, setShouldRender] = useState(false);
74
+ const messagesEndRef = useRef<HTMLDivElement>(null);
75
+ const inputRef = useRef<HTMLInputElement>(null);
76
+ const initialQuestionSent = useRef(false);
77
+ const messagesRef = useRef<ChatMessage[]>([]);
78
+ messagesRef.current = messages;
79
+
80
+ useHideBodyOverflow(isOpen);
81
+
82
+ // --- Callbacks (defined before effects that use them) ---
83
+
84
+ const handleSSEEvent = useCallback((event: string, data: string) => {
85
+ if (event === 'token') {
86
+ setMessages((prev) => {
87
+ const updated = [...prev];
88
+ const last = updated[updated.length - 1];
89
+ if (last?.role === 'assistant') {
90
+ updated[updated.length - 1] = { ...last, content: last.content + data };
91
+ }
92
+ return updated;
93
+ });
94
+ } else if (event === 'done') {
95
+ try {
96
+ const parsed = JSON.parse(data);
97
+ setMessages((prev) => {
98
+ const updated = [...prev];
99
+ const last = updated[updated.length - 1];
100
+ if (last?.role === 'assistant') {
101
+ updated[updated.length - 1] = {
102
+ ...last,
103
+ content: parsed.answer || last.content,
104
+ products: parsed.products?.length > 0 ? parsed.products : undefined,
105
+ };
106
+ }
107
+ return updated;
108
+ });
109
+ } catch {
110
+ // Ignore parse errors
111
+ }
112
+ } else if (event === 'error') {
113
+ try {
114
+ const parsed = JSON.parse(data);
115
+ setError(parsed.message || 'An error occurred');
116
+ } catch {
117
+ setError('An error occurred');
118
+ }
119
+ }
120
+ }, []);
121
+
122
+ const sendMessageRef = useRef<(question: string) => Promise<void>>();
123
+ const sendMessage = useCallback(
124
+ async (question: string) => {
125
+ if (!question.trim()) return;
126
+
127
+ setError(null);
128
+ const userMessage: ChatMessage = { role: 'user', content: question };
129
+ const history = messagesRef.current.map((m) => ({
130
+ role: m.role,
131
+ content: m.content,
132
+ }));
133
+
134
+ setMessages((prev) => [...prev, userMessage]);
135
+ setInput('');
136
+ setIsStreaming(true);
137
+
138
+ // Add placeholder assistant message
139
+ setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
140
+
141
+ try {
142
+ const response = await fetch(`${apiUrl}/v1/assistant/ask`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({
146
+ question,
147
+ brandSlug,
148
+ productSlug,
149
+ history,
150
+ }),
151
+ });
152
+
153
+ if (!response.ok) {
154
+ const err = await response.json().catch(() => ({}));
155
+ throw new Error(
156
+ (err as any)?.error?.message || `Request failed (${response.status})`
157
+ );
158
+ }
159
+
160
+ if (!response.body) throw new Error('No response body');
161
+
162
+ const reader = response.body.getReader();
163
+ const decoder = new TextDecoder();
164
+ let buffer = '';
165
+ let currentEvent = '';
166
+
167
+ while (true) {
168
+ const { done, value } = await reader.read();
169
+ if (done) break;
170
+
171
+ buffer += decoder.decode(value, { stream: true });
172
+ const lines = buffer.split('\n');
173
+ buffer = lines.pop() || '';
174
+
175
+ for (const line of lines) {
176
+ if (line.trim() === '') {
177
+ currentEvent = '';
178
+ continue;
179
+ }
180
+ if (line.trimStart().startsWith('event:')) {
181
+ currentEvent = line.trimStart().slice(6).trim();
182
+ } else if (line.trimStart().startsWith('data:')) {
183
+ // Per SSE spec: strip only the single leading space after "data:"
184
+ const afterPrefix = line.trimStart().slice(5);
185
+ const data = afterPrefix.startsWith(' ') ? afterPrefix.slice(1) : afterPrefix;
186
+ handleSSEEvent(currentEvent || 'token', data);
187
+ }
188
+ }
189
+ }
190
+ } catch (err: any) {
191
+ setError(err.message || 'Something went wrong');
192
+ // Remove the empty assistant message
193
+ setMessages((prev) => {
194
+ const last = prev[prev.length - 1];
195
+ if (last?.role === 'assistant' && !last.content) {
196
+ return prev.slice(0, -1);
197
+ }
198
+ return prev;
199
+ });
200
+ } finally {
201
+ setIsStreaming(false);
202
+ }
203
+ },
204
+ [apiUrl, brandSlug, productSlug, handleSSEEvent]
205
+ );
206
+ sendMessageRef.current = sendMessage;
207
+
208
+ // --- Effects ---
209
+
210
+ // Animation handling
211
+ useEffect(() => {
212
+ if (isOpen) {
213
+ setShouldRender(true);
214
+ requestAnimationFrame(() => {
215
+ requestAnimationFrame(() => {
216
+ setIsAnimating(true);
217
+ });
218
+ });
219
+ } else {
220
+ setIsAnimating(false);
221
+ const timer = setTimeout(() => {
222
+ setShouldRender(false);
223
+ setMessages([]);
224
+ setInput('');
225
+ setError(null);
226
+ initialQuestionSent.current = false;
227
+ }, 300);
228
+ return () => clearTimeout(timer);
229
+ }
230
+ }, [isOpen]);
231
+
232
+ // Send initial question when opened
233
+ useEffect(() => {
234
+ if (isOpen && initialQuestion && !initialQuestionSent.current) {
235
+ initialQuestionSent.current = true;
236
+ if (initialQuestion.trim()) {
237
+ setTimeout(() => sendMessageRef.current?.(initialQuestion), 100);
238
+ } else {
239
+ setTimeout(() => inputRef.current?.focus(), 350);
240
+ }
241
+ }
242
+ }, [isOpen, initialQuestion]);
243
+
244
+ // Scroll to bottom on new messages
245
+ useEffect(() => {
246
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
247
+ }, [messages]);
248
+
249
+ // ESC to close
250
+ useEffect(() => {
251
+ const handleEsc = (e: KeyboardEvent) => {
252
+ if (e.key === 'Escape' && isOpen) onClose();
253
+ };
254
+ if (isOpen) {
255
+ document.addEventListener('keydown', handleEsc);
256
+ return () => document.removeEventListener('keydown', handleEsc);
257
+ }
258
+ }, [isOpen, onClose]);
259
+
260
+ // --- Handlers ---
261
+
262
+ const handleSubmit = (e: React.FormEvent) => {
263
+ e.preventDefault();
264
+ if (input.trim()) {
265
+ sendMessage(input.trim());
266
+ }
267
+ };
268
+
269
+ // --- Render ---
270
+
271
+ if (!shouldRender) return null;
272
+
273
+ const drawerContent = (
274
+ <div
275
+ className={cn(
276
+ 'fixed inset-0 z-50 transition-opacity duration-300',
277
+ isAnimating ? 'opacity-100' : 'opacity-0'
278
+ )}
279
+ >
280
+ {/* Overlay */}
281
+ <div
282
+ className="absolute inset-0 bg-black/40 backdrop-blur-sm"
283
+ onClick={onClose}
284
+ />
285
+
286
+ {/* Drawer — bottom sheet on mobile, right drawer on desktop */}
287
+ <div
288
+ className={cn(
289
+ 'absolute bg-white flex flex-col transition-transform duration-300 ease-out',
290
+ // Mobile: bottom sheet
291
+ 'inset-x-0 bottom-0 top-[10vh] rounded-t-2xl',
292
+ // Desktop: right side drawer
293
+ 'sm:inset-y-0 sm:right-0 sm:left-auto sm:w-[420px] sm:max-w-full sm:rounded-t-none sm:rounded-l-2xl',
294
+ // Animation
295
+ isAnimating
296
+ ? 'translate-y-0 sm:translate-x-0'
297
+ : 'translate-y-full sm:translate-y-0 sm:translate-x-full',
298
+ className
299
+ )}
300
+ >
301
+ {/* Header */}
302
+ <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 flex-shrink-0">
303
+ <div className="flex items-center gap-2">
304
+ <Sparkles className="h-5 w-5 text-orange-500" />
305
+ <span className="font-semibold text-gray-900">{assistantName}</span>
306
+ <span className="text-xs text-gray-400 bg-gray-100 rounded px-1.5 py-0.5">beta</span>
307
+ </div>
308
+ <button
309
+ type="button"
310
+ onClick={onClose}
311
+ className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
312
+ aria-label="Close"
313
+ >
314
+ <X className="h-5 w-5" />
315
+ </button>
316
+ </div>
317
+
318
+ {/* Messages */}
319
+ <div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
320
+ {messages.length === 0 && !isStreaming && (
321
+ <div className="text-center text-gray-400 text-sm py-8">
322
+ Ask me anything about our products!
323
+ </div>
324
+ )}
325
+
326
+ {messages.map((msg, i) => (
327
+ <div key={i}>
328
+ {msg.role === 'user' ? (
329
+ <div className="flex justify-end">
330
+ <div className="bg-gray-800 text-white rounded-2xl rounded-br-sm px-4 py-2 max-w-[80%] text-sm">
331
+ {msg.content}
332
+ </div>
333
+ </div>
334
+ ) : (
335
+ <div className="space-y-3">
336
+ {/* Text answer */}
337
+ <div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
338
+ {msg.content}
339
+ {isStreaming && i === messages.length - 1 && (
340
+ <span className="inline-block w-1.5 h-4 bg-gray-400 animate-pulse ml-0.5 align-text-bottom" />
341
+ )}
342
+ </div>
343
+
344
+ {/* Product cards */}
345
+ {msg.products && msg.products.length > 0 && (
346
+ <div className="space-y-2">
347
+ {msg.products.map((product) => (
348
+ <button
349
+ key={product.slug}
350
+ type="button"
351
+ onClick={() => onProductClick?.(product.slug)}
352
+ className="flex items-center gap-3 w-full rounded-xl border border-gray-200 p-2 hover:bg-gray-50 transition-colors text-left"
353
+ >
354
+ {product.thumbnailUrl ? (
355
+ <img
356
+ src={product.thumbnailUrl}
357
+ alt={product.name}
358
+ className="h-16 w-16 rounded-lg object-cover flex-shrink-0"
359
+ />
360
+ ) : (
361
+ <div className="h-16 w-16 rounded-lg bg-gray-100 flex-shrink-0" />
362
+ )}
363
+ <div className="min-w-0">
364
+ <p className="text-sm font-medium text-gray-900 line-clamp-2">
365
+ {product.name}
366
+ </p>
367
+ <p className="text-sm font-semibold text-blue-600">
368
+ {formatCurrency(product.price)}
369
+ </p>
370
+ </div>
371
+ </button>
372
+ ))}
373
+ </div>
374
+ )}
375
+ </div>
376
+ )}
377
+ </div>
378
+ ))}
379
+
380
+ {/* Error */}
381
+ {error && (
382
+ <div className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
383
+ {error}
384
+ </div>
385
+ )}
386
+
387
+ <div ref={messagesEndRef} />
388
+ </div>
389
+
390
+ {/* Input */}
391
+ <div className="border-t border-gray-200 p-3 flex-shrink-0">
392
+ <form onSubmit={handleSubmit} className="flex items-center gap-2">
393
+ <input
394
+ ref={inputRef}
395
+ type="text"
396
+ value={input}
397
+ onChange={(e) => setInput(e.target.value)}
398
+ placeholder={`Ask ${assistantName} a question`}
399
+ disabled={isStreaming}
400
+ className={cn(
401
+ 'flex-1 rounded-full border border-gray-300 px-4 py-2.5 text-sm',
402
+ 'focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-400/30',
403
+ 'disabled:opacity-50'
404
+ )}
405
+ />
406
+ <button
407
+ type="submit"
408
+ disabled={!input.trim() || isStreaming}
409
+ className={cn(
410
+ 'flex h-10 w-10 items-center justify-center rounded-full',
411
+ 'bg-gray-800 text-white',
412
+ 'hover:bg-gray-700 transition-colors',
413
+ 'disabled:opacity-40 disabled:cursor-not-allowed'
414
+ )}
415
+ >
416
+ {isStreaming ? (
417
+ <Loader2 className="h-4 w-4 animate-spin" />
418
+ ) : (
419
+ <Send className="h-4 w-4" />
420
+ )}
421
+ </button>
422
+ </form>
423
+ </div>
424
+ </div>
425
+ </div>
426
+ );
427
+
428
+ if (disablePortal) return drawerContent;
429
+ return createPortal(drawerContent, document.body);
430
+ }
@@ -409,6 +409,7 @@ export function Checkout({
409
409
  const { firstName: psFirstName, lastName: psLastName } = splitFullName(formData.fullName);
410
410
  paystack.initializePayment({
411
411
  email: cart?.customerEmail || `${formData.phone.replace(/\D/g, '')}@customer.instock.ng`,
412
+ phone: formData.phone,
412
413
  amount: amountInKobo,
413
414
  currency: 'NGN',
414
415
  metadata: {
@@ -0,0 +1,42 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ProductAssistantChips } from './ProductAssistantChips';
3
+
4
+ const meta = {
5
+ title: 'Components/ProductAssistantChips',
6
+ component: ProductAssistantChips,
7
+ parameters: {
8
+ layout: 'centered',
9
+ },
10
+ tags: ['autodocs'],
11
+ } satisfies Meta<typeof ProductAssistantChips>;
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof meta>;
15
+
16
+ const mockFaqs = [
17
+ { question: 'Are these leggings stretchy?', answer: 'Yes, they are made with 4-way stretch fabric.' },
18
+ { question: 'Do they have pockets?', answer: 'Yes, two side pockets deep enough for a phone.' },
19
+ { question: 'Can they be worn during workouts?', answer: 'Absolutely, they are designed for high-intensity workouts.' },
20
+ { question: 'What sizes are available?', answer: 'Available in S, M, L, and XL.' },
21
+ ];
22
+
23
+ export const Default: Story = {
24
+ args: {
25
+ faqs: mockFaqs,
26
+ onAsk: (question) => console.log('Asked:', question),
27
+ },
28
+ };
29
+
30
+ export const FewQuestions: Story = {
31
+ args: {
32
+ faqs: mockFaqs.slice(0, 2),
33
+ onAsk: (question) => console.log('Asked:', question),
34
+ },
35
+ };
36
+
37
+ export const NoFaqs: Story = {
38
+ args: {
39
+ faqs: [],
40
+ onAsk: (question) => console.log('Asked:', question),
41
+ },
42
+ };
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ProductAssistantChips Component
5
+ *
6
+ * Displays FAQ-based question pills under a product image.
7
+ * Clicking a chip opens the AssistantDrawer with that question pre-sent.
8
+ */
9
+
10
+ import { Sparkles } from 'lucide-react';
11
+ import { cn } from '../lib/utils';
12
+
13
+ export interface ProductAssistantChipsProps {
14
+ /** FAQ questions to display as chips */
15
+ faqs?: Array<{ question: string; answer: string }>;
16
+ /** Callback when a chip is clicked — should open the drawer with this question */
17
+ onAsk: (question: string) => void;
18
+ /** Custom class name */
19
+ className?: string;
20
+ }
21
+
22
+ export function ProductAssistantChips({
23
+ faqs = [],
24
+ onAsk,
25
+ className,
26
+ }: ProductAssistantChipsProps) {
27
+ if (faqs.length === 0) return null;
28
+
29
+ return (
30
+ <div className={cn('space-y-2', className)}>
31
+ <div className="flex items-center gap-1.5 text-sm font-medium text-gray-700">
32
+ <Sparkles className="h-4 w-4 text-orange-500" />
33
+ Ask AI
34
+ </div>
35
+ <div className="flex flex-wrap gap-2">
36
+ {faqs.map((faq, i) => (
37
+ <button
38
+ key={i}
39
+ type="button"
40
+ onClick={() => onAsk(faq.question)}
41
+ className={cn(
42
+ 'rounded-full border border-gray-200 bg-blue-50/60 px-3 py-1.5 text-sm text-gray-700',
43
+ 'hover:bg-blue-100 hover:border-blue-200 transition-colors',
44
+ 'active:scale-95'
45
+ )}
46
+ >
47
+ {faq.question}
48
+ </button>
49
+ ))}
50
+ <button
51
+ type="button"
52
+ onClick={() => onAsk('')}
53
+ className={cn(
54
+ 'rounded-full border border-gray-300 bg-gray-800 px-3 py-1.5 text-sm text-white font-medium',
55
+ 'hover:bg-gray-700 transition-colors',
56
+ 'active:scale-95'
57
+ )}
58
+ >
59
+ Ask something else
60
+ </button>
61
+ </div>
62
+ </div>
63
+ );
64
+ }