Files
sitemente/components/SiteMenteVoiceWidget.tsx
T
Haitham Khalifa 11252e6520 Initial commit
2026-02-16 12:02:45 +01:00

505 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
type SpeechRecognitionInstance = {
lang: string;
interimResults: boolean;
continuous: boolean;
onresult: ((event: { results: Array<{ 0?: { transcript?: string } }> }) => void) | null;
onerror: ((event: unknown) => void) | null;
onend: (() => void) | null;
start: () => void;
stop: () => void;
};
type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance;
type ChatMessage = {
role: "user" | "assistant";
content: string;
timestamp: number;
};
type ApiResponse = {
response: string;
shouldCaptureEmail: boolean;
suggestedActions: string[];
};
type SiteMenteVoiceWidgetProps = {
initialLang?: "es" | "en";
};
const quickActions = {
es: [
{ label: "¿Cuánto cuesta?", icon: "💰" },
{ label: "Ver casos de éxito", icon: "🎯" },
{ label: "¿Cómo funciona?", icon: "⚙️" },
],
en: [
{ label: "Pricing?", icon: "💰" },
{ label: "Success stories", icon: "🎯" },
{ label: "How it works?", icon: "⚙️" },
],
} as const;
const initialGreeting = {
es: "Hola, soy el cerebro de SiteMente. ¿En qué te puedo ayudar hoy?",
en: "Hi, I'm the SiteMente brain. How can I help you today?",
} as const;
export default function SiteMenteVoiceWidget({
initialLang = "es",
}: SiteMenteVoiceWidgetProps) {
const [isOpen, setIsOpen] = useState(false);
const [lang, setLang] = useState<"es" | "en">(initialLang);
const [messages, setMessages] = useState<ChatMessage[]>([
{
role: "assistant",
content: initialGreeting[initialLang],
timestamp: Date.now(),
},
]);
const [input, setInput] = useState("");
const [voiceMode, setVoiceMode] = useState(true);
const [isRecording, setIsRecording] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [transcript, setTranscript] = useState("");
const [speechSupported, setSpeechSupported] = useState(true);
const [showTooltip, setShowTooltip] = useState(false);
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null);
const isRecordingRef = useRef(false);
const transcriptRef = useRef("");
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const localeLabel = useMemo(
() => (lang === "es" ? "ES" : "EN"),
[lang]
);
useEffect(() => {
setLang(initialLang);
}, [initialLang]);
useEffect(() => {
const seen = window.localStorage.getItem("sitemente:voice-tooltip");
if (!seen) {
setShowTooltip(true);
window.localStorage.setItem("sitemente:voice-tooltip", "1");
const timeout = window.setTimeout(() => setShowTooltip(false), 4000);
return () => window.clearTimeout(timeout);
}
return undefined;
}, []);
useEffect(() => {
isRecordingRef.current = isRecording;
}, [isRecording]);
useEffect(() => {
transcriptRef.current = transcript;
}, [transcript]);
useEffect(() => {
const SpeechRecognitionImpl =
typeof window !== "undefined"
? ((window as typeof window & {
webkitSpeechRecognition?: SpeechRecognitionConstructor;
}).SpeechRecognition ||
(window as typeof window & {
webkitSpeechRecognition?: SpeechRecognitionConstructor;
}).webkitSpeechRecognition)
: undefined;
if (!SpeechRecognitionImpl) {
setSpeechSupported(false);
return;
}
const recognition = new SpeechRecognitionImpl();
recognition.lang = lang === "es" ? "es-ES" : "en-US";
recognition.interimResults = true;
recognition.continuous = false;
recognition.onresult = (event) => {
const result = Array.from(event.results)
.map((res) => res[0]?.transcript ?? "")
.join(" ");
setTranscript(result.trim());
};
recognition.onerror = () => {
setIsRecording(false);
};
recognition.onend = () => {
if (isRecordingRef.current) {
setIsRecording(false);
const finalTranscript = transcriptRef.current.trim();
if (finalTranscript) {
handleSend(finalTranscript);
}
setTranscript("");
}
};
recognitionRef.current = recognition;
}, [lang]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isOpen]);
useEffect(() => {
if (!isSpeaking) return;
return () => {
window.speechSynthesis?.cancel();
};
}, [isSpeaking]);
useEffect(() => {
setMessages((prev) => {
if (prev.length === 0) return prev;
const updated = [...prev];
if (updated[0].role === "assistant") {
updated[0] = {
...updated[0],
content: initialGreeting[lang],
};
}
return updated;
});
}, [lang]);
const startRecording = () => {
if (!speechSupported || !recognitionRef.current) return;
setTranscript("");
setIsRecording(true);
recognitionRef.current.start();
};
const stopRecording = () => {
if (!recognitionRef.current) return;
recognitionRef.current.stop();
setIsRecording(false);
};
const speak = (text: string) => {
if (!("speechSynthesis" in window)) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = lang === "es" ? "es-ES" : "en-US";
utterance.onstart = () => setIsSpeaking(true);
utterance.onend = () => setIsSpeaking(false);
utterance.onerror = () => setIsSpeaking(false);
window.speechSynthesis.speak(utterance);
};
const handleSend = async (text: string) => {
if (!text.trim() || isLoading) return;
const userMessage: ChatMessage = {
role: "user",
content: text,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsLoading(true);
try {
const response = await fetch("/api/chat/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: text,
locale: lang,
history: messages.slice(-6),
}),
});
if (!response.ok) {
throw new Error("Failed to fetch response.");
}
const data = (await response.json()) as ApiResponse;
const assistantMessage: ChatMessage = {
role: "assistant",
content: data.response,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, assistantMessage]);
if (voiceMode) {
speak(data.response);
}
} catch (error) {
const fallbackMessage: ChatMessage = {
role: "assistant",
content:
lang === "es"
? "Hubo un problema al responder. ¿Quieres intentarlo de nuevo?"
: "There was a problem responding. Want to try again?",
timestamp: Date.now(),
};
setMessages((prev) => [...prev, fallbackMessage]);
} finally {
setIsLoading(false);
}
};
const voiceIndicator = isRecording
? "🎤"
: isSpeaking
? "🔊"
: "🎤";
return (
<>
{!isOpen && (
<div className="fixed bottom-6 right-6 z-[9999] flex flex-col items-end gap-2">
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(true)}
className="relative flex h-[68px] w-[68px] items-center justify-center rounded-full bg-gradient-to-br from-[#8B5CF6] to-[#EC4899] text-white shadow-lg transition hover:scale-110 hover:shadow-[0_12px_30px_rgba(236,72,153,0.45)]"
>
<div className="flex items-center gap-1">
<span className="h-3 w-1 rounded-full bg-white/80 animate-pulse" />
<span className="h-5 w-1 rounded-full bg-white/90 animate-pulse" />
<span className="h-4 w-1 rounded-full bg-white/80 animate-pulse" />
</div>
</button>
{showTooltip && (
<div className="absolute right-[76px] top-1/2 -translate-y-1/2 rounded-full bg-white px-3 py-1 text-xs font-semibold text-brand-purple-dark shadow-md">
{lang === "es" ? "Prueba la voz" : "Try voice"}
</div>
)}
<div className="absolute -top-6 right-2 rounded-full bg-white/20 px-2 py-1 text-[10px] font-semibold text-white backdrop-blur">
Demo
</div>
</div>
</div>
)}
{isOpen && (
<div className="fixed inset-0 z-[9999] flex items-end justify-end p-4 sm:p-6">
<div className="absolute inset-0 bg-black/50" onClick={() => setIsOpen(false)} />
<div className="relative z-10 flex h-full w-full max-w-[440px] flex-col overflow-hidden rounded-3xl border border-white/15 bg-[#4f3a78] shadow-[0_30px_80px_rgba(0,0,0,0.45)] sm:h-[700px]">
<div className="flex items-center justify-between bg-gradient-to-r from-[#6d4cc2] to-[#ff66b5] px-5 py-4 text-white">
<div>
<div className="flex items-center gap-2 text-lg font-semibold">
<span>SiteMente IA</span>
<span className="flex items-center gap-1">
<span className="h-2 w-1 rounded-full bg-white/80 animate-pulse" />
<span className="h-3 w-1 rounded-full bg-white/90 animate-pulse" />
<span className="h-2 w-1 rounded-full bg-white/80 animate-pulse" />
</span>
</div>
<p className="text-xs text-white/80">
{lang === "es" ? "El cerebro de tu web" : "Your website brain"}
</p>
</div>
<div className="flex items-center gap-3 text-lg">
<span>{voiceIndicator}</span>
<button
type="button"
onClick={() => setLang((prev) => (prev === "es" ? "en" : "es"))}
className="rounded-full border border-white/30 px-2 py-1 text-xs font-semibold"
>
{localeLabel}
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="text-xl"
>
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">
{messages.length === 1 && (
<div className="mb-4 flex flex-wrap gap-2">
{quickActions[lang].map((action) => (
<button
key={action.label}
type="button"
onClick={() => handleSend(action.label)}
className="rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs text-white/90 transition hover:bg-white/20"
>
{action.icon} {action.label}
</button>
))}
</div>
)}
<div className="space-y-4">
{messages.map((message) => (
<div
key={`${message.timestamp}-${message.role}`}
className={`group flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm shadow-md ${
message.role === "user"
? "bg-[#c4a1ff] text-[#3b1c66]"
: "bg-[#6a4bb0] text-white"
}`}
>
<div className="flex items-center gap-2">
{message.role === "assistant" && (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/20 text-xs font-semibold">
SM
</span>
)}
<p>{message.content}</p>
</div>
<span className="mt-2 block text-[10px] text-white/60 opacity-0 transition group-hover:opacity-100">
{new Date(message.timestamp).toLocaleTimeString(
lang === "es" ? "es-ES" : "en-US",
{ hour: "2-digit", minute: "2-digit" }
)}
</span>
</div>
</div>
))}
{isLoading && (
<div className="flex items-center gap-2 text-white/70">
<span className="inline-flex h-2 w-2 animate-bounce rounded-full bg-white/70" />
<span className="inline-flex h-2 w-2 animate-bounce rounded-full bg-white/60 delay-150" />
<span className="inline-flex h-2 w-2 animate-bounce rounded-full bg-white/50 delay-300" />
</div>
)}
{isSpeaking && (
<div className="flex items-center gap-2 text-xs text-white/70">
<span className="flex items-center gap-1">
<span className="h-2 w-1 rounded-full bg-white/80 animate-pulse" />
<span className="h-3 w-1 rounded-full bg-white/90 animate-pulse" />
<span className="h-2 w-1 rounded-full bg-white/80 animate-pulse" />
</span>
{lang === "es" ? "Hablando..." : "Speaking..."}
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
<div className="border-t border-white/10 bg-[#4a3572] px-5 py-4">
{voiceMode ? (
<div className="flex flex-col items-center gap-3">
{transcript && (
<p className="w-full rounded-xl bg-white/10 px-4 py-2 text-sm text-white/90">
{transcript}
</p>
)}
<button
type="button"
onClick={() => {
if (isRecording) {
stopRecording();
return;
}
if (isSpeaking) {
window.speechSynthesis?.cancel();
setIsSpeaking(false);
return;
}
startRecording();
}}
className={`flex h-14 w-14 items-center justify-center rounded-full text-white transition ${
isRecording
? "bg-red-500 animate-pulse"
: isSpeaking
? "bg-blue-500 animate-pulse"
: "bg-white/20 hover:bg-white/30"
}`}
disabled={!speechSupported}
>
{isRecording ? "🔴" : isSpeaking ? "🔊" : "🎤"}
</button>
<p className="text-xs text-white/80">
{isRecording
? lang === "es"
? "Escuchando..."
: "Listening..."
: isSpeaking
? lang === "es"
? "Hablando..."
: "Speaking..."
: lang === "es"
? "Toca para hablar"
: "Tap to talk"}
</p>
{isSpeaking && (
<div className="h-1 w-full overflow-hidden rounded-full bg-white/10">
<div className="h-full w-1/2 animate-pulse rounded-full bg-brand-pink/80" />
</div>
)}
<div className="flex w-full items-center justify-between text-xs text-white/70">
<button
type="button"
onClick={() => setVoiceMode(false)}
className="flex items-center gap-1"
>
{lang === "es" ? "Texto" : "Text"}
</button>
<button
type="button"
onClick={() => {
window.speechSynthesis?.cancel();
setIsSpeaking(false);
}}
className="flex items-center gap-1"
>
{lang === "es" ? "Pausar" : "Pause"}
</button>
</div>
</div>
) : (
<div className="flex items-center gap-3">
<input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={
lang === "es"
? "Escribe tu mensaje..."
: "Type your message..."
}
className="flex-1 rounded-full border border-white/20 bg-white/10 px-4 py-2 text-sm text-white placeholder:text-white/50 focus:border-white/50 focus:outline-none"
/>
<button
type="button"
onClick={() => handleSend(input)}
className="rounded-full bg-brand-pink px-4 py-2 text-sm font-semibold text-white"
>
</button>
<button
type="button"
onClick={() => setVoiceMode(true)}
className="rounded-full border border-white/20 px-3 py-2 text-white/80"
>
🎤
</button>
</div>
)}
{!speechSupported && (
<p className="mt-2 text-center text-xs text-white/60">
{lang === "es"
? "Tu navegador no soporta voz. Usa el modo texto."
: "Your browser doesn't support voice. Use text mode."}
</p>
)}
</div>
</div>
</div>
)}
</>
);
}