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();