Initial commit
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user