Files
sitemente/components/SiteMenteVoiceWidget.tsx
T
2026-02-23 16:07:47 +00:00

326 lines
13 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 { useState, useRef, useEffect } from "react";
import Vapi from "@vapi-ai/web";
const VAPI_PUBLIC_KEY = "ee086729-fc5c-447e-9a40-840f44bc006b";
const ASSISTANT_ID = "92630ca5-e165-4360-bce0-dd8730882569";
interface SiteMenteVoiceWidgetProps {
businessName?: string;
businessType?: "restaurant" | "real-estate" | "clinic" | "car-rental" | "default";
theme?: "dark" | "light";
initialLang?: string;
}
type Mode = "text" | "voice";
export default function SiteMenteVoiceWidget({
businessName = "SiteMente",
businessType = "default",
theme = "dark",
initialLang = "es"
}: SiteMenteVoiceWidgetProps) {
const [mode, setMode] = useState<Mode>("text");
const [isActive, setIsActive] = useState(false);
const [status, setStatus] = useState<"idle" | "connecting" | "active" | "error">("idle");
const [transcript, setTranscript] = useState("");
const [errorMsg, setErrorMsg] = useState<string>("");
// Text chat state
const [messages, setMessages] = useState<{role: "user" | "assistant", content: string}[]>([]);
const [input, setInput] = useState("");
const [isSending, setIsSending] = useState(false);
const vapiRef = useRef<any>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-hide chat when switching to voice mode
useEffect(() => {
if (mode === "voice") {
setShowChat(false);
}
}, [mode]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Voice mode functions
const startCall = async () => {
try {
console.log("Starting voice call...");
setErrorMsg("");
setStatus("connecting");
// Verify mic
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log("✅ Mic stream created");
} catch (micErr) {
console.log("❌ Mic error:", micErr);
setErrorMsg("Microphone access denied");
setStatus("error");
return;
}
const vapi = new Vapi(VAPI_PUBLIC_KEY);
vapiRef.current = vapi;
vapi.on("error", (error: any) => {
console.log("Vapi error:", error);
const msg = String(error?.message || error?.error?.message || "Voice call failed");
setErrorMsg(msg);
setStatus("error");
setIsActive(false);
});
vapi.on("call-start", () => {
console.log("✅ Call started!");
setStatus("active");
});
vapi.on("call-end", () => {
console.log("Call ended");
setStatus("idle");
setIsActive(false);
});
vapi.on("transcript", (transcript: any) => {
const text = typeof transcript === "string" ? transcript : transcript?.text || "";
setTranscript(text);
});
await vapi.start(ASSISTANT_ID);
setIsActive(true);
} catch (error: any) {
console.log("Start error:", error);
const msg = String(error?.message || "Failed to start call");
setErrorMsg(msg);
setStatus("error");
}
};
const endCall = async () => {
try {
if (vapiRef.current) {
await vapiRef.current.stop();
}
setIsActive(false);
setStatus("idle");
setTranscript("");
} catch (error) {
console.error("End call error:", error);
}
};
// Text mode functions
const sendMessage = async () => {
if (!input.trim() || isSending) return;
const userMessage = input.trim();
setInput("");
setIsSending(true);
// Add user message
setMessages(prev => [...prev, { role: "user", content: userMessage }]);
try {
// TODO: Replace with actual AI API call
// For now, simulate response
await new Promise(resolve => setTimeout(resolve, 1000));
const responses: Record<string, string> = {
"hola": `¡Hola! 👋 Soy el asistente de ${businessName}. ¿En qué puedo ayudarte hoy?`,
"horario": `Nuestros horarios de atención son de lunes a domingo. ¿Tienes alguna pregunta específica?`,
"reservar": "Para hacer una reserva, puedo ayudarte ahora mismo. ¿Qué servicio te interesa?",
"contacto": `Puedes llamarnos al +34 XXX XXX XXX o escribirnos aquí.`,
"default": "Gracias por tu mensaje. Un miembro de nuestro equipo te responderá pronto. ¿Hay algo específico en lo que pueda ayudarte?"
};
const lowerInput = userMessage.toLowerCase();
let response = responses.default;
for (const [key, value] of Object.entries(responses)) {
if (lowerInput.includes(key)) {
response = value;
break;
}
}
setMessages(prev => [...prev, { role: "assistant", content: response }]);
} catch (error) {
console.error("Send error:", error);
setMessages(prev => [...prev, { role: "assistant", content: "Lo siento, hubo un error. Inténtalo de nuevo." }]);
} finally {
setIsSending(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const toggleMode = () => {
// End any active call when switching
if (isActive) {
endCall();
}
setShowChat(mode === "voice"); // Show chat when switching to text
setMode(prev => prev === "text" ? "voice" : "text");
};
// Add state for chat panel visibility
const [showChat, setShowChat] = useState(true);
const buttonColor = theme === "dark" ? "bg-brand-pink" : "bg-blue-600";
const bgColor = theme === "dark" ? "bg-[#1a1625]" : "bg-white";
const textColor = theme === "dark" ? "text-white" : "text-gray-900";
const inputBg = theme === "dark" ? "bg-white/10" : "bg-gray-100";
return (
<div className="fixed bottom-6 right-6 z-50">
{/* Mode Toggle Button */}
<button
onClick={toggleMode}
className={`absolute bottom-16 right-0 ${buttonColor} px-3 py-1.5 rounded-full text-xs text-white shadow-lg mb-2 flex items-center gap-1.5 hover:scale-105 transition-transform`}
title={mode === "text" ? "Switch to Voice" : "Switch to Text"}
>
{mode === "text" ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
Voice
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Text
</>
)}
</button>
{/* Error Message */}
{status === "error" && errorMsg && (
<div className="absolute bottom-28 right-0 w-64 bg-red-600 text-white text-xs p-2 rounded-lg mb-2">
{errorMsg}
</div>
)}
{/* Chat Panel - Text Mode */}
{mode === "text" && showChat && (
<div className={`absolute bottom-16 right-0 w-80 ${bgColor} border border-white/20 rounded-xl shadow-2xl mb-2 overflow-hidden`}>
{/* Header */}
<div className={`flex items-center justify-between p-3 border-b ${theme === "dark" ? "border-white/10" : "border-gray-200"}`}>
<div className="flex items-center gap-2">
<span className="text-sm font-medium ${textColor}">💬 {businessName}</span>
</div>
<span className="w-2 h-2 rounded-full bg-green-500"></span>
</div>
{/* Messages */}
<div className="h-64 overflow-y-auto p-3 space-y-3">
{messages.length === 0 && (
<div className="text-center text-sm text-gray-500 py-4">
👋 ¡Hola! Escríbeme para ayudarte
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
msg.role === "user"
? "bg-brand-pink text-white"
: theme === "dark" ? "bg-white/10 text-white" : "bg-gray-100 text-gray-900"
}`}>
{msg.content}
</div>
</div>
))}
{isSending && (
<div className="flex justify-start">
<div className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${theme === "dark" ? "bg-white/10 text-white/70" : "bg-gray-100 text-gray-500"}`}>
Escribiendo...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className={`p-3 border-t ${theme === "dark" ? "border-white/10" : "border-gray-200"}`}>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Escribe tu mensaje..."
className={`flex-1 px-3 py-2 rounded-lg text-sm ${inputBg} ${textColor} placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-pink`}
disabled={isSending}
/>
<button
onClick={sendMessage}
disabled={!input.trim() || isSending}
className={`${buttonColor} p-2 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</div>
</div>
)}
{/* Voice Mode Panel */}
{mode === "voice" && isActive && (
<div className={`absolute bottom-16 right-0 w-80 ${bgColor} border border-white/20 rounded-xl p-4 shadow-2xl mb-2`}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium ${textColor}">🎙 Voice Chat</span>
<span className={`w-2 h-2 rounded-full ${status === "active" ? "bg-green-500 animate-pulse" : "bg-yellow-500"}`}></span>
</div>
<div className={`h-32 overflow-y-auto text-sm ${theme === "dark" ? "text-white/70" : "text-gray-600"} bg-white/5 rounded-lg p-2`}>
{transcript || "Listening..."}
</div>
</div>
)}
{/* Main Button */}
<button
onClick={mode === "voice" ? (isActive ? endCall : startCall) : () => setShowChat(!showChat)}
className={`${buttonColor} w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110 ${
mode === "voice" && isActive ? "animate-pulse ring-4 ring-red-500/50" : ""
}`}
title={mode === "voice" ? (isActive ? "End Call" : "Start Voice Chat") : (showChat ? "Close Chat" : "Open Chat")}
>
{mode === "voice" ? (
isActive ? (
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 8l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M5 3a2 2 0 00-2 2v1c0 8.284 6.716 15 15 15h1a2 2 0 002-2v-3.28a1 1 0 00-.684-.948l-4.493-1.498a1 1 0 00-1.21.502l-1.13 2.257a11.042 11.042 0 01-5.516-5.517l2.257-1.128a1 1 0 00.502-1.21L9.228 3.683A1 1 0 008.279 3H5z" />
</svg>
) : (
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
)
) : (
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
</button>
{mode === "voice" && status === "connecting" && (
<div className="absolute -top-8 right-0 bg-white/10 backdrop-blur px-3 py-1 rounded-full text-xs text-white">
Connecting...
</div>
)}
</div>
);
}