@instockng/storefront-ui 1.0.106 → 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 (296) 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/ProductAssistantChips.d.ts +13 -0
  4. package/dist/components/ProductAssistantChips.d.ts.map +1 -0
  5. package/dist/components/SearchAssistantTrigger.d.ts +12 -0
  6. package/dist/components/SearchAssistantTrigger.d.ts.map +1 -0
  7. package/dist/index.d.ts +6 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.mjs +127 -121
  10. package/dist/index10.mjs +3 -3
  11. package/dist/index100.mjs +8 -68
  12. package/dist/index101.mjs +71 -33
  13. package/dist/index102.mjs +3 -42
  14. package/dist/index103.mjs +2 -2
  15. package/dist/index104.mjs +82 -5
  16. package/dist/index105.mjs +53 -1133
  17. package/dist/index106.mjs +5 -19
  18. package/dist/index107.mjs +4 -54
  19. package/dist/index108.mjs +174 -28
  20. package/dist/index109.mjs +53 -2
  21. package/dist/index11.mjs +5 -5
  22. package/dist/index110.mjs +69 -2
  23. package/dist/index111.mjs +2 -2
  24. package/dist/index112.mjs +35 -26
  25. package/dist/index113.mjs +42 -17
  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 +4 -4
  33. package/dist/index120.mjs +13 -10
  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 +3 -3
  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 +1 -1
  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 +1 -1
  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 +6 -6
  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 +14 -34
  86. package/dist/index169.mjs +25 -11
  87. package/dist/index17.mjs +4 -4
  88. package/dist/index170.mjs +34 -14
  89. package/dist/index171.mjs +18 -2
  90. package/dist/index172.mjs +11 -13
  91. package/dist/index173.mjs +2 -46
  92. package/dist/index174.mjs +20 -2
  93. package/dist/index175.mjs +41 -25
  94. package/dist/index176.mjs +2 -18
  95. package/dist/index177.mjs +30 -2
  96. package/dist/index178.mjs +18 -2
  97. package/dist/index179.mjs +2 -2
  98. package/dist/index18.mjs +5 -5
  99. package/dist/index180.mjs +2 -2
  100. package/dist/index181.mjs +2 -72
  101. package/dist/index182.mjs +2 -2
  102. package/dist/index183.mjs +59 -40
  103. package/dist/index185.mjs +48 -31
  104. package/dist/index186.mjs +2 -152
  105. package/dist/index187.mjs +36 -2
  106. package/dist/index188.mjs +152 -2
  107. package/dist/index189.mjs +2 -19
  108. package/dist/index19.mjs +3 -3
  109. package/dist/index190.mjs +2 -2
  110. package/dist/index191.mjs +19 -2
  111. package/dist/index192.mjs +2 -26
  112. package/dist/index193.mjs +2 -2
  113. package/dist/index194.mjs +26 -2
  114. package/dist/index195.mjs +2 -24
  115. package/dist/index196.mjs +2 -23
  116. package/dist/index197.mjs +24 -2
  117. package/dist/index198.mjs +23 -2
  118. package/dist/index199.mjs +2 -2
  119. package/dist/index2.mjs +2 -2
  120. package/dist/index20.mjs +11 -11
  121. package/dist/index200.mjs +2 -23
  122. package/dist/index202.mjs +16 -16
  123. package/dist/index203.mjs +2 -2
  124. package/dist/index204.mjs +23 -2
  125. package/dist/index205.mjs +2 -2
  126. package/dist/index206.mjs +2 -23
  127. package/dist/index207.mjs +2 -2
  128. package/dist/index208.mjs +15 -15
  129. package/dist/index21.mjs +256 -51
  130. package/dist/index210.mjs +23 -2
  131. package/dist/index211.mjs +2 -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/index217.mjs +2 -127
  136. package/dist/index218.mjs +2 -2
  137. package/dist/index219.mjs +123 -70
  138. package/dist/index22.mjs +45 -62
  139. package/dist/index220.mjs +2 -74
  140. package/dist/index221.mjs +74 -13
  141. package/dist/index222.mjs +74 -7
  142. package/dist/index223.mjs +2 -31
  143. package/dist/index224.mjs +13 -11
  144. package/dist/index225.mjs +7 -4
  145. package/dist/index226.mjs +11 -3
  146. package/dist/index227.mjs +4 -11
  147. package/dist/index228.mjs +33 -5
  148. package/dist/index229.mjs +31 -33
  149. package/dist/index23.mjs +22 -22
  150. package/dist/index230.mjs +26 -29
  151. package/dist/index231.mjs +59 -26
  152. package/dist/index232.mjs +28 -58
  153. package/dist/index233.mjs +11 -2
  154. package/dist/index234.mjs +4 -2
  155. package/dist/index235.mjs +4 -2
  156. package/dist/index236.mjs +2 -2
  157. package/dist/index237.mjs +2 -2
  158. package/dist/index238.mjs +2 -2
  159. package/dist/index239.mjs +2 -2
  160. package/dist/index24.mjs +55 -104
  161. package/dist/index240.mjs +2 -2
  162. package/dist/index241.mjs +2 -108
  163. package/dist/index242.mjs +2 -2
  164. package/dist/index243.mjs +2 -2
  165. package/dist/index244.mjs +96 -25
  166. package/dist/index246.mjs +2 -244
  167. package/dist/index247.mjs +37 -2
  168. package/dist/index248.mjs +2 -33
  169. package/dist/index249.mjs +2 -65
  170. package/dist/index25.mjs +62 -42
  171. package/dist/index250.mjs +243 -24
  172. package/dist/index251.mjs +2 -2
  173. package/dist/index252.mjs +33 -2
  174. package/dist/index253.mjs +65 -2
  175. package/dist/index254.mjs +25 -2
  176. package/dist/index255.mjs +2 -2
  177. package/dist/index256.mjs +2 -2
  178. package/dist/index257.mjs +2 -2
  179. package/dist/index259.mjs +2 -2
  180. package/dist/index26.mjs +22 -40
  181. package/dist/index260.mjs +2 -2
  182. package/dist/index261.mjs +2 -4
  183. package/dist/index262.mjs +2 -2
  184. package/dist/index263.mjs +4 -2
  185. package/dist/index264.mjs +2 -3
  186. package/dist/index265.mjs +2 -2
  187. package/dist/index266.mjs +2 -2
  188. package/dist/index267.mjs +2 -17
  189. package/dist/index268.mjs +2 -13
  190. package/dist/index269.mjs +18 -6
  191. package/dist/index27.mjs +107 -87
  192. package/dist/index270.mjs +45 -28
  193. package/dist/index271.mjs +2 -2
  194. package/dist/index272.mjs +2 -2
  195. package/dist/index273.mjs +2 -18
  196. package/dist/index274.mjs +2 -47
  197. package/dist/index275.mjs +91 -2
  198. package/dist/index276.mjs +2 -2
  199. package/dist/index277.mjs +3 -2
  200. package/dist/index278.mjs +2 -2
  201. package/dist/index279.mjs +2 -91
  202. package/dist/index28.mjs +42 -32
  203. package/dist/index280.mjs +17 -2
  204. package/dist/index281.mjs +13 -2
  205. package/dist/index282.mjs +6 -2
  206. package/dist/index283.mjs +30 -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 +4 -4
  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 +1 -1
  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 +1 -1
  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 +1 -1
  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 +6 -6
  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 +21 -150
  266. package/dist/index8.mjs +6 -6
  267. package/dist/index80.mjs +2 -5
  268. package/dist/index81.mjs +149 -71
  269. package/dist/index82.mjs +23 -15
  270. package/dist/index83.mjs +68 -56
  271. package/dist/index84.mjs +14 -234
  272. package/dist/index85.mjs +62 -5
  273. package/dist/index86.mjs +4 -133
  274. package/dist/index87.mjs +2 -68
  275. package/dist/index88.mjs +5 -86
  276. package/dist/index89.mjs +1133 -28
  277. package/dist/index9.mjs +4 -4
  278. package/dist/index90.mjs +19 -8
  279. package/dist/index91.mjs +51 -71
  280. package/dist/index92.mjs +32 -3
  281. package/dist/index93.mjs +2 -2
  282. package/dist/index94.mjs +227 -75
  283. package/dist/index95.mjs +5 -53
  284. package/dist/index96.mjs +133 -5
  285. package/dist/index97.mjs +67 -4
  286. package/dist/index98.mjs +79 -171
  287. package/dist/index99.mjs +27 -51
  288. package/dist/styles.css +1 -1
  289. package/package.json +1 -1
  290. package/src/components/AssistantDrawer.stories.tsx +140 -0
  291. package/src/components/AssistantDrawer.tsx +430 -0
  292. package/src/components/ProductAssistantChips.stories.tsx +42 -0
  293. package/src/components/ProductAssistantChips.tsx +64 -0
  294. package/src/components/SearchAssistantTrigger.stories.tsx +35 -0
  295. package/src/components/SearchAssistantTrigger.tsx +41 -0
  296. package/src/index.ts +10 -0
