shopify-chatbot-widget 1.0.8 → 1.0.10

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 (46) hide show
  1. package/.env.development +2 -0
  2. package/dist/jaweb-chatbot.css +1 -1
  3. package/dist/jaweb-chatbot.iife.js +192 -599
  4. package/home.html +1 -1
  5. package/jawebDirect.js +1 -1
  6. package/package.json +1 -1
  7. package/src/App.tsx +9 -0
  8. package/src/Chatbot/Chatbot.css +193 -113
  9. package/src/Chatbot/Chatbot.tsx +26 -19
  10. package/src/Chatbot/components/Cart/Index.tsx +10 -8
  11. package/src/Chatbot/components/ChatInput.tsx +11 -11
  12. package/src/Chatbot/components/Home/Home.css +456 -0
  13. package/src/Chatbot/components/Home/Home.tsx +293 -0
  14. package/src/Chatbot/components/Messages/Addcart.tsx +1 -1
  15. package/src/Chatbot/components/Messages/AssistantMesage.tsx +78 -37
  16. package/src/Chatbot/components/Messages/CardSwiper.tsx +1 -1
  17. package/src/Chatbot/components/Messages/Carousel/CardSwiper.tsx +87 -92
  18. package/src/Chatbot/components/Messages/Carousel/ProductSwiper.css +361 -0
  19. package/src/Chatbot/components/Messages/Carousel/TryOn.tsx +2 -2
  20. package/src/Chatbot/components/Messages/ChatMessages.css +32 -1
  21. package/src/Chatbot/components/Messages/ChatMessages.tsx +21 -38
  22. package/src/Chatbot/components/Messages/OrderCard.css +177 -0
  23. package/src/Chatbot/components/Messages/OrderCard.tsx +120 -0
  24. package/src/Chatbot/components/Messages/ProductCard.css +1 -1
  25. package/src/Chatbot/components/Messages/Support.tsx +311 -118
  26. package/src/Chatbot/components/Messages/Typing.css +31 -35
  27. package/src/Chatbot/components/Messages/Typing.tsx +19 -18
  28. package/src/Chatbot/components/Messenger/Messenger.tsx +67 -14
  29. package/src/Chatbot/components/Powered.css +26 -28
  30. package/src/Chatbot/components/Powered.tsx +1 -1
  31. package/src/Chatbot/components/Sessions/CreateSession.tsx +12 -10
  32. package/src/Chatbot/components/TryOn/View.tsx +2 -2
  33. package/src/Chatbot/components/VoiceMode.css +247 -0
  34. package/src/Chatbot/components/VoiceMode.tsx +613 -0
  35. package/src/hooks/config.tsx +9 -10
  36. package/src/hooks/useChatbotAPI.tsx +146 -61
  37. package/src/hooks/useChatbotData.tsx +8 -5
  38. package/src/hooks/useSessionMessages.tsx +0 -1
  39. package/src/hooks/useWebsocke.tsx +40 -18
  40. package/src/i18n.tsx +384 -10
  41. package/src/Chatbot/components/Messages/Carousel/CardSwiperSalla.tsx +0 -187
  42. package/src/Chatbot/components/Messages/Carousel/CardSwiperZid.tsx +0 -208
  43. package/src/Chatbot/components/Messages/Carousel/SallaSwiper.css +0 -245
  44. package/src/Chatbot/components/Messages/SallaAssistantMessage.tsx +0 -202
  45. package/src/Chatbot/components/Messages/ZidAssistantMesage.tsx +0 -203
  46. package/src/Chatbot/components/Sessions/RenderList.tsx +0 -519
@@ -1,14 +1,117 @@
1
- import React, { useState, useEffect } from 'react';
1
+ import React, { useState, useEffect, useRef } from 'react';
2
2
  import { motion, AnimatePresence } from 'framer-motion';
3
- import { Mail } from 'lucide-react';
3
+ import { Ticket as TicketIcon, MailCheck } from 'lucide-react';
4
4
  import config from '../../../hooks/config';
