metabinaries 1.3.3

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,1786 @@
1
+ export const aiChatTemplates = {
2
+ 'components/layout/AIChat.tsx': `'use client';
3
+ import React, { useState, useCallback, useRef } from 'react';
4
+ import { useAIChat } from '@/hooks/useAIChat';
5
+ import { usePathname } from 'next/navigation';
6
+ import { useTranslations } from 'next-intl';
7
+ import AIQuestionPopup, { AIQuestionPopupRef } from './AIQuestionPopup';
8
+ import { AIChatWidget } from './AIChatWidget';
9
+ import { AIChatToggle } from './AIChatToggle';
10
+
11
+ export const AIChat: React.FC = () => {
12
+ const [isOpen, setIsOpen] = useState(false);
13
+ const { messages, sendMessage } = useAIChat();
14
+ const popupRef = useRef<AIQuestionPopupRef>(null);
15
+ const pathname = usePathname();
16
+
17
+ const handleToggle = () => {
18
+ setIsOpen(!isOpen);
19
+ };
20
+
21
+ const handleQuestionClick = useCallback(
22
+ async (question: string) => {
23
+ if (!isOpen) {
24
+ setIsOpen(true);
25
+ }
26
+
27
+ setTimeout(async () => {
28
+ try {
29
+ await sendMessage(question, undefined, {
30
+ sendText: true,
31
+ sendVoice: false,
32
+ });
33
+ } catch (error) {
34
+ console.error('Error sending question to AI:', error);
35
+ }
36
+ }, 100);
37
+ },
38
+ [isOpen, sendMessage]
39
+ );
40
+
41
+ const unreadCount = (messages || []).filter(
42
+ msg => msg.senderId === 'ai-assistant' && !msg.isAIResponse
43
+ ).length;
44
+ const t = useTranslations('aiQuestions');
45
+
46
+ const sampleQuestions = [
47
+ { id: 1, text: t('questions.meta'), icon: '🌟' },
48
+ { id: 2, text: t('questions.initials'), icon: '🌟' },
49
+ { id: 3, text: t('questions.hottorun'), icon: '🌟' },
50
+ ];
51
+
52
+ if (pathname.includes('/admin')) return null;
53
+
54
+ return (
55
+ <>
56
+ <AIChatToggle
57
+ isOpen={isOpen}
58
+ onToggle={handleToggle}
59
+ messageCount={unreadCount}
60
+ />
61
+ <AIChatWidget
62
+ isOpen={isOpen}
63
+ onToggle={handleToggle}
64
+ aiName={t('title') || 'AI Assistant'}
65
+ placeholder={t('placeholder') || 'Ask me anything...'}
66
+ emptyStateTitle={t('emptyStateTitle') || 'How can I help you today?'}
67
+ emptyStateSubtitle={t('emptyStateSubtitle') || 'I can answer questions, summarize text, and more.'}
68
+ disclaimer={t('disclaimer') || 'AI may produce inaccurate information.'}
69
+ />
70
+ <AIQuestionPopup
71
+ ref={popupRef}
72
+ isOpen={isOpen}
73
+ onToggle={handleToggle}
74
+ onQuestionClick={handleQuestionClick}
75
+ questions={sampleQuestions}
76
+ title={t('title')}
77
+ subtitle={t('subtitle')}
78
+ clickToAskText={t('clickToAsk')}
79
+ />
80
+ </>
81
+ );
82
+ };`,
83
+
84
+ 'components/layout/AIChatToggle.tsx': `'use client';
85
+ import React from 'react';
86
+ import { Button } from '@/components/ui/button';
87
+ import { Bot, MessageSquare } from 'lucide-react';
88
+ import { useMediaQuery } from '@/hooks/use-media-query';
89
+ import { cn } from '@/lib/utils';
90
+
91
+ interface AIChatToggleProps {
92
+ isOpen: boolean;
93
+ onToggle: () => void;
94
+ messageCount?: number;
95
+ icon?: React.ReactNode;
96
+ className?: string;
97
+ }
98
+
99
+ export const AIChatToggle: React.FC<AIChatToggleProps> = ({
100
+ isOpen,
101
+ onToggle,
102
+ messageCount = 0,
103
+ icon,
104
+ className,
105
+ }) => {
106
+ const isMobile = useMediaQuery('(max-width: 640px)');
107
+ const hasUnreadMessages = messageCount > 0;
108
+
109
+ return (
110
+ <Button
111
+ onClick={onToggle}
112
+ className={cn(
113
+ 'fixed z-40 rounded-full shadow-2xl transition-all duration-300 border-2 border-white flex items-center justify-center overflow-visible',
114
+ 'bg-blue-600 hover:bg-blue-700 text-white',
115
+ isMobile
116
+ ? 'bottom-6 right-6 w-14 h-14'
117
+ : 'bottom-8 right-8 w-20 h-20 hover:scale-110 active:scale-95',
118
+ isOpen && 'scale-90 opacity-0 pointer-events-none',
119
+ className
120
+ )}
121
+ disabled={isOpen}
122
+ aria-label={isOpen ? 'Close chat' : 'Open chat'}
123
+ >
124
+ <div className='relative flex items-center justify-center w-full h-full'>
125
+ {icon || (isMobile ? <MessageSquare className='w-7 h-7' /> : <Bot className='w-12 h-12' />)}
126
+
127
+ {hasUnreadMessages && (
128
+ <span
129
+ className={cn(
130
+ 'absolute bg-red-500 text-white font-bold rounded-full flex items-center justify-center border-2 border-white shadow-sm',
131
+ isMobile
132
+ ? '-top-1 -right-1 w-5 h-5 text-[10px]'
133
+ : '-top-1 -right-1 w-6 h-6 text-xs'
134
+ )}
135
+ >
136
+ {messageCount > 9 ? '9+' : messageCount}
137
+ </span>
138
+ )}
139
+
140
+ {hasUnreadMessages && !isOpen && (
141
+ <span
142
+ className='absolute inset-0 rounded-full border-2 border-red-500 animate-ping opacity-40'
143
+ aria-hidden='true'
144
+ />
145
+ )}
146
+ </div>
147
+ </Button>
148
+ );
149
+ };`,
150
+
151
+ 'components/layout/AIChatWidget.tsx': `'use client';
152
+ import React, {
153
+ useState,
154
+ useRef,
155
+ useEffect,
156
+ useCallback,
157
+ useMemo,
158
+ } from 'react';
159
+ import { useAIChat } from '@/hooks/useAIChat';
160
+ import { Button } from '@/components/ui/button';
161
+ import { Input } from '@/components/ui/input';
162
+ import { ScrollArea } from '@/components/ui/scroll-area';
163
+ import {
164
+ Send,
165
+ Bot,
166
+ X,
167
+ RotateCcw,
168
+ Mic,
169
+ MicOff,
170
+ Volume2,
171
+ VolumeX,
172
+ } from 'lucide-react';
173
+ import { cn } from '@/lib/utils';
174
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
175
+ import { Skeleton } from '@/components/ui/skeleton';
176
+ import { useMediaQuery } from '@/hooks/use-media-query';
177
+ import { AudioPlayer } from '@/components/ui/audio-player';
178
+ import { useTranslations } from 'next-intl';
179
+ import { useConfirmation } from '@/components/ui/confirmation-dialog';
180
+
181
+ interface AIChatWidgetProps {
182
+ isOpen: boolean;
183
+ onToggle: () => void;
184
+ aiName?: string;
185
+ assistantIcon?: React.ReactNode;
186
+ placeholder?: string;
187
+ emptyStateTitle?: string;
188
+ emptyStateSubtitle?: string;
189
+ disclaimer?: string;
190
+ }
191
+
192
+ export const AIChatWidget: React.FC<AIChatWidgetProps> = ({
193
+ isOpen,
194
+ onToggle,
195
+ aiName = 'AI Assistant',
196
+ assistantIcon = <Bot className='w-5 h-5' />,
197
+ placeholder = 'Type your message...',
198
+ emptyStateTitle = 'How can I help you today?',
199
+ emptyStateSubtitle = 'I can answer questions, summarize text, and more.',
200
+ disclaimer = 'AI may produce inaccurate information.',
201
+ }) => {
202
+ const t = useTranslations('aiChat');
203
+ const [inputValue, setInputValue] = useState('');
204
+ const [isRecording, setIsRecording] = useState(false);
205
+ const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
206
+ const [voiceEnabled, setVoiceEnabled] = useState(false);
207
+ const [isRequestingPermission, setIsRequestingPermission] = useState(false);
208
+ const [dialogConfig, setDialogConfig] = useState<{
209
+ title: string;
210
+ description: string;
211
+ confirmText: string;
212
+ cancelText: string;
213
+ variant: 'danger' | 'warning' | 'info' | 'success';
214
+ }>({
215
+ title: '',
216
+ description: '',
217
+ confirmText: '',
218
+ cancelText: '',
219
+ variant: 'danger',
220
+ });
221
+ const {
222
+ messages,
223
+ isLoading,
224
+ isTyping,
225
+ sendMessage,
226
+ clearConversation,
227
+ error,
228
+ isConnected,
229
+ } = useAIChat();
230
+ const { confirm, ConfirmDialog } = useConfirmation();
231
+ const isMobile = useMediaQuery('(max-width: 768px)');
232
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
233
+ const audioChunksRef = useRef<Blob[]>([]);
234
+
235
+ const messagesEndRef = useRef<HTMLDivElement>(null);
236
+ const inputRef = useRef<HTMLInputElement>(null);
237
+
238
+ const scrollToBottom = useCallback(() => {
239
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
240
+ }, []);
241
+
242
+ useEffect(() => {
243
+ scrollToBottom();
244
+ }, [messages, scrollToBottom]);
245
+
246
+ useEffect(() => {
247
+ if (isOpen && inputRef.current) {
248
+ inputRef.current.focus();
249
+ }
250
+ }, [isOpen]);
251
+
252
+ const startRecording = useCallback(async () => {
253
+ try {
254
+ setIsRequestingPermission(true);
255
+ if (!window.MediaRecorder || !navigator.mediaDevices?.getUserMedia) {
256
+ throw new Error('Voice recording is not supported in this browser.');
257
+ }
258
+
259
+ const stream = await navigator.mediaDevices.getUserMedia({
260
+ audio: {
261
+ echoCancellation: true,
262
+ noiseSuppression: true,
263
+ },
264
+ });
265
+
266
+ setIsRequestingPermission(false);
267
+
268
+ const supportedTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'];
269
+ let mimeType = supportedTypes.find(type => MediaRecorder.isTypeSupported(type)) || '';
270
+
271
+ const mediaRecorder = new MediaRecorder(stream, { mimeType });
272
+ mediaRecorderRef.current = mediaRecorder;
273
+ audioChunksRef.current = [];
274
+
275
+ mediaRecorder.ondataavailable = event => {
276
+ if (event.data.size > 0) audioChunksRef.current.push(event.data);
277
+ };
278
+
279
+ mediaRecorder.onstop = () => {
280
+ const blob = new Blob(audioChunksRef.current, { type: mimeType });
281
+ setAudioBlob(blob);
282
+ stream.getTracks().forEach(track => track.stop());
283
+ };
284
+
285
+ mediaRecorder.start(1000);
286
+ setIsRecording(true);
287
+ } catch (err: any) {
288
+ setIsRequestingPermission(false);
289
+ console.error('Recording error:', err);
290
+ setDialogConfig({
291
+ title: t('micErrorTitle') || 'Microphone Error',
292
+ description: err.message || t('micErrorDesc') || 'Microphone access denied',
293
+ confirmText: t('ok') || 'OK',
294
+ cancelText: t('cancel') || 'Cancel',
295
+ variant: 'warning',
296
+ });
297
+ await confirm();
298
+ }
299
+ }, [confirm, t]);
300
+
301
+ const stopRecording = useCallback(() => {
302
+ if (mediaRecorderRef.current && isRecording) {
303
+ mediaRecorderRef.current.stop();
304
+ setIsRecording(false);
305
+ }
306
+ }, [isRecording]);
307
+
308
+ const handleSendVoiceMessage = useCallback(async () => {
309
+ if (!audioBlob) return;
310
+ try {
311
+ const reader = new FileReader();
312
+ reader.onload = async () => {
313
+ const base64Audio = (reader.result as string).split(',')[1];
314
+ await sendMessage('', base64Audio, {
315
+ sendText: true,
316
+ sendVoice: voiceEnabled,
317
+ });
318
+ setAudioBlob(null);
319
+ };
320
+ reader.readAsDataURL(audioBlob);
321
+ } catch (err) {
322
+ console.error('Error sending voice:', err);
323
+ }
324
+ }, [audioBlob, sendMessage, voiceEnabled]);
325
+
326
+ const handleSendMessage = useCallback(async () => {
327
+ if (!inputValue.trim() || isTyping) return;
328
+ const msg = inputValue.trim();
329
+ setInputValue('');
330
+ await sendMessage(msg, undefined, {
331
+ sendText: true,
332
+ sendVoice: voiceEnabled,
333
+ });
334
+ }, [inputValue, isTyping, sendMessage, voiceEnabled]);
335
+
336
+ const handleKeyPress = (e: React.KeyboardEvent) => {
337
+ if (e.key === 'Enter' && !e.shiftKey) {
338
+ e.preventDefault();
339
+ handleSendMessage();
340
+ }
341
+ };
342
+
343
+ const handleClearConversation = useCallback(async () => {
344
+ if (messages.length === 0) return;
345
+ setDialogConfig({
346
+ title: t('confirmClearTitle') || 'Clear Conversation',
347
+ description:
348
+ t('confirmClear') ||
349
+ 'Are you sure you want to clear the conversation history? This action cannot be undone.',
350
+ confirmText: t('clear') || 'Clear',
351
+ cancelText: t('cancel') || 'Cancel',
352
+ variant: 'danger',
353
+ });
354
+ const confirmed = await confirm();
355
+ if (confirmed) {
356
+ await clearConversation();
357
+ }
358
+ }, [clearConversation, confirm, messages.length, t]);
359
+
360
+ const sortedMessages = useMemo(() => {
361
+ return [...(messages || [])].sort(
362
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
363
+ );
364
+ }, [messages]);
365
+
366
+ const isAIMessage = (message: any) =>
367
+ message.isAIResponse || message.senderId === 'ai-assistant';
368
+
369
+ if (!isOpen) return null;
370
+
371
+ return (
372
+ <div
373
+ className={cn(
374
+ 'fixed z-50 bg-white rounded-lg shadow-2xl border border-gray-200 flex flex-col transition-all duration-300',
375
+ isMobile
376
+ ? 'bottom-0 inset-x-0 h-[80vh] rounded-b-none'
377
+ : 'bottom-6 right-6 w-96 h-[600px] overflow-hidden'
378
+ )}
379
+ >
380
+ {/* Header */}
381
+ <div className='flex items-center justify-between p-4 border-b border-gray-100 bg-white text-gray-900'>
382
+ <div className='flex items-center gap-3'>
383
+ <div className='relative'>
384
+ <div className='w-10 h-10 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-full flex items-center justify-center shadow-md'>
385
+ {assistantIcon}
386
+ </div>
387
+ <div className={cn(
388
+ 'absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-white',
389
+ isConnected ? 'bg-green-500' : 'bg-gray-400'
390
+ )} />
391
+ </div>
392
+ <div>
393
+ <h3 className='font-bold text-sm'>{aiName}</h3>
394
+ <p className='text-[10px] text-gray-500 uppercase tracking-widest font-medium'>
395
+ {isConnected ? (t('online') || 'Online') : (t('offline') || 'Offline')}
396
+ </p>
397
+ </div>
398
+ </div>
399
+
400
+ <div className='flex items-center gap-1'>
401
+ <Button onClick={handleClearConversation} variant='ghost' size='icon' className='h-8 w-8 text-gray-400 hover:text-red-500' disabled={messages.length === 0}>
402
+ <RotateCcw className='w-4 h-4' />
403
+ </Button>
404
+ <Button onClick={() => setVoiceEnabled(!voiceEnabled)} variant='ghost' size='icon' className={cn('h-8 w-8', voiceEnabled ? 'text-blue-600' : 'text-gray-400')}>
405
+ {voiceEnabled ? <Volume2 className='w-4 h-4' /> : <VolumeX className='w-4 h-4' />}
406
+ </Button>
407
+ <Button onClick={onToggle} variant='ghost' size='icon' className='h-8 w-8 text-gray-400'>
408
+ <X className='w-4 h-4' />
409
+ </Button>
410
+ </div>
411
+ </div>
412
+
413
+ <ScrollArea className='flex-1 p-4 bg-gray-50/50'>
414
+ <div className='space-y-4'>
415
+ {sortedMessages.length === 0 && !isLoading && (
416
+ <div className='text-center py-12 px-6'>
417
+ <div className='w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center mx-auto mb-4'>
418
+ <Bot className='w-8 h-8 text-blue-500' />
419
+ </div>
420
+ <h4 className='text-gray-900 font-bold mb-1'>{emptyStateTitle}</h4>
421
+ <p className='text-xs text-gray-500'>{emptyStateSubtitle}</p>
422
+ </div>
423
+ )}
424
+
425
+ {sortedMessages.map((msg, i) => (
426
+ <div key={i} className={cn('flex gap-2', isAIMessage(msg) ? 'justify-start' : 'justify-end')}>
427
+ {isAIMessage(msg) && (
428
+ <Avatar className='h-7 w-7 mt-1 border'>
429
+ <AvatarFallback className='bg-blue-50 text-blue-600'><Bot className='h-4 w-4' /></AvatarFallback>
430
+ </Avatar>
431
+ )}
432
+ <div className={cn(
433
+ 'max-w-[80%] px-4 py-2.5 rounded-2xl text-sm shadow-sm transition-all',
434
+ isAIMessage(msg)
435
+ ? 'bg-white text-gray-800 rounded-tl-none border border-gray-100'
436
+ : 'bg-blue-600 text-white rounded-tr-none'
437
+ )}>
438
+ <p className='leading-relaxed'>{msg.text}</p>
439
+ {msg.hasVoiceResponse && msg.voiceUrl && (
440
+ <div className='mt-2'><AudioPlayer audioUrl={msg.voiceUrl} size='sm' /></div>
441
+ )}
442
+ {!isAIMessage(msg) && msg.userVoiceUrl && (
443
+ <div className='mt-2'><AudioPlayer audioUrl={msg.userVoiceUrl} size='sm' /></div>
444
+ )}
445
+ <span className={cn('text-[9px] mt-1 block opacity-50', isAIMessage(msg) ? 'text-gray-500' : 'text-white')}>
446
+ {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
447
+ </span>
448
+ </div>
449
+ </div>
450
+ ))}
451
+
452
+ {isTyping && (
453
+ <div className='flex gap-2 justify-start'>
454
+ <Avatar className='h-7 w-7 border'><AvatarFallback className='bg-blue-50'><Bot className='h-4 w-4 text-blue-500' /></AvatarFallback></Avatar>
455
+ <div className='bg-white border border-gray-100 rounded-2xl rounded-tl-none px-4 py-2.5 shadow-sm'>
456
+ <div className='flex gap-1'><span className='w-1.5 h-1.5 bg-blue-400 rounded-full animate-bounce'></span><span className='w-1.5 h-1.5 bg-blue-400 rounded-full animate-bounce delay-100'></span><span className='w-1.5 h-1.5 bg-blue-400 rounded-full animate-bounce delay-200'></span></div>
457
+ </div>
458
+ </div>
459
+ )}
460
+ <div ref={messagesEndRef} />
461
+ </div>
462
+ </ScrollArea>
463
+
464
+ <div className='p-4 border-t border-gray-100'>
465
+ {isRecording ? (
466
+ <div className='flex items-center justify-between bg-red-50 p-3 rounded-xl border border-red-100 animate-pulse'>
467
+ <div className='flex items-center gap-3 text-red-600 text-sm font-medium'>
468
+ <Mic className='w-4 h-4' /> Recording...
469
+ </div>
470
+ <Button onClick={stopRecording} size='sm' variant='ghost' className='text-red-600 hover:bg-red-100 h-8 w-8 p-0'><X className='w-4 h-4' /></Button>
471
+ </div>
472
+ ) : audioBlob ? (
473
+ <div className='flex items-center gap-2 bg-blue-50 p-2 rounded-xl border border-blue-100'>
474
+ <div className='flex-1'><AudioPlayer audioUrl={URL.createObjectURL(audioBlob)} size='sm' /></div>
475
+ <Button onClick={handleSendVoiceMessage} size='icon' className='rounded-full bg-blue-600 hover:bg-blue-700 h-9 w-9'><Send className='w-4 h-4' /></Button>
476
+ <Button onClick={() => setAudioBlob(null)} variant='ghost' size='icon' className='h-8 w-8 text-gray-400'><X className='w-4 h-4' /></Button>
477
+ </div>
478
+ ) : (
479
+ <div className='flex gap-2 items-center'>
480
+ <Input
481
+ ref={inputRef}
482
+ value={inputValue}
483
+ onChange={e => setInputValue(e.target.value)}
484
+ onKeyDown={handleKeyPress}
485
+ placeholder={placeholder}
486
+ disabled={isLoading}
487
+ className='flex-1 h-11 border-gray-200 focus-visible:ring-blue-500 rounded-xl text-black'
488
+ />
489
+ <div className='flex items-center gap-1'>
490
+ <Button
491
+ onClick={startRecording}
492
+ disabled={isLoading || isRequestingPermission}
493
+ size='icon'
494
+ variant='ghost'
495
+ className={cn('rounded-full h-10 w-10 text-gray-400 hover:text-blue-600 hover:bg-blue-50', isRequestingPermission && 'animate-spin')}
496
+ >
497
+ {isRequestingPermission ? <div className='w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full' /> : <Mic className='w-5 h-5' />}
498
+ </Button>
499
+ <Button
500
+ onClick={handleSendMessage}
501
+ disabled={!inputValue.trim() || isLoading}
502
+ size='icon'
503
+ className='rounded-full bg-blue-600 hover:bg-blue-700 h-10 w-10 shadow-lg shadow-blue-200'
504
+ >
505
+ <Send className='w-4 h-4' />
506
+ </Button>
507
+ </div>
508
+ </div>
509
+ )}
510
+ <p className='text-[10px] text-gray-400 mt-3 text-center px-4 leading-tight'>{disclaimer}</p>
511
+ </div>
512
+
513
+ <ConfirmDialog
514
+ title={dialogConfig.title}
515
+ description={dialogConfig.description}
516
+ confirmText={dialogConfig.confirmText}
517
+ cancelText={dialogConfig.cancelText}
518
+ variant={dialogConfig.variant}
519
+ />
520
+ </div>
521
+ );
522
+ };`,
523
+
524
+ 'components/layout/AIQuestionPopup.tsx': `'use client';
525
+ import React, {
526
+ useState,
527
+ useEffect,
528
+ useCallback,
529
+ forwardRef,
530
+ useImperativeHandle,
531
+ } from 'react';
532
+ import { Button } from '@/components/ui/button';
533
+ import { X, Bot, Pause, Play } from 'lucide-react';
534
+ import { cn } from '@/lib/utils';
535
+
536
+ export interface AIQuestion {
537
+ id: string | number;
538
+ text: string;
539
+ icon?: string;
540
+ }
541
+
542
+ interface AIQuestionPopupProps {
543
+ isOpen: boolean;
544
+ onToggle: () => void;
545
+ onQuestionClick: (question: string) => void;
546
+ questions: AIQuestion[];
547
+ title?: string;
548
+ subtitle?: string;
549
+ clickToAskText?: string;
550
+ initialDelay?: number;
551
+ intervalDelay?: number;
552
+ displayDuration?: number;
553
+ }
554
+
555
+ export interface AIQuestionPopupRef {
556
+ handleManualTrigger: () => void;
557
+ }
558
+
559
+ export const AIQuestionPopup = forwardRef<
560
+ AIQuestionPopupRef,
561
+ AIQuestionPopupProps
562
+ >(
563
+ (
564
+ {
565
+ isOpen,
566
+ onToggle,
567
+ onQuestionClick,
568
+ questions,
569
+ title = 'AI Assistant',
570
+ subtitle = 'Ask me anything!',
571
+ clickToAskText = 'Click to ask',
572
+ initialDelay = 10000,
573
+ intervalDelay = 30000,
574
+ displayDuration = 8000,
575
+ },
576
+ ref
577
+ ) => {
578
+ const [currentQuestion, setCurrentQuestion] = useState<AIQuestion | null>(
579
+ null
580
+ );
581
+ const [isVisible, setIsVisible] = useState(false);
582
+ const [showInterval, setShowInterval] = useState<NodeJS.Timeout | null>(
583
+ null
584
+ );
585
+ const [lastShown, setLastShown] = useState<Date | null>(null);
586
+ const [isPaused, setIsPaused] = useState(false);
587
+
588
+ const getRandomQuestion = useCallback(() => {
589
+ if (!questions || questions.length === 0) return null;
590
+ const randomIndex = Math.floor(Math.random() * questions.length);
591
+ return questions[randomIndex];
592
+ }, [questions]);
593
+
594
+ useImperativeHandle(
595
+ ref,
596
+ () => ({
597
+ handleManualTrigger: () => {
598
+ const question = getRandomQuestion();
599
+ if (question) {
600
+ setCurrentQuestion(question);
601
+ setIsVisible(true);
602
+ setLastShown(new Date());
603
+ }
604
+ },
605
+ }),
606
+ [getRandomQuestion]
607
+ );
608
+
609
+ useEffect(() => {
610
+ if (isPaused || !questions || questions.length === 0) return;
611
+
612
+ const showPopup = () => {
613
+ if (isOpen) return;
614
+
615
+ const question = getRandomQuestion();
616
+ if (question) {
617
+ setCurrentQuestion(question);
618
+ setIsVisible(true);
619
+ setLastShown(new Date());
620
+ }
621
+ };
622
+
623
+ const initialTimer = setTimeout(showPopup, initialDelay);
624
+ const interval = setInterval(showPopup, intervalDelay);
625
+ setShowInterval(interval);
626
+
627
+ return () => {
628
+ clearTimeout(initialTimer);
629
+ clearInterval(interval);
630
+ };
631
+ }, [isPaused, isOpen, questions, getRandomQuestion, initialDelay, intervalDelay]);
632
+
633
+ useEffect(() => {
634
+ if (isOpen && isVisible) {
635
+ setIsVisible(false);
636
+ setCurrentQuestion(null);
637
+ }
638
+ }, [isOpen, isVisible]);
639
+
640
+ useEffect(() => {
641
+ if (isVisible && currentQuestion) {
642
+ const hideTimer = setTimeout(() => {
643
+ setIsVisible(false);
644
+ setCurrentQuestion(null);
645
+ }, displayDuration);
646
+
647
+ return () => clearTimeout(hideTimer);
648
+ }
649
+ }, [isVisible, currentQuestion, displayDuration]);
650
+
651
+ const handleQuestionClick = useCallback(
652
+ (question: string) => {
653
+ onQuestionClick(question);
654
+ setIsVisible(false);
655
+ setCurrentQuestion(null);
656
+ },
657
+ [onQuestionClick]
658
+ );
659
+
660
+ const handleClose = useCallback(() => {
661
+ setIsVisible(false);
662
+ setCurrentQuestion(null);
663
+ }, []);
664
+
665
+ const handlePause = useCallback(() => {
666
+ setIsPaused(!isPaused);
667
+ if (showInterval && !isPaused) {
668
+ clearInterval(showInterval);
669
+ setShowInterval(null);
670
+ }
671
+ }, [isPaused, showInterval]);
672
+
673
+ if (!isVisible || !currentQuestion) return null;
674
+
675
+ return (
676
+ <div className='fixed z-50 bottom-32 md:bottom-38 md:right-12 right-4 pointer-events-none max-w-[350px] sm:max-w-xs'>
677
+ <div className='bg-white rounded-2xl shadow-2xl border border-gray-200 pointer-events-auto animate-fade-in-up relative'>
678
+ <div className='absolute -bottom-2 right-6 w-4 h-4 bg-white border-r border-b border-gray-200 transform rotate-45'></div>
679
+
680
+ <div className='flex items-center gap-3 p-4 border-b border-gray-100'>
681
+ <div className='relative'>
682
+ <div className='w-8 h-8 bg-gradient-to-r from-blue-900 to-blue-500 rounded-full flex items-center justify-center'>
683
+ <Bot className='w-4 h-4 text-white' />
684
+ </div>
685
+ <div className='absolute -top-1 -right-1 w-3 h-3 bg-blue-400 rounded-full border-2 border-white'></div>
686
+ </div>
687
+ <div className='flex-1'>
688
+ <p className='text-sm font-medium text-gray-900'>{title}</p>
689
+ <p className='text-xs text-gray-500'>{subtitle}</p>
690
+ </div>
691
+ <div className='flex items-center gap-1'>
692
+ <Button
693
+ variant='ghost'
694
+ size='sm'
695
+ onClick={handlePause}
696
+ className='p-1 h-6 w-6 text-gray-400 hover:text-gray-600'
697
+ title={isPaused ? 'Resume notifications' : 'Pause notifications'}
698
+ >
699
+ {isPaused ? <Play className='w-3 h-3' /> : <Pause className='w-3 h-3' />}
700
+ </Button>
701
+ <Button
702
+ variant='ghost'
703
+ size='sm'
704
+ onClick={handleClose}
705
+ className='p-1 h-6 w-6 text-gray-400 hover:text-gray-600'
706
+ >
707
+ <X className='w-3 h-3' />
708
+ </Button>
709
+ </div>
710
+ </div>
711
+
712
+ <div className='p-2'>
713
+ <Button
714
+ variant='outline'
715
+ className='justify-start text-left h-auto p-3 border-gray-200 hover:border-blue-600 hover:bg-blue-50 transition-colors bg-gray-50 w-full min-h-0'
716
+ onClick={() => handleQuestionClick(currentQuestion.text)}
717
+ >
718
+ <div className='flex items-start gap-3 w-full'>
719
+ {currentQuestion.icon && (
720
+ <span className='text-lg flex-shrink-0 mt-0.5'>{currentQuestion.icon}</span>
721
+ )}
722
+ <span className='break-words leading-relaxed text-gray-700 text-xs sm:text-sm flex-1 overflow-hidden'>
723
+ {currentQuestion.text}
724
+ </span>
725
+ </div>
726
+ </Button>
727
+ </div>
728
+
729
+ <div className='px-4 pb-3'>
730
+ <div className='flex items-center justify-between text-xs text-gray-400'>
731
+ <span>{clickToAskText}</span>
732
+ {lastShown && (
733
+ <span>
734
+ {lastShown.toLocaleTimeString([], {
735
+ hour: '2-digit',
736
+ minute: '2-digit',
737
+ })}
738
+ </span>
739
+ )}
740
+ </div>
741
+ </div>
742
+ </div>
743
+ </div>
744
+ );
745
+ }
746
+ );
747
+ AIQuestionPopup.displayName = 'AIQuestionPopup';
748
+ export default AIQuestionPopup;`,
749
+
750
+ 'components/ui/audio-player.tsx': `'use client';
751
+ import React, { useState, useRef } from 'react';
752
+ import { Play, Pause, Volume2 } from 'lucide-react';
753
+ import { cn } from '@/lib/utils';
754
+
755
+ interface AudioPlayerProps {
756
+ audioUrl: string;
757
+ className?: string;
758
+ size?: 'sm' | 'md' | 'lg';
759
+ }
760
+
761
+ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
762
+ audioUrl,
763
+ className = '',
764
+ size = 'md',
765
+ }) => {
766
+ const [isPlaying, setIsPlaying] = useState(false);
767
+ const [currentTime, setCurrentTime] = useState(0);
768
+ const [duration, setDuration] = useState(0);
769
+ const audioRef = useRef<HTMLAudioElement>(null);
770
+
771
+ const sizeClasses = {
772
+ sm: 'h-10 px-3',
773
+ md: 'h-12 px-4',
774
+ lg: 'h-14 px-5',
775
+ };
776
+
777
+ const formatTime = (time: number) => {
778
+ const minutes = Math.floor(time / 60);
779
+ const seconds = Math.floor(time % 60);
780
+ return \`\${minutes}:\${seconds.toString().padStart(2, '0')}\`;
781
+ };
782
+
783
+ const togglePlay = () => {
784
+ if (audioRef.current) {
785
+ if (isPlaying) {
786
+ audioRef.current.pause();
787
+ } else {
788
+ audioRef.current.play();
789
+ }
790
+ setIsPlaying(!isPlaying);
791
+ }
792
+ };
793
+
794
+ const handleTimeUpdate = () => {
795
+ if (audioRef.current) {
796
+ setCurrentTime(audioRef.current.currentTime);
797
+ }
798
+ };
799
+
800
+ const handleLoadedMetadata = () => {
801
+ if (audioRef.current) {
802
+ const duration = audioRef.current.duration;
803
+ if (isFinite(duration) && duration > 0) {
804
+ setDuration(duration);
805
+ }
806
+ }
807
+ };
808
+
809
+ const handleEnded = () => {
810
+ setIsPlaying(false);
811
+ setCurrentTime(0);
812
+ };
813
+
814
+ const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
815
+ if (audioRef.current && duration > 0) {
816
+ const rect = e.currentTarget.getBoundingClientRect();
817
+ const clickX = e.clientX - rect.left;
818
+ const percentage = clickX / rect.width;
819
+ const newTime = percentage * duration;
820
+ audioRef.current.currentTime = newTime;
821
+ setCurrentTime(newTime);
822
+ }
823
+ };
824
+
825
+ return (
826
+ <div className={cn('bg-gray-50 rounded-lg border flex items-center gap-3', sizeClasses[size], className)}>
827
+ <button
828
+ onClick={togglePlay}
829
+ className='flex-shrink-0 w-8 h-8 rounded-full bg-blue-500 hover:bg-blue-600 text-white flex items-center justify-center transition-colors'
830
+ >
831
+ {isPlaying ? <Pause size={16} /> : <Play size={16} />}
832
+ </button>
833
+
834
+ <div className='flex-1 flex flex-col gap-1'>
835
+ <div
836
+ className='w-full h-2 bg-gray-200 rounded-full cursor-pointer relative'
837
+ onClick={handleProgressClick}
838
+ >
839
+ <div
840
+ className='h-full bg-blue-500 rounded-full transition-all'
841
+ style={{ width: \`\${duration > 0 ? (currentTime / duration) * 100 : 0}%\` }}
842
+ />
843
+ </div>
844
+ <div className='flex justify-between text-xs text-gray-500'>
845
+ <span>{formatTime(currentTime)}</span>
846
+ <span>{formatTime(duration)}</span>
847
+ </div>
848
+ </div>
849
+ <Volume2 size={16} className='text-gray-400 flex-shrink-0' />
850
+ <audio
851
+ ref={audioRef}
852
+ src={audioUrl}
853
+ onTimeUpdate={handleTimeUpdate}
854
+ onLoadedMetadata={handleLoadedMetadata}
855
+ onEnded={handleEnded}
856
+ />
857
+ </div>
858
+ );
859
+ };`,
860
+
861
+ 'hooks/useAIChat.ts': `'use client';
862
+ import { useAIChatStore } from '@/store/features/aiChat/useAIChatStore';
863
+ import type { AIMessage } from '@/services/ai-service';
864
+
865
+ interface UseAIChatReturn {
866
+ messages: AIMessage[];
867
+ isLoading: boolean;
868
+ isTyping: boolean;
869
+ sendMessage: (
870
+ text: string,
871
+ audioUrl?: string,
872
+ replyConfig?: { sendText: boolean; sendVoice: boolean }
873
+ ) => Promise<void>;
874
+ clearConversation: () => Promise<void>;
875
+ error: string | null;
876
+ isConnected: boolean;
877
+ forceLoadMessages: () => Promise<any>;
878
+ }
879
+
880
+ export const useAIChat = (): UseAIChatReturn => {
881
+ return useAIChatStore();
882
+ };`,
883
+
884
+ 'hooks/use-media-query.ts': `import { useState, useEffect } from 'react';
885
+
886
+ export function useMediaQuery(query: string): boolean {
887
+ const [matches, setMatches] = useState(() => {
888
+ if (typeof window !== 'undefined') {
889
+ return window.matchMedia(query).matches;
890
+ }
891
+ return false;
892
+ });
893
+
894
+ useEffect(() => {
895
+ if (typeof window === 'undefined') return;
896
+
897
+ const mediaQuery = window.matchMedia(query);
898
+ setMatches(mediaQuery.matches);
899
+
900
+ const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
901
+ mediaQuery.addEventListener('change', handler);
902
+
903
+ return () => mediaQuery.removeEventListener('change', handler);
904
+ }, [query]);
905
+
906
+ return matches;
907
+ }`,
908
+
909
+ 'services/ai-service.ts': `import { api } from '@/lib/axios';
910
+
911
+ export interface AIMessage {
912
+ _id: string;
913
+ text: string;
914
+ senderId: string;
915
+ receiverId: string;
916
+ image?: string;
917
+ isAIResponse?: boolean;
918
+ isError?: boolean;
919
+ hasVoiceResponse?: boolean;
920
+ voiceUrl?: string;
921
+ userVoiceUrl?: string;
922
+ createdAt: string;
923
+ updatedAt: string;
924
+ }
925
+
926
+ class AIService {
927
+ private baseURL = '/api/ai';
928
+
929
+ async sendMessage(text: string): Promise<{ success: boolean }> {
930
+ try {
931
+ const response = await api.post(\`\${this.baseURL}/send\`, { text });
932
+ return response.data.data;
933
+ } catch (error) {
934
+ console.error('Error sending message to AI:', error);
935
+ throw error;
936
+ }
937
+ }
938
+
939
+ async getMessages(): Promise<AIMessage[]> {
940
+ try {
941
+ const response = await api.get(\`\${this.baseURL}/messages\`);
942
+ return response.data.data;
943
+ } catch (error) {
944
+ console.error('Error getting AI messages:', error);
945
+ throw error;
946
+ }
947
+ }
948
+
949
+ async clearConversation(): Promise<{ success: boolean }> {
950
+ try {
951
+ const response = await api.delete(\`\${this.baseURL}/clear\`);
952
+ return response.data.data;
953
+ } catch (error) {
954
+ console.error('Error clearing AI conversation:', error);
955
+ throw error;
956
+ }
957
+ }
958
+ }
959
+
960
+ export const aiService = new AIService();
961
+ export default aiService;`,
962
+
963
+ 'services/shared-socket-service.ts': `import { io, Socket } from 'socket.io-client';
964
+
965
+ let socket: Socket | null = null;
966
+ let isConnected = false;
967
+ let currentUserId: string | null = null;
968
+ let reconnectionDisabled = false;
969
+ let isSettingUp = false;
970
+
971
+ export const setupSocket = (userId: string): Socket => {
972
+ if (isSettingUp) return socket || io();
973
+ if (socket && socket.connected && currentUserId === userId) return socket;
974
+ if (socket && currentUserId === userId && !socket.connected && !reconnectionDisabled) {
975
+ socket.connect();
976
+ return socket;
977
+ }
978
+
979
+ if (socket) {
980
+ socket.removeAllListeners();
981
+ socket.disconnect();
982
+ socket = null;
983
+ isConnected = false;
984
+ }
985
+
986
+ isSettingUp = true;
987
+ let socketUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:5000';
988
+ socketUrl = socketUrl.replace('/api', '').replace(/\\/$/, '');
989
+
990
+ socket = io(socketUrl, {
991
+ path: '/socket.io/',
992
+ withCredentials: true,
993
+ transports: ['polling', 'websocket'],
994
+ timeout: 20000,
995
+ query: { userId },
996
+ auth: { userId },
997
+ reconnection: true,
998
+ reconnectionAttempts: 5,
999
+ forceNew: true,
1000
+ });
1001
+
1002
+ currentUserId = userId;
1003
+ isSettingUp = false;
1004
+
1005
+ socket.on('connect', () => {
1006
+ isConnected = true;
1007
+ reconnectionDisabled = false;
1008
+ socket?.emit('registerUser', userId);
1009
+ });
1010
+
1011
+ socket.on('disconnect', reason => {
1012
+ isConnected = false;
1013
+ if (reason === 'io server disconnect' || reason === 'transport close') {
1014
+ reconnectionDisabled = true;
1015
+ }
1016
+ });
1017
+
1018
+ return socket;
1019
+ };
1020
+
1021
+ export const getSocket = () => socket;
1022
+ export const isSocketConnected = () => socket?.connected || false;
1023
+ export const emit = (event: string, data?: any) => {
1024
+ if (socket && isConnected) socket.emit(event, data);
1025
+ };
1026
+ export const on = (event: string, callback: (data: any) => void) => socket?.on(event, callback);
1027
+ export const off = (event: string, callback?: (data: any) => void) => {
1028
+ if (callback) socket?.off(event, callback);
1029
+ else socket?.off(event);
1030
+ };
1031
+ export const disconnect = () => {
1032
+ if (socket) {
1033
+ socket.disconnect();
1034
+ socket = null;
1035
+ isConnected = false;
1036
+ }
1037
+ };`,
1038
+
1039
+ 'store/features/aiChat/aiChatSlice.ts': `import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
1040
+ import aiService, { type AIMessage } from '@/services/ai-service';
1041
+ import { RootState } from './index';
1042
+
1043
+ interface AIChatState {
1044
+ messages: AIMessage[];
1045
+ isLoading: boolean;
1046
+ isTyping: boolean;
1047
+ error: string | null;
1048
+ isConnected: boolean;
1049
+ messagesLoaded: boolean;
1050
+ lastLoadSource: 'socket' | 'rest' | null;
1051
+ }
1052
+
1053
+ const initialState: AIChatState = {
1054
+ messages: [],
1055
+ isLoading: false,
1056
+ isTyping: false,
1057
+ error: null,
1058
+ isConnected: false,
1059
+ messagesLoaded: false,
1060
+ lastLoadSource: null,
1061
+ };
1062
+
1063
+ export const loadMessages = createAsyncThunk('aiChat/loadMessages', async (_, { rejectWithValue }) => {
1064
+ try { return await aiService.getMessages() || []; }
1065
+ catch (error: any) { return rejectWithValue(error.message); }
1066
+ });
1067
+
1068
+ export const clearConversation = createAsyncThunk('aiChat/clearConversation', async (_, { rejectWithValue }) => {
1069
+ try { await aiService.clearConversation(); }
1070
+ catch (error) { return rejectWithValue('Failed to clear conversation'); }
1071
+ });
1072
+
1073
+ const aiChatSlice = createSlice({
1074
+ name: 'aiChat',
1075
+ initialState,
1076
+ reducers: {
1077
+ addMessage: (state, action) => {
1078
+ if (!state.messages) state.messages = [];
1079
+ if (!state.messages.some(m => m._id === action.payload._id)) state.messages.push(action.payload);
1080
+ },
1081
+ setMessages: (state, action) => { state.messages = action.payload; state.messagesLoaded = true; },
1082
+ setMessagesFromSocket: (state, action) => { state.messages = action.payload; state.messagesLoaded = true; state.lastLoadSource = 'socket'; },
1083
+ setMessagesFromRest: (state, action) => { if (state.lastLoadSource !== 'socket') { state.messages = action.payload; state.messagesLoaded = true; state.lastLoadSource = 'rest'; } },
1084
+ setTyping: (state, action) => { state.isTyping = action.payload; },
1085
+ setConnected: (state, action) => { state.isConnected = action.payload; },
1086
+ setError: (state, action) => { state.error = action.payload; },
1087
+ clearError: state => { state.error = null; },
1088
+ resetMessagesState: state => { Object.assign(state, initialState); },
1089
+ },
1090
+ extraReducers: builder => {
1091
+ builder
1092
+ .addCase(loadMessages.pending, state => { state.isLoading = true; })
1093
+ .addCase(loadMessages.fulfilled, (state, action) => { state.isLoading = false; if (state.lastLoadSource !== 'socket') { state.messages = action.payload; state.messagesLoaded = true; state.lastLoadSource = 'rest'; } })
1094
+ .addCase(loadMessages.rejected, (state, action) => { state.isLoading = false; state.error = action.payload as string; })
1095
+ .addCase(clearConversation.fulfilled, state => { state.isLoading = false; state.messages = []; });
1096
+ },
1097
+ });
1098
+
1099
+ export const { addMessage, setMessages, setMessagesFromSocket, setMessagesFromRest, setTyping, setConnected, setError, clearError, resetMessagesState } = aiChatSlice.actions;
1100
+ export const selectMessages = (state: RootState) => state.aiChat?.messages || [];
1101
+ export const selectIsLoading = (state: RootState) => state.aiChat?.isLoading || false;
1102
+ export const selectIsTyping = (state: RootState) => state.aiChat?.isTyping || false;
1103
+ export const selectError = (state: RootState) => state.aiChat?.error || null;
1104
+ export const selectIsConnected = (state: RootState) => state.aiChat?.isConnected || false;
1105
+ export const selectMessagesLoaded = (state: RootState) => state.aiChat?.messagesLoaded || false;
1106
+ export const selectLastLoadSource = (state: RootState) => state.aiChat?.lastLoadSource || null;
1107
+ export default aiChatSlice.reducer;`,
1108
+
1109
+ 'store/features/aiChat/index.ts': `import { configureStore, combineReducers } from '@reduxjs/toolkit';
1110
+ import aiChatReducer from './aiChatSlice';
1111
+
1112
+ export interface RootState {
1113
+ aiChat: ReturnType<typeof aiChatReducer>;
1114
+ }
1115
+
1116
+ const rootReducer = combineReducers({
1117
+ aiChat: aiChatReducer,
1118
+ });
1119
+
1120
+ let storeInstance: ReturnType<typeof configureStore> | undefined;
1121
+
1122
+ export const createStore = () => {
1123
+ if (typeof window === 'undefined') {
1124
+ return configureStore({ reducer: rootReducer, middleware: gdm => gdm({ serializableCheck: false }) });
1125
+ }
1126
+ if (!storeInstance) {
1127
+ storeInstance = configureStore({ reducer: rootReducer, middleware: gdm => gdm({ serializableCheck: false }) });
1128
+ }
1129
+ return storeInstance;
1130
+ };
1131
+
1132
+ export const store = createStore();
1133
+ export type AppDispatch = typeof store.dispatch;`,
1134
+
1135
+ 'store/features/aiChat/Provider.tsx': `'use client';
1136
+ import { Provider } from 'react-redux';
1137
+ import { store } from './index';
1138
+ import { useEffect, useState } from 'react';
1139
+
1140
+ export function StoreProvider({ children }: { children: React.ReactNode }) {
1141
+ const [mounted, setMounted] = useState(false);
1142
+ useEffect(() => {
1143
+ setMounted(true);
1144
+ }, []);
1145
+
1146
+ if (!mounted) return null;
1147
+
1148
+ return <Provider store={store}>{children}</Provider>;
1149
+ }`,
1150
+
1151
+ 'store/features/aiChat/useAIChatStore.ts': `'use client';
1152
+ import { useCallback, useEffect, useRef, useMemo } from 'react';
1153
+ import { useDispatch, useSelector } from 'react-redux';
1154
+ import { setupSocket, emit, on, off, isSocketConnected } from '@/services/shared-socket-service';
1155
+ import {
1156
+ addMessage,
1157
+ setMessages,
1158
+ setMessagesFromSocket,
1159
+ setTyping,
1160
+ setConnected,
1161
+ setError,
1162
+ clearError,
1163
+ loadMessages,
1164
+ clearConversation,
1165
+ resetMessagesState,
1166
+ selectMessages,
1167
+ selectIsLoading,
1168
+ selectIsTyping,
1169
+ selectError,
1170
+ selectIsConnected,
1171
+ selectMessagesLoaded,
1172
+ selectLastLoadSource,
1173
+ } from './aiChatSlice';
1174
+ import type { AIMessage } from '@/services/ai-service';
1175
+ import { AppDispatch } from './index';
1176
+
1177
+ const getGuestId = () => {
1178
+ if (typeof window === 'undefined') return null;
1179
+ let id = localStorage.getItem('ai_chat_guest_id');
1180
+ if (!id) {
1181
+ id = \`guest_\${Math.random().toString(36).substring(2, 11)}_\${Date.now()}\`;
1182
+ localStorage.setItem('ai_chat_guest_id', id);
1183
+ }
1184
+ return id;
1185
+ };
1186
+
1187
+ export const useAIChatStore = () => {
1188
+ const dispatch = useDispatch<AppDispatch>();
1189
+ const guestId = useMemo(() => getGuestId(), []);
1190
+ const effectiveId = guestId;
1191
+
1192
+ const messages = useSelector(selectMessages);
1193
+ const isLoading = useSelector(selectIsLoading);
1194
+ const isTyping = useSelector(selectIsTyping);
1195
+ const error = useSelector(selectError);
1196
+ const isConnected = useSelector(selectIsConnected);
1197
+ const messagesLoaded = useSelector(selectMessagesLoaded);
1198
+ const lastLoadSource = useSelector(selectLastLoadSource);
1199
+
1200
+ const prevIdRef = useRef<string | null>(null);
1201
+
1202
+ useEffect(() => {
1203
+ if (prevIdRef.current && prevIdRef.current !== effectiveId) dispatch(resetMessagesState());
1204
+ prevIdRef.current = effectiveId;
1205
+ }, [effectiveId, dispatch]);
1206
+
1207
+ const normalizeMessage = useCallback((msg: any): AIMessage => ({
1208
+ ...msg,
1209
+ isAIResponse: msg.isAIResponse ?? ['000000000000000000000001', 'ai-assistant'].includes(msg.senderId),
1210
+ isError: !!msg.isError,
1211
+ }), []);
1212
+
1213
+ useEffect(() => {
1214
+ if (!effectiveId) return;
1215
+ setupSocket(effectiveId);
1216
+
1217
+ const onConnect = () => {
1218
+ dispatch(setConnected(true));
1219
+ dispatch(clearError());
1220
+ emit('getAIConversationHistory', effectiveId);
1221
+ setTimeout(() => { if (!messagesLoaded) dispatch(loadMessages() as any); }, 3000);
1222
+ };
1223
+
1224
+ on('connected', onConnect);
1225
+ on('chatMessages', (msg: any) => {
1226
+ const normalized = normalizeMessage(msg);
1227
+ if (normalized.isAIResponse) { dispatch(setTyping(false)); dispatch(addMessage(normalized)); }
1228
+ });
1229
+ on('aiConversationHistory', (history: any[]) => {
1230
+ if (Array.isArray(history)) dispatch(setMessagesFromSocket(history.map(normalizeMessage)));
1231
+ });
1232
+
1233
+ if (isSocketConnected()) onConnect();
1234
+ return () => { off('connected'); off('chatMessages'); off('aiConversationHistory'); };
1235
+ }, [effectiveId, normalizeMessage, dispatch, messagesLoaded]);
1236
+
1237
+ const sendMessage = useCallback(async (text: string, audioUrl?: string, config?: any) => {
1238
+ if (!text.trim() && !audioUrl) return;
1239
+
1240
+ // Force Demo Mode logic
1241
+ dispatch(clearError());
1242
+ dispatch(setTyping(true));
1243
+
1244
+ const userMsg = {
1245
+ _id: \`opt-\${Date.now()}\`,
1246
+ text: text || 'Voice',
1247
+ senderId: effectiveId || 'guest',
1248
+ receiverId: 'ai-assistant',
1249
+ createdAt: new Date().toISOString(),
1250
+ updatedAt: new Date().toISOString(),
1251
+ isAIResponse: false,
1252
+ hasVoiceResponse: false,
1253
+ isError: false
1254
+ };
1255
+
1256
+ dispatch(addMessage(userMsg as any));
1257
+
1258
+ setTimeout(() => {
1259
+ dispatch(setTyping(false));
1260
+ const aiMsg = {
1261
+ _id: \`ai-\${Date.now()}\`,
1262
+ text: "I am currently in offline demo mode. To get real AI responses, please ensure the backend is connected.",
1263
+ senderId: 'ai-assistant',
1264
+ receiverId: effectiveId || 'guest',
1265
+ isAIResponse: true,
1266
+ createdAt: new Date().toISOString(),
1267
+ updatedAt: new Date().toISOString(),
1268
+ hasVoiceResponse: false,
1269
+ isError: false
1270
+ };
1271
+ dispatch(addMessage(aiMsg as any));
1272
+ }, 1500);
1273
+ }, [effectiveId, dispatch]);
1274
+
1275
+ const handleClearConversation = useCallback(async () => {
1276
+ // Demo/Offline mode clear
1277
+ dispatch(resetMessagesState());
1278
+ }, [dispatch]);
1279
+
1280
+ return { messages, isLoading, isTyping, error, isConnected, sendMessage, clearConversation: handleClearConversation, forceLoadMessages: () => dispatch(loadMessages() as any) };
1281
+ };`,
1282
+
1283
+ 'components/layout/ChatInput.tsx': `'use client';
1284
+ import { useState, useRef } from 'react';
1285
+ import { SendHorizonal } from 'lucide-react';
1286
+
1287
+ interface ChatInputProps {
1288
+ onSend: (text: string) => void;
1289
+ disabled?: boolean;
1290
+ }
1291
+
1292
+ export const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled }) => {
1293
+ const [value, setValue] = useState('');
1294
+ const inputRef = useRef<HTMLInputElement>(null);
1295
+
1296
+ const handleSend = () => {
1297
+ if (value.trim()) {
1298
+ onSend(value.trim());
1299
+ setValue('');
1300
+ inputRef.current?.focus();
1301
+ }
1302
+ };
1303
+
1304
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
1305
+ if (e.key === 'Enter') {
1306
+ handleSend();
1307
+ }
1308
+ };
1309
+
1310
+ return (
1311
+ <div className='flex items-center rounded-xl gap-2 p-2 border-t bg-white'>
1312
+ <input
1313
+ ref={inputRef}
1314
+ className='flex-1 px-3 py-2 rounded-lg border outline-none text-sm bg-gray-50'
1315
+ type='text'
1316
+ placeholder='Type your message...'
1317
+ value={value}
1318
+ onChange={e => setValue(e.target.value)}
1319
+ onKeyDown={handleKeyDown}
1320
+ disabled={disabled}
1321
+ />
1322
+ <button
1323
+ className='p-2 rounded-full bg-blue-600 text-white disabled:opacity-50'
1324
+ onClick={handleSend}
1325
+ disabled={disabled || !value.trim()}
1326
+ aria-label='Send'
1327
+ >
1328
+ <SendHorizonal size={20} />
1329
+ </button>
1330
+ </div>
1331
+ );
1332
+ };
1333
+ export default ChatInput;`,
1334
+
1335
+ 'components/layout/ChatMessages.tsx': `'use client';
1336
+ import { useRef, useEffect } from 'react';
1337
+
1338
+ interface Message {
1339
+ text: string;
1340
+ sender: 'user' | 'ai';
1341
+ isAIResponse?: boolean;
1342
+ isError?: boolean;
1343
+ }
1344
+
1345
+ interface ChatMessagesProps {
1346
+ messages: Message[];
1347
+ }
1348
+
1349
+ export const ChatMessages: React.FC<ChatMessagesProps> = ({ messages }) => {
1350
+ const bottomRef = useRef<HTMLDivElement>(null);
1351
+
1352
+ useEffect(() => {
1353
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
1354
+ }, [messages]);
1355
+
1356
+ return (
1357
+ <div className='flex-1 overflow-y-auto px-4 py-2 space-y-2'>
1358
+ {messages.map((msg, idx) => (
1359
+ <div
1360
+ key={idx}
1361
+ className={\`max-w-[80%] rounded-lg px-3 py-2 text-sm shadow-sm \${
1362
+ msg.isError
1363
+ ? 'bg-red-100 text-red-700 self-center'
1364
+ : msg.isAIResponse
1365
+ ? 'bg-gray-100 text-gray-800 self-start mr-auto'
1366
+ : 'bg-blue-600 text-white self-end ml-auto'
1367
+ }\`}
1368
+ >
1369
+ {msg.text}
1370
+ </div>
1371
+ ))}
1372
+ <div ref={bottomRef} />
1373
+ </div>
1374
+ );
1375
+ };
1376
+ export default ChatMessages;`,
1377
+
1378
+ 'components/layout/ChatWidget.tsx': `'use client';
1379
+ import { useState } from 'react';
1380
+ import { X } from 'lucide-react';
1381
+ import ChatInput from './ChatInput';
1382
+ import ChatMessages from './ChatMessages';
1383
+
1384
+ interface Message {
1385
+ text: string;
1386
+ sender: 'user' | 'ai';
1387
+ }
1388
+
1389
+ const mockAIResponse = async (userText: string): Promise<string> => {
1390
+ await new Promise(res => setTimeout(res, 800));
1391
+ return \`AI: You said "\${userText}"\`;
1392
+ };
1393
+
1394
+ export const ChatWidget = ({ onClose }: { onClose: () => void }) => {
1395
+ const [messages, setMessages] = useState<Message[]>([]);
1396
+ const [loading, setLoading] = useState(false);
1397
+
1398
+ const handleSend = async (text: string) => {
1399
+ setMessages(msgs => [...msgs, { text, sender: 'user' }]);
1400
+ setLoading(true);
1401
+ const aiText = await mockAIResponse(text);
1402
+ setMessages(msgs => [...msgs, { text: aiText, sender: 'ai' }]);
1403
+ setLoading(false);
1404
+ };
1405
+
1406
+ return (
1407
+ <div className='h-96 z-50 max-w-sm bg-white rounded-xl shadow-2xl flex flex-col border border-gray-200'>
1408
+ <div className='flex items-center justify-between px-4 py-2 border-b bg-blue-600 rounded-t-xl'>
1409
+ <span className='text-white font-semibold'>AI Chat</span>
1410
+ <button
1411
+ onClick={onClose}
1412
+ className='text-white hover:text-red-400 p-1 rounded-full'
1413
+ >
1414
+ <X size={20} />
1415
+ </button>
1416
+ </div>
1417
+ <ChatMessages messages={messages as any} />
1418
+ <ChatInput onSend={handleSend} disabled={loading} />
1419
+ </div>
1420
+ );
1421
+ };
1422
+ export default ChatWidget;`,
1423
+
1424
+ 'components/ui/confirmation-dialog.tsx': `'use client';
1425
+
1426
+ import * as React from 'react';
1427
+ import {
1428
+ AlertTriangle,
1429
+ Trash2,
1430
+ Edit,
1431
+ Info,
1432
+ CheckCircle,
1433
+ Loader2,
1434
+ } from 'lucide-react';
1435
+ import {
1436
+ Dialog,
1437
+ DialogContent,
1438
+ DialogDescription,
1439
+ DialogFooter,
1440
+ DialogHeader,
1441
+ DialogTitle,
1442
+ } from '@/components/ui/dialog';
1443
+ import { Button } from '@/components/ui/button';
1444
+ import { Input } from '@/components/ui/input';
1445
+ import { Label } from '@/components/ui/label';
1446
+ import { cn } from '@/lib/utils';
1447
+
1448
+ // Confirmation dialog variants
1449
+ export type ConfirmVariant = 'danger' | 'warning' | 'info' | 'success';
1450
+
1451
+ // Variant configurations
1452
+ const variantConfig = {
1453
+ danger: {
1454
+ icon: Trash2,
1455
+ iconColor: 'text-red-500',
1456
+ iconBg: 'bg-red-50 dark:bg-red-900/20',
1457
+ confirmButton: 'destructive',
1458
+ },
1459
+ warning: {
1460
+ icon: AlertTriangle,
1461
+ iconColor: 'text-amber-500',
1462
+ iconBg: 'bg-amber-50 dark:bg-amber-900/20',
1463
+ confirmButton: 'default',
1464
+ },
1465
+ info: {
1466
+ icon: Info,
1467
+ iconColor: 'text-blue-500',
1468
+ iconBg: 'bg-blue-50 dark:bg-blue-900/20',
1469
+ confirmButton: 'default',
1470
+ },
1471
+ success: {
1472
+ icon: CheckCircle,
1473
+ iconColor: 'text-green-500',
1474
+ iconBg: 'bg-green-50 dark:bg-green-900/20',
1475
+ confirmButton: 'default',
1476
+ },
1477
+ };
1478
+
1479
+ export interface ConfirmationDialogProps {
1480
+ /** Whether the dialog is open */
1481
+ open: boolean;
1482
+ /** Callback when open state changes */
1483
+ onOpenChange: (open: boolean) => void;
1484
+ /** Dialog title */
1485
+ title: string;
1486
+ /** Dialog description/message */
1487
+ description: string;
1488
+ /** Confirm button text */
1489
+ confirmText?: string;
1490
+ /** Cancel button text */
1491
+ cancelText?: string;
1492
+ /** Optional text that must be typed to enable confirmation */
1493
+ requiredConfirmationText?: string;
1494
+ /** Label for the confirmation input */
1495
+ confirmationInputLabel?: string;
1496
+ /** Callback when confirmed */
1497
+ onConfirm: () => void | Promise<void>;
1498
+ /** Callback when cancelled */
1499
+ onCancel?: () => void;
1500
+ /** Dialog variant - affects styling */
1501
+ variant?: ConfirmVariant;
1502
+ /** Whether the confirm action is loading */
1503
+ isLoading?: boolean;
1504
+ /** Custom icon component */
1505
+ icon?: React.ComponentType<{ className?: string }>;
1506
+ /** Whether to show the icon */
1507
+ showIcon?: boolean;
1508
+ }
1509
+
1510
+ export function ConfirmationDialog({
1511
+ open,
1512
+ onOpenChange,
1513
+ title,
1514
+ description,
1515
+ confirmText = 'Confirm',
1516
+ cancelText = 'Cancel',
1517
+ requiredConfirmationText,
1518
+ confirmationInputLabel,
1519
+ onConfirm,
1520
+ onCancel,
1521
+ variant = 'danger',
1522
+ isLoading = false,
1523
+ icon,
1524
+ showIcon = true,
1525
+ }: ConfirmationDialogProps) {
1526
+ const [inputValue, setInputValue] = React.useState('');
1527
+ const config = variantConfig[variant];
1528
+ const IconComponent = icon || config.icon;
1529
+
1530
+ const isConfirmDisabled =
1531
+ isLoading ||
1532
+ (!!requiredConfirmationText &&
1533
+ inputValue.toLowerCase() !== requiredConfirmationText.toLowerCase());
1534
+
1535
+ // Reset input when dialog opens/closes
1536
+ React.useEffect(() => {
1537
+ if (!open) {
1538
+ setInputValue('');
1539
+ }
1540
+ }, [open]);
1541
+
1542
+ const handleConfirm = async () => {
1543
+ if (isConfirmDisabled) return;
1544
+ await onConfirm();
1545
+ onOpenChange(false);
1546
+ };
1547
+
1548
+ const handleCancel = () => {
1549
+ onCancel?.();
1550
+ onOpenChange(false);
1551
+ };
1552
+
1553
+ return (
1554
+ <Dialog open={open} onOpenChange={onOpenChange}>
1555
+ <DialogContent className='sm:max-w-[420px] p-0 overflow-hidden border-zinc-200 dark:border-zinc-800 shadow-2xl'>
1556
+ <div className='p-6 pt-8 space-y-6'>
1557
+ <DialogHeader className='flex flex-col items-center text-center sm:text-center space-y-4'>
1558
+ {showIcon && (
1559
+ <div
1560
+ className={cn(
1561
+ 'rounded-2xl p-4 w-fit mx-auto transition-colors duration-300',
1562
+ config.iconBg
1563
+ )}
1564
+ >
1565
+ <IconComponent className={cn('size-8', config.iconColor)} />
1566
+ </div>
1567
+ )}
1568
+ <div className='space-y-2'>
1569
+ <DialogTitle className='text-xl font-bold tracking-tight text-center'>
1570
+ {title}
1571
+ </DialogTitle>
1572
+ <DialogDescription className='text-zinc-500 dark:text-zinc-400 text-sm leading-relaxed max-w-[320px] mx-auto text-center'>
1573
+ {description}
1574
+ </DialogDescription>
1575
+ </div>
1576
+ </DialogHeader>
1577
+
1578
+ {requiredConfirmationText && (
1579
+ <div className='space-y-3 px-2'>
1580
+ <Label className='text-xs font-semibold tracking-wider text-zinc-500 dark:text-zinc-400'>
1581
+ {confirmationInputLabel ||
1582
+ \`Type "\${requiredConfirmationText}" to confirm\`}
1583
+ </Label>
1584
+ <Input
1585
+ value={inputValue}
1586
+ onChange={e => setInputValue(e.target.value)}
1587
+ placeholder={requiredConfirmationText}
1588
+ className='h-12 text-center font-medium border-2 focus-visible:ring-0 focus-visible:border-black dark:focus-visible:border-white transition-all'
1589
+ autoFocus
1590
+ />
1591
+ </div>
1592
+ )}
1593
+
1594
+ <DialogFooter className='flex flex-col sm:flex-row gap-3 sm:justify-stretch mt-2'>
1595
+ <Button
1596
+ variant='outline'
1597
+ onClick={handleCancel}
1598
+ disabled={isLoading}
1599
+ className='flex-1 h-11 border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-all'
1600
+ >
1601
+ {cancelText}
1602
+ </Button>
1603
+ <Button
1604
+ variant={variant === 'danger' ? 'destructive' : 'default'}
1605
+ onClick={handleConfirm}
1606
+ disabled={isConfirmDisabled}
1607
+ className={cn(
1608
+ 'flex-1 h-11 font-semibold transition-all',
1609
+ variant === 'danger' &&
1610
+ !isConfirmDisabled &&
1611
+ 'bg-red-600 hover:bg-red-700'
1612
+ )}
1613
+ >
1614
+ {isLoading ? (
1615
+ <div className='flex items-center gap-2'>
1616
+ <Loader2 className='size-4 animate-spin' />
1617
+ <span>Processing...</span>
1618
+ </div>
1619
+ ) : (
1620
+ confirmText
1621
+ )}
1622
+ </Button>
1623
+ </DialogFooter>
1624
+ </div>
1625
+ </DialogContent>
1626
+ </Dialog>
1627
+ );
1628
+ }
1629
+
1630
+ export interface DeleteConfirmationProps {
1631
+ open: boolean;
1632
+ onOpenChange: (open: boolean) => void;
1633
+ onConfirm: () => void | Promise<void>;
1634
+ itemName?: string;
1635
+ isLoading?: boolean;
1636
+ /** Optional text that must be typed to enable deletion (e.g. 'DELETE') */
1637
+ requiredConfirmationText?: string;
1638
+ }
1639
+
1640
+ export function DeleteConfirmation({
1641
+ open,
1642
+ onOpenChange,
1643
+ onConfirm,
1644
+ itemName = 'this item',
1645
+ isLoading,
1646
+ requiredConfirmationText,
1647
+ }: DeleteConfirmationProps) {
1648
+ return (
1649
+ <ConfirmationDialog
1650
+ open={open}
1651
+ onOpenChange={onOpenChange}
1652
+ title='Delete Confirmation'
1653
+ description={\`Are you sure you want to delete \${itemName}? This action cannot be undone.\`}
1654
+ confirmText='Delete'
1655
+ cancelText='Cancel'
1656
+ requiredConfirmationText={requiredConfirmationText}
1657
+ onConfirm={onConfirm}
1658
+ variant='danger'
1659
+ isLoading={isLoading}
1660
+ icon={Trash2}
1661
+ />
1662
+ );
1663
+ }
1664
+
1665
+ export interface EditConfirmationProps {
1666
+ open: boolean;
1667
+ onOpenChange: (open: boolean) => void;
1668
+ onConfirm: () => void | Promise<void>;
1669
+ message?: string;
1670
+ isLoading?: boolean;
1671
+ }
1672
+
1673
+ export function EditConfirmation({
1674
+ open,
1675
+ onOpenChange,
1676
+ onConfirm,
1677
+ message = 'Are you sure you want to save these changes?',
1678
+ isLoading,
1679
+ }: EditConfirmationProps) {
1680
+ return (
1681
+ <ConfirmationDialog
1682
+ open={open}
1683
+ onOpenChange={onOpenChange}
1684
+ title='Confirm Changes'
1685
+ description={message}
1686
+ confirmText='Save Changes'
1687
+ cancelText='Cancel'
1688
+ onConfirm={onConfirm}
1689
+ variant='warning'
1690
+ isLoading={isLoading}
1691
+ icon={Edit}
1692
+ />
1693
+ );
1694
+ }
1695
+
1696
+ export interface UnsavedChangesConfirmationProps {
1697
+ open: boolean;
1698
+ onOpenChange: (open: boolean) => void;
1699
+ onConfirm: () => void | Promise<void>;
1700
+ onCancel?: () => void;
1701
+ }
1702
+
1703
+ export function UnsavedChangesConfirmation({
1704
+ open,
1705
+ onOpenChange,
1706
+ onConfirm,
1707
+ onCancel,
1708
+ }: UnsavedChangesConfirmationProps) {
1709
+ return (
1710
+ <ConfirmationDialog
1711
+ open={open}
1712
+ onOpenChange={onOpenChange}
1713
+ title='Unsaved Changes'
1714
+ description='You have unsaved changes. Are you sure you want to leave? Your changes will be lost.'
1715
+ confirmText='Leave'
1716
+ cancelText='Stay'
1717
+ onConfirm={onConfirm}
1718
+ onCancel={onCancel}
1719
+ variant='warning'
1720
+ icon={AlertTriangle}
1721
+ />
1722
+ );
1723
+ }
1724
+
1725
+ export interface UseConfirmationReturn {
1726
+ isOpen: boolean;
1727
+ open: () => void;
1728
+ close: () => void;
1729
+ confirm: () => Promise<boolean>;
1730
+ ConfirmDialog: React.FC<
1731
+ Omit<ConfirmationDialogProps, 'open' | 'onOpenChange' | 'onConfirm'>
1732
+ >;
1733
+ }
1734
+
1735
+ export function useConfirmation(): UseConfirmationReturn {
1736
+ const [isOpen, setIsOpen] = React.useState(false);
1737
+ const resolveRef = React.useRef<(value: boolean) => void>(() => {
1738
+ return;
1739
+ });
1740
+
1741
+ const open = React.useCallback(() => setIsOpen(true), []);
1742
+ const close = React.useCallback(() => setIsOpen(false), []);
1743
+
1744
+ const confirm = React.useCallback(() => {
1745
+ setIsOpen(true);
1746
+ return new Promise<boolean>(resolve => {
1747
+ resolveRef.current = resolve;
1748
+ });
1749
+ }, []);
1750
+
1751
+ const handleConfirm = React.useCallback(() => {
1752
+ resolveRef.current?.(true);
1753
+ setIsOpen(false);
1754
+ }, []);
1755
+
1756
+ const handleCancel = React.useCallback(() => {
1757
+ resolveRef.current?.(false);
1758
+ setIsOpen(false);
1759
+ }, []);
1760
+
1761
+ const ConfirmDialog: React.FC<
1762
+ Omit<ConfirmationDialogProps, 'open' | 'onOpenChange' | 'onConfirm'>
1763
+ > = React.useCallback(
1764
+ props => (
1765
+ <ConfirmationDialog
1766
+ {...props}
1767
+ open={isOpen}
1768
+ onOpenChange={open => {
1769
+ if (!open) handleCancel();
1770
+ }}
1771
+ onConfirm={handleConfirm}
1772
+ onCancel={handleCancel}
1773
+ />
1774
+ ),
1775
+ [isOpen, handleConfirm, handleCancel]
1776
+ );
1777
+
1778
+ return {
1779
+ isOpen,
1780
+ open,
1781
+ close,
1782
+ confirm,
1783
+ ConfirmDialog,
1784
+ };
1785
+ }`,
1786
+ };