@@ -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
+ }
@@ -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,35 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { SearchAssistantTrigger } from './SearchAssistantTrigger';
3
+
4
+ const meta = {
5
+ title: 'Components/SearchAssistantTrigger',
6
+ component: SearchAssistantTrigger,
7
+ parameters: {
8
+ layout: 'centered',
9
+ },
10
+ tags: ['autodocs'],
11
+ } satisfies Meta<typeof SearchAssistantTrigger>;
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof meta>;
15
+
16
+ export const Default: Story = {
17
+ args: {
18
+ searchQuery: 'toys for babies',
19
+ onAsk: (question) => console.log('Asked:', question),
20
+ },
21
+ };
22
+
23
+ export const CustomLabel: Story = {
24
+ args: {
25
+ searchQuery: 'maternity leggings',
26
+ onAsk: (question) => console.log('Asked:', question),
27
+ label: 'Ask AI about "maternity leggings"',
28
+ },
29
+ };
30
+
31
+ export const EmptyQuery: Story = {
32
+ args: {
33
+ onAsk: (question) => console.log('Asked:', question),
34
+ },
35
+ };
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * SearchAssistantTrigger Component
5
+ *
6
+ * "Ask AI" button shown in search results (e.g., when search returns no results).
7
+ * Clicking opens the AssistantDrawer with the search query as the question.
8
+ */
9
+
10
+ import { Sparkles } from 'lucide-react';
11
+ import { Button } from './ui/button';
12
+ import { cn } from '../lib/utils';
13
+
14
+ export interface SearchAssistantTriggerProps {
15
+ /** The search query to pre-fill */
16
+ searchQuery?: string;
17
+ /** Callback to open the drawer with a question */
18
+ onAsk: (question: string) => void;
19
+ /** Custom class name */
20
+ className?: string;
21
+ /** Custom label */
22
+ label?: string;
23
+ }
24
+
25
+ export function SearchAssistantTrigger({
26
+ searchQuery = '',
27
+ onAsk,
28
+ className,
29
+ label = 'Ask AI',
30
+ }: SearchAssistantTriggerProps) {
31
+ return (
32
+ <Button
33
+ variant="outline"
34
+ onClick={() => onAsk(searchQuery)}
35
+ className={cn('gap-2', className)}
36
+ >
37
+ <Sparkles className="h-4 w-4 text-orange-500" />
38
+ {label}
39
+ </Button>
40
+ );
41
+ }
package/src/index.ts CHANGED
@@ -64,6 +64,16 @@ export type { DiscountCodeInputProps } from './components/DiscountCodeInput';
64
64
  export { Checkout } from './components/Checkout';
65
65
  export type { CheckoutProps, CheckoutFormData } from './components/Checkout';
66
66
 
67
+ // AI Assistant components
68
+ export { AssistantDrawer } from './components/AssistantDrawer';
69
+ export type { AssistantDrawerProps } from './components/AssistantDrawer';
70
+
71
+ export { ProductAssistantChips } from './components/ProductAssistantChips';
72
+ export type { ProductAssistantChipsProps } from './components/ProductAssistantChips';
73
+
74
+ export { SearchAssistantTrigger } from './components/SearchAssistantTrigger';
75
+ export type { SearchAssistantTriggerProps } from './components/SearchAssistantTrigger';
76
+
67
77
  // Export UI primitives (for customization)
68
78
  export { Button } from './components/ui/button';
69
79
  export { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from './components/ui/card';