5
+ import { useTranslation } from 'react-i18next';
6
+ import i18nInstance from '../../../i18n';
7
+
8
+ export interface SupportTicket {
9
+ ticket_id: number;
10
+ status?: string;
11
+ created_at?: string;
12
+ }
5
13
 
6
14
  interface SupportConnectProps {
7
15
  sessionId?: string;
8
16
  colorCode?: string;
17
+ ticket?: SupportTicket | null;
9
18
  }
10
19
 
11
- type Stage = 'email_sent' | 'connecting' | 'auto';
20
+ const timeAgo = (iso?: string): string => {
21
+ if (!iso) return '';
22
+ const mins = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000));
23
+ if (mins < 1) return i18nInstance.t('time.justNow');
24
+ if (mins < 60) return i18nInstance.t('time.m', { n: mins });
25
+ const hours = Math.round(mins / 60);
26
+ if (hours < 24) return i18nInstance.t('time.h', { n: hours });
27
+ return i18nInstance.t('time.d', { n: Math.round(hours / 24) });
28
+ };
29
+
30
+ /** "Ticket submitted" divider + ticket card. Used by the live support flow
31
+ * and by AssistantMessage when a saved message carries a ticket block. */
32
+ export const TicketCard: React.FC<{ ticket: SupportTicket; colorCode?: string }> = ({
33
+ ticket,
34
+ colorCode = '#111111',
35
+ }) => {
36
+ const { t } = useTranslation();
37
+ const statusColor = (ticket.status || 'Open') === 'Solved' ? '#16a34a' : colorCode;
38
+ return (
39
+ <div style={{ maxWidth: 320, marginBottom: 10, fontFamily: "'Inter', -apple-system, sans-serif" }}>
40
+ <div
41
+ style={{
42
+ display: 'flex',
43
+ alignItems: 'center',
44
+ gap: 8,
45
+ margin: '4px 0 10px',
46
+ color: '#9ca3af',
47
+ fontSize: 11,
48
+ fontWeight: 600,
49
+ letterSpacing: '0.05em',
50
+ textTransform: 'uppercase',
51
+ }}
52
+ >
53
+ <span style={{ flex: 1, height: 1, background: '#ececec' }} />
54
+ <TicketIcon size={12} />
55
+ {t('support.ticketSubmitted')}
56
+ <span style={{ flex: 1, height: 1, background: '#ececec' }} />
57
+ </div>
58
+
59
+ <div
60
+ style={{
61
+ background: '#ffffff',
62
+ border: '1px solid #ececec',
63
+ borderRadius: 16,
64
+ padding: '12px 14px',
65
+ boxShadow: '0 1px 2px rgba(17,24,39,0.05)',
66
+ display: 'flex',
67
+ alignItems: 'center',
68
+ gap: 12,
69
+ position: 'relative',
70
+ overflow: 'hidden',
71
+ }}
72
+ >
73
+ <span
74
+ style={{
75
+ width: 36,
76
+ height: 36,
77
+ borderRadius: 10,
78
+ background: `color-mix(in srgb, ${colorCode} 10%, #ffffff)`,
79
+ display: 'flex',
80
+ alignItems: 'center',
81
+ justifyContent: 'center',
82
+ color: colorCode,
83
+ flexShrink: 0,
84
+ }}
85
+ >
86
+ <TicketIcon size={17} />
87
+ </span>
88
+ <div style={{ flex: 1, minWidth: 0 }}>
89
+ <p style={{ margin: 0, fontSize: 13, fontWeight: 700, color: '#111827' }}>
90
+ {t('home.ticketN', { id: ticket.ticket_id })}
91
+ </p>
92
+ <p style={{ margin: 0, fontSize: 12, fontWeight: 600, color: statusColor }}>
93
+ {t(`status.${ticket.status || 'Open'}`, ticket.status || 'Open')}
94
+ </p>
95
+ </div>
96
+ <span style={{ fontSize: 11, color: '#9ca3af', flexShrink: 0 }}>
97
+ {timeAgo(ticket.created_at)}
98
+ </span>
99
+ <span
100
+ style={{
101
+ position: 'absolute',
102
+ left: 0,
103
+ right: 0,
104
+ bottom: 0,
105
+ height: 3,
106
+ background: `linear-gradient(90deg, ${colorCode}, color-mix(in srgb, ${colorCode} 30%, #ffffff))`,
107
+ }}
108
+ />
109
+ </div>
110
+ </div>
111
+ );
112
+ };
113
+
114
+ type Stage = 'email_sent' | 'connecting' | 'auto' | 'notified';
12
115
 
