@rahul_vendure/ai-chat-dashboard 0.1.2 → 0.2.0

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.
@@ -0,0 +1,308 @@
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { SparklesIcon, XIcon, Maximize2Icon, ArrowUp, Bot } from 'lucide-react';
3
+ import type { ChatMessage } from '../types';
4
+ import { ChatMessageBubble } from './ChatMessageBubble';
5
+
6
+ function generateSessionId(): string {
7
+ return `toolbar-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
8
+ }
9
+
10
+ function MiniLoadingDots() {
11
+ return (
12
+ <div style={{
13
+ display: 'flex', gap: 10, padding: 12, margin: '8px 0',
14
+ borderRadius: 10, border: '1px solid var(--border, #e2e8f0)',
15
+ background: 'var(--background, #fff)',
16
+ }}>
17
+ <div style={{
18
+ width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
19
+ background: 'var(--foreground, #0f172a)', color: 'var(--background, #fff)',
20
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
21
+ }}>
22
+ <Bot style={{ width: 12, height: 12 }} />
23
+ </div>
24
+ <div style={{ display: 'flex', alignItems: 'center', gap: 3, paddingTop: 4 }}>
25
+ <span className="ac-dot" />
26
+ <span className="ac-dot" style={{ animationDelay: '0.15s' }} />
27
+ <span className="ac-dot" style={{ animationDelay: '0.3s' }} />
28
+ </div>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ export function AiChatToolbarButton() {
34
+ const [open, setOpen] = useState(false);
35
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
36
+ const [input, setInput] = useState('');
37
+ const [isLoading, setIsLoading] = useState(false);
38
+ const [sessionId, setSessionId] = useState<string>(generateSessionId);
39
+ const scrollRef = useRef<HTMLDivElement>(null);
40
+ const panelRef = useRef<HTMLDivElement>(null);
41
+ const fabRef = useRef<HTMLButtonElement>(null);
42
+
43
+ useEffect(() => {
44
+ scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
45
+ }, [messages]);
46
+
47
+ // Close on click outside
48
+ useEffect(() => {
49
+ if (!open) return;
50
+ function handleClick(e: MouseEvent) {
51
+ if (
52
+ panelRef.current && !panelRef.current.contains(e.target as Node) &&
53
+ fabRef.current && !fabRef.current.contains(e.target as Node)
54
+ ) {
55
+ setOpen(false);
56
+ }
57
+ }
58
+ document.addEventListener('mousedown', handleClick);
59
+ return () => document.removeEventListener('mousedown', handleClick);
60
+ }, [open]);
61
+
62
+ const sendMessage = useCallback(
63
+ async (text: string) => {
64
+ if (!text.trim() || isLoading) return;
65
+ const trimmed = text.trim();
66
+ setMessages(prev => [
67
+ ...prev,
68
+ { id: `u-${Date.now()}`, role: 'user', content: trimmed },
69
+ ]);
70
+ setInput('');
71
+ setIsLoading(true);
72
+
73
+ try {
74
+ const sessionToken = localStorage.getItem('vendure-session-token');
75
+ const channelToken = localStorage.getItem('vendure-selected-channel-token');
76
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
77
+ if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
78
+ if (channelToken) headers['vendure-token'] = channelToken;
79
+
80
+ const res = await fetch('/admin-ai-chat/chat', {
81
+ method: 'POST',
82
+ headers,
83
+ credentials: 'include',
84
+ body: JSON.stringify({
85
+ message: trimmed,
86
+ sessionId,
87
+ history: messages.map(m => ({ role: m.role, content: m.content })),
88
+ currentPage: window.location.pathname,
89
+ }),
90
+ });
91
+ const data = await res.json();
92
+ if (data.sessionId && data.sessionId !== sessionId) {
93
+ setSessionId(data.sessionId);
94
+ }
95
+ setMessages(prev => [
96
+ ...prev,
97
+ {
98
+ id: `a-${Date.now()}`,
99
+ role: 'assistant',
100
+ content: data.message ?? 'No response',
101
+ products: data.products,
102
+ collections: data.collections,
103
+ orders: data.orders,
104
+ },
105
+ ]);
106
+ if (data.navigateTo) {
107
+ setTimeout(() => {
108
+ const path = data.navigateTo.startsWith('/') ? `/dashboard${data.navigateTo}` : `/dashboard/${data.navigateTo}`;
109
+ window.location.href = path;
110
+ }, 1200);
111
+ }
112
+ } catch (err) {
113
+ setMessages(prev => [
114
+ ...prev,
115
+ {
116
+ id: `e-${Date.now()}`,
117
+ role: 'assistant',
118
+ content: `Error: ${err instanceof Error ? err.message : 'Request failed'}`,
119
+ },
120
+ ]);
121
+ } finally {
122
+ setIsLoading(false);
123
+ }
124
+ },
125
+ [isLoading, messages, sessionId],
126
+ );
127
+
128
+ const canSend = input.trim() && !isLoading;
129
+
130
+ return (
131
+ <>
132
+ {/* Hidden toolbar slot */}
133
+ <span style={{ display: 'none' }} />
134
+
135
+ {/* FAB button */}
136
+ <button
137
+ ref={fabRef}
138
+ onClick={() => setOpen(prev => !prev)}
139
+ style={{
140
+ position: 'fixed', zIndex: 9998, bottom: 20, right: 20,
141
+ width: 48, height: 48, borderRadius: '50%', border: 'none',
142
+ background: 'linear-gradient(135deg, #7c3aed, #4f46e5)',
143
+ color: '#fff', cursor: 'pointer',
144
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
145
+ boxShadow: '0 4px 12px rgba(79,70,229,0.4)',
146
+ transition: 'transform 0.2s, box-shadow 0.2s',
147
+ }}
148
+ onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.05)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(79,70,229,0.5)'; }}
149
+ onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(79,70,229,0.4)'; }}
150
+ title="AI Assistant"
151
+ >
152
+ {open
153
+ ? <XIcon style={{ width: 20, height: 20 }} />
154
+ : <SparklesIcon style={{ width: 20, height: 20 }} />
155
+ }
156
+ </button>
157
+
158
+ {/* Floating chat panel */}
159
+ {open && (
160
+ <div
161
+ ref={panelRef}
162
+ style={{
163
+ position: 'fixed', zIndex: 9999,
164
+ bottom: 80, right: 20,
165
+ width: 400, height: 'min(560px, calc(100vh - 110px))',
166
+ display: 'flex', flexDirection: 'column',
167
+ borderRadius: 12, overflow: 'hidden',
168
+ border: '1px solid var(--border, #e2e8f0)',
169
+ background: 'var(--background, #fff)',
170
+ boxShadow: '0 16px 48px rgba(0,0,0,0.16)',
171
+ }}
172
+ >
173
+ {/* Header */}
174
+ <div style={{
175
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
176
+ borderBottom: '1px solid var(--border, #e2e8f0)',
177
+ padding: '10px 16px', flexShrink: 0,
178
+ background: 'var(--background, #fff)',
179
+ }}>
180
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
181
+ <SparklesIcon style={{ width: 16, height: 16, color: '#7c3aed' }} />
182
+ <span style={{ fontSize: 14, fontWeight: 600 }}>AI Assistant</span>
183
+ </div>
184
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
185
+ <a
186
+ href="/dashboard/extensions/ai-chat"
187
+ style={{
188
+ width: 24, height: 24, borderRadius: 6, border: 'none',
189
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
190
+ color: 'var(--muted-foreground, #64748b)', textDecoration: 'none',
191
+ }}
192
+ title="Open full view"
193
+ >
194
+ <Maximize2Icon style={{ width: 14, height: 14 }} />
195
+ </a>
196
+ <button
197
+ onClick={() => setOpen(false)}
198
+ style={{
199
+ width: 24, height: 24, borderRadius: 6, border: 'none',
200
+ background: 'transparent', cursor: 'pointer',
201
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
202
+ color: 'var(--muted-foreground, #64748b)',
203
+ }}
204
+ >
205
+ <XIcon style={{ width: 14, height: 14 }} />
206
+ </button>
207
+ </div>
208
+ </div>
209
+
210
+ {/* Messages */}
211
+ <div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px', minHeight: 0 }}>
212
+ {messages.length === 0 ? (
213
+ <div style={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}>
214
+ <div style={{ textAlign: 'center', padding: '0 16px' }}>
215
+ <div style={{
216
+ width: 40, height: 40, borderRadius: '50%',
217
+ background: 'rgba(124,58,237,0.1)',
218
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
219
+ margin: '0 auto 8px',
220
+ }}>
221
+ <SparklesIcon style={{ width: 20, height: 20, color: '#7c3aed' }} />
222
+ </div>
223
+ <p style={{ fontSize: 14, fontWeight: 500, margin: '0 0 4px' }}>Ask anything about your store</p>
224
+ <p style={{ fontSize: 12, color: 'var(--muted-foreground, #64748b)', margin: '0 0 12px' }}>
225
+ Products, orders, customers...
226
+ </p>
227
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, justifyContent: 'center' }}>
228
+ {['Recent orders', 'Find a product', 'Store overview'].map(q => (
229
+ <button
230
+ key={q}
231
+ onClick={() => sendMessage(q)}
232
+ style={{
233
+ padding: '4px 12px', borderRadius: 99, fontSize: 12,
234
+ border: '1px solid var(--border, #e2e8f0)',
235
+ background: 'transparent', cursor: 'pointer',
236
+ color: 'var(--muted-foreground, #64748b)',
237
+ }}
238
+ >
239
+ {q}
240
+ </button>
241
+ ))}
242
+ </div>
243
+ </div>
244
+ </div>
245
+ ) : (
246
+ <>
247
+ {messages.map(msg => (
248
+ <ChatMessageBubble key={msg.id} message={msg} />
249
+ ))}
250
+ {isLoading && <MiniLoadingDots />}
251
+ <div ref={scrollRef} style={{ height: 4 }} />
252
+ </>
253
+ )}
254
+ </div>
255
+
256
+ {/* Input */}
257
+ <div style={{
258
+ borderTop: '1px solid var(--border, #e2e8f0)',
259
+ padding: '10px 12px', flexShrink: 0,
260
+ background: 'var(--background, #fff)',
261
+ }}>
262
+ <form
263
+ style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}
264
+ onSubmit={e => { e.preventDefault(); sendMessage(input); }}
265
+ >
266
+ <textarea
267
+ value={input}
268
+ onChange={e => setInput(e.target.value)}
269
+ onKeyDown={e => {
270
+ if (e.key === 'Enter' && !e.shiftKey) {
271
+ e.preventDefault();
272
+ if (canSend) sendMessage(input);
273
+ }
274
+ }}
275
+ placeholder="Ask something..."
276
+ disabled={isLoading}
277
+ rows={1}
278
+ style={{
279
+ flex: 1, resize: 'none', borderRadius: 8,
280
+ border: '1px solid var(--border, #e2e8f0)',
281
+ background: 'var(--background, #fff)',
282
+ padding: '8px 12px', fontSize: 13, outline: 'none',
283
+ color: 'var(--foreground, #0f172a)',
284
+ maxHeight: 80, minHeight: 36, fontFamily: 'inherit',
285
+ opacity: isLoading ? 0.5 : 1,
286
+ }}
287
+ />
288
+ <button
289
+ type="submit"
290
+ disabled={!canSend}
291
+ style={{
292
+ width: 36, height: 36, borderRadius: 8, border: 'none',
293
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
294
+ flexShrink: 0, cursor: canSend ? 'pointer' : 'default',
295
+ background: canSend ? 'var(--primary, #6366f1)' : 'var(--muted, #f1f5f9)',
296
+ color: canSend ? '#fff' : 'var(--muted-foreground, #94a3b8)',
297
+ transition: 'background 0.15s',
298
+ }}
299
+ >
300
+ <ArrowUp style={{ width: 16, height: 16 }} />
301
+ </button>
302
+ </form>
303
+ </div>
304
+ </div>
305
+ )}
306
+ </>
307
+ );
308
+ }
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+ import {
3
+ Package, ShoppingCart, Users,
4
+ Layers, Truck, BarChart3, ArrowRight,
5
+ } from 'lucide-react';
6
+
7
+ const SUGGESTIONS = [
8
+ { text: 'Show recent orders', icon: ShoppingCart },
9
+ { text: 'Search for a product', icon: Package },
10
+ { text: 'List all collections', icon: Layers },
11
+ { text: 'Find a customer', icon: Users },
12
+ { text: 'Check shipping methods', icon: Truck },
13
+ { text: 'Show store overview', icon: BarChart3 },
14
+ ];
15
+
16
+ interface ChatEmptyStateProps {
17
+ onSend: (text: string) => void;
18
+ }
19
+
20
+ export function ChatEmptyState({ onSend }: ChatEmptyStateProps) {
21
+ return (
22
+ <div style={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}>
23
+ <div style={{ maxWidth: 440, textAlign: 'center' }}>
24
+ <h1 style={{ fontSize: 24, fontWeight: 600, color: 'var(--foreground, #0f172a)', marginBottom: 12 }}>
25
+ AI Assistant
26
+ </h1>
27
+ <p style={{ fontSize: 14, color: 'var(--muted-foreground, #64748b)', lineHeight: 1.6, marginBottom: 20 }}>
28
+ Ask me anything about your store. I can help with products, orders, customers,
29
+ and more. Here are some ideas to get you started:
30
+ </p>
31
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
32
+ {SUGGESTIONS.map(s => {
33
+ const Icon = s.icon;
34
+ return (
35
+ <button
36
+ key={s.text}
37
+ onClick={() => onSend(s.text)}
38
+ style={{
39
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
40
+ padding: 12, borderRadius: 8, textAlign: 'left', fontSize: 13,
41
+ border: '1px solid var(--border, #e2e8f0)',
42
+ background: 'var(--background, #fff)',
43
+ color: 'var(--foreground, #0f172a)',
44
+ cursor: 'pointer', transition: 'background 0.15s',
45
+ }}
46
+ onMouseEnter={e => (e.currentTarget.style.background = 'var(--muted, #f1f5f9)')}
47
+ onMouseLeave={e => (e.currentTarget.style.background = 'var(--background, #fff)')}
48
+ >
49
+ <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
50
+ <Icon style={{ width: 16, height: 16, color: 'var(--muted-foreground, #64748b)' }} />
51
+ <span>{s.text}</span>
52
+ </span>
53
+ <ArrowRight style={{ width: 16, height: 16, color: 'var(--muted-foreground, #64748b)', opacity: 0.3 }} />
54
+ </button>
55
+ );
56
+ })}
57
+ </div>
58
+ </div>
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,76 @@
1
+ import React, { useState } from 'react';
2
+ import { ArrowUp } from 'lucide-react';
3
+
4
+ interface ChatInputProps {
5
+ value: string;
6
+ onChange: (value: string) => void;
7
+ onSubmit: () => void;
8
+ disabled: boolean;
9
+ }
10
+
11
+ export function ChatInput({ value, onChange, onSubmit, disabled }: ChatInputProps) {
12
+ const canSend = value.trim() && !disabled;
13
+ const [focused, setFocused] = useState(false);
14
+
15
+ return (
16
+ <form
17
+ style={{ padding: '16px 20px' }}
18
+ onSubmit={e => {
19
+ e.preventDefault();
20
+ if (canSend) onSubmit();
21
+ }}
22
+ >
23
+ <div
24
+ style={{
25
+ display: 'flex', alignItems: 'flex-end', gap: 12,
26
+ borderRadius: 12,
27
+ border: `1px solid ${focused ? 'var(--primary, #6366f1)' : 'var(--border, #e2e8f0)'}`,
28
+ background: 'var(--background, #fff)',
29
+ padding: '10px 16px',
30
+ boxShadow: focused ? '0 0 0 3px rgba(99,102,241,0.12)' : '0 1px 2px rgba(0,0,0,0.04)',
31
+ transition: 'border-color 0.15s, box-shadow 0.15s',
32
+ opacity: disabled ? 0.5 : 1,
33
+ }}
34
+ >
35
+ <textarea
36
+ value={value}
37
+ onChange={e => onChange(e.target.value)}
38
+ onFocus={() => setFocused(true)}
39
+ onBlur={() => setFocused(false)}
40
+ onKeyDown={e => {
41
+ if (e.key === 'Enter' && !e.shiftKey) {
42
+ e.preventDefault();
43
+ if (canSend) onSubmit();
44
+ }
45
+ }}
46
+ placeholder="Ask the AI assistant..."
47
+ disabled={disabled}
48
+ rows={1}
49
+ style={{
50
+ flex: 1, resize: 'none', border: 'none', outline: 'none',
51
+ background: 'transparent', padding: '6px 0',
52
+ fontSize: 14, lineHeight: 1.5,
53
+ color: 'var(--foreground, #0f172a)',
54
+ maxHeight: 120, minHeight: 32,
55
+ fontFamily: 'inherit',
56
+ }}
57
+ />
58
+ <button
59
+ type="submit"
60
+ disabled={!canSend}
61
+ style={{
62
+ width: 28, height: 28, borderRadius: 8,
63
+ border: 'none', cursor: canSend ? 'pointer' : 'default',
64
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
65
+ flexShrink: 0, marginBottom: 2,
66
+ background: canSend ? 'var(--primary, #6366f1)' : 'var(--muted, #f1f5f9)',
67
+ color: canSend ? '#fff' : 'var(--muted-foreground, #94a3b8)',
68
+ transition: 'background 0.15s, transform 0.1s',
69
+ }}
70
+ >
71
+ <ArrowUp style={{ width: 14, height: 14 }} />
72
+ </button>
73
+ </div>
74
+ </form>
75
+ );
76
+ }
@@ -0,0 +1,110 @@
1
+ import React from 'react';
2
+ import Markdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { Bot, User } from 'lucide-react';
5
+ import type { ChatMessage } from '../types';
6
+ import { ProductCard } from './ProductCard';
7
+ import { OrderCard } from './OrderCard';
8
+
9
+ interface ChatMessageBubbleProps {
10
+ message: ChatMessage;
11
+ }
12
+
13
+ export function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
14
+ const isUser = message.role === 'user';
15
+
16
+ if (isUser) {
17
+ return (
18
+ <div style={{ display: 'flex', gap: 12, padding: '16px 0' }}>
19
+ <div style={{
20
+ width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
21
+ background: 'var(--muted, #f1f5f9)',
22
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
23
+ border: '1px solid var(--border, #e2e8f0)',
24
+ }}>
25
+ <User style={{ width: 16, height: 16, color: 'var(--muted-foreground, #64748b)' }} />
26
+ </div>
27
+ <div style={{ flex: 1, paddingTop: 4, fontSize: 13, color: 'var(--foreground, #0f172a)', whiteSpace: 'pre-wrap' }}>
28
+ {message.content}
29
+ </div>
30
+ </div>
31
+ );
32
+ }
33
+
34
+ return (
35
+ <div style={{
36
+ borderRadius: 12, border: '1px solid var(--border, #e2e8f0)',
37
+ background: 'var(--background, #fff)', margin: '16px 0',
38
+ boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
39
+ }}>
40
+ <div style={{ display: 'flex', gap: 12, padding: 16 }}>
41
+ <div style={{
42
+ width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
43
+ background: 'var(--foreground, #0f172a)', color: 'var(--background, #fff)',
44
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
45
+ }}>
46
+ <Bot style={{ width: 16, height: 16 }} />
47
+ </div>
48
+ <div style={{ minWidth: 0, flex: 1, overflow: 'hidden' }}>
49
+ <div className="ac-md" style={{ fontSize: 13, lineHeight: 1.7, color: 'var(--foreground, #0f172a)' }}>
50
+ <Markdown
51
+ remarkPlugins={[remarkGfm]}
52
+ components={{
53
+ // Hide inline images — products are shown as cards below
54
+ img: () => null,
55
+ }}
56
+ >
57
+ {message.content}
58
+ </Markdown>
59
+ </div>
60
+ </div>
61
+ </div>
62
+
63
+ {/* Attached cards */}
64
+ {(message.products?.length || message.orders?.length) ? (
65
+ <div style={{
66
+ borderTop: '1px solid var(--border, #e2e8f0)',
67
+ background: 'var(--muted, #f8fafc)', padding: 12,
68
+ borderRadius: '0 0 12px 12px',
69
+ }}>
70
+ {message.products && message.products.length > 0 && (
71
+ <div style={{ display: 'flex', gap: 8, overflowX: 'auto' }}>
72
+ {message.products.map(p => (
73
+ <ProductCard key={p.id} product={p} />
74
+ ))}
75
+ </div>
76
+ )}
77
+ {message.orders && message.orders.length > 0 && (
78
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
79
+ {message.orders.map(o => (
80
+ <OrderCard key={o.id} order={o} />
81
+ ))}
82
+ </div>
83
+ )}
84
+ </div>
85
+ ) : null}
86
+
87
+ <style>{`
88
+ .ac-md p { margin: 6px 0; }
89
+ .ac-md p:first-child { margin-top: 0; }
90
+ .ac-md p:last-child { margin-bottom: 0; }
91
+ .ac-md strong { font-weight: 600; }
92
+ .ac-md ul, .ac-md ol { margin: 4px 0 8px; padding-left: 20px; }
93
+ .ac-md li { margin: 2px 0; }
94
+ .ac-md code { background: var(--muted, #f1f5f9); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
95
+ .ac-md pre { background: var(--muted, #f1f5f9); padding: 12px; border-radius: 8px; overflow-x: auto; margin: 8px 0; }
96
+ .ac-md pre code { background: none; padding: 0; }
97
+ .ac-md table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 13px; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; overflow: hidden; }
98
+ .ac-md thead { background: var(--muted, #f1f5f9); }
99
+ .ac-md th { text-align: left; padding: 8px 12px; font-weight: 600; font-size: 12px; border-bottom: 2px solid var(--border, #e2e8f0); }
100
+ .ac-md td { padding: 8px 12px; border-bottom: 1px solid var(--border, #f1f5f9); }
101
+ .ac-md tbody tr:last-child td { border-bottom: none; }
102
+ .ac-md a { color: var(--primary, #6366f1); text-decoration: underline; }
103
+ .ac-md h1 { font-size: 18px; font-weight: 600; margin: 12px 0 8px; }
104
+ .ac-md h2 { font-size: 16px; font-weight: 600; margin: 10px 0 6px; }
105
+ .ac-md h3 { font-size: 14px; font-weight: 600; margin: 8px 0 4px; }
106
+ .ac-md img { display: none; }
107
+ `}</style>
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,69 @@
1
+ import React, { useState } from 'react';
2
+ import { api } from '@vendure/dashboard';
3
+ import { Database, Loader2, Check, AlertCircle } from 'lucide-react';
4
+
5
+ type EmbedStatus = 'idle' | 'loading' | 'success' | 'error';
6
+
7
+ const TRIGGER_EMBED_ALL = `
8
+ mutation TriggerEmbedAll {
9
+ triggerEmbedAll {
10
+ jobId
11
+ message
12
+ }
13
+ }
14
+ `;
15
+
16
+ export function EmbedAllButton() {
17
+ const [status, setStatus] = useState<EmbedStatus>('idle');
18
+
19
+ const handleEmbedAll = async () => {
20
+ if (status === 'loading') return;
21
+ setStatus('loading');
22
+
23
+ try {
24
+ const result = await api.mutate(TRIGGER_EMBED_ALL);
25
+ if (!result.triggerEmbedAll) throw new Error('No result');
26
+ setStatus('success');
27
+ setTimeout(() => setStatus('idle'), 3000);
28
+ } catch {
29
+ setStatus('error');
30
+ setTimeout(() => setStatus('idle'), 3000);
31
+ }
32
+ };
33
+
34
+ const Icon = status === 'loading' ? Loader2
35
+ : status === 'success' ? Check
36
+ : status === 'error' ? AlertCircle
37
+ : Database;
38
+
39
+ const label = status === 'loading' ? 'Embedding...'
40
+ : status === 'success' ? 'Done'
41
+ : status === 'error' ? 'Failed'
42
+ : 'Embed All';
43
+
44
+ return (
45
+ <button
46
+ onClick={handleEmbedAll}
47
+ disabled={status === 'loading'}
48
+ style={{
49
+ display: 'inline-flex', alignItems: 'center', gap: 6,
50
+ padding: '4px 8px', borderRadius: 6, border: 'none',
51
+ background: 'transparent', cursor: status === 'loading' ? 'wait' : 'pointer',
52
+ fontSize: 12, fontWeight: 500, fontFamily: 'inherit',
53
+ color: status === 'success' ? '#10b981'
54
+ : status === 'error' ? '#ef4444'
55
+ : 'var(--muted-foreground, #64748b)',
56
+ transition: 'color 0.15s',
57
+ }}
58
+ onMouseEnter={e => { if (status === 'idle') e.currentTarget.style.color = 'var(--foreground, #0f172a)'; }}
59
+ onMouseLeave={e => { if (status === 'idle') e.currentTarget.style.color = 'var(--muted-foreground, #64748b)'; }}
60
+ >
61
+ <Icon style={{
62
+ width: 12, height: 12,
63
+ ...(status === 'loading' ? { animation: 'ac-spin 1s linear infinite' } : {}),
64
+ }} />
65
+ {label}
66
+ <style>{`@keyframes ac-spin { to { transform: rotate(360deg); } }`}</style>
67
+ </button>
68
+ );
69
+ }