@instockng/storefront-ui 1.0.106 → 1.0.108

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 (298) hide show
  1. package/dist/components/AssistantDrawer.d.ts +22 -0
  2. package/dist/components/AssistantDrawer.d.ts.map +1 -0
  3. package/dist/components/ProductAssistantChips.d.ts +13 -0
  4. package/dist/components/ProductAssistantChips.d.ts.map +1 -0
  5. package/dist/contexts/AssistantContext.d.ts +19 -0
  6. package/dist/contexts/AssistantContext.d.ts.map +1 -0
  7. package/dist/index.d.ts +5 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.mjs +144 -138
  10. package/dist/index10.mjs +103 -73
  11. package/dist/index100.mjs +53 -68
  12. package/dist/index101.mjs +6 -37
  13. package/dist/index102.mjs +4 -42
  14. package/dist/index103.mjs +179 -2
  15. package/dist/index104.mjs +53 -6
  16. package/dist/index105.mjs +69 -1134
  17. package/dist/index106.mjs +2 -20
  18. package/dist/index107.mjs +5 -54
  19. package/dist/index108.mjs +1126 -25
  20. package/dist/index109.mjs +20 -2
  21. package/dist/index11.mjs +74 -190
  22. package/dist/index110.mjs +55 -2
  23. package/dist/index111.mjs +33 -2
  24. package/dist/index112.mjs +2 -28
  25. package/dist/index113.mjs +2 -18
  26. package/dist/index114.mjs +2 -215
  27. package/dist/index115.mjs +26 -178
  28. package/dist/index116.mjs +10 -14
  29. package/dist/index117.mjs +209 -17
  30. package/dist/index118.mjs +173 -26
  31. package/dist/index119.mjs +17 -151
  32. package/dist/index12.mjs +172 -107
  33. package/dist/index120.mjs +12 -9
  34. package/dist/index121.mjs +24 -22
  35. package/dist/index122.mjs +148 -76
  36. package/dist/index123.mjs +13 -31
  37. package/dist/index124.mjs +24 -138
  38. package/dist/index125.mjs +78 -49
  39. package/dist/index126.mjs +32 -17
  40. package/dist/index127.mjs +139 -21
  41. package/dist/index128.mjs +51 -19
  42. package/dist/index129.mjs +16 -18
  43. package/dist/index13.mjs +127 -98
  44. package/dist/index130.mjs +18 -12
  45. package/dist/index131.mjs +15 -14
  46. package/dist/index132.mjs +17 -13
  47. package/dist/index133.mjs +14 -58
  48. package/dist/index134.mjs +15 -11
  49. package/dist/index135.mjs +14 -32
  50. package/dist/index136.mjs +57 -16
  51. package/dist/index137.mjs +11 -27
  52. package/dist/index138.mjs +31 -19
  53. package/dist/index139.mjs +17 -12
  54. package/dist/index14.mjs +94 -90
  55. package/dist/index140.mjs +27 -14
  56. package/dist/index141.mjs +20 -40
  57. package/dist/index142.mjs +11 -15
  58. package/dist/index143.mjs +17 -264
  59. package/dist/index144.mjs +40 -63
  60. package/dist/index145.mjs +22 -7
  61. package/dist/index146.mjs +268 -2
  62. package/dist/index147.mjs +70 -2
  63. package/dist/index148.mjs +7 -32
  64. package/dist/index149.mjs +2 -2
  65. package/dist/index15.mjs +84 -149
  66. package/dist/index150.mjs +2 -21
  67. package/dist/index151.mjs +31 -54
  68. package/dist/index152.mjs +2 -29
  69. package/dist/index153.mjs +20 -6
  70. package/dist/index154.mjs +53 -49
  71. package/dist/index155.mjs +29 -6
  72. package/dist/index156.mjs +6 -11
  73. package/dist/index157.mjs +49 -4
  74. package/dist/index158.mjs +5 -27
  75. package/dist/index159.mjs +12 -2
  76. package/dist/index16.mjs +157 -203
  77. package/dist/index160.mjs +6 -69
  78. package/dist/index161.mjs +27 -166
  79. package/dist/index162.mjs +2 -2
  80. package/dist/index163.mjs +70 -2
  81. package/dist/index164.mjs +167 -2
  82. package/dist/index165.mjs +2 -2
  83. package/dist/index166.mjs +2 -18
  84. package/dist/index167.mjs +2 -32
  85. package/dist/index168.mjs +2 -38
  86. package/dist/index169.mjs +11 -11
  87. package/dist/index17.mjs +198 -107
  88. package/dist/index170.mjs +25 -11
  89. package/dist/index171.mjs +38 -2
  90. package/dist/index172.mjs +11 -13
  91. package/dist/index173.mjs +11 -39
  92. package/dist/index174.mjs +2 -2
  93. package/dist/index175.mjs +15 -25
  94. package/dist/index176.mjs +39 -11
  95. package/dist/index178.mjs +30 -2
  96. package/dist/index179.mjs +18 -2
  97. package/dist/index18.mjs +97 -130
  98. package/dist/index181.mjs +2 -72
  99. package/dist/index182.mjs +2 -2
  100. package/dist/index183.mjs +2 -53
  101. package/dist/index184.mjs +72 -2
  102. package/dist/index185.mjs +2 -36
  103. package/dist/index186.mjs +38 -137
  104. package/dist/index187.mjs +2 -2
  105. package/dist/index188.mjs +36 -2
  106. package/dist/index189.mjs +147 -14
  107. package/dist/index19.mjs +140 -87
  108. package/dist/index191.mjs +2 -2
  109. package/dist/index192.mjs +10 -17
  110. package/dist/index193.mjs +2 -2
  111. package/dist/index194.mjs +2 -2
  112. package/dist/index195.mjs +18 -16
  113. package/dist/index196.mjs +2 -23
  114. package/dist/index197.mjs +2 -2
  115. package/dist/index198.mjs +24 -2
  116. package/dist/index199.mjs +23 -2
  117. package/dist/index2.mjs +30 -16
  118. package/dist/index20.mjs +82 -708
  119. package/dist/index200.mjs +2 -23
  120. package/dist/index201.mjs +2 -2
  121. package/dist/index202.mjs +2 -23
  122. package/dist/index203.mjs +23 -2
  123. package/dist/index204.mjs +2 -2
  124. package/dist/index205.mjs +23 -2
  125. package/dist/index206.mjs +2 -23
  126. package/dist/index207.mjs +2 -2
  127. package/dist/index208.mjs +2 -23
  128. package/dist/index209.mjs +23 -2
  129. package/dist/index21.mjs +718 -54
  130. package/dist/index210.mjs +2 -2
  131. package/dist/index211.mjs +23 -2
  132. package/dist/index212.mjs +2 -2
  133. package/dist/index213.mjs +2 -2
  134. package/dist/index215.mjs +2 -2
  135. package/dist/index216.mjs +2 -2
  136. package/dist/index217.mjs +2 -127
  137. package/dist/index218.mjs +2 -2
  138. package/dist/index219.mjs +2 -74
  139. package/dist/index22.mjs +263 -62
  140. package/dist/index220.mjs +123 -70
  141. package/dist/index221.mjs +2 -13
  142. package/dist/index222.mjs +74 -7
  143. package/dist/index223.mjs +73 -30
  144. package/dist/index224.mjs +30 -10
  145. package/dist/index225.mjs +10 -3
  146. package/dist/index226.mjs +3 -3
  147. package/dist/index227.mjs +3 -11
  148. package/dist/index228.mjs +13 -5
  149. package/dist/index229.mjs +7 -33
  150. package/dist/index23.mjs +44 -23
  151. package/dist/index230.mjs +11 -30
  152. package/dist/index231.mjs +5 -28
  153. package/dist/index232.mjs +33 -61
  154. package/dist/index233.mjs +31 -2
  155. package/dist/index234.mjs +28 -2
  156. package/dist/index235.mjs +61 -2
  157. package/dist/index236.mjs +2 -2
  158. package/dist/index237.mjs +108 -2
  159. package/dist/index238.mjs +2 -2
  160. package/dist/index239.mjs +2 -2
  161. package/dist/index24.mjs +55 -104
  162. package/dist/index240.mjs +2 -2
  163. package/dist/index241.mjs +2 -108
  164. package/dist/index242.mjs +2 -2
  165. package/dist/index244.mjs +2 -37
  166. package/dist/index245.mjs +2 -2
  167. package/dist/index246.mjs +2 -244
  168. package/dist/index247.mjs +37 -2
  169. package/dist/index248.mjs +2 -33
  170. package/dist/index249.mjs +2 -65
  171. package/dist/index25.mjs +62 -42
  172. package/dist/index250.mjs +243 -24
  173. package/dist/index251.mjs +2 -2
  174. package/dist/index252.mjs +33 -2
  175. package/dist/index253.mjs +65 -2
  176. package/dist/index254.mjs +25 -2
  177. package/dist/index255.mjs +2 -2
  178. package/dist/index256.mjs +2 -2
  179. package/dist/index257.mjs +2 -2
  180. package/dist/index259.mjs +2 -2
  181. package/dist/index26.mjs +22 -40
  182. package/dist/index260.mjs +2 -2
  183. package/dist/index261.mjs +2 -4
  184. package/dist/index262.mjs +2 -2
  185. package/dist/index263.mjs +2 -2
  186. package/dist/index264.mjs +4 -3
  187. package/dist/index265.mjs +2 -2
  188. package/dist/index266.mjs +2 -2
  189. package/dist/index267.mjs +3 -17
  190. package/dist/index268.mjs +2 -13
  191. package/dist/index269.mjs +2 -6
  192. package/dist/index27.mjs +107 -87
  193. package/dist/index270.mjs +17 -30
  194. package/dist/index271.mjs +13 -2
  195. package/dist/index272.mjs +6 -2
  196. package/dist/index273.mjs +29 -17
  197. package/dist/index274.mjs +2 -47
  198. package/dist/index275.mjs +2 -2
  199. package/dist/index276.mjs +18 -2
  200. package/dist/index277.mjs +47 -2
  201. package/dist/index278.mjs +2 -2
  202. package/dist/index279.mjs +2 -91
  203. package/dist/index28.mjs +42 -32
  204. package/dist/index280.mjs +2 -2
  205. package/dist/index282.mjs +91 -2
  206. package/dist/index283.mjs +2 -2
  207. package/dist/index284.mjs +5 -0
  208. package/dist/index285.mjs +5 -0
  209. package/dist/index286.mjs +5 -0
  210. package/dist/index29.mjs +42 -9
  211. package/dist/index3.mjs +7 -7
  212. package/dist/index30.mjs +84 -17
  213. package/dist/index31.mjs +29 -35
  214. package/dist/index32.mjs +8 -39
  215. package/dist/index33.mjs +21 -125
  216. package/dist/index34.mjs +35 -46
  217. package/dist/index35.mjs +38 -9
  218. package/dist/index36.mjs +121 -6
  219. package/dist/index37.mjs +49 -123
  220. package/dist/index38.mjs +11 -28
  221. package/dist/index39.mjs +11 -91
  222. package/dist/index4.mjs +34 -101
  223. package/dist/index40.mjs +121 -123
  224. package/dist/index41.mjs +28 -11
  225. package/dist/index42.mjs +91 -35
  226. package/dist/index43.mjs +116 -37
  227. package/dist/index44.mjs +9 -9
  228. package/dist/index45.mjs +33 -121
  229. package/dist/index46.mjs +42 -385
  230. package/dist/index47.mjs +10 -24
  231. package/dist/index48.mjs +122 -31
  232. package/dist/index49.mjs +388 -27
  233. package/dist/index5.mjs +92 -102
  234. package/dist/index50.mjs +24 -6
  235. package/dist/index51.mjs +30 -1431
  236. package/dist/index52.mjs +26 -68
  237. package/dist/index53.mjs +7 -2
  238. package/dist/index54.mjs +1425 -52
  239. package/dist/index55.mjs +69 -50
  240. package/dist/index56.mjs +2 -33
  241. package/dist/index57.mjs +59 -14
  242. package/dist/index58.mjs +47 -2259
  243. package/dist/index59.mjs +33 -36
  244. package/dist/index6.mjs +111 -15
  245. package/dist/index60.mjs +14 -43
  246. package/dist/index61.mjs +2256 -96
  247. package/dist/index62.mjs +36 -81
  248. package/dist/index63.mjs +43 -18
  249. package/dist/index64.mjs +102 -128
  250. package/dist/index65.mjs +45 -89
  251. package/dist/index66.mjs +15 -75
  252. package/dist/index67.mjs +84 -130
  253. package/dist/index68.mjs +84 -66
  254. package/dist/index69.mjs +69 -26
  255. package/dist/index7.mjs +15 -195
  256. package/dist/index70.mjs +138 -58
  257. package/dist/index71.mjs +82 -56
  258. package/dist/index72.mjs +28 -55
  259. package/dist/index73.mjs +79 -46
  260. package/dist/index74.mjs +58 -112
  261. package/dist/index75.mjs +47 -54
  262. package/dist/index76.mjs +60 -22
  263. package/dist/index77.mjs +135 -2
  264. package/dist/index78.mjs +68 -21
  265. package/dist/index79.mjs +4 -152
  266. package/dist/index8.mjs +136 -190
  267. package/dist/index80.mjs +23 -4
  268. package/dist/index81.mjs +2 -75
  269. package/dist/index82.mjs +153 -15
  270. package/dist/index83.mjs +21 -61
  271. package/dist/index84.mjs +69 -229
  272. package/dist/index85.mjs +15 -6
  273. package/dist/index86.mjs +55 -126
  274. package/dist/index87.mjs +33 -64
  275. package/dist/index88.mjs +40 -84
  276. package/dist/index89.mjs +229 -23
  277. package/dist/index9.mjs +240 -99
  278. package/dist/index90.mjs +5 -8
  279. package/dist/index91.mjs +125 -66
  280. package/dist/index92.mjs +67 -3
  281. package/dist/index93.mjs +87 -2
  282. package/dist/index94.mjs +24 -78
  283. package/dist/index95.mjs +7 -52
  284. package/dist/index96.mjs +74 -5
  285. package/dist/index97.mjs +3 -4
  286. package/dist/index98.mjs +2 -179
  287. package/dist/index99.mjs +79 -49
  288. package/dist/providers/StorefrontProvider.d.ts +3 -1
  289. package/dist/providers/StorefrontProvider.d.ts.map +1 -1
  290. package/dist/styles.css +1 -1
  291. package/package.json +1 -1
  292. package/src/components/AssistantDrawer.stories.tsx +90 -0
  293. package/src/components/AssistantDrawer.tsx +426 -0
  294. package/src/components/ProductAssistantChips.stories.tsx +42 -0
  295. package/src/components/ProductAssistantChips.tsx +64 -0
  296. package/src/contexts/AssistantContext.tsx +62 -0
  297. package/src/index.ts +11 -0
  298. package/src/providers/StorefrontProvider.tsx +17 -2
