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