13
116
  interface SupportData {
14
117
  userImage?: string;
@@ -17,80 +120,161 @@ interface SupportData {
17
120
  agentAvatar?: string;
18
121
  }
19
122
 
123
+ /** Circle with the agent's initial — used when no real avatar exists. */
124
+ const Initial: React.FC<{ name?: string; src?: string; accent: string }> = ({
125
+ name,
126
+ src,
127
+ accent,
128
+ }) => {
129
+ const [broken, setBroken] = useState(false);
130
+ const showImg = src && !broken && src.startsWith('http');
131
+ if (showImg) {
132
+ return (
133
+ <img
134
+ src={src}
135
+ alt={name || 'Agent'}
136
+ onError={() => setBroken(true)}
137
+ style={{
138
+ width: 36,
139
+ height: 36,
140
+ borderRadius: '50%',
141
+ objectFit: 'cover',
142
+ border: '1px solid #ececec',
143
+ flexShrink: 0,
144
+ }}
145
+ />
146
+ );
147
+ }
148
+ return (
149
+ <span
150
+ style={{
151
+ width: 36,
152
+ height: 36,
153
+ borderRadius: '50%',
154
+ background: accent,
155
+ color: '#fff',
156
+ display: 'flex',
157
+ alignItems: 'center',
158
+ justifyContent: 'center',
159
+ fontSize: 14,
160
+ fontWeight: 700,
161
+ flexShrink: 0,
162
+ }}
163
+ >
164
+ {(name || 'S').slice(0, 1).toUpperCase()}
165
+ </span>
166
+ );
167
+ };
168
+
169
+ const card: React.CSSProperties = {
170
+ background: '#ffffff',
171
+ border: '1px solid #ececec',
172
+ borderRadius: 16,
173
+ padding: '12px 14px',
174
+ boxShadow: '0 1px 2px rgba(17,24,39,0.05)',
175
+ display: 'flex',
176
+ alignItems: 'center',
177
+ gap: 12,
178
+ maxWidth: 320,
179
+ fontFamily: "'Inter', -apple-system, sans-serif",
180
+ };
181
+
20
182
  const SupportConnect: React.FC<SupportConnectProps> = ({
21
183
  sessionId,
22
- colorCode = '#000',
184
+ colorCode = '#111111',
185
+ ticket,
23
186
  }) => {
24
- const [stage, setStage] = useState<Stage | null>(null);
187
+ const { t } = useTranslation();
188
+ const [stage, setStage] = useState<Stage>('email_sent');
25
189
  const [data, setData] = useState<SupportData>({});
26
- const [progress, setProgress] = useState<number>(0);
27
- const dots = [0, 1, 2];
190
+ const stageRef = useRef<Stage>('email_sent');
191
+ stageRef.current = stage;
28
192
 
193
+ // Live stage updates, with reconnect — the consumer replays the last
194
+ // cached stage on connect, so late joins and refreshes recover state.
29
195
  useEffect(() => {
30
196
  if (!sessionId) return;
31
- const ws = new WebSocket(
32
- `${config.websocketUrl}/support/${sessionId}/`
33
- );
197
+ let closed = false;
198
+ let attempts = 0;
199
+ let timer: number | undefined;
200
+ let ws: WebSocket;
34
201
 
35
- ws.onmessage = ({ data: text }) => {
36
- const msg = JSON.parse(text);
37
- setStage(msg.stage as Stage);
38
- setData(msg.data || {});
202
+ const connect = () => {
203
+ ws = new WebSocket(`${config.websocketUrl}/support/${sessionId}/`);
204
+ ws.onopen = () => {
205
+ attempts = 0;
206
+ };
207
+ ws.onmessage = ({ data: text }) => {
208
+ try {
209
+ const msg = JSON.parse(text);
210
+ if (!msg.stage) return;
211
+ // A replayed "in progress" stage older than 2 minutes means nobody
212
+ // picked up — show the honest offline state, don't re-run the show.
213
+ const ageSec = msg.ts ? Date.now() / 1000 - msg.ts : 0;
214
+ if (
215
+ ageSec > 120 &&
216
+ (msg.stage === 'email_sent' || msg.stage === 'connecting')
217
+ ) {
218
+ setStage('notified');
219
+ return;
220
+ }
221
+ setStage(msg.stage as Stage);
222
+ setData(msg.data || {});
223
+ } catch {
224
+ /* ignore malformed frames */
225
+ }
226
+ };
227
+ ws.onclose = () => {
228
+ if (closed) return;
229
+ const delay = Math.min(1000 * 2 ** attempts, 15000);
230
+ attempts += 1;
231
+ timer = window.setTimeout(connect, delay);
232
+ };
39
233
  };
40
- ws.onclose = () => console.log('Support socket closed');
41
- return () => ws.close();
42
- }, [sessionId]);
43
-
44
- useEffect(() => {
45
- let timerId: number;
46
- let start: number;
47
-
48
- if (stage === 'connecting') {
49
- start = Date.now();
50
- setProgress(100);
51
-
52
- timerId = window.setInterval(() => {
53
- const elapsed = (Date.now() - start) / 1000; // seconds
54
- const pct = Math.max(100 - (elapsed / 7) * 100, 0);
55
- setProgress(pct);
56
- if (pct <= 0) window.clearInterval(timerId);
57
- }, 100);
58
- }
59
234
 
235
+ connect();
60
236
  return () => {
61
- if (timerId) window.clearInterval(timerId);
237
+ closed = true;
238
+ if (timer) window.clearTimeout(timer);
239
+ ws.close();
62
240
  };
63
- }, [stage]);
241
+ }, [sessionId]);
242
+
243
+ // Never strand the visitor on an animation: if nobody picks up within
244
+ // 75s (celery down, empty team, lost events), fall back to "notified".
245
+ useEffect(() => {
246
+ const t = window.setTimeout(() => {
247
+ if (stageRef.current === 'email_sent' || stageRef.current === 'connecting') {
248
+ setStage('notified');
249
+ }
250
+ }, 75000);
251
+ return () => window.clearTimeout(t);
252
+ }, []);
64
253
 
65
- if (!stage) return null;
254
+ const agent = data.agentName || t('support.teamMember');
66
255
 
67
256
  return (
68
- <div className="bg-opacity-50 flex items-center justify-center z-50">
69
- <AnimatePresence>
257
+ <div style={{ marginTop: 8 }}>
258
+ {ticket && <TicketCard ticket={ticket} colorCode={colorCode} />}
259
+ <AnimatePresence mode="wait">
70
260
  {stage === 'email_sent' && (
71
261
  <motion.div
72
262
  key="email"
73
- className="bg-white rounded-2xl shadow-xl p-6 flex flex-col items-center"
74
- initial={{ scale: 0.9, opacity: 0 }}
75
- animate={{ scale: 1, opacity: 1 }}
76
- exit={{ scale: 0.8, opacity: 0 }}
263
+ style={card}
264
+ initial={{ opacity: 0, y: 10 }}
265
+ animate={{ opacity: 1, y: 0 }}
266
+ exit={{ opacity: 0, y: -8 }}
77
267
  >
78
- <Mail size={32} className="mb-2" style={{ color: colorCode }} />
79
- <p className="text-lg font-semibold">
80
- Sending your request to support…
81
- </p>
82
- <div className="flex space-x-2 mt-2">
83
- {dots.map((_, i) => (
84
- <motion.span
85
- key={i}
86
- className="text-2xl"
87
- style={{ color: colorCode }}
88
- animate={{ scale: [0.7, 1.3, 0.7] }}
89
- transition={{ delay: i * 0.2, repeat: Infinity, duration: 0.8 }}
90
- >
91
-
92
- </motion.span>
93
- ))}
268
+ <span className="jwbS-bars" style={{ '--jwbS-accent': colorCode } as React.CSSProperties}>
269
+ <span /><span /><span />
270
+ </span>
271
+ <div>
272
+ <p style={{ margin: 0, fontSize: 13, fontWeight: 700, color: '#111827' }}>
273
+ {t('support.reaching')}
274
+ </p>
275
+ <p style={{ margin: 0, fontSize: 12, color: '#6b7280' }}>
276
+ {t('support.requestSent')}
277
+ </p>
94
278
  </div>
95
279
  </motion.div>
96
280
  )}
@@ -98,75 +282,84 @@ const SupportConnect: React.FC<SupportConnectProps> = ({
98
282
  {stage === 'connecting' && (
99
283
  <motion.div
100
284
  key="connecting"
101
- className="bg-white rounded-2xl shadow-xl p-6 flex flex-col items-center"
102
- initial={{ scale: 0.9, opacity: 0 }}
103
- animate={{ scale: 1, opacity: 1 }}
104
- exit={{ scale: 0.8, opacity: 0 }}
285
+ style={card}
286
+ initial={{ opacity: 0, y: 10 }}
287
+ animate={{ opacity: 1, y: 0 }}
288
+ exit={{ opacity: 0, y: -8 }}
105
289
  >
106
- <div className="flex items-center space-x-4">
107
- <img
108
- src={data.userImage || 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSaC0jamaBcOGTbS86459UezKUs2Wo0lPU_mw&s'}
109
- alt="User"
110
- className="w-12 h-12 rounded-full border-2"
111
- style={{ borderColor: colorCode }}
112
- />
113
- <p className="text-lg">
114
- Connecting you to{' '}
115
- <span className="text-indigo-600">
116
- {data.agentName || 'Support Agent'}
117
- </span>
290
+ <Initial name={agent} src={data.userImage} accent={colorCode} />
291
+ <div style={{ minWidth: 0 }}>
292
+ <p style={{ margin: 0, fontSize: 13, fontWeight: 700, color: '#111827' }}>
293
+ {t('support.connectingTo', { name: agent })}
294
+ </p>
295
+ <p style={{ margin: 0, fontSize: 12, color: '#6b7280' }}>
296
+ {t('support.oneMoment')}
118
297
  </p>
119
- <img
120
- src={data.companyImage || 'https://cdn-icons-png.flaticon.com/512/4812/4812244.png'}
121
- alt="Company"
122
- className="w-12 h-12 rounded-full border"
123
- />
124
- </div>
125
- <div className="flex space-x-2 mt-3">
126
- {dots.map((_, i) => (
127
- <motion.span
128
- key={i}
129
- className="text-2xl"
130
- style={{ color: colorCode }}
131
- animate={{ scale: [0.7, 1.3, 0.7] }}
132
- transition={{ delay: i * 0.2, repeat: Infinity, duration: 0.8 }}
133
- >
134
-
135
- </motion.span>
136
- ))}
137
- </div>
138
- <div className="w-full bg-gray-200 h-1 rounded-full mt-4 overflow-hidden">
139
- <motion.div
140
- className="h-full"
141
- style={{ width: `${progress}%`, backgroundColor: colorCode }}
142
- transition={{ ease: 'linear', duration: 1 }}
143
- />
144
298
  </div>
299
+ <span
300
+ className="jwbS-bars"
301
+ style={{ '--jwbS-accent': colorCode, marginLeft: 'auto' } as React.CSSProperties}
302
+ >
303
+ <span /><span /><span />
304
+ </span>
145
305
  </motion.div>
146
306
  )}
147
307
 
148
308
  {stage === 'auto' && (
149
309
  <motion.div
150
- key="auto"
151
- className="bg-white rounded-2xl shadow-xl p-6 flex items-center space-x-4"
152
- initial={{ y: 50, opacity: 0 }}
153
- animate={{ y: 0, opacity: 1 }}
154
- exit={{ y: -50, opacity: 0 }}
310
+ key="connected"
311
+ style={card}
312
+ initial={{ opacity: 0, y: 10 }}
313
+ animate={{ opacity: 1, y: 0 }}
155
314
  >
156
- <img
157
- src={
158
- data.agentAvatar ||
159
- 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSaC0jamaBcOGTbS86459UezKUs2Wo0lPU_mw&s'
160
- }
161
- alt="Auto Support"
162
- className="w-12 h-12 rounded-2xl"
163
- />
164
- <p className="text-lg">
165
- You’re now connected to{' '}
166
- <span style={{ color: colorCode }}>
167
- {data.agentName || 'Auto-Support'}
168
- </span>
169
- </p>
315
+ <Initial name={agent} src={data.agentAvatar} accent={colorCode} />
316
+ <div style={{ minWidth: 0 }}>
317
+ <p style={{ margin: 0, fontSize: 13, fontWeight: 700, color: '#111827' }}>
318
+ {t('support.chattingWith', { name: agent })}
319
+ </p>
320
+ <p
321
+ style={{
322
+ margin: 0,
323
+ fontSize: 12,
324
+ color: '#16a34a',
325
+ display: 'flex',
326
+ alignItems: 'center',
327
+ gap: 5,
328
+ }}
329
+ >
330
+ <span
331
+ style={{
332
+ width: 6,
333
+ height: 6,
334
+ borderRadius: '50%',
335
+ background: '#16a34a',
336
+ display: 'inline-block',
337
+ }}
338
+ />
339
+ {t('support.liveNow')}
340
+ </p>
341
+ </div>
342
+ </motion.div>
343
+ )}
344
+
345
+ {stage === 'notified' && (
346
+ <motion.div
347
+ key="notified"
348
+ style={card}
349
+ initial={{ opacity: 0, y: 10 }}
350
+ animate={{ opacity: 1, y: 0 }}
351
+ >
352
+ <span style={{ color: '#6b7280', display: 'flex' }}>
353
+ <MailCheck size={20} />
354
+ </span>
355
+ <div style={{ minWidth: 0 }}>
356
+ <p style={{ margin: 0, fontSize: 13, fontWeight: 700, color: '#111827' }}>
357
+ {t('support.teamOffline')}
358
+ </p>
359
+ <p style={{ margin: 0, fontSize: 12, color: '#6b7280' }}>
360
+ {t('support.savedSpot')}
361
+ </p>
362
+ </div>
170
363
  </motion.div>
171
364
  )}
172
365
  </AnimatePresence>
@@ -1,55 +1,51 @@
1
- /* Typing.css */
1
+ /* Typing indicator — mini equalizer bars in a message bubble */
2
2
 
3
3
  .typing-container {
4
- /* Layout */
5
4
  display: inline-flex;
6
5
  align-items: center;
7
6
  justify-content: center;
8
7
  gap: var(--typing-gap);
9
-
10
- /* Appearance */
8
+
11
9
  background-color: var(--typing-bg);
12
10
  padding: var(--typing-padding);
13
- border-radius: 9999px; /* Pill shape */
14
-
15
- /* Optional: Add a subtle shadow for depth */
16
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
17
-
18
- /* Prevent layout shift */
19
- will-change: transform;
20
- }
11
+ border: 1px solid #ececec;
12
+ border-radius: 16px 16px 16px 5px; /* matches assistant bubbles */
13
+ box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
21
14
 
22
- .typing-dot {
23
- width: var(--typing-dot-size);
24
- height: var(--typing-dot-size);
25
- background-color: var(--typing-dot-color);
26
- border-radius: 50%;
27
-
28
- /* Animation definition */
29
- animation: typingBounce 1.4s infinite ease-in-out both;
15
+ will-change: transform;
30
16
  }
31
17
 
32
- /* Stagger the animations */
33
- .typing-dot:nth-child(1) {
34
- animation-delay: -0.32s;
18
+ .typing-bar {
19
+ width: var(--typing-bar-w);
20
+ height: var(--typing-bar-h);
21
+ border-radius: 3px;
22
+ background: var(--typing-accent);
23
+ transform-origin: center;
24
+ animation: typingWave 1.1s ease-in-out infinite;
35
25
  }
36
26
 
37
- .typing-dot:nth-child(2) {
38
- animation-delay: -0.16s;
27
+ .typing-bar:nth-child(2) {
28
+ animation-delay: 0.18s;
39
29
  }
40
30
 
41
- .typing-dot:nth-child(3) {
42
- animation-delay: 0s;
31
+ .typing-bar:nth-child(3) {
32
+ animation-delay: 0.36s;
43
33
  }
44
34
 
45
- /* The Animation Keyframes */
46
- @keyframes typingBounce {
47
- 0%, 80%, 100% {
48
- transform: scale(0.8);
49
- opacity: 0.5;
35
+ @keyframes typingWave {
36
+ 0%, 100% {
37
+ transform: scaleY(0.35);
38
+ opacity: 0.55;
50
39
  }
51
- 40% {
52
- transform: scale(1) translateY(-4px); /* Slight lift */
40
+ 50% {
41
+ transform: scaleY(1);
53
42
  opacity: 1;
54
43
  }
55
- }
44
+ }
45
+
46
+ @media (prefers-reduced-motion: reduce) {
47
+ .typing-bar {
48
+ animation: none;
49
+ transform: scaleY(0.7);
50
+ }
51
+ }
@@ -2,43 +2,44 @@ import React from 'react';
2
2
  import './Typing.css';
3
3
 
4
4
  interface TypingProps {
5
- /** Color of the moving dots */
5
+ /** Accent color of the animated bars */
6
6
  dotColor?: string;
7
7
  /** Color of the bubble background */
8
8
  bubbleColor?: string;
9
- /** Size of the dots in pixels */
9
+ /** Base size in pixels (drives bar dimensions) */
10
10
  size?: number;
11
11
  /** Optional class name for the container */
12
12
  className?: string;
13
13
  }
14
14
 
15
- const Typing: React.FC<TypingProps> = ({
16
- dotColor = '#6b7280', // Default dark grey
17
- bubbleColor = '#e5e7eb', // Default light grey bubble
18
- size = 8,
19
- className = ''
15
+ /** Typing indicator mini equalizer bars, same motif as voice mode. */
16
+ const Typing: React.FC<TypingProps> = ({
17
+ dotColor = '#111827',
18
+ bubbleColor = '#ffffff',
19
+ size = 10,
20
+ className = '',
20
21
  }) => {
21
- // We pass dynamic values as CSS variables for better performance
22
22
  const style = {
23
- '--typing-dot-color': dotColor,
23
+ '--typing-accent': dotColor,
24
24
  '--typing-bg': bubbleColor,
25
- '--typing-dot-size': `${size}px`,
26
- '--typing-gap': `${size * 0.5}px`, // Gap scales with size
27
- '--typing-padding': `${size * 1.25}px ${size * 1.5}px` // Padding scales with size
25
+ '--typing-bar-w': `${Math.max(3, Math.round(size * 0.4))}px`,
26
+ '--typing-bar-h': `${Math.round(size * 1.6)}px`,
27
+ '--typing-gap': `${Math.max(2, Math.round(size * 0.35))}px`,
28
+ '--typing-padding': `${size}px ${Math.round(size * 1.4)}px`,
28
29
  } as React.CSSProperties;
29
30
 
30
31
  return (
31
- <div
32
- className={`typing-container ${className}`}
32
+ <div
33
+ className={`typing-container ${className}`}
33
34
  style={style}
34
35
  role="status"
35
36
  aria-label="Typing..."
36
37
  >
37
- <span className="typing-dot" />
38
- <span className="typing-dot" />
39
- <span className="typing-dot" />
38
+ <span className="typing-bar" />
39
+ <span className="typing-bar" />
40
+ <span className="typing-bar" />
40
41
  </div>
41
42
  );
42
43
  };
43
44
 
44
- export default Typing;
45
+ export default Typing;