@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,66 @@
1
+ import React, { useState } from 'react';
2
+ import { ArrowUpRight } from 'lucide-react';
3
+ import type { Order } from '../types';
4
+
5
+ interface OrderCardProps {
6
+ order: Order;
7
+ }
8
+
9
+ function getStateColor(state: string): string {
10
+ switch (state) {
11
+ case 'PaymentSettled':
12
+ case 'Delivered':
13
+ return '#10b981';
14
+ case 'Cancelled':
15
+ return '#ef4444';
16
+ case 'Shipped':
17
+ case 'PartiallyShipped':
18
+ return '#3b82f6';
19
+ default:
20
+ return '#94a3b8';
21
+ }
22
+ }
23
+
24
+ export function OrderCard({ order }: OrderCardProps) {
25
+ const total = `$${(order.total / 100).toFixed(2)}`;
26
+ const date = order.orderPlacedAt
27
+ ? new Date(order.orderPlacedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
28
+ : '';
29
+ const [hovered, setHovered] = useState(false);
30
+
31
+ return (
32
+ <div
33
+ style={{
34
+ display: 'flex', alignItems: 'center', gap: 16,
35
+ borderRadius: 8, padding: '12px 16px', cursor: 'pointer',
36
+ border: '1px solid var(--border, #e2e8f0)',
37
+ background: hovered ? 'var(--muted, #f8fafc)' : 'var(--background, #fff)',
38
+ transition: 'background 0.15s',
39
+ }}
40
+ onClick={() => window.open(`/dashboard/orders/${order.id}`, '_self')}
41
+ onMouseEnter={() => setHovered(true)}
42
+ onMouseLeave={() => setHovered(false)}
43
+ >
44
+ {/* Status dot */}
45
+ <div style={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0, background: getStateColor(order.state) }} />
46
+
47
+ {/* Info */}
48
+ <div style={{ flex: 1, minWidth: 0 }}>
49
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
50
+ <span style={{ fontFamily: 'monospace', fontSize: 14, fontWeight: 500, color: 'var(--foreground, #0f172a)' }}>{order.code}</span>
51
+ <span style={{ fontSize: 12, color: 'var(--muted-foreground, #64748b)' }}>{order.state}</span>
52
+ </div>
53
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 2, fontSize: 12, color: 'var(--muted-foreground, #64748b)' }}>
54
+ {date && <span>{date}</span>}
55
+ <span>{order.lines.length} item{order.lines.length !== 1 ? 's' : ''}</span>
56
+ </div>
57
+ </div>
58
+
59
+ {/* Total + arrow */}
60
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
61
+ <span style={{ fontSize: 14, fontWeight: 500, color: 'var(--foreground, #0f172a)', fontVariantNumeric: 'tabular-nums' }}>{total}</span>
62
+ <ArrowUpRight style={{ width: 14, height: 14, color: 'var(--muted-foreground, #94a3b8)', opacity: hovered ? 1 : 0, transition: 'opacity 0.15s' }} />
63
+ </div>
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,40 @@
1
+ import React, { useState } from 'react';
2
+ import { ImageIcon } from 'lucide-react';
3
+ import type { Product } from '../types';
4
+
5
+ interface ProductCardProps {
6
+ product: Product;
7
+ }
8
+
9
+ export function ProductCard({ product }: ProductCardProps) {
10
+ const price = `$${(product.price / 100).toFixed(2)}`;
11
+ const [hovered, setHovered] = useState(false);
12
+
13
+ return (
14
+ <div
15
+ style={{
16
+ flexShrink: 0, width: 128, cursor: 'pointer', borderRadius: 8, overflow: 'hidden',
17
+ border: `1px solid ${hovered ? 'var(--foreground, #334155)' : 'var(--border, #e2e8f0)'}`,
18
+ background: 'var(--background, #fff)',
19
+ transition: 'border-color 0.15s',
20
+ }}
21
+ onClick={() => window.open(`/dashboard/products/${product.id}`, '_blank')}
22
+ onMouseEnter={() => setHovered(true)}
23
+ onMouseLeave={() => setHovered(false)}
24
+ >
25
+ <div style={{ height: 80, width: '100%', overflow: 'hidden', background: 'var(--muted, #f1f5f9)' }}>
26
+ {product.image ? (
27
+ <img src={product.image} alt={product.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
28
+ ) : (
29
+ <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.25 }}>
30
+ <ImageIcon style={{ width: 20, height: 20, color: 'var(--muted-foreground, #94a3b8)' }} />
31
+ </div>
32
+ )}
33
+ </div>
34
+ <div style={{ padding: '8px 10px' }}>
35
+ <p style={{ fontSize: 11, fontWeight: 500, color: 'var(--foreground, #0f172a)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', margin: 0 }}>{product.name}</p>
36
+ <p style={{ fontSize: 11, color: 'var(--muted-foreground, #64748b)', margin: '2px 0 0' }}>{price}</p>
37
+ </div>
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,119 @@
1
+ import React, { useState } from 'react';
2
+ import { Plus, Trash2, MessageSquare } from 'lucide-react';
3
+ import type { SessionSummary } from '../hooks/use-session-list';
4
+
5
+ function formatDate(d: string | null): string {
6
+ if (!d) return '';
7
+ const date = new Date(d);
8
+ const now = new Date();
9
+ const diffMs = now.getTime() - date.getTime();
10
+ const diffMins = Math.floor(diffMs / 60_000);
11
+ if (diffMins < 1) return 'Just now';
12
+ if (diffMins < 60) return `${diffMins}m ago`;
13
+ const diffHrs = Math.floor(diffMins / 60);
14
+ if (diffHrs < 24) return `${diffHrs}h ago`;
15
+ const diffDays = Math.floor(diffHrs / 24);
16
+ if (diffDays < 7) return `${diffDays}d ago`;
17
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
18
+ }
19
+
20
+ interface SessionSidebarProps {
21
+ sessions: SessionSummary[];
22
+ activeSessionId: string;
23
+ onSelect: (sessionId: string) => void;
24
+ onNew: () => void;
25
+ onDelete: (sessionId: string) => void;
26
+ }
27
+
28
+ export function SessionSidebar({ sessions, activeSessionId, onSelect, onNew, onDelete }: SessionSidebarProps) {
29
+ return (
30
+ <div style={{
31
+ display: 'flex', flexDirection: 'column', height: '100%',
32
+ borderRight: '1px solid var(--border, #e2e8f0)',
33
+ background: 'var(--muted, #f8fafc)',
34
+ width: 240, minWidth: 200, flexShrink: 0,
35
+ }}>
36
+ {/* New chat button */}
37
+ <div style={{ padding: 12, borderBottom: '1px solid var(--border, #e2e8f0)' }}>
38
+ <button
39
+ onClick={onNew}
40
+ style={{
41
+ display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'center',
42
+ gap: 8, padding: '8px 12px', borderRadius: 6,
43
+ border: '1px solid var(--border, #e2e8f0)',
44
+ background: 'var(--background, #fff)',
45
+ fontSize: 13, fontWeight: 500, cursor: 'pointer',
46
+ color: 'var(--foreground, #0f172a)',
47
+ }}
48
+ >
49
+ <Plus style={{ width: 16, height: 16 }} />
50
+ New Chat
51
+ </button>
52
+ </div>
53
+
54
+ {/* Session list */}
55
+ <div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
56
+ {sessions.length === 0 && (
57
+ <div style={{ textAlign: 'center', fontSize: 12, color: 'var(--muted-foreground, #64748b)', padding: '24px 0' }}>
58
+ No previous chats
59
+ </div>
60
+ )}
61
+ {sessions.map(s => {
62
+ const isActive = s.sessionId === activeSessionId;
63
+ return <SessionItem key={s.sessionId} session={s} isActive={isActive} onSelect={onSelect} onDelete={onDelete} />;
64
+ })}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ function SessionItem({ session, isActive, onSelect, onDelete }: {
71
+ session: SessionSummary;
72
+ isActive: boolean;
73
+ onSelect: (id: string) => void;
74
+ onDelete: (id: string) => void;
75
+ }) {
76
+ const [hovered, setHovered] = useState(false);
77
+
78
+ return (
79
+ <div
80
+ style={{
81
+ display: 'flex', alignItems: 'center', gap: 8,
82
+ padding: '8px 10px', borderRadius: 6, cursor: 'pointer',
83
+ marginBottom: 2,
84
+ background: isActive ? 'var(--muted, #e2e8f0)' : hovered ? 'rgba(0,0,0,0.03)' : 'transparent',
85
+ color: isActive ? 'var(--foreground, #0f172a)' : 'var(--muted-foreground, #64748b)',
86
+ transition: 'background 0.1s',
87
+ }}
88
+ onClick={() => onSelect(session.sessionId)}
89
+ onMouseEnter={() => setHovered(true)}
90
+ onMouseLeave={() => setHovered(false)}
91
+ >
92
+ <MessageSquare style={{ width: 14, height: 14, flexShrink: 0 }} />
93
+ <div style={{ flex: 1, minWidth: 0 }}>
94
+ <div style={{
95
+ fontSize: 12, fontWeight: 500,
96
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
97
+ }}>
98
+ {session.title || 'Untitled'}
99
+ </div>
100
+ <div style={{ fontSize: 10, opacity: 0.6 }}>
101
+ {formatDate(session.lastMessageAt || session.createdAt)}
102
+ </div>
103
+ </div>
104
+ {hovered && (
105
+ <button
106
+ onClick={e => { e.stopPropagation(); onDelete(session.sessionId); }}
107
+ style={{
108
+ padding: 4, borderRadius: 4, border: 'none',
109
+ background: 'transparent', cursor: 'pointer',
110
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
111
+ }}
112
+ title="Delete"
113
+ >
114
+ <Trash2 style={{ width: 12, height: 12, color: '#ef4444' }} />
115
+ </button>
116
+ )}
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,138 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react';
2
+ import type { ChatMessage } from '../types';
3
+
4
+ function generateSessionId(): string {
5
+ return `admin-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
6
+ }
7
+
8
+ export function useAdminChat() {
9
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
10
+ const [input, setInput] = useState('');
11
+ const [isLoading, setIsLoading] = useState(false);
12
+ const [sessionId, setSessionId] = useState<string>(generateSessionId);
13
+ const scrollRef = useRef<HTMLDivElement>(null);
14
+
15
+ useEffect(() => {
16
+ scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
17
+ }, [messages]);
18
+
19
+ const newConversation = useCallback(() => {
20
+ setMessages([]);
21
+ setSessionId(generateSessionId());
22
+ }, []);
23
+
24
+ /** Load a previous session from the server. */
25
+ const loadSession = useCallback(async (sid: string) => {
26
+ try {
27
+ const sessionToken = localStorage.getItem('vendure-session-token');
28
+ const channelToken = localStorage.getItem('vendure-selected-channel-token');
29
+ const headers: Record<string, string> = {};
30
+ if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
31
+ if (channelToken) headers['vendure-token'] = channelToken;
32
+
33
+ const res = await fetch(`/admin-ai-chat/sessions/${sid}`, { headers, credentials: 'include' });
34
+ if (!res.ok) return;
35
+ const session = await res.json();
36
+ if (!session?.messages) return;
37
+
38
+ setSessionId(sid);
39
+ setMessages(
40
+ session.messages.map((m: any, i: number) => ({
41
+ id: `${m.role[0]}-${i}-${Date.now()}`,
42
+ role: m.role,
43
+ content: m.content,
44
+ ...(m.products?.length ? { products: m.products } : {}),
45
+ ...(m.collections?.length ? { collections: m.collections } : {}),
46
+ ...(m.orders?.length ? { orders: m.orders } : {}),
47
+ })),
48
+ );
49
+ } catch {
50
+ // ignore
51
+ }
52
+ }, []);
53
+
54
+ const sendMessage = useCallback(
55
+ async (text: string) => {
56
+ if (!text.trim() || isLoading) return;
57
+
58
+ const trimmed = text.trim();
59
+ setMessages(prev => [
60
+ ...prev,
61
+ { id: `u-${Date.now()}`, role: 'user', content: trimmed },
62
+ ]);
63
+ setInput('');
64
+ setIsLoading(true);
65
+
66
+ try {
67
+ const sessionToken = localStorage.getItem('vendure-session-token');
68
+ const channelToken = localStorage.getItem('vendure-selected-channel-token');
69
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
70
+ if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
71
+ if (channelToken) headers['vendure-token'] = channelToken;
72
+
73
+ const res = await fetch('/admin-ai-chat/chat', {
74
+ method: 'POST',
75
+ headers,
76
+ credentials: 'include',
77
+ body: JSON.stringify({
78
+ message: trimmed,
79
+ sessionId,
80
+ history: messages.map(m => ({ role: m.role, content: m.content })),
81
+ currentPage: window.location.pathname,
82
+ }),
83
+ });
84
+
85
+ const data = await res.json();
86
+
87
+ // Server may return a sessionId (first turn creates it)
88
+ if (data.sessionId && data.sessionId !== sessionId) {
89
+ setSessionId(data.sessionId);
90
+ }
91
+
92
+ setMessages(prev => [
93
+ ...prev,
94
+ {
95
+ id: `a-${Date.now()}`,
96
+ role: 'assistant',
97
+ content: data.message ?? 'No response',
98
+ products: data.products,
99
+ collections: data.collections,
100
+ orders: data.orders,
101
+ },
102
+ ]);
103
+
104
+ // Handle navigation instruction from AI
105
+ if (data.navigateTo) {
106
+ setTimeout(() => {
107
+ const path = data.navigateTo.startsWith('/') ? `/dashboard${data.navigateTo}` : `/dashboard/${data.navigateTo}`;
108
+ window.location.href = path;
109
+ }, 1200);
110
+ }
111
+ } catch (err) {
112
+ setMessages(prev => [
113
+ ...prev,
114
+ {
115
+ id: `e-${Date.now()}`,
116
+ role: 'assistant',
117
+ content: `Error: ${err instanceof Error ? err.message : 'Request failed'}`,
118
+ },
119
+ ]);
120
+ } finally {
121
+ setIsLoading(false);
122
+ }
123
+ },
124
+ [isLoading, messages, sessionId],
125
+ );
126
+
127
+ return {
128
+ messages,
129
+ input,
130
+ setInput,
131
+ isLoading,
132
+ scrollRef,
133
+ sendMessage,
134
+ sessionId,
135
+ newConversation,
136
+ loadSession,
137
+ };
138
+ }
@@ -0,0 +1,69 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+
3
+ export interface SessionSummary {
4
+ sessionId: string;
5
+ title: string;
6
+ messageCount: number;
7
+ model: string;
8
+ isActive: boolean;
9
+ lastMessageAt: string | null;
10
+ createdAt: string;
11
+ }
12
+
13
+ function getHeaders(): Record<string, string> {
14
+ const headers: Record<string, string> = {};
15
+ const sessionToken = localStorage.getItem('vendure-session-token');
16
+ const channelToken = localStorage.getItem('vendure-selected-channel-token');
17
+ if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
18
+ if (channelToken) headers['vendure-token'] = channelToken;
19
+ return headers;
20
+ }
21
+
22
+ export function useSessionList() {
23
+ const [sessions, setSessions] = useState<SessionSummary[]>([]);
24
+ const [loading, setLoading] = useState(false);
25
+
26
+ const refresh = useCallback(async () => {
27
+ setLoading(true);
28
+ try {
29
+ const res = await fetch('/admin-ai-chat/sessions?take=50', {
30
+ headers: getHeaders(),
31
+ credentials: 'include',
32
+ });
33
+ if (!res.ok) return;
34
+ const data = await res.json();
35
+ setSessions(
36
+ (data.items ?? []).map((s: any) => ({
37
+ sessionId: s.sessionId,
38
+ title: s.title,
39
+ messageCount: s.messageCount,
40
+ model: s.model,
41
+ isActive: s.isActive,
42
+ lastMessageAt: s.lastMessageAt,
43
+ createdAt: s.createdAt,
44
+ })),
45
+ );
46
+ } catch {
47
+ // ignore
48
+ } finally {
49
+ setLoading(false);
50
+ }
51
+ }, []);
52
+
53
+ const deleteSession = useCallback(async (sessionId: string) => {
54
+ try {
55
+ await fetch(`/admin-ai-chat/sessions/${sessionId}`, {
56
+ method: 'DELETE',
57
+ headers: getHeaders(),
58
+ credentials: 'include',
59
+ });
60
+ setSessions(prev => prev.filter(s => s.sessionId !== sessionId));
61
+ } catch {
62
+ // ignore
63
+ }
64
+ }, []);
65
+
66
+ useEffect(() => { refresh(); }, [refresh]);
67
+
68
+ return { sessions, loading, refresh, deleteSession };
69
+ }
@@ -1,33 +1,13 @@
1
1
  import { defineDashboardExtension } from '@vendure/dashboard';
2
2
  import { AdminChatWidget } from './AdminChatWidget';
3
+ import { AiChatToolbarButton } from './components/AiChatToolbarButton';
4
+ import { EmbedAllButton } from './components/EmbedAllButton';
3
5
  import React from 'react';
4
6
 
5
7
  function AiChatPage() {
6
8
  return (
7
- <div style={{
8
- display: 'flex',
9
- flexDirection: 'column',
10
- height: 'calc(100vh - 80px)',
11
- padding: '24px',
12
- }}>
13
- <h1 style={{
14
- fontSize: '24px',
15
- fontWeight: 600,
16
- marginBottom: '16px',
17
- color: 'var(--color-foreground, #0f172a)',
18
- }}>
19
- AI Chat Assistant
20
- </h1>
21
- <div style={{
22
- flex: 1,
23
- minHeight: 0,
24
- border: '1px solid var(--color-border, #e2e8f0)',
25
- borderRadius: '8px',
26
- overflow: 'hidden',
27
- backgroundColor: 'var(--color-card, #fff)',
28
- }}>
29
- <AdminChatWidget />
30
- </div>
9
+ <div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', minHeight: 0 }}>
10
+ <AdminChatWidget />
31
11
  </div>
32
12
  );
33
13
  }
@@ -52,4 +32,18 @@ defineDashboardExtension({
52
32
  order: 150,
53
33
  },
54
34
  ],
35
+ actionBarItems: [
36
+ {
37
+ pageId: 'global-setting-detail',
38
+ id: 'embed-all-button',
39
+ component: () => <EmbedAllButton />,
40
+ },
41
+ ],
42
+ toolbarItems: [
43
+ {
44
+ id: 'ai-chat-toolbar',
45
+ component: AiChatToolbarButton,
46
+ position: { itemId: 'alerts', order: 'before' },
47
+ },
48
+ ],
55
49
  });
@@ -0,0 +1,46 @@
1
+ // ─── Types (mirror backend response) ────────────────────────────────
2
+
3
+ export interface Product {
4
+ id: string;
5
+ name: string;
6
+ slug: string;
7
+ price: number;
8
+ image: string | null;
9
+ variantId: string;
10
+ }
11
+
12
+ export interface Collection {
13
+ id: string;
14
+ name: string;
15
+ slug: string;
16
+ }
17
+
18
+ export interface OrderLine {
19
+ productName: string;
20
+ quantity: number;
21
+ image: string | null;
22
+ linePriceWithTax: number;
23
+ }
24
+
25
+ export interface Order {
26
+ id: string;
27
+ code: string;
28
+ state: string;
29
+ total: number;
30
+ orderPlacedAt: string | null;
31
+ lines: OrderLine[];
32
+ fulfillments: Array<{
33
+ state: string;
34
+ method: string;
35
+ trackingCode: string | null;
36
+ }>;
37
+ }
38
+
39
+ export interface ChatMessage {
40
+ id: string;
41
+ role: 'user' | 'assistant';
42
+ content: string;
43
+ products?: Product[];
44
+ collections?: Collection[];
45
+ orders?: Order[];
46
+ }