Files
holacompi/app/dashboard/chat/page.tsx
T
Haitham Khalifa b538d84e17 Initial commit
2026-02-16 12:18:06 +01:00

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>
);
}