@@ -0,0 +1,426 @@
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
+ /** Custom class name */
49
+ className?: string;
50
+ /** Render inline instead of using a portal (useful for Storybook) */
51
+ disablePortal?: boolean;
52
+ }
53
+
54
+ export function AssistantDrawer({
55
+ isOpen,
56
+ onClose,
57
+ apiUrl,
58
+ brandSlug,
59
+ productSlug,
60
+ initialQuestion,
61
+ assistantName = 'AI Assistant',
62
+ className,
63
+ disablePortal = false,
64
+ }: AssistantDrawerProps) {
65
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
66
+ const [input, setInput] = useState('');
67
+ const [isStreaming, setIsStreaming] = useState(false);
68
+ const [error, setError] = useState<string | null>(null);
69
+ const [isAnimating, setIsAnimating] = useState(false);
70
+ const [shouldRender, setShouldRender] = useState(false);
71
+ const messagesEndRef = useRef<HTMLDivElement>(null);
72
+ const inputRef = useRef<HTMLInputElement>(null);
73
+ const initialQuestionSent = useRef(false);
74
+ const messagesRef = useRef<ChatMessage[]>([]);
75
+ messagesRef.current = messages;
76
+
77
+ useHideBodyOverflow(isOpen);
78
+
79
+ // --- Callbacks (defined before effects that use them) ---
80
+
81
+ const handleSSEEvent = useCallback((event: string, data: string) => {
82
+ if (event === 'token') {
83
+ setMessages((prev) => {
84
+ const updated = [...prev];
85
+ const last = updated[updated.length - 1];
86
+ if (last?.role === 'assistant') {
87
+ updated[updated.length - 1] = { ...last, content: last.content + data };
88
+ }
89
+ return updated;
90
+ });
91
+ } else if (event === 'done') {
92
+ try {
93
+ const parsed = JSON.parse(data);
94
+ setMessages((prev) => {
95
+ const updated = [...prev];
96
+ const last = updated[updated.length - 1];
97
+ if (last?.role === 'assistant') {
98
+ updated[updated.length - 1] = {
99
+ ...last,
100
+ content: parsed.answer || last.content,
101
+ products: parsed.products?.length > 0 ? parsed.products : undefined,
102
+ };
103
+ }
104
+ return updated;
105
+ });
106
+ } catch {
107
+ // Ignore parse errors
108
+ }
109
+ } else if (event === 'error') {
110
+ try {
111
+ const parsed = JSON.parse(data);
112
+ setError(parsed.message || 'An error occurred');
113
+ } catch {
114
+ setError('An error occurred');
115
+ }
116
+ }
117
+ }, []);
118
+
119
+ const sendMessageRef = useRef<(question: string) => Promise<void>>();
120
+ const sendMessage = useCallback(
121
+ async (question: string) => {
122
+ if (!question.trim()) return;
123
+
124
+ setError(null);
125
+ const userMessage: ChatMessage = { role: 'user', content: question };
126
+ const history = messagesRef.current.map((m) => ({
127
+ role: m.role,
128
+ content: m.content,
129
+ }));
130
+
131
+ setMessages((prev) => [...prev, userMessage]);
132
+ setInput('');
133
+ setIsStreaming(true);
134
+
135
+ // Add placeholder assistant message
136
+ setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
137
+
138
+ try {
139
+ const response = await fetch(`${apiUrl}/v1/assistant/ask`, {
140
+ method: 'POST',
141
+ headers: { 'Content-Type': 'application/json' },
142
+ body: JSON.stringify({
143
+ question,
144
+ brandSlug,
145
+ productSlug,
146
+ history,
147
+ }),
148
+ });
149
+
150
+ if (!response.ok) {
151
+ const err = await response.json().catch(() => ({}));
152
+ throw new Error(
153
+ (err as any)?.error?.message || `Request failed (${response.status})`
154
+ );
155
+ }
156
+
157
+ if (!response.body) throw new Error('No response body');
158
+
159
+ const reader = response.body.getReader();
160
+ const decoder = new TextDecoder();
161
+ let buffer = '';
162
+ let currentEvent = '';
163
+
164
+ while (true) {
165
+ const { done, value } = await reader.read();
166
+ if (done) break;
167
+
168
+ buffer += decoder.decode(value, { stream: true });
169
+ const lines = buffer.split('\n');
170
+ buffer = lines.pop() || '';
171
+
172
+ for (const line of lines) {
173
+ if (line.trim() === '') {
174
+ currentEvent = '';
175
+ continue;
176
+ }
177
+ if (line.trimStart().startsWith('event:')) {
178
+ currentEvent = line.trimStart().slice(6).trim();
179
+ } else if (line.trimStart().startsWith('data:')) {
180
+ // Per SSE spec: strip only the single leading space after "data:"
181
+ const afterPrefix = line.trimStart().slice(5);
182
+ const data = afterPrefix.startsWith(' ') ? afterPrefix.slice(1) : afterPrefix;
183
+ handleSSEEvent(currentEvent || 'token', data);
184
+ }
185
+ }
186
+ }
187
+ } catch (err: any) {
188
+ setError(err.message || 'Something went wrong');
189
+ // Remove the empty assistant message
190
+ setMessages((prev) => {
191
+ const last = prev[prev.length - 1];
192
+ if (last?.role === 'assistant' && !last.content) {
193
+ return prev.slice(0, -1);
194
+ }
195
+ return prev;
196
+ });
197
+ } finally {
198
+ setIsStreaming(false);
199
+ }
200
+ },
201
+ [apiUrl, brandSlug, productSlug, handleSSEEvent]
202
+ );
203
+ sendMessageRef.current = sendMessage;
204
+
205
+ // --- Effects ---
206
+
207
+ // Animation handling
208
+ useEffect(() => {
209
+ if (isOpen) {
210
+ setShouldRender(true);
211
+ requestAnimationFrame(() => {
212
+ requestAnimationFrame(() => {
213
+ setIsAnimating(true);
214
+ });
215
+ });
216
+ } else {
217
+ setIsAnimating(false);
218
+ const timer = setTimeout(() => {
219
+ setShouldRender(false);
220
+ setMessages([]);
221
+ setInput('');
222
+ setError(null);
223
+ initialQuestionSent.current = false;
224
+ }, 300);
225
+ return () => clearTimeout(timer);
226
+ }
227
+ }, [isOpen]);
228
+
229
+ // Send initial question when opened
230
+ useEffect(() => {
231
+ if (isOpen && initialQuestion && !initialQuestionSent.current) {
232
+ initialQuestionSent.current = true;
233
+ if (initialQuestion.trim()) {
234
+ setTimeout(() => sendMessageRef.current?.(initialQuestion), 100);
235
+ } else {
236
+ setTimeout(() => inputRef.current?.focus(), 350);
237
+ }
238
+ }
239
+ }, [isOpen, initialQuestion]);
240
+
241
+ // Scroll to bottom on new messages
242
+ useEffect(() => {
243
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
244
+ }, [messages]);
245
+
246
+ // ESC to close
247
+ useEffect(() => {
248
+ const handleEsc = (e: KeyboardEvent) => {
249
+ if (e.key === 'Escape' && isOpen) onClose();
250
+ };
251
+ if (isOpen) {
252
+ document.addEventListener('keydown', handleEsc);
253
+ return () => document.removeEventListener('keydown', handleEsc);
254
+ }
255
+ }, [isOpen, onClose]);
256
+
257
+ // --- Handlers ---
258
+
259
+ const handleSubmit = (e: React.FormEvent) => {
260
+ e.preventDefault();
261
+ if (input.trim()) {
262
+ sendMessage(input.trim());
263
+ }
264
+ };
265
+
266
+ // --- Render ---
267
+
268
+ if (!shouldRender) return null;
269
+
270
+ const drawerContent = (
271
+ <div
272
+ className={cn(
273
+ 'fixed inset-0 z-50 transition-opacity duration-300',
274
+ isAnimating ? 'opacity-100' : 'opacity-0'
275
+ )}
276
+ >
277
+ {/* Overlay */}
278
+ <div
279
+ className="absolute inset-0 bg-black/40 backdrop-blur-sm"
280
+ onClick={onClose}
281
+ />
282
+
283
+ {/* Drawer — bottom sheet on mobile, right drawer on desktop */}
284
+ <div
285
+ className={cn(
286
+ 'absolute bg-white flex flex-col transition-transform duration-300 ease-out',
287
+ // Mobile: bottom sheet
288
+ 'inset-x-0 bottom-0 top-[10vh] rounded-t-2xl',
289
+ // Desktop: right side drawer
290
+ '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',
291
+ // Animation
292
+ isAnimating
293
+ ? 'translate-y-0 sm:translate-x-0'
294
+ : 'translate-y-full sm:translate-y-0 sm:translate-x-full',
295
+ className
296
+ )}
297
+ >
298
+ {/* Header */}
299
+ <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 flex-shrink-0">
300
+ <div className="flex items-center gap-2">
301
+ <Sparkles className="h-5 w-5 text-orange-500" />
302
+ <span className="font-semibold text-gray-900">{assistantName}</span>
303
+ <span className="text-xs text-gray-400 bg-gray-100 rounded px-1.5 py-0.5">beta</span>
304
+ </div>
305
+ <button
306
+ type="button"
307
+ onClick={onClose}
308
+ className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
309
+ aria-label="Close"
310
+ >
311
+ <X className="h-5 w-5" />
312
+ </button>
313
+ </div>
314
+
315
+ {/* Messages */}
316
+ <div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
317
+ {messages.length === 0 && !isStreaming && (
318
+ <div className="text-center text-gray-400 text-sm py-8">
319
+ Ask me anything about our products!
320
+ </div>
321
+ )}
322
+
323
+ {messages.map((msg, i) => (
324
+ <div key={i}>
325
+ {msg.role === 'user' ? (
326
+ <div className="flex justify-end">
327
+ <div className="bg-gray-800 text-white rounded-2xl rounded-br-sm px-4 py-2 max-w-[80%] text-sm">
328
+ {msg.content}
329
+ </div>
330
+ </div>
331
+ ) : (
332
+ <div className="space-y-3">
333
+ {/* Text answer */}
334
+ <div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
335
+ {msg.content}
336
+ {isStreaming && i === messages.length - 1 && (
337
+ <span className="inline-block w-1.5 h-4 bg-gray-400 animate-pulse ml-0.5 align-text-bottom" />
338
+ )}
339
+ </div>
340
+
341
+ {/* Product cards */}
342
+ {msg.products && msg.products.length > 0 && (
343
+ <div className="space-y-2">
344
+ {msg.products.map((product) => (
345
+ <a
346
+ key={product.slug}
347
+ href={`/product/${product.slug}`}
348
+ className="flex items-center gap-3 w-full rounded-xl border border-gray-200 p-2 hover:bg-gray-50 transition-colors text-left no-underline"
349
+ >
350
+ {product.thumbnailUrl ? (
351
+ <img
352
+ src={product.thumbnailUrl}
353
+ alt={product.name}
354
+ className="h-16 w-16 rounded-lg object-cover flex-shrink-0"
355
+ />
356
+ ) : (
357
+ <div className="h-16 w-16 rounded-lg bg-gray-100 flex-shrink-0" />
358
+ )}
359
+ <div className="min-w-0">
360
+ <p className="text-sm font-medium text-gray-900 line-clamp-2">
361
+ {product.name}
362
+ </p>
363
+ <p className="text-sm font-semibold text-blue-600">
364
+ {formatCurrency(product.price)}
365
+ </p>
366
+ </div>
367
+ </a>
368
+ ))}
369
+ </div>
370
+ )}
371
+ </div>
372
+ )}
373
+ </div>
374
+ ))}
375
+
376
+ {/* Error */}
377
+ {error && (
378
+ <div className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
379
+ {error}
380
+ </div>
381
+ )}
382
+
383
+ <div ref={messagesEndRef} />
384
+ </div>
385
+
386
+ {/* Input */}
387
+ <div className="border-t border-gray-200 p-3 flex-shrink-0">
388
+ <form onSubmit={handleSubmit} className="flex items-center gap-2">
389
+ <input
390
+ ref={inputRef}
391
+ type="text"
392
+ value={input}
393
+ onChange={(e) => setInput(e.target.value)}
394
+ placeholder={`Ask ${assistantName} a question`}
395
+ disabled={isStreaming}
396
+ className={cn(
397
+ 'flex-1 rounded-full border border-gray-300 px-4 py-2.5 text-sm',
398
+ 'focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-400/30',
399
+ 'disabled:opacity-50'
400
+ )}
401
+ />
402
+ <button
403
+ type="submit"
404
+ disabled={!input.trim() || isStreaming}
405
+ className={cn(
406
+ 'flex h-10 w-10 items-center justify-center rounded-full',
407
+ 'bg-gray-800 text-white',
408
+ 'hover:bg-gray-700 transition-colors',
409
+ 'disabled:opacity-40 disabled:cursor-not-allowed'
410
+ )}
411
+ >
412
+ {isStreaming ? (
413
+ <Loader2 className="h-4 w-4 animate-spin" />
414
+ ) : (
415
+ <Send className="h-4 w-4" />
416
+ )}
417
+ </button>
418
+ </form>
419
+ </div>
420
+ </div>
421
+ </div>
422
+ );
423
+
424
+ if (disablePortal) return drawerContent;
425
+ return createPortal(drawerContent, document.body);
426
+ }
@@ -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
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
4
+ import { AssistantDrawer } from '../components/AssistantDrawer';
5
+
6
+ type AssistantContextType = {
7
+ /** Open the assistant drawer with a question. Pass productSlug if on a product page. */
8
+ ask: (question: string, productSlug?: string) => void;
9
+ /** Close the assistant drawer */
10
+ close: () => void;
11
+ /** Whether the drawer is currently open */
12
+ isOpen: boolean;
13
+ };
14
+
15
+ const AssistantContext = createContext<AssistantContextType | null>(null);
16
+
17
+ export function useAssistant() {
18
+ const ctx = useContext(AssistantContext);
19
+ if (!ctx) throw new Error('useAssistant must be used within StorefrontProvider');
20
+ return ctx;
21
+ }
22
+
23
+ export interface AssistantProviderProps {
24
+ children: ReactNode;
25
+ apiUrl: string;
26
+ brandSlug: string;
27
+ assistantName?: string;
28
+ }
29
+
30
+ export function AssistantProvider({
31
+ children,
32
+ apiUrl,
33
+ brandSlug,
34
+ assistantName,
35
+ }: AssistantProviderProps) {
36
+ const [isOpen, setIsOpen] = useState(false);
37
+ const [question, setQuestion] = useState('');
38
+ const [productSlug, setProductSlug] = useState<string | undefined>();
39
+
40
+ const ask = useCallback((q: string, slug?: string) => {
41
+ setQuestion(q);
42
+ setProductSlug(slug);
43
+ setIsOpen(true);
44
+ }, []);
45
+
46
+ const close = useCallback(() => setIsOpen(false), []);
47
+
48
+ return (
49
+ <AssistantContext.Provider value={{ ask, close, isOpen }}>
50
+ {children}
51
+ <AssistantDrawer
52
+ isOpen={isOpen}
53
+ onClose={close}
54
+ apiUrl={apiUrl}
55
+ brandSlug={brandSlug}
56
+ productSlug={productSlug}
57
+ initialQuestion={question}
58
+ assistantName={assistantName}
59
+ />
60
+ </AssistantContext.Provider>
61
+ );
62
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,9 @@ export type { StorefrontProviderProps } from './providers/StorefrontProvider';
11
11
  // Export cart context (use this instead of prop drilling)
12
12
  export { CartProvider, useCart } from './contexts/CartContext';
13
13
 
14
+ // Export AI assistant context
15
+ export { useAssistant } from './contexts/AssistantContext';
16
+
14
17
  // Export Meta Pixel tracking
15
18
  export { useMetaPixel, MetaPixelProvider } from './providers/MetaPixelProvider';
16
19
  export type { MetaPixelProviderProps, PurchaseItem } from './providers/MetaPixelProvider';
@@ -64,6 +67,14 @@ export type { DiscountCodeInputProps } from './components/DiscountCodeInput';
64
67
  export { Checkout } from './components/Checkout';
65
68
  export type { CheckoutProps, CheckoutFormData } from './components/Checkout';
66
69
 
70
+ // AI Assistant components
71
+ export { AssistantDrawer } from './components/AssistantDrawer';
72
+ export type { AssistantDrawerProps } from './components/AssistantDrawer';
73
+
74
+ export { ProductAssistantChips } from './components/ProductAssistantChips';
75
+ export type { ProductAssistantChipsProps } from './components/ProductAssistantChips';
76
+
77
+
67
78
  // Export UI primitives (for customization)
68
79
  export { Button } from './components/ui/button';
69
80
  export { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from './components/ui/card';
@@ -28,6 +28,7 @@
28
28
  import { ReactNode } from 'react';
29
29
  import { ApiClientProvider, useGetBrand } from '@instockng/api-client';
30
30
  import { CartProvider, CartProviderProps } from '../contexts/CartContext';
31
+ import { AssistantProvider } from '../contexts/AssistantContext';
31
32
  import { MetaPixelProvider } from './MetaPixelProvider';
32
33
  import { TikTokPixelProvider } from './TikTokPixelProvider';
33
34
 
@@ -41,6 +42,8 @@ export interface StorefrontProviderProps {
41
42
  initialCartId?: string;
42
43
  /** Optional: Props to pass to the ShoppingCart component */
43
44
  shoppingCartProps?: CartProviderProps['shoppingCartProps'];
45
+ /** Optional: AI assistant display name */
46
+ assistantName?: string;
44
47
  }
45
48
 
46
49
  /**
@@ -51,7 +54,9 @@ function StorefrontProviderInner({
51
54
  brandSlug,
52
55
  initialCartId,
53
56
  shoppingCartProps,
54
- }: Omit<StorefrontProviderProps, 'apiUrl'>) {
57
+ assistantName,
58
+ apiUrl,
59
+ }: Omit<StorefrontProviderProps, 'apiUrl'> & { apiUrl: string }) {
55
60
  const { data: brand } = useGetBrand(brandSlug);
56
61
 
57
62
  return (
@@ -62,7 +67,13 @@ function StorefrontProviderInner({
62
67
  initialCartId={initialCartId}
63
68
  shoppingCartProps={shoppingCartProps}
64
69
  >
65
- {children}
70
+ <AssistantProvider
71
+ apiUrl={apiUrl}
72
+ brandSlug={brandSlug}
73
+ assistantName={assistantName}
74
+ >
75
+ {children}
76
+ </AssistantProvider>
66
77
  </CartProvider>
67
78
  </TikTokPixelProvider>
68
79
  </MetaPixelProvider>
@@ -83,9 +94,11 @@ export function StorefrontProvider({
83
94
  apiUrl,
84
95
  initialCartId,
85
96
  shoppingCartProps,
97
+ assistantName,
86
98
  }: StorefrontProviderProps) {
87
99
  // Storefront-ui doesn't use authenticated endpoints, so provide a no-op getAuthToken
88
100
  const getAuthToken = async () => '';
101
+ const resolvedApiUrl = apiUrl || 'https://oms-api.instock.ng';
89
102
 
90
103
  return (
91
104
  <ApiClientProvider getAuthToken={getAuthToken} {...(apiUrl && { baseURL: apiUrl })}>
@@ -93,6 +106,8 @@ export function StorefrontProvider({
93
106
  brandSlug={brandSlug}
94
107
  initialCartId={initialCartId}
95
108
  shoppingCartProps={shoppingCartProps}
109
+ assistantName={assistantName}
110
+ apiUrl={resolvedApiUrl}
96
111
  >
97
112
  {children}
98
113
  </StorefrontProviderInner>