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
@@ -0,0 +1,613 @@
1
+ import React, { useEffect, useRef, useState, useCallback } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { X, Check, Plus } from 'lucide-react';
4
+ import axios from 'axios';
5
+ import config from '../../hooks/config';
6
+ import { useCart } from '../../hooks/useCart';
7
+ import OrderCard, { type OrderStatusData } from './Messages/OrderCard';
8
+ import './VoiceMode.css';
9
+
10
+ type VoiceStage = 'listening' | 'thinking' | 'speaking' | 'error';
11
+
12
+ interface UploadAudioResponse {
13
+ audio_url: string;
14
+ transcription_text: string;
15
+ }
16
+
17
+ interface MiniProduct {
18
+ name: string;
19
+ price?: string;
20
+ link?: string;
21
+ img?: string;
22
+ variantId?: string;
23
+ }
24
+
25
+ interface Props {
26
+ sessionId: string;
27
+ colorCode?: string;
28
+ agentName?: string;
29
+ newSession?: boolean;
30
+ setNewSession?: (v: boolean) => void;
31
+ onClose: () => void;
32
+ setMessages: React.Dispatch<React.SetStateAction<any[]>>;
33
+ }
34
+
35
+ /** Pull lightweight product info out of a reply's card blocks. */
36
+ const parseProducts = (raw: string): MiniProduct[] => {
37
+ const items: MiniProduct[] = [];
38
+ let cur: MiniProduct | null = null;
39
+ for (const part of raw.split('|||')) {
40
+ if (part.includes('VariantId')) {
41
+ const name = part.match(/Name\s*[:\-]\s*(.+)/i)?.[1]?.trim();
42
+ if (!name) continue;
43
+ cur = {
44
+ name,
45
+ price: part.match(/Price\s*[:\-]\s*(.+)/i)?.[1]?.trim(),
46
+ link: part.match(/Link\s*[:\-]\s*(https?:\/\/\S+)/i)?.[1],
47
+ variantId: part.match(/VariantId\s*[:\-]\s*(.+)/i)?.[1]?.trim(),
48
+ };
49
+ items.push(cur);
50
+ } else if (part.trim().startsWith('img') && cur) {
51
+ cur.img = part.replace(/^\s*img\s*[-:]\s*/, '').trim();
52
+ }
53
+ }
54
+ const seen = new Set<string>();
55
+ return items.filter((i) => !seen.has(i.name) && !!seen.add(i.name));
56
+ };
57
+
58
+ const parseOrder = (raw: string): OrderStatusData | null => {
59
+ const m = raw.match(/\[\[ORDER_STATUS\]\]([\s\S]*?)\[\[\/ORDER_STATUS\]\]/);
60
+ if (!m) return null;
61
+ try {
62
+ return JSON.parse(m[1]);
63
+ } catch {
64
+ return null;
65
+ }
66
+ };
67
+
68
+ const stripForSpeech = (raw: string) =>
69
+ raw
70
+ .replace(/\[\[ORDER_STATUS\]\][\s\S]*?\[\[\/ORDER_STATUS\]\]/g, '')
71
+ .replace(/\[\[SUPPORT_TICKET\]\][\s\S]*?\[\[\/SUPPORT_TICKET\]\]/g, '')
72
+ .split('|||')
73
+ .filter((s) => !s.includes('VariantId') && !s.trim().startsWith('img'))
74
+ .join(' ')
75
+ .replace(/\s+/g, ' ')
76
+ .trim();
77
+
78
+ const BAR_COUNT = 5;
79
+ const SPEECH_RMS = 0.022; // voice detection threshold
80
+ const SILENCE_MS = 1300; // stop this long after the last speech
81
+ const MIN_SPEECH_MS = 350; // ignore coughs/taps
82
+ const MAX_TURN_MS = 30000;
83
+
84
+ const VoiceMode: React.FC<Props> = ({
85
+ sessionId,
86
+ colorCode = '#111111',
87
+ agentName = 'Assistant',
88
+ newSession = false,
89
+ setNewSession,
90
+ onClose,
91
+ setMessages,
92
+ }) => {
93
+ const { t } = useTranslation();
94
+ const [stage, setStage] = useState<VoiceStage>('listening');
95
+ const [transcript, setTranscript] = useState('');
96
+ const [reply, setReply] = useState('');
97
+ const [products, setProducts] = useState<MiniProduct[]>([]);
98
+ const [order, setOrder] = useState<OrderStatusData | null>(null);
99
+ const [hint, setHint] = useState('');
100
+ const [added, setAdded] = useState<Record<string, boolean>>({});
101
+
102
+ const { addToCart } = useCart(sessionId);
103
+
104
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
105
+ const chunksRef = useRef<Blob[]>([]);
106
+ const streamRef = useRef<MediaStream | null>(null);
107
+ const audioRef = useRef<HTMLAudioElement | null>(null);
108
+ const closedRef = useRef(false);
109
+ const stageRef = useRef<VoiceStage>('listening');
110
+ stageRef.current = stage;
111
+ const needsChatlogRef = useRef(newSession);
112
+
113
+ // Web Audio graph (shared for mic + playback visualization)
114
+ const audioCtxRef = useRef<AudioContext | null>(null);
115
+ const analyserRef = useRef<AnalyserNode | null>(null);
116
+ const micSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
117
+ const rafRef = useRef<number>(0);
118
+ const barsRef = useRef<HTMLButtonElement | null>(null);
119
+
120
+ // TTS sentence pipeline
121
+ const ttsQueueRef = useRef<Promise<Blob | null>[]>([]);
122
+ const playingRef = useRef(false);
123
+ const generationRef = useRef(0); // bumps to cancel stale pipelines
124
+
125
+ const ensureAudioCtx = () => {
126
+ if (!audioCtxRef.current) {
127
+ audioCtxRef.current = new (window.AudioContext ||
128
+ (window as any).webkitAudioContext)();
129
+ }
130
+ if (audioCtxRef.current.state === 'suspended') void audioCtxRef.current.resume();
131
+ return audioCtxRef.current;
132
+ };
133
+
134
+ /* ---------- Bars: driven by real audio levels ---------- */
135
+ const driveBars = useCallback(() => {
136
+ const analyser = analyserRef.current;
137
+ const bars = barsRef.current?.children;
138
+ if (analyser && bars) {
139
+ const freq = new Uint8Array(analyser.frequencyBinCount);
140
+ analyser.getByteFrequencyData(freq);
141
+ const bands = BAR_COUNT;
142
+ const usable = Math.floor(freq.length * 0.6); // speech range
143
+ for (let i = 0; i < bands; i++) {
144
+ const start = Math.floor((usable / bands) * i);
145
+ const end = Math.floor((usable / bands) * (i + 1));
146
+ let sum = 0;
147
+ for (let j = start; j < end; j++) sum += freq[j];
148
+ const level = sum / (end - start) / 255;
149
+ const el = bars[i] as HTMLElement;
150
+ if (el) {
151
+ el.style.animation = 'none'; // JS drives; CSS is the fallback
152
+ el.style.transform = `scaleY(${(0.25 + level * 1.15).toFixed(3)})`;
153
+ }
154
+ }
155
+ }
156
+ rafRef.current = requestAnimationFrame(driveBars);
157
+ }, []);
158
+
159
+ const resetBars = () => {
160
+ const bars = barsRef.current?.children;
161
+ if (!bars) return;
162
+ for (let i = 0; i < bars.length; i++) {
163
+ const el = bars[i] as HTMLElement;
164
+ el.style.transform = '';
165
+ el.style.animation = '';
166
+ }
167
+ };
168
+
169
+ /* ---------- Listening with voice-activity detection ---------- */
170
+ const stopStream = () => {
171
+ streamRef.current?.getTracks().forEach((t) => t.stop());
172
+ streamRef.current = null;
173
+ micSourceRef.current?.disconnect();
174
+ micSourceRef.current = null;
175
+ };
176
+
177
+ const startListening = useCallback(async () => {
178
+ if (closedRef.current) return;
179
+ setTranscript('');
180
+ setReply('');
181
+ setHint('');
182
+ try {
183
+ const stream = await navigator.mediaDevices.getUserMedia({
184
+ audio: { echoCancellation: true, noiseSuppression: true },
185
+ });
186
+ streamRef.current = stream;
187
+
188
+ // analyser for VAD + bar visualization
189
+ const ctx = ensureAudioCtx();
190
+ const source = ctx.createMediaStreamSource(stream);
191
+ const analyser = ctx.createAnalyser();
192
+ analyser.fftSize = 512;
193
+ analyser.smoothingTimeConstant = 0.72;
194
+ source.connect(analyser);
195
+ micSourceRef.current = source;
196
+ analyserRef.current = analyser;
197
+
198
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm')
199
+ ? 'audio/webm'
200
+ : 'audio/mp4';
201
+ const recorder = new MediaRecorder(stream, { mimeType });
202
+ chunksRef.current = [];
203
+ recorder.ondataavailable = (e) => {
204
+ if (e.data.size > 0) chunksRef.current.push(e.data);
205
+ };
206
+ recorder.onstop = () => {
207
+ stopStream();
208
+ const blob = new Blob(chunksRef.current, { type: mimeType });
209
+ if (!closedRef.current) void handleTurn(blob);
210
+ };
211
+ mediaRecorderRef.current = recorder;
212
+ recorder.start();
213
+ setStage('listening');
214
+
215
+ // --- VAD loop: auto-send after a natural pause ---
216
+ const data = new Uint8Array(analyser.fftSize);
217
+ const startedAt = Date.now();
218
+ let speechMs = 0;
219
+ let lastVoice = 0;
220
+ let lastTick = Date.now();
221
+ const tick = () => {
222
+ if (closedRef.current || mediaRecorderRef.current !== recorder) return;
223
+ if (recorder.state !== 'recording') return;
224
+ analyser.getByteTimeDomainData(data);
225
+ let sum = 0;
226
+ for (let i = 0; i < data.length; i++) {
227
+ const v = (data[i] - 128) / 128;
228
+ sum += v * v;
229
+ }
230
+ const rms = Math.sqrt(sum / data.length);
231
+ const now = Date.now();
232
+ const dt = now - lastTick;
233
+ lastTick = now;
234
+ if (rms > SPEECH_RMS) {
235
+ speechMs += dt;
236
+ lastVoice = now;
237
+ }
238
+ const spoke = speechMs > MIN_SPEECH_MS;
239
+ if (
240
+ (spoke && lastVoice && now - lastVoice > SILENCE_MS) ||
241
+ now - startedAt > MAX_TURN_MS
242
+ ) {
243
+ if (spoke) recorder.stop();
244
+ else {
245
+ // 30s of nothing — go idle instead of uploading silence
246
+ recorder.onstop = null;
247
+ recorder.stop();
248
+ stopStream();
249
+ setHint(t('voice.tapToTalk'));
250
+ setStage('error');
251
+ }
252
+ return;
253
+ }
254
+ window.setTimeout(tick, 55);
255
+ };
256
+ tick();
257
+ } catch {
258
+ setStage('error');
259
+ setHint(t('voice.micNeeded'));
260
+ }
261
+ }, []);
262
+
263
+ const finishListening = () => {
264
+ if (mediaRecorderRef.current?.state === 'recording') {
265
+ mediaRecorderRef.current.stop();
266
+ }
267
+ };
268
+
269
+ /* ---------- TTS: sentence-streamed playback ---------- */
270
+ const fetchTTS = async (text: string): Promise<Blob | null> => {
271
+ try {
272
+ const res = await fetch(`${config.apiUrl}tts-reply/`, {
273
+ method: 'POST',
274
+ headers: { 'Content-Type': 'application/json' },
275
+ body: JSON.stringify({
276
+ text,
277
+ company_username: localStorage.getItem('company_username'),
278
+ }),
279
+ });
280
+ if (!res.ok) return null;
281
+ return await res.blob();
282
+ } catch {
283
+ return null;
284
+ }
285
+ };
286
+
287
+ const playQueue = async (generation: number) => {
288
+ if (playingRef.current) return;
289
+ playingRef.current = true;
290
+ try {
291
+ while (ttsQueueRef.current.length > 0) {
292
+ if (closedRef.current || generationRef.current !== generation) return;
293
+ const next = ttsQueueRef.current.shift()!;
294
+ const blob = await next;
295
+ if (!blob) continue;
296
+ if (closedRef.current || generationRef.current !== generation) return;
297
+ setStage('speaking');
298
+ await new Promise<void>((resolve) => {
299
+ const audio = new Audio(URL.createObjectURL(blob));
300
+ audioRef.current = audio;
301
+ // visualize the spoken audio on the bars
302
+ try {
303
+ const ctx = ensureAudioCtx();
304
+ const src = ctx.createMediaElementSource(audio);
305
+ const analyser = ctx.createAnalyser();
306
+ analyser.fftSize = 512;
307
+ analyser.smoothingTimeConstant = 0.7;
308
+ src.connect(analyser);
309
+ analyser.connect(ctx.destination);
310
+ analyserRef.current = analyser;
311
+ } catch {
312
+ /* visualization is best-effort */
313
+ }
314
+ audio.onended = () => resolve();
315
+ audio.onerror = () => resolve();
316
+ void audio.play().catch(() => resolve());
317
+ });
318
+ }
319
+ } finally {
320
+ playingRef.current = false;
321
+ }
322
+ };
323
+
324
+ /* ---------- One conversation turn ---------- */
325
+ const handleTurn = async (blob: Blob) => {
326
+ setStage('thinking');
327
+ const generation = ++generationRef.current;
328
+ ttsQueueRef.current = [];
329
+ try {
330
+ // 1) Speech -> text
331
+ const form = new FormData();
332
+ form.append('audio', blob);
333
+ const stt = await axios.post<UploadAudioResponse>(
334
+ `${config.apiUrl}upload-audio/`,
335
+ form
336
+ );
337
+ const text = (stt.data?.transcription_text || '').trim();
338
+ if (!text) {
339
+ setHint(t('voice.didntCatch'));
340
+ void startListening();
341
+ return;
342
+ }
343
+ setTranscript(text);
344
+ setMessages((prev) => [...prev, { role: 'user', content: text }]);
345
+
346
+ // Brand-new session: the chatlog must exist before message-create,
347
+ // same as the typed path — otherwise the backend 404s.
348
+ if (needsChatlogRef.current) {
349
+ await fetch(`${config.apiUrl}chatlog-create/`, {
350
+ method: 'POST',
351
+ headers: { 'Content-Type': 'application/json' },
352
+ body: JSON.stringify({
353
+ visitor_id: localStorage.getItem('visitor_id'),
354
+ company_username: localStorage.getItem('company_username'),
355
+ session_id: sessionId,
356
+ }),
357
+ });
358
+ needsChatlogRef.current = false;
359
+ setNewSession?.(false);
360
+ }
361
+
362
+ const body = JSON.stringify({
363
+ message: text,
364
+ isBusiness: true,
365
+ session_id: sessionId,
366
+ organization: localStorage.getItem('company_username'),
367
+ isImage: false,
368
+ is_voice: true,
369
+ });
370
+
371
+ // 2) Stream the reply; speak sentence-by-sentence as it arrives
372
+ let answer = '';
373
+ let finalData: any = null;
374
+ let spokenSoFar = 0;
375
+ let streamed = false;
376
+
377
+ const enqueueSpeech = (upTo: number) => {
378
+ const chunk = answer.slice(spokenSoFar, upTo).trim();
379
+ if (!chunk) return;
380
+ spokenSoFar = upTo;
381
+ const speakable = stripForSpeech(chunk);
382
+ if (speakable) {
383
+ ttsQueueRef.current.push(fetchTTS(speakable));
384
+ void playQueue(generation);
385
+ }
386
+ };
387
+
388
+ try {
389
+ const streamRes = await fetch(`${config.apiUrl}message-create-stream/`, {
390
+ method: 'POST',
391
+ headers: { 'Content-Type': 'application/json' },
392
+ body,
393
+ });
394
+ if (
395
+ streamRes.ok &&
396
+ streamRes.body &&
397
+ (streamRes.headers.get('content-type') || '').includes('text/event-stream')
398
+ ) {
399
+ streamed = true;
400
+ const reader = streamRes.body.getReader();
401
+ const decoder = new TextDecoder();
402
+ let buffer = '';
403
+ while (true) {
404
+ const { done, value } = await reader.read();
405
+ if (done) break;
406
+ buffer += decoder.decode(value, { stream: true });
407
+ const events = buffer.split('\n\n');
408
+ buffer = events.pop() || '';
409
+ for (const evt of events) {
410
+ const line = evt.split('\n').find((l) => l.startsWith('data: '));
411
+ if (!line) continue;
412
+ const payload = JSON.parse(line.slice(6));
413
+ if (payload.delta) {
414
+ answer += payload.delta;
415
+ setReply(stripForSpeech(answer).slice(0, 200));
416
+ const tail = answer.slice(spokenSoFar);
417
+ if (spokenSoFar === 0) {
418
+ // First audio ASAP: speak the opening clause the moment a
419
+ // comma/colon/sentence break lands past ~40 chars
420
+ const c = tail.match(/[\s\S]*?[.!?…,:;](?=\s|$)/);
421
+ if (c && c[0].trim().length > 40) {
422
+ enqueueSpeech(c[0].length);
423
+ continue;
424
+ }
425
+ }
426
+ // then speak whole sentences as they finish
427
+ const m = tail.match(/[\s\S]*?[.!?…](?=\s|$)/);
428
+ if (m && m[0].trim().length > 20) {
429
+ enqueueSpeech(spokenSoFar + m[0].length);
430
+ }
431
+ } else if (payload.done) {
432
+ finalData = payload.response;
433
+ } else if (payload.error) {
434
+ throw new Error(payload.error);
435
+ }
436
+ }
437
+ }
438
+ }
439
+ } catch {
440
+ streamed = false;
441
+ }
442
+
443
+ // Fallback to the blocking endpoint if streaming failed
444
+ if (!streamed || (!answer && !finalData)) {
445
+ const res = await fetch(`${config.apiUrl}message-create/`, {
446
+ method: 'POST',
447
+ headers: { 'Content-Type': 'application/json' },
448
+ body,
449
+ });
450
+ if (!res.ok) throw new Error('reply failed');
451
+ finalData = await res.json();
452
+ }
453
+
454
+ const fullAnswer: string = finalData?.message || answer || '';
455
+ // speak whatever's left (covers tool replies that never streamed)
456
+ answer = fullAnswer;
457
+ enqueueSpeech(fullAnswer.length);
458
+
459
+ setReply(stripForSpeech(fullAnswer).slice(0, 200));
460
+ setProducts(parseProducts(fullAnswer));
461
+ setOrder(parseOrder(fullAnswer));
462
+ setMessages((prev) => [
463
+ ...prev.filter((m) => !m.typing),
464
+ {
465
+ role: 'assistant',
466
+ content: fullAnswer,
467
+ sender: 'Ai Agent',
468
+ isBusiness: false,
469
+ isConnect: !!finalData?.isConnect,
470
+ },
471
+ ]);
472
+
473
+ // wait for speech to finish, then listen again
474
+ while (
475
+ (playingRef.current || ttsQueueRef.current.length > 0) &&
476
+ !closedRef.current &&
477
+ generationRef.current === generation
478
+ ) {
479
+ await new Promise((r) => setTimeout(r, 120));
480
+ }
481
+ if (!closedRef.current && generationRef.current === generation) {
482
+ void startListening();
483
+ }
484
+ } catch {
485
+ if (closedRef.current) return;
486
+ setStage('error');
487
+ setHint(t('voice.wentWrong'));
488
+ }
489
+ };
490
+
491
+ /* ---------- Interactions ---------- */
492
+ const handleBarsTap = () => {
493
+ if (stage === 'listening') finishListening();
494
+ else if (stage === 'speaking') {
495
+ // barge-in: skip the rest and listen
496
+ generationRef.current += 1;
497
+ ttsQueueRef.current = [];
498
+ audioRef.current?.pause();
499
+ audioRef.current = null;
500
+ void startListening();
501
+ } else if (stage === 'error') void startListening();
502
+ };
503
+
504
+ const handleAdd = async (p: MiniProduct) => {
505
+ if (!p.variantId || added[p.variantId]) return;
506
+ try {
507
+ await addToCart(p.variantId, p.name, 1, p.img || '', p.price || '');
508
+ setAdded((prev) => ({ ...prev, [p.variantId!]: true }));
509
+ } catch {
510
+ /* cart hiccup — leave the button tappable */
511
+ }
512
+ };
513
+
514
+ /* ---------- Lifecycle ---------- */
515
+ useEffect(() => {
516
+ closedRef.current = false;
517
+ rafRef.current = requestAnimationFrame(driveBars);
518
+ void startListening();
519
+ return () => {
520
+ closedRef.current = true;
521
+ generationRef.current += 1;
522
+ cancelAnimationFrame(rafRef.current);
523
+ audioRef.current?.pause();
524
+ if (mediaRecorderRef.current?.state === 'recording') {
525
+ mediaRecorderRef.current.onstop = null;
526
+ mediaRecorderRef.current.stop();
527
+ }
528
+ stopStream();
529
+ void audioCtxRef.current?.close();
530
+ audioCtxRef.current = null;
531
+ };
532
+ }, [startListening, driveBars]);
533
+
534
+ useEffect(() => {
535
+ if (stage === 'thinking' || stage === 'error') {
536
+ analyserRef.current = null; // let the CSS animation take over
537
+ resetBars();
538
+ }
539
+ }, [stage]);
540
+
541
+ const label =
542
+ stage === 'listening'
543
+ ? t('voice.listening')
544
+ : stage === 'thinking'
545
+ ? t('voice.thinking')
546
+ : stage === 'speaking'
547
+ ? t('voice.speaking', { name: agentName })
548
+ : t('voice.title');
549
+
550
+ return (
551
+ <div className="jwbV-overlay" style={{ '--jwbV-accent': colorCode } as React.CSSProperties}>
552
+ <button className="jwbV-close" onClick={onClose} aria-label={t('voice.exit')}>
553
+ <X size={18} />
554
+ </button>
555
+
556
+ <button
557
+ ref={barsRef}
558
+ className={`jwbV-bars jwbV-bars--${stage}`}
559
+ onClick={handleBarsTap}
560
+ aria-label={label}
561
+ >
562
+ {Array.from({ length: BAR_COUNT }).map((_, i) => (
563
+ <span key={i} className="jwbV-bar" style={{ animationDelay: `${i * 0.12}s` }} />
564
+ ))}
565
+ </button>
566
+
567
+ <p className="jwbV-status">{label}</p>
568
+
569
+ {transcript && <p className="jwbV-transcript">“{transcript}”</p>}
570
+ {(stage === 'speaking' || stage === 'thinking') && reply && (
571
+ <p className="jwbV-reply">{reply}</p>
572
+ )}
573
+ {hint && <p className="jwbV-hint">{hint}</p>}
574
+
575
+ {order && (
576
+ <div className="jwbV-payload">
577
+ <OrderCard data={order} colorCode={colorCode} />
578
+ </div>
579
+ )}
580
+
581
+ {products.length > 0 && (
582
+ <div className="jwbV-payload jwbV-products">
583
+ {products.map((p) => (
584
+ <div key={p.name} className="jwbV-product">
585
+ <a
586
+ href={p.link}
587
+ target="_blank"
588
+ rel="noopener noreferrer"
589
+ className="jwbV-productLink"
590
+ >
591
+ {p.img && <img src={p.img} alt={p.name} className="jwbV-productImg" />}
592
+ <span className="jwbV-productName">{p.name}</span>
593
+ {p.price && <span className="jwbV-productPrice">{p.price}</span>}
594
+ </a>
595
+ {p.variantId && (
596
+ <button
597
+ className={`jwbV-add ${added[p.variantId] ? 'jwbV-add--done' : ''}`}
598
+ onClick={() => void handleAdd(p)}
599
+ aria-label={added[p.variantId] ? t('product.addedToCart') : t('product.addToCart')}
600
+ >
601
+ {added[p.variantId] ? <Check size={13} /> : <Plus size={13} />}
602
+ {added[p.variantId] ? t('product.added') : t('product.addToCart')}
603
+ </button>
604
+ )}
605
+ </div>
606
+ ))}
607
+ </div>
608
+ )}
609
+ </div>
610
+ );
611
+ };
612
+
613
+ export default VoiceMode;
@@ -1,13 +1,12 @@
1
-
1
+ // Backend host is resolved at BUILD time:
2
+ // - `npm run dev` reads .env.development -> localhost
3
+ // - `npm run build` (and anything published to npm) falls back to
4
+ // production, so a forgotten env can never ship localhost to stores.
5
+ const API_BASE = (import.meta.env.VITE_API_URL || 'https://api.jaweb.me').replace(/\/+$/, '');
2
6
 
3
7
  const config = {
4
- apiUrl:'https://jawebcrm.onrender.com/api/',
5
- websocketUrl:'wss://jawebcrm.onrender.com/ws' ,
6
-
7
- // apiUrl:'http://localhost:8000/api/',
8
- // websocketUrl:'ws://localhost:8000/ws',
9
-
10
- };
11
- export default config;
12
-
8
+ apiUrl: `${API_BASE}/api/`,
9
+ websocketUrl: `${API_BASE.replace(/^http/, 'ws')}/ws`,
10
+ };
13
11
 
12
+ export default config;