@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.
- package/dist/components/AssistantDrawer.d.ts +24 -0
- package/dist/components/AssistantDrawer.d.ts.map +1 -0
- package/dist/components/ProductAssistantChips.d.ts +13 -0
- package/dist/components/ProductAssistantChips.d.ts.map +1 -0
- package/dist/components/SearchAssistantTrigger.d.ts +12 -0
- package/dist/components/SearchAssistantTrigger.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +127 -121
- package/dist/index10.mjs +3 -3
- package/dist/index100.mjs +8 -68
- package/dist/index101.mjs +71 -33
- package/dist/index102.mjs +3 -42
- package/dist/index103.mjs +2 -2
- package/dist/index104.mjs +82 -5
- package/dist/index105.mjs +53 -1133
- package/dist/index106.mjs +5 -19
- package/dist/index107.mjs +4 -54
- package/dist/index108.mjs +174 -28
- package/dist/index109.mjs +53 -2
- package/dist/index11.mjs +5 -5
- package/dist/index110.mjs +69 -2
- package/dist/index111.mjs +2 -2
- package/dist/index112.mjs +35 -26
- package/dist/index113.mjs +42 -17
- package/dist/index114.mjs +2 -215
- package/dist/index115.mjs +26 -178
- package/dist/index116.mjs +10 -14
- package/dist/index117.mjs +209 -17
- package/dist/index118.mjs +173 -26
- package/dist/index119.mjs +17 -151
- package/dist/index12.mjs +4 -4
- package/dist/index120.mjs +13 -10
- package/dist/index121.mjs +24 -22
- package/dist/index122.mjs +148 -76
- package/dist/index123.mjs +13 -31
- package/dist/index124.mjs +24 -138
- package/dist/index125.mjs +78 -49
- package/dist/index126.mjs +32 -17
- package/dist/index127.mjs +139 -21
- package/dist/index128.mjs +51 -19
- package/dist/index129.mjs +16 -18
- package/dist/index13.mjs +3 -3
- package/dist/index130.mjs +18 -12
- package/dist/index131.mjs +15 -14
- package/dist/index132.mjs +17 -13
- package/dist/index133.mjs +14 -58
- package/dist/index134.mjs +15 -11
- package/dist/index135.mjs +14 -32
- package/dist/index136.mjs +57 -16
- package/dist/index137.mjs +11 -27
- package/dist/index138.mjs +31 -19
- package/dist/index139.mjs +17 -12
- package/dist/index14.mjs +1 -1
- package/dist/index140.mjs +27 -14
- package/dist/index141.mjs +20 -40
- package/dist/index142.mjs +11 -15
- package/dist/index143.mjs +17 -264
- package/dist/index144.mjs +40 -63
- package/dist/index145.mjs +22 -7
- package/dist/index146.mjs +268 -2
- package/dist/index147.mjs +70 -2
- package/dist/index148.mjs +7 -32
- package/dist/index149.mjs +2 -2
- package/dist/index15.mjs +1 -1
- package/dist/index150.mjs +2 -21
- package/dist/index151.mjs +31 -54
- package/dist/index152.mjs +2 -29
- package/dist/index153.mjs +20 -6
- package/dist/index154.mjs +53 -49
- package/dist/index155.mjs +29 -6
- package/dist/index156.mjs +6 -11
- package/dist/index157.mjs +49 -4
- package/dist/index158.mjs +5 -27
- package/dist/index159.mjs +12 -2
- package/dist/index16.mjs +6 -6
- package/dist/index160.mjs +6 -69
- package/dist/index161.mjs +27 -166
- package/dist/index162.mjs +2 -2
- package/dist/index163.mjs +70 -2
- package/dist/index164.mjs +167 -2
- package/dist/index165.mjs +2 -2
- package/dist/index166.mjs +2 -18
- package/dist/index167.mjs +2 -32
- package/dist/index168.mjs +14 -34
- package/dist/index169.mjs +25 -11
- package/dist/index17.mjs +4 -4
- package/dist/index170.mjs +34 -14
- package/dist/index171.mjs +18 -2
- package/dist/index172.mjs +11 -13
- package/dist/index173.mjs +2 -46
- package/dist/index174.mjs +20 -2
- package/dist/index175.mjs +41 -25
- package/dist/index176.mjs +2 -18
- package/dist/index177.mjs +30 -2
- package/dist/index178.mjs +18 -2
- package/dist/index179.mjs +2 -2
- package/dist/index18.mjs +5 -5
- package/dist/index180.mjs +2 -2
- package/dist/index181.mjs +2 -72
- package/dist/index182.mjs +2 -2
- package/dist/index183.mjs +59 -40
- package/dist/index185.mjs +48 -31
- package/dist/index186.mjs +2 -152
- package/dist/index187.mjs +36 -2
- package/dist/index188.mjs +152 -2
- package/dist/index189.mjs +2 -19
- package/dist/index19.mjs +3 -3
- package/dist/index190.mjs +2 -2
- package/dist/index191.mjs +19 -2
- package/dist/index192.mjs +2 -26
- package/dist/index193.mjs +2 -2
- package/dist/index194.mjs +26 -2
- package/dist/index195.mjs +2 -24
- package/dist/index196.mjs +2 -23
- package/dist/index197.mjs +24 -2
- package/dist/index198.mjs +23 -2
- package/dist/index199.mjs +2 -2
- package/dist/index2.mjs +2 -2
- package/dist/index20.mjs +11 -11
- package/dist/index200.mjs +2 -23
- package/dist/index202.mjs +16 -16
- package/dist/index203.mjs +2 -2
- package/dist/index204.mjs +23 -2
- package/dist/index205.mjs +2 -2
- package/dist/index206.mjs +2 -23
- package/dist/index207.mjs +2 -2
- package/dist/index208.mjs +15 -15
- package/dist/index21.mjs +256 -51
- package/dist/index210.mjs +23 -2
- package/dist/index211.mjs +2 -2
- package/dist/index212.mjs +2 -2
- package/dist/index213.mjs +2 -2
- package/dist/index215.mjs +2 -2
- package/dist/index217.mjs +2 -127
- package/dist/index218.mjs +2 -2
- package/dist/index219.mjs +123 -70
- package/dist/index22.mjs +45 -62
- package/dist/index220.mjs +2 -74
- package/dist/index221.mjs +74 -13
- package/dist/index222.mjs +74 -7
- package/dist/index223.mjs +2 -31
- package/dist/index224.mjs +13 -11
- package/dist/index225.mjs +7 -4
- package/dist/index226.mjs +11 -3
- package/dist/index227.mjs +4 -11
- package/dist/index228.mjs +33 -5
- package/dist/index229.mjs +31 -33
- package/dist/index23.mjs +22 -22
- package/dist/index230.mjs +26 -29
- package/dist/index231.mjs +59 -26
- package/dist/index232.mjs +28 -58
- package/dist/index233.mjs +11 -2
- package/dist/index234.mjs +4 -2
- package/dist/index235.mjs +4 -2
- package/dist/index236.mjs +2 -2
- package/dist/index237.mjs +2 -2
- package/dist/index238.mjs +2 -2
- package/dist/index239.mjs +2 -2
- package/dist/index24.mjs +55 -104
- package/dist/index240.mjs +2 -2
- package/dist/index241.mjs +2 -108
- package/dist/index242.mjs +2 -2
- package/dist/index243.mjs +2 -2
- package/dist/index244.mjs +96 -25
- package/dist/index246.mjs +2 -244
- package/dist/index247.mjs +37 -2
- package/dist/index248.mjs +2 -33
- package/dist/index249.mjs +2 -65
- package/dist/index25.mjs +62 -42
- package/dist/index250.mjs +243 -24
- package/dist/index251.mjs +2 -2
- package/dist/index252.mjs +33 -2
- package/dist/index253.mjs +65 -2
- package/dist/index254.mjs +25 -2
- package/dist/index255.mjs +2 -2
- package/dist/index256.mjs +2 -2
- package/dist/index257.mjs +2 -2
- package/dist/index259.mjs +2 -2
- package/dist/index26.mjs +22 -40
- package/dist/index260.mjs +2 -2
- package/dist/index261.mjs +2 -4
- package/dist/index262.mjs +2 -2
- package/dist/index263.mjs +4 -2
- package/dist/index264.mjs +2 -3
- package/dist/index265.mjs +2 -2
- package/dist/index266.mjs +2 -2
- package/dist/index267.mjs +2 -17
- package/dist/index268.mjs +2 -13
- package/dist/index269.mjs +18 -6
- package/dist/index27.mjs +107 -87
- package/dist/index270.mjs +45 -28
- package/dist/index271.mjs +2 -2
- package/dist/index272.mjs +2 -2
- package/dist/index273.mjs +2 -18
- package/dist/index274.mjs +2 -47
- package/dist/index275.mjs +91 -2
- package/dist/index276.mjs +2 -2
- package/dist/index277.mjs +3 -2
- package/dist/index278.mjs +2 -2
- package/dist/index279.mjs +2 -91
- package/dist/index28.mjs +42 -32
- package/dist/index280.mjs +17 -2
- package/dist/index281.mjs +13 -2
- package/dist/index282.mjs +6 -2
- package/dist/index283.mjs +30 -2
- package/dist/index284.mjs +5 -0
- package/dist/index285.mjs +5 -0
- package/dist/index286.mjs +5 -0
- package/dist/index29.mjs +42 -9
- package/dist/index3.mjs +4 -4
- package/dist/index30.mjs +84 -17
- package/dist/index31.mjs +29 -35
- package/dist/index32.mjs +8 -39
- package/dist/index33.mjs +21 -125
- package/dist/index34.mjs +35 -46
- package/dist/index35.mjs +38 -9
- package/dist/index36.mjs +121 -6
- package/dist/index37.mjs +49 -123
- package/dist/index38.mjs +11 -28
- package/dist/index39.mjs +11 -91
- package/dist/index4.mjs +1 -1
- package/dist/index40.mjs +121 -123
- package/dist/index41.mjs +28 -11
- package/dist/index42.mjs +91 -35
- package/dist/index43.mjs +116 -37
- package/dist/index44.mjs +9 -9
- package/dist/index45.mjs +33 -121
- package/dist/index46.mjs +42 -385
- package/dist/index47.mjs +10 -24
- package/dist/index48.mjs +122 -31
- package/dist/index49.mjs +388 -27
- package/dist/index5.mjs +1 -1
- package/dist/index50.mjs +24 -6
- package/dist/index51.mjs +30 -1431
- package/dist/index52.mjs +26 -68
- package/dist/index53.mjs +7 -2
- package/dist/index54.mjs +1425 -52
- package/dist/index55.mjs +69 -50
- package/dist/index56.mjs +2 -33
- package/dist/index57.mjs +59 -14
- package/dist/index58.mjs +47 -2259
- package/dist/index59.mjs +33 -36
- package/dist/index6.mjs +1 -1
- package/dist/index60.mjs +14 -43
- package/dist/index61.mjs +2256 -96
- package/dist/index62.mjs +36 -81
- package/dist/index63.mjs +43 -18
- package/dist/index64.mjs +102 -128
- package/dist/index65.mjs +45 -89
- package/dist/index66.mjs +15 -75
- package/dist/index67.mjs +84 -130
- package/dist/index68.mjs +84 -66
- package/dist/index69.mjs +69 -26
- package/dist/index7.mjs +6 -6
- package/dist/index70.mjs +138 -58
- package/dist/index71.mjs +82 -56
- package/dist/index72.mjs +28 -55
- package/dist/index73.mjs +79 -46
- package/dist/index74.mjs +58 -112
- package/dist/index75.mjs +47 -54
- package/dist/index76.mjs +60 -22
- package/dist/index77.mjs +135 -2
- package/dist/index78.mjs +68 -21
- package/dist/index79.mjs +21 -150
- package/dist/index8.mjs +6 -6
- package/dist/index80.mjs +2 -5
- package/dist/index81.mjs +149 -71
- package/dist/index82.mjs +23 -15
- package/dist/index83.mjs +68 -56
- package/dist/index84.mjs +14 -234
- package/dist/index85.mjs +62 -5
- package/dist/index86.mjs +4 -133
- package/dist/index87.mjs +2 -68
- package/dist/index88.mjs +5 -86
- package/dist/index89.mjs +1133 -28
- package/dist/index9.mjs +4 -4
- package/dist/index90.mjs +19 -8
- package/dist/index91.mjs +51 -71
- package/dist/index92.mjs +32 -3
- package/dist/index93.mjs +2 -2
- package/dist/index94.mjs +227 -75
- package/dist/index95.mjs +5 -53
- package/dist/index96.mjs +133 -5
- package/dist/index97.mjs +67 -4
- package/dist/index98.mjs +79 -171
- package/dist/index99.mjs +27 -51
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/AssistantDrawer.stories.tsx +140 -0
- package/src/components/AssistantDrawer.tsx +430 -0
- package/src/components/ProductAssistantChips.stories.tsx +42 -0
- package/src/components/ProductAssistantChips.tsx +64 -0
- package/src/components/SearchAssistantTrigger.stories.tsx +35 -0
- package/src/components/SearchAssistantTrigger.tsx +41 -0
- 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';
|