const { useState, useEffect, useRef, useCallback, createContext, useContext } = React; // ────────────────────────────────────────────────────────────── // ElevenLabs config // ────────────────────────────────────────────────────────────── // 1) Agente PÚBLICO (allowlist desactivada en el dashboard): // basta con pegar el agentId aquí. // 2) Agente PRIVADO: // tu backend debe devolver un signed URL desde la API de ElevenLabs // (GET /v1/convai/conversation/get-signed-url?agent_id=...). // Deja AGENT_ID en "" y pon la URL de tu endpoint en SIGNED_URL_ENDPOINT. const ELEVENLABS = { AGENT_ID: 'agent_5901kskjrvbhfd284dy26c6606fg', // <-- pega aquí tu agentId SIGNED_URL_ENDPOINT: '', // <-- o pon aquí tu endpoint (ej. '/api/elevenlabs/signed-url') }; // --- i18n --- const COPY = { es: { nav_services: 'Servicios', eyebrow: 'Voice Agent · En línea', headline_a: 'Una voz que', headline_em: 'escucha', headline_b: ', responde y agenda por ti.', subheadline: 'Agentes de voz AI para tu negocio. Atienden, prospectan, agendan y dan seguimiento — todo el día, en español natural.', cta: 'Agenda una demo con voz', cta_alert: 'Demo solicitada ✦', powered: 'Powered by ElevenLabs', capacities: 'Capacidades', state_idle: 'Toca para hablar con tu agente', state_listening: 'Escuchando…', state_processing: 'Procesando…', speech: 'Hola, soy tu asistente de voz. ¿En qué puedo ayudarte hoy?', aria_activate: 'Activar agente de voz', aria_stop: 'Detener agente de voz', aria_services: 'Capacidades del voicebot', aria_lang: 'Cambiar idioma', footer: '© 2026 Arkana Tech', services: [ { title: 'Llamadas Entrantes', desc: '24/7, sin tiempos de espera' }, { title: 'Llamadas Salientes', desc: 'Leads, seguimiento y reactivación' }, { title: 'Agendar Citas', desc: 'Integración directa con tu calendario' }, { title: 'Calificación de Leads', desc: 'Califica y enriquece en tiempo real' }, { title: 'Fuera de Horario', desc: 'Nunca pierdas una llamada' }, { title: 'Soporte Multilingüe', desc: 'Inglés y español nativos' }, { title: 'Auto-Logging al CRM', desc: 'Resumen y notas después de cada llamada' }, { title: 'Transferencia a Humano', desc: 'Escalado inteligente cuando el caso lo requiere' }, ], }, en: { nav_services: 'Services', eyebrow: 'Voice Agent · Online', headline_a: 'A voice that', headline_em: 'listens', headline_b: ', answers, and books for you.', subheadline: 'AI voice agents for your business. They answer, prospect, schedule and follow up — all day, in natural language.', cta: 'Book a demo by voice', cta_alert: 'Demo requested ✦', powered: 'Powered by ElevenLabs', capacities: 'Capabilities', state_idle: 'Tap to talk with your agent', state_listening: 'Listening…', state_processing: 'Processing…', speech: "Hi, I'm your voice assistant. How can I help you today?", aria_activate: 'Activate voice agent', aria_stop: 'Stop voice agent', aria_services: 'Voicebot capabilities', aria_lang: 'Change language', footer: '© 2026 Arkana Tech', services: [ { title: 'Inbound Calls', desc: '24/7, zero wait time' }, { title: 'Outbound Calls', desc: 'Leads, follow-ups and reactivation' }, { title: 'Book Appointments', desc: 'Direct calendar integration' }, { title: 'Lead Qualification', desc: 'Qualify and enrich in real time' }, { title: 'After-Hours Coverage', desc: 'Never miss a call' }, { title: 'Multilingual Support', desc: 'Native English and Spanish' }, { title: 'CRM Auto-Logging', desc: 'Summary and notes after every call' }, { title: 'Human Handoff', desc: 'Smart escalation when the case demands it' }, ], }, }; const LangContext = createContext({ lang: 'es', setLang: () => {}, t: COPY.es }); const useT = () => useContext(LangContext); // --- Icons --- const Icon = { PhoneOut: () => ( ), PhoneIn: () => ( ), Calendar: () => ( ), Target: () => ( ), Mail: () => ( ), Brain: () => ( ), Moon: () => ( ), Globe: () => ( ), Clipboard: () => ( ), Handoff: () => ( ), ArrowRight: () => ( ), }; const SERVICE_ICONS = [Icon.PhoneIn, Icon.PhoneOut, Icon.Calendar, Icon.Target, Icon.Moon, Icon.Globe, Icon.Clipboard, Icon.Handoff]; // --- Logo --- function Logo() { return ( Arkana Tech ); } // --- Language toggle --- function LangToggle() { const { lang, setLang, t } = useT(); return (
); } // --- Header --- function Header() { const { t } = useT(); const [scrolled, setScrolled] = useState(false); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 20); window.addEventListener('scroll', onScroll); return () => window.removeEventListener('scroll', onScroll); }, []); return (
); } // --- Core --- function VoiceCore({ state, onClick, speechText, onEnd }) { const { t, lang } = useT(); const isActive = state !== 'idle'; return (
{isActive && ( )}
); } function StateCaption({ state, speechText }) { const { t } = useT(); if (state === 'idle') return
{t.state_idle}
; if (state === 'listening') return
{t.state_listening}
; if (state === 'processing') return
{t.state_processing}
; // 'speaking' is rendered inside ChatWindow on the side panel return
{t.state_speaking || 'Hablando…'}
; } // --- Chat window (left side, typewriter) --- function ChatWindow({ state, speechText }) { const { lang } = useT(); const [displayed, setDisplayed] = useState(''); const target = speechText || ''; // Reset when a brand-new utterance starts (target no longer starts with displayed) useEffect(() => { if (!target) { setDisplayed(''); return; } if (!target.startsWith(displayed)) setDisplayed(''); }, [target]); // Type one char at a time toward the target useEffect(() => { if (!target || displayed.length >= target.length) return; const id = setTimeout(() => { setDisplayed(target.slice(0, displayed.length + 1)); }, 22); return () => clearTimeout(id); }, [target, displayed]); if (state === 'idle' && !displayed) return null; const isTyping = displayed.length < target.length; const statusLabel = (() => { if (state === 'listening') return lang === 'es' ? 'Escuchando' : 'Listening'; if (state === 'processing') return lang === 'es' ? 'Pensando' : 'Thinking'; if (state === 'speaking') return lang === 'es' ? 'Hablando' : 'Speaking'; return lang === 'es' ? 'Conectado' : 'Connected'; })(); const placeholder = (() => { if (state === 'listening') return lang === 'es' ? 'Te escucho…' : "I'm listening…"; if (state === 'processing') return lang === 'es' ? 'Procesando tu mensaje…' : 'Processing your message…'; return ''; })(); return ( ); } // --- Services panel --- function ServicesPanel({ onServiceClick }) { const { lang, t } = useT(); return ( ); } // --- App body that consumes lang context --- function Page() { const { lang, t } = useT(); const [state, setState] = useState('idle'); const [speechText, setSpeechText] = useState(''); const timersRef = useRef([]); const conversationRef = useRef(null); const clearTimers = () => { timersRef.current.forEach(clearTimeout); timersRef.current = []; }; // when language flips mid-speech, reset to idle and close any live call useEffect(() => { clearTimers(); if (conversationRef.current) { conversationRef.current.endSession().catch(() => {}); conversationRef.current = null; } setState('idle'); setSpeechText(''); }, [lang]); // ── ElevenLabs session ──────────────────────────────────────── const waitForSDK = () => new Promise((resolve) => { if (window.ElevenLabsConversation) return resolve(window.ElevenLabsConversation); window.addEventListener('elevenlabs-ready', () => resolve(window.ElevenLabsConversation), { once: true }); }); const startCall = useCallback(async (opts = {}) => { clearTimers(); setState('processing'); // conectando… setSpeechText(''); // Una vez que el agente termina su primera intervención (greeting), inyectamos // el "user message" del servicio. Esto evita el conflicto de turnos que cerraba // la sesión cuando intentábamos mandar firstMessage + userMessage simultáneos. let firstReplyDone = false; let prevMode = null; try { // Permiso de micro await navigator.mediaDevices.getUserMedia({ audio: true }); const Conversation = await waitForSDK(); if (!Conversation) throw new Error('ElevenLabs SDK no cargado'); const sessionOpts = { onConnect: () => setState('listening'), onDisconnect: () => { setState('idle'); setSpeechText(''); conversationRef.current = null; }, onError: (err) => { console.error('[ElevenLabs] error', err); setState('idle'); setSpeechText(''); conversationRef.current = null; }, onModeChange: ({ mode }) => { setState(mode); if (mode === 'listening') setSpeechText(''); // Cuando el agente pasa de "speaking" → "listening" por primera vez, // su saludo inicial ya terminó. Es seguro inyectar el mensaje del // usuario sobre el servicio. if ( prevMode === 'speaking' && mode === 'listening' && !firstReplyDone && opts.userMessage && conversationRef.current ) { firstReplyDone = true; try { conversationRef.current.sendUserMessage?.(opts.userMessage); } catch (_) {} } prevMode = mode; }, onMessage: ({ source, message }) => { if (source === 'ai' && message) setSpeechText(message); }, }; if (ELEVENLABS.SIGNED_URL_ENDPOINT) { const r = await fetch(ELEVENLABS.SIGNED_URL_ENDPOINT); const { signed_url, signedUrl } = await r.json(); sessionOpts.signedUrl = signed_url || signedUrl; } else { sessionOpts.agentId = ELEVENLABS.AGENT_ID; } // Solo overrideamos el IDIOMA (que es el override más estable). // El firstMessage lo dejamos al agente para no romper la negociación // inicial. La selección del servicio se inyecta DESPUÉS del saludo // usando sendUserMessage al detectar fin del primer turno del agente. sessionOpts.overrides = { agent: { language: lang } }; let conversation; try { conversation = await Conversation.startSession(sessionOpts); } catch (overrideErr) { console.warn('[ElevenLabs] overrides rechazados, reintentando sin overrides:', overrideErr); delete sessionOpts.overrides; conversation = await Conversation.startSession(sessionOpts); } conversationRef.current = conversation; // Refuerzo de idioma vía contextual update — con delay para no chocar // con el saludo del agente. No requiere config en dashboard. setTimeout(() => { if (!conversationRef.current) return; const langDirective = lang === 'es' ? 'IMPORTANTE: El usuario seleccionó español. Responde SIEMPRE en español de aquí en adelante.' : 'IMPORTANT: The user selected English. From now on, reply ONLY in English.'; try { conversationRef.current.sendContextualUpdate?.(langDirective); } catch (_) {} if (opts.contextualUpdate) { try { conversationRef.current.sendContextualUpdate?.(opts.contextualUpdate); } catch (_) {} } }, 400); } catch (err) { console.error('[ElevenLabs] startCall failed', err); setState('idle'); setSpeechText( lang === 'es' ? 'No pude iniciar la llamada. Revisa el micrófono.' : "Couldn't start the call. Please check your mic." ); setTimeout(() => setSpeechText(''), 3500); } }, [lang]); const endCall = useCallback(async () => { clearTimers(); try { if (conversationRef.current) await conversationRef.current.endSession(); } catch (e) { /* ignore */ } conversationRef.current = null; setState('idle'); setSpeechText(''); }, []); const handleCoreClick = () => { if (state === 'idle') startCall(); else endCall(); }; // Clic en una capacidad → que el agente reconozca el servicio y ofrezca ejemplo const askAboutService = useCallback((service) => { const userMessage = lang === 'es' ? `Me interesa ${service.title}. ¿Me puedes dar un ejemplo de cómo funciona?` : `I'm interested in ${service.title}. Could you walk me through an example of how it works?`; const contextualUpdate = lang === 'es' ? `El usuario hizo clic en la capacidad "${service.title}" (${service.desc}). Responde en español y ofrece un ejemplo concreto.` : `User clicked the capability "${service.title}" (${service.desc}). Reply in English only and offer a concrete example.`; // Si ya hay llamada activa, manda el mensaje del usuario directamente if (conversationRef.current) { try { conversationRef.current.sendContextualUpdate?.(contextualUpdate); } catch (_) {} try { conversationRef.current.sendUserMessage?.(userMessage); } catch (_) {} return; } // Si no, arranca la sesión. El userMessage se inyectará automáticamente // cuando el agente termine su saludo inicial (detectado por onModeChange). startCall({ userMessage, contextualUpdate }); }, [lang, startCall]); useEffect(() => () => { clearTimers(); if (conversationRef.current) { conversationRef.current.endSession().catch(() => {}); conversationRef.current = null; } }, []); return (
{t.eyebrow}

{t.headline_a} {t.headline_em}{t.headline_b}

{t.subheadline}

); } // --- App with language provider --- function App() { const [lang, setLang] = useState(() => { const stored = localStorage.getItem('arkana_lang'); if (stored === 'es' || stored === 'en') return stored; return (navigator.language || '').toLowerCase().startsWith('en') ? 'en' : 'es'; }); useEffect(() => { localStorage.setItem('arkana_lang', lang); document.documentElement.lang = lang; }, [lang]); return ( ); } ReactDOM.createRoot(document.getElementById('root')).render();