478 lines
16 KiB
TypeScript
478 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import {
|
|
Loader2,
|
|
MessageSquare,
|
|
Mic,
|
|
MicOff,
|
|
PhoneCall,
|
|
Send,
|
|
Sparkles,
|
|
} from 'lucide-react';
|
|
import { useVapi } from '@/hooks/useVapi';
|
|
import toast from 'react-hot-toast';
|
|
|
|
interface Message {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
export default function Chat() {
|
|
const { user, isLoading: authLoading } = useAuth();
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [input, setInput] = useState('');
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [permissionError, setPermissionError] = useState<string | null>(null);
|
|
|
|
// Voice state
|
|
const [isListening, setIsListening] = useState(false);
|
|
const recognitionRef = useRef<any>(null);
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
const { status, error: callError, durationSeconds, callStats, startCall, endCall } =
|
|
useVapi();
|
|
|
|
// Redirect if not logged in
|
|
useEffect(() => {
|
|
if (!authLoading && !user) {
|
|
router.push('/login');
|
|
}
|
|
}, [user, authLoading, router]);
|
|
|
|
// Auto-scroll
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages]);
|
|
|
|
// Init speech recognition (client only)
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const SpeechRecognition =
|
|
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
|
|
|
if (!SpeechRecognition) {
|
|
console.warn('Speech recognition not supported in this browser');
|
|
return;
|
|
}
|
|
|
|
const recognition = new SpeechRecognition();
|
|
recognition.lang = 'en-US'; // change to 'es-ES' etc if you want
|
|
recognition.continuous = false;
|
|
recognition.interimResults = true;
|
|
|
|
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
|
let finalText = '';
|
|
let interimText = '';
|
|
|
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
const result = event.results[i];
|
|
if (result.isFinal) {
|
|
finalText += result[0].transcript;
|
|
} else {
|
|
interimText += result[0].transcript;
|
|
}
|
|
}
|
|
|
|
// show interim in input while speaking
|
|
if (interimText) {
|
|
setInput(interimText);
|
|
}
|
|
|
|
if (finalText) {
|
|
const cleaned = finalText.trim();
|
|
setInput(cleaned);
|
|
// If you want auto-send after speech, uncomment:
|
|
// if (cleaned) {
|
|
// handleVoiceSend(cleaned);
|
|
// }
|
|
}
|
|
};
|
|
|
|
recognition.onerror = (event: any) => {
|
|
console.error('Speech recognition error', event.error);
|
|
setIsListening(false);
|
|
};
|
|
|
|
recognition.onend = () => {
|
|
setIsListening(false);
|
|
};
|
|
|
|
recognitionRef.current = recognition;
|
|
|
|
return () => {
|
|
try {
|
|
recognition.stop();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
recognitionRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
const sendMessage = async (
|
|
e?: React.FormEvent,
|
|
overrideText?: string,
|
|
) => {
|
|
if (e) e.preventDefault();
|
|
|
|
const textToSend = overrideText ?? input;
|
|
if (!textToSend.trim() || isSending) return;
|
|
|
|
const userMessage: Message = { role: 'user', content: textToSend };
|
|
const updatedMessages = [...messages, userMessage];
|
|
setMessages(updatedMessages);
|
|
setInput('');
|
|
setIsSending(true);
|
|
|
|
try {
|
|
const response = await fetch('/api/chat', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
messages: updatedMessages,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to get response');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
throw new Error(data.error);
|
|
}
|
|
|
|
setMessages([
|
|
...updatedMessages,
|
|
{ role: 'assistant', content: data.text },
|
|
]);
|
|
} catch (error) {
|
|
console.error('Error sending message:', error);
|
|
setMessages([
|
|
...updatedMessages,
|
|
{
|
|
role: 'assistant',
|
|
content:
|
|
'Sorry, I encountered an error. Please try again.',
|
|
},
|
|
]);
|
|
toast.error('Unable to send your message.');
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
};
|
|
|
|
// Manual send from voice text (if you want to click after dictation)
|
|
const handleVoiceSend = (text: string) => {
|
|
sendMessage(undefined, text);
|
|
};
|
|
|
|
const toggleListening = () => {
|
|
const recognition = recognitionRef.current;
|
|
if (!recognition) {
|
|
console.warn('No recognition instance; browser may not support it');
|
|
return;
|
|
}
|
|
|
|
if (!isListening) {
|
|
setInput('');
|
|
setIsListening(true);
|
|
try {
|
|
recognition.start();
|
|
} catch (err) {
|
|
console.error('Error starting recognition', err);
|
|
setIsListening(false);
|
|
}
|
|
} else {
|
|
try {
|
|
recognition.stop();
|
|
} catch (err) {
|
|
console.error('Error stopping recognition', err);
|
|
}
|
|
setIsListening(false);
|
|
}
|
|
};
|
|
|
|
const formatDuration = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}m ${secs.toString().padStart(2, '0')}s`;
|
|
};
|
|
|
|
const callStatusLabel = useMemo(() => {
|
|
if (status === 'connecting') return 'Connecting...';
|
|
if (status === 'active') return 'On Call';
|
|
if (status === 'ended') return 'Call Ended';
|
|
if (status === 'error') return 'Error';
|
|
return 'Ready to Call';
|
|
}, [status]);
|
|
|
|
const handleVoiceCall = async () => {
|
|
setPermissionError(null);
|
|
|
|
if (status === 'active' || status === 'connecting') {
|
|
await endCall();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
} catch (error) {
|
|
console.error('Microphone permission denied', error);
|
|
setPermissionError(
|
|
'Microphone access is required. Please allow permission and use a modern browser.'
|
|
);
|
|
toast.error('Microphone access is required to start a call.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await startCall();
|
|
toast.success('Call started.');
|
|
} catch (error) {
|
|
console.error('Unable to start call', error);
|
|
toast.error('Unable to start the call.');
|
|
}
|
|
};
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] flex items-center justify-center">
|
|
<div className="text-white text-xl animate-pulse">Loading...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const navLinks = [
|
|
{ label: 'Dashboard', href: '/dashboard' },
|
|
{ label: 'Scheduled Calls', href: '/dashboard/scheduled-calls' },
|
|
{ label: 'Voice Agent', href: '/dashboard/agent-settings' },
|
|
{ label: 'Credits', href: '/dashboard/credits' },
|
|
{ label: 'Notifications', href: '/dashboard/notifications' },
|
|
];
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-[#0f0b1a] via-[#1b1330] to-[#0f1b3d] text-white">
|
|
<nav className="border-b border-white/10 bg-white/5 backdrop-blur-sm">
|
|
<div className="max-w-5xl mx-auto px-6 py-4 flex flex-wrap items-center justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-10 w-10 rounded-2xl bg-[#8b5cf6]/20 border border-[#8b5cf6]/40 flex items-center justify-center">
|
|
<MessageSquare className="h-5 w-5 text-[#8b5cf6]" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">Chat</h1>
|
|
<p className="text-xs text-[#9ca3af]">Talk to HolaCompi in real time</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{navLinks.map((link) => {
|
|
const isActive = pathname === link.href;
|
|
return (
|
|
<Link
|
|
key={link.href}
|
|
href={link.href}
|
|
className={`px-3 py-1.5 rounded-full border text-xs transition ${
|
|
isActive
|
|
? 'bg-[#8b5cf6]/20 border-[#8b5cf6]/40 text-white'
|
|
: 'bg-white/5 border-white/10 text-[#9ca3af] hover:text-white'
|
|
}`}
|
|
>
|
|
{link.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="max-w-5xl mx-auto px-6 py-8">
|
|
<div className="bg-[#1a1625] rounded-3xl border border-[#8b5cf6]/20 p-6 shadow-2xl h-[70vh] flex flex-col">
|
|
<div className="mb-4 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4 flex flex-col gap-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className={`h-10 w-10 rounded-2xl flex items-center justify-center border ${
|
|
status === 'active'
|
|
? 'bg-red-500/20 border-red-400/40'
|
|
: 'bg-[#8b5cf6]/20 border-[#8b5cf6]/40'
|
|
}`}
|
|
>
|
|
<PhoneCall
|
|
className={`h-5 w-5 ${
|
|
status === 'active' ? 'text-red-300' : 'text-[#8b5cf6]'
|
|
}`}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold">{callStatusLabel}</p>
|
|
<div className="text-xs text-[#9ca3af] flex items-center gap-2">
|
|
{status === 'connecting' && (
|
|
<>
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Connecting to Vapi...
|
|
</>
|
|
)}
|
|
{status === 'active' && (
|
|
<>
|
|
<span className="h-2 w-2 rounded-full bg-red-400 animate-pulse" />
|
|
Duration: {formatDuration(durationSeconds)}
|
|
</>
|
|
)}
|
|
{status === 'idle' && 'Tap start to begin a voice call'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleVoiceCall}
|
|
disabled={status === 'connecting'}
|
|
className={`px-5 py-3 rounded-2xl font-semibold shadow-2xl transition flex items-center gap-2 ${
|
|
status === 'active'
|
|
? 'bg-red-500 text-white hover:bg-red-600'
|
|
: 'bg-gradient-to-r from-[#8b5cf6] to-[#4f46e5] text-white hover:from-[#7c3aed] hover:to-[#4338ca]'
|
|
} ${status === 'connecting' ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
>
|
|
<Mic className="h-4 w-4" />
|
|
{status === 'active'
|
|
? 'End Call'
|
|
: status === 'connecting'
|
|
? 'Connecting...'
|
|
: 'Start Call'}
|
|
</button>
|
|
</div>
|
|
|
|
{(permissionError || callError) && (
|
|
<div className="text-xs text-red-300">
|
|
{permissionError || callError}
|
|
</div>
|
|
)}
|
|
|
|
{status === 'ended' && callStats && (
|
|
<div className="rounded-2xl border border-white/10 bg-[#0f0b1a]/60 p-4">
|
|
<p className="text-sm font-semibold">Call Summary</p>
|
|
<div className="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-[#9ca3af]">
|
|
<div>
|
|
<p className="uppercase">Duration</p>
|
|
<p className="text-white">{formatDuration(callStats.durationSeconds)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="uppercase">Credits Used</p>
|
|
<p className="text-white">{callStats.creditsUsed}</p>
|
|
</div>
|
|
<div>
|
|
<p className="uppercase">Transcript</p>
|
|
<button className="text-[#8b5cf6] hover:text-white transition">
|
|
View Transcript
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto mb-4 space-y-4">
|
|
{messages.length === 0 && (
|
|
<div className="text-[#9ca3af] text-center mt-20">
|
|
Start a conversation with your AI companion
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((message, index) => (
|
|
<div
|
|
key={index}
|
|
className={`flex ${
|
|
message.role === 'user'
|
|
? 'justify-end'
|
|
: 'justify-start'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`max-w-[70%] rounded-2xl p-4 shadow ${
|
|
message.role === 'user'
|
|
? 'bg-[#8b5cf6] text-white'
|
|
: 'bg-[#0f0b1a] border border-white/10 text-white'
|
|
}`}
|
|
>
|
|
<p className="whitespace-pre-wrap">
|
|
{message.content}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{isSending && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-[#0f0b1a] border border-white/10 rounded-2xl p-4">
|
|
<div className="flex space-x-2">
|
|
<div className="w-2 h-2 bg-[#9ca3af] rounded-full animate-bounce"></div>
|
|
<div
|
|
className="w-2 h-2 bg-[#9ca3af] rounded-full animate-bounce"
|
|
style={{ animationDelay: '0.1s' }}
|
|
></div>
|
|
<div
|
|
className="w-2 h-2 bg-[#9ca3af] rounded-full animate-bounce"
|
|
style={{ animationDelay: '0.2s' }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
<form onSubmit={(e) => sendMessage(e)} className="flex gap-2 items-center">
|
|
<div className="flex-1 flex items-center gap-2 bg-[#0f0b1a] border border-white/10 rounded-2xl px-4 py-3 focus-within:ring-2 focus-within:ring-[#8b5cf6] transition">
|
|
<Sparkles className="h-4 w-4 text-[#8b5cf6]" />
|
|
<input
|
|
type="text"
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
placeholder={isListening ? 'Listening...' : 'Type your message...'}
|
|
className="flex-1 bg-transparent text-white placeholder:text-[#9ca3af] focus:outline-none"
|
|
disabled={isSending}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={toggleListening}
|
|
className={`px-4 py-3 rounded-2xl font-semibold transition flex items-center gap-2 ${
|
|
isListening
|
|
? 'bg-red-500/80 hover:bg-red-500 text-white'
|
|
: 'bg-white/5 border border-white/10 text-[#9ca3af] hover:text-white'
|
|
}`}
|
|
>
|
|
{isListening ? <MicOff className="h-4 w-4" /> : <Mic className="h-4 w-4" />}
|
|
</button>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isSending || !input.trim()}
|
|
className="bg-[#8b5cf6] hover:bg-[#7c3aed] disabled:bg-white/10 disabled:text-[#9ca3af] disabled:cursor-not-allowed px-6 py-3 rounded-2xl font-semibold transition flex items-center gap-2"
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
Send
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|