// vita-root.jsx — Done screen, profile sheet, root state + conversation engine + tweaks // ── 4. Done — the clinical history Vita built ─────────────────── function DoneScreen({ t, persona, profile, onRestart, onReopen }) { const name = profile.nombre || 'tu historia'; const entries = Object.entries(profile); return (
{Icon.check({ s: 14, c: t.green, w: 3 })} Historia iniciada

Encantada,
{profile.nombre ? profile.nombre.split(' ')[0] : 'ya nos conocemos'}.

Ya empecé a construir tu historia clínica. De ahora en adelante yo la administro y la mantengo al día en cada paso de tu salud.

{/* clinical history card */}
{Icon.heart({ s: 18, c: persona.accent, fill: persona.accent })} Tu historia con Vita
{entries.map(([k, v], i) => (
{PROFILE_LABELS[k] || k}
{Array.isArray(v) ? v.join(' · ') : v}
))}
{/* longevity baseline */}

Cada examen, síntoma y avance que compartas, lo registro y lo conecto con tu historia. Estoy aquí cuando me necesites.

Seguir hablando Reiniciar demo
); } function BaselineStat({ t, label, value, accent, icon }) { return (
{icon({ s: 18, c: accent })}
{label}
{value}
); } // ── Profile bottom-sheet ──────────────────────────────────────── function ProfileSheet({ t, persona, profile, progress, onClose }) { const entries = Object.entries(profile); return (
e.stopPropagation()} style={{ width: '100%', maxHeight: '78%', overflow: 'auto', borderRadius: '26px 26px 0 0', background: t.dark ? t.bg1 : '#FFFFFF', border: `1px solid ${t.stroke}`, borderBottom: 'none', padding: '14px 22px 40px', animation: 'vitaSheet .32s cubic-bezier(.2,.8,.2,1) both', }}>

Tu historia clínica

{Icon.x({ s: 18, c: t.textSoft })}
Vita la actualiza mientras conversan
{entries.length === 0 ? (
Cuéntale algo a Vita y aparecerá aquí.
) : (
{entries.map(([k, v], i) => (
{PROFILE_LABELS[k] || k}
{Array.isArray(v) ? v.join(' · ') : v}
))}
)}
); } // Nombre completo de la cuenta real (Redux), no del onboarding local. function nombreDeCuenta(usuarioSesion) { if (!usuarioSesion) return null; return [usuarioSesion.nombre, usuarioSesion.apellido].filter(Boolean).join(' ').trim() || null; } // ── URL real para cada pantalla de la app del paciente ─────────── // Traduce entre el `flow` (estado local) y la URL del navegador, usando la // tabla RUTAS_PACIENTE ya resuelta por window.VitaCore.rutas (BrowserRouter). // 'telemedicina' se deja fuera a propósito de la restauración por URL: sin el // id de la cita en memoria no hay forma honesta de reconstruir esa pantalla // en un recargado en frío — sólo se empuja al entrar, para el botón "atrás". const SERVICIOS_VALIDOS = new Set(['citas', 'nutricion', 'psicologia', 'diagnostico']); function rutaParaPantalla(flow, serviceView) { const ir = window.VitaCore.rutas.irAPaciente; switch (flow) { case 'home': return ir.home(); case 'chat': return ir.hablar(); case 'care': return ir.plan(); case 'history': return ir.historia(); case 'services': return serviceView ? ir.servicio(serviceView) : ir.servicios(); case 'notifs': return ir.notificaciones(); case 'profile': return ir.perfil(); case 'telemed': return ir.telemedicina(); default: return null; } } function pantallaParaRutaResuelta(rutaResuelta) { switch (rutaResuelta && rutaResuelta.nombre) { case 'home': return { flow: 'home' }; case 'hablar': return { flow: 'chat' }; case 'plan': return { flow: 'care' }; case 'historia': return { flow: 'history' }; case 'servicios': return { flow: 'services', serviceView: null }; case 'servicioDetalle': { const tipo = rutaResuelta.params.tipo; return { flow: 'services', serviceView: SERVICIOS_VALIDOS.has(tipo) ? tipo : null }; } case 'notificaciones': return { flow: 'notifs' }; case 'perfil': return { flow: 'profile' }; default: return null; } } // ── Boot — comprobando si hay una sesión real vigente ──────────── function BootLoading({ t, persona }) { const p = t.paleta; return (
); } // ── Root ──────────────────────────────────────────────────────── function VitaApp() { const [tw, setTweak] = useTweaks(TWEAK_DEFAULTS); const persona = VITA_PERSONAS[tw.persona] || VITA_PERSONAS.calida; const t = getTheme(tw.dark, persona.accent, persona.accentDeep); // ── sesión real contra el backend ───────────────────────────── // El token de acceso vive en el vault (memoria); Redux sólo sabe QUIÉN es // el usuario. Al montar, `useRestaurarSesionQuery` intenta reanudar la // sesión con la cookie httpOnly del refresh token — así quien vuelve no // tiene que loguearse si su sesión sigue vigente. const nucleo = window.VitaCore; const { useDispatch: useVitaDispatch, useSelector: useVitaSelector, acciones, api } = nucleo.estado; const dispatch = useVitaDispatch(); const sesionEstado = useVitaSelector((s) => s.session.estado); // La cuenta real (correo, rol) — para que se note la diferencia entre // sesiones: el header y el perfil muestran a QUIÉN de verdad se logueó, // no sólo el nombre que dejó en el onboarding. const usuarioSesion = useVitaSelector((s) => s.session.usuario); const { isSuccess: sesionRestauradaOk, isError: sesionRestauradaFallo, data: sesionRestaurada, } = api.auth.useRestaurarSesionQuery(); React.useEffect(() => { if (sesionRestauradaOk && sesionRestaurada) { // Si quien tenía la sesión no es Paciente (p.ej. abrió el link de la // app de pacientes con la cookie de un médico), esta no es su app. const destino = { Medico: '/medico', Administrador: '/admin' }[sesionRestaurada.usuario.rol]; if (destino) { window.location.href = destino; return; } nucleo.contenedor.vault.guardar({ accessToken: sesionRestaurada.accessToken, expiraEn: Date.now() + sesionRestaurada.expiresIn * 1000, }); dispatch(acciones.sesion.sesionIniciada(acciones.sesion.usuarioSesionDesdeBackend(sesionRestaurada.usuario))); } else if (sesionRestauradaFallo) { dispatch(acciones.sesion.sesionCerrada()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sesionRestauradaOk, sesionRestauradaFallo, sesionRestaurada]); // Todo lo que el paciente le cuenta a Vita (persona elegida, respuestas del // onboarding, ASIS, última conversación) vive en /perfil, ligado a su // cuenta real. Se pide sólo una vez autenticado. const { data: perfilRemoto } = api.perfil.useObtenerPerfilPropioQuery(undefined, { skip: sesionEstado !== 'autenticado', }); const [actualizarPerfilRemoto] = api.perfil.useActualizarPerfilPropioMutation(); // ── session memory ────────────────────────────────────────── const SAVE_KEY = 'vita_session_v1'; const saved = React.useMemo(() => { try { return JSON.parse(localStorage.getItem(SAVE_KEY) || 'null'); } catch (e) { return null; } }, []); const resuming = !!(saved && saved.triageDone && saved.asis); // Arranca siempre comprobando la sesión real: sólo cuando se sabe si hay // alguien autenticado se decide si se muestra el login o se continúa. const [flow, setFlow] = React.useState('comprobando'); // comprobando | auth | facescan | splash | choose | chat | collect | asis | home const perfilHidratado = React.useRef(false); const [sheet, setSheet] = React.useState(false); const [secSheet, setSecSheet] = React.useState(false); const [pendingAuth, setPendingAuth] = React.useState(null); const voice = useVitaVoice(); // conversation state const [messages, setMessages] = React.useState(resuming ? (saved.messages || []) : []); const [profile, setProfile] = React.useState(resuming ? (saved.profile || {}) : {}); const [progress, setProgress] = React.useState(resuming ? (saved.progress || 100) : 0); const [loading, setLoading] = React.useState(false); const [done, setDone] = React.useState(resuming); const [triageDone, setTriageDone] = React.useState(resuming); const [docs, setDocs] = React.useState(resuming ? (saved.docs || []) : []); const [asis, setAsis] = React.useState(resuming ? saved.asis : null); const [seguimiento, setSeguimiento] = React.useState(resuming ? (saved.seguimiento || []) : []); const [pic, setPic] = React.useState(resuming ? (saved.pic || null) : null); const [picReady, setPicReady] = React.useState(resuming ? (saved.picReady || false) : false); const [picGenerating, setPicGenerating] = React.useState(false); const [changelog, setChangelog] = React.useState(null); // { cambios, version } const [reanalyzing, setReanalyzing] = React.useState(null); // label of new info const [segItem, setSegItem] = React.useState(null); const [addSheet, setAddSheet] = React.useState(false); const [picSheet, setPicSheet] = React.useState(false); const [shareSheet, setShareSheet] = React.useState(false); const [hcFormal, setHcFormal] = React.useState(resuming ? (saved.hcFormal || null) : null); const [hcLoading, setHcLoading] = React.useState(false); const [family, setFamily] = React.useState(resuming ? (saved.family || []) : []); const [activeId, setActiveId] = React.useState('titular'); const [pendingSwitch, setPendingSwitch] = React.useState(null); const [addMemberOpen, setAddMemberOpen] = React.useState(false); const [dataSheet, setDataSheet] = React.useState(false); const [nutricion, setNutricion] = React.useState(resuming ? (saved.nutricion || null) : null); const [nutLoading, setNutLoading] = React.useState(false); const [serviceView, setServiceView] = React.useState(null); const [telemedCita, setTelemedCita] = React.useState(null); // proactive care const [careDone, setCareDone] = React.useState(resuming && saved.careDone && saved.careDone.day === todayKey() ? saved.careDone.map : {}); const [postCitaSheet, setPostCitaSheet] = React.useState(false); const [postCitaDone, setPostCitaDone] = React.useState(resuming ? (saved.postCitaDone || false) : false); // notifications const [notifRead, setNotifRead] = React.useState(resuming && saved.notifRead && saved.notifRead.day === todayKey() ? saved.notifRead.map : {}); const [notifSettings, setNotifSettings] = React.useState(resuming ? (saved.notifSettings || {}) : {}); const [toastQueue, setToastQueue] = React.useState([]); const [activeToast, setActiveToast] = React.useState(null); const [notifSheet, setNotifSheet] = React.useState(false); const simRef = React.useRef(null); // null | 'citas' | 'nutricion' | 'psicologia' | 'diagnostico' const startedRef = React.useRef(resuming); const asisRef = React.useRef(resuming ? saved.asis : null); asisRef.current = asis; const versionRef = React.useRef(resuming ? (saved.version || 1) : 1); const ttsRef = React.useRef(tw.tts); ttsRef.current = tw.tts; const personaRef = React.useRef(persona); personaRef.current = persona; const speakReply = (txt) => { if (ttsRef.current) voice.speak(txt, personaRef.current); }; // Espejo de `messages` en una ref: en modo llamada los turnos se encadenan // más rápido de lo que React re-renderiza, y construir el siguiente turno // desde el estado daría una transcripción con huecos. const messagesRef = React.useRef(messages); messagesRef.current = messages; // `hablar` es el interruptor clave. En modo texto Vita habla al terminar el // turno; en modo llamada NO, porque quien decide cuándo hablar —y cuándo // reabrir el micrófono— es la máquina de turnos del núcleo. const runTurn = async (nextMessages, { hablar = true } = {}) => { setLoading(true); const res = await askVita(personaRef.current, profile, nextMessages); const perfilActualizado = mergeProfile(profile, res.profile); setProfile(perfilActualizado); const conRespuesta = [...nextMessages, { role: 'vita', text: res.reply }]; setMessages(conRespuesta); messagesRef.current = conRespuesta; if (res.progress != null) setProgress(res.progress); else setProgress(p => Math.min(100, Math.max(p, Object.keys(profile).length * 9))); if (res.done) setDone(true); setLoading(false); if (hablar) speakReply(res.reply); // Todo lo que el paciente le cuenta a Vita se guarda en su cuenta real // turno a turno, no sólo al terminar el triage — así nada se pierde si // cierra la app a la mitad de la conversación. if (sesionEstado === 'autenticado' && !triageDone) { actualizarPerfilRemoto({ respuestasOnboarding: perfilActualizado, ultimaConversacion: { messages: conRespuesta, docs }, }); } return res.reply; }; // Turno del modo llamada: registra lo que dijo el paciente, obtiene la // respuesta y la DEVUELVE sin reproducirla. const turnoDeLlamada = async (userText) => { const next = [...messagesRef.current, { role: 'user', text: userText }]; setMessages(next); messagesRef.current = next; return runTurn(next, { hablar: false }); }; const startConversation = () => { if (startedRef.current) return; startedRef.current = true; runTurn([]); // empty → Vita greets }; const send = (userText) => { const next = [...messages, { role: 'user', text: userText }]; setMessages(next); runTurn(next); }; const attachDoc = (label) => { setDocs(d => (d.includes(label) ? d : [...d, label])); send('📎 Adjunté: ' + label); }; const finishTriage = () => { voice.stopSpeaking(); setFlow('collect'); }; // collect screen done → store plan + docs + pendings, then build ASIS const completeCollect = async ({ docs: newDocs, eps, prepagada, pendings }) => { setProfile(p => ({ ...p, ...(eps ? { eps } : {}), ...(prepagada ? { prepagada } : {}) })); const mergedDocs = [...new Set([...docs, ...(newDocs || [])])]; setDocs(mergedDocs); setFlow('asis'); const profForAsis = { ...profile, ...(eps ? { eps } : {}), ...(prepagada ? { prepagada } : {}) }; const [result, suggested] = await Promise.all([ generateAsis(personaRef.current, profForAsis, messages, mergedDocs), suggestSeguimiento(profForAsis, messages), new Promise(r => setTimeout(r, 3200)), ]); versionRef.current = 1; setAsis(result); // merge user pendings + AI-suggested, dedupe by titulo const all = [...(pendings || []), ...suggested]; const seen = new Set(); const dedup = all.filter(x => { const k = (x.titulo || '').toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; }).slice(0, 5); setSeguimiento(dedup); setPicReady(true); // ASIS has the minimum info → Vita can build the PIC setChangelog(null); setTriageDone(true); // Vita now remembers: triage is done setFlow('home'); // Cierra el primer paso de verdad: queda en la cuenta del paciente, no // sólo en este navegador, así vuelva desde donde vuelva ya está todo. if (sesionEstado === 'autenticado') { actualizarPerfilRemoto({ personaVoz: personaRef.current.id, onboardingCompletado: true, respuestasOnboarding: profForAsis, asis: result, ultimaConversacion: { messages: messagesRef.current, docs: mergedDocs, seguimiento: dedup }, }); } }; // living ASIS — feed new info, re-analyze, show changelog const addInfo = async (label) => { setAddSheet(false); setReanalyzing(label); setDocs(d => (d.includes(label) ? d : [...d, label])); const updated = await generateAsis(personaRef.current, profile, messages, [...docs, label], { prev: asisRef.current, nuevoInput: label, seguimiento, }); versionRef.current += 1; setAsis(updated); setChangelog({ cambios: updated.cambios && updated.cambios.length ? updated.cambios : ['Registré lo que subiste y lo sumé a tu historia.'], version: versionRef.current }); // if we already have a PIC, refresh it quietly with the new picture if (pic) { const np = await generatePic(personaRef.current, profile, updated, { prev: pic }); setPic(np); } setReanalyzing(null); }; // Vita takes charge of a pending item const advanceSeg = (item) => { setSeguimiento(list => list.map(x => x.id === item.id ? { ...x, estado: 'gestionando' } : x)); setSegItem(it => it && it.id === item.id ? { ...it, estado: 'gestionando' } : it); setTimeout(() => { const doneState = item.tipo === 'medicamento' ? 'activo' : 'agendado'; setSeguimiento(list => list.map(x => x.id === item.id ? { ...x, estado: doneState } : x)); setSegItem(it => it && it.id === item.id ? { ...it, estado: doneState } : it); }, 1900); }; // activate the PIC (care plan) const activatePic = async () => { setPicGenerating(true); const p = await generatePic(personaRef.current, profile, asisRef.current); setPic(p); setPicReady(false); setPicGenerating(false); setPicSheet(true); }; // generate the formal clinical history (Colombian norms) — lazily, cached const generateFormalHC = async () => { if (hcFormal || hcLoading) return; setHcLoading(true); const hc = await generateHistoriaClinica(profile, asisRef.current, messages, docs, seguimiento); setHcFormal(hc); setHcLoading(false); }; // ── family / patient switching ────────────────────────────── const activeMember = activeId === 'titular' ? null : family.find(m => m.id === activeId); const requestSwitch = (target) => { if (!target || target.id === 'titular') { setActiveId('titular'); setFlow('home'); return; } if (target.id === activeId) return; setPendingSwitch(target); setFlow('switchface'); }; const confirmSwitch = () => { if (pendingSwitch) setActiveId(pendingSwitch.id); setPendingSwitch(null); setFlow('home'); }; const addMember = (m) => { setFamily(f => [...f, m]); setAddMemberOpen(false); }; // services: nutrition plan (lazy, cached) + add anything to seguimiento const generateNutPlan = async () => { if (nutricion || nutLoading) return; setNutLoading(true); const n = await generateNutricion(profile, asisRef.current); setNutricion(n); setNutLoading(false); }; const addToSeguimiento = (item) => { setSeguimiento(list => { if (list.some(x => x.id === item.id)) return list; return [...list, item]; }); }; // ── proactive care ────────────────────────────────────────── const careTasks = React.useMemo(() => triageDone ? buildTodayPlan({ profile, asis, pic, seguimiento }) : [], [triageDone, profile, asis, pic, seguimiento]); const careDetails = React.useMemo(() => triageDone ? buildCareDetails({ profile, seguimiento }) : { streaks: [], terapias: [], controles: [] }, [triageDone, profile, seguimiento]); const toggleCare = (id) => setCareDone(d => ({ ...d, [id]: !d[id] })); // a post-cita check-in surfaces when there's a scheduled cita and it hasn't been resolved const postCitaItem = React.useMemo(() => { if (postCitaDone) return null; const cita = (seguimiento || []).find(s => s.tipo === 'cita' && ['agendado', 'completado'].includes(s.estado)); return cita || null; }, [seguimiento, postCitaDone]); const resolvePostCita = (nuevos) => { (nuevos || []).forEach(addToSeguimiento); setPostCitaDone(true); }; // ── notifications: Vita as a daily presence ───────────────── const notifs = React.useMemo(() => { if (!triageDone) return []; const built = buildNotifications({ profile, personaId: persona.id, careTasks, seguimiento, careDetails }); // filter by settings + reflect done state from careDone return built.filter(nf => { if (nf.tipo === 'medicamento' && notifSettings.medicamento === false) return false; if ((nf.tipo === 'cita') && notifSettings.citas === false) return false; if ((nf.tipo === 'habito' || nf.tipo === 'actividad') && notifSettings.habitos === false) return false; if ((nf.tipo === 'proactivo' || nf.tipo === 'logro') && notifSettings.proactivo === false) return false; return true; }).map(nf => ({ ...nf, done: nf.taskId ? !!careDone[nf.taskId] : !!notifRead[nf.id] })); }, [triageDone, profile, persona.id, careTasks, seguimiento, careDetails, notifSettings, careDone, notifRead]); const handleNotifAction = (k, nf) => { if (k === 'done' && nf.taskId) setCareDone(d => ({ ...d, [nf.taskId]: true })); else if (k === 'done') setNotifRead(r => ({ ...r, [nf.id]: true })); else if (k === 'snooze') { // re-show after a short delay (demo) setTimeout(() => setToastQueue(q => [...q, nf]), 4000); } else if (k === 'reply') { setActiveToast(null); setToastQueue([]); if (simRef.current) { clearTimeout(simRef.current); simRef.current = null; } setFlow('chat'); } else if (k === 'view') { setActiveToast(null); setToastQueue([]); if (simRef.current) { clearTimeout(simRef.current); simRef.current = null; } if (nf.tipo === 'cita') setFlow('home'); else if (nf.tipo === 'terapia') setFlow('care'); else setFlow('home'); } }; // banner queue: show one at a time React.useEffect(() => { if (!activeToast && toastQueue.length) { setActiveToast(toastQueue[0]); setToastQueue(q => q.slice(1)); } }, [activeToast, toastQueue]); const enqueueToasts = (arr) => setToastQueue(q => [...q, ...arr]); // "Vivir un día con Vita": fire the day's notifications as a sequence const simulateDay = () => { if (simRef.current) clearTimeout(simRef.current); setActiveToast(null); const seq = notifs.filter(nf => !nf.done).slice(0, 6); if (!seq.length) { enqueueToasts(notifs.slice(0, 3)); return; } let i = 0; const fire = () => { setToastQueue(q => [...q, seq[i]]); i++; if (i < seq.length) simRef.current = setTimeout(fire, 2600); }; fire(); }; const saliendoRef = React.useRef(false); const reset = async () => { if (saliendoRef.current) return; saliendoRef.current = true; voice.stopSpeaking(); startedRef.current = false; try { localStorage.removeItem(SAVE_KEY); } catch (e) {} setMessages([]); setProfile({}); setProgress(0); setDone(false); setTriageDone(false); setDocs([]); setAsis(null); setSeguimiento([]); setPic(null); setPicReady(false); setChangelog(null); setReanalyzing(null); setSegItem(null); setAddSheet(false); setPicSheet(false); setShareSheet(false); setHcFormal(null); setHcLoading(false); setFamily([]); setActiveId('titular'); setPendingSwitch(null); setAddMemberOpen(false); setDataSheet(false); setNutricion(null); setNutLoading(false); setServiceView(null); setCareDone({}); setPostCitaSheet(false); setPostCitaDone(false); setNotifRead({}); setNotifSettings({}); setToastQueue([]); setActiveToast(null); setNotifSheet(false); if (simRef.current) { clearTimeout(simRef.current); simRef.current = null; } versionRef.current = 1; perfilHidratado.current = false; // Cierra la sesión real ESPERANDO al backend (revoca el refresh token y // borra la cookie) y recarga limpio. Sin la espera, una restauración de // sesión en paralelo podía rotar el token antes del logout y revivir la // sesión. La recarga descarta audio, micrófono y caché en memoria. await nucleo.sesion.cerrar(); window.location.replace('/'); }; // ── persist session whenever memory changes ───────────────── React.useEffect(() => { if (!triageDone) return; try { localStorage.setItem(SAVE_KEY, JSON.stringify({ triageDone: true, profile, docs, asis, seguimiento, pic, picReady, hcFormal, family, nutricion, careDone: { day: todayKey(), map: careDone }, postCitaDone, notifRead: { day: todayKey(), map: notifRead }, notifSettings, progress, version: versionRef.current, messages: messages.slice(-40), })); } catch (e) {} }, [triageDone, profile, docs, asis, seguimiento, pic, picReady, progress, messages, hcFormal, family, nutricion, careDone, postCitaDone, notifRead, notifSettings]); React.useEffect(() => { if (flow === 'chat') startConversation(); }, [flow]); // ── decide a dónde ir una vez se sabe si hay sesión real ───── React.useEffect(() => { if (sesionEstado === 'comprobando') return; if (sesionEstado === 'anonimo') { if (flow === 'comprobando') setFlow('auth'); return; } // autenticado: si el flow ya avanzó (login manual, escaneo facial…) no lo pisamos. if (flow !== 'comprobando') return; // La URL con la que llegó/recargó gana sobre la caché local: si entró // directo a /servicios, se queda en /servicios en vez de saltar a home. const desdeUrl = pantallaParaRutaResuelta(nucleo.contenedor.router.actual); if (desdeUrl && desdeUrl.flow !== 'home') { setFlow(desdeUrl.flow); if (desdeUrl.serviceView !== undefined) setServiceView(desdeUrl.serviceView); return; } setFlow(resuming ? 'home' : 'splash'); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sesionEstado]); // ── mantener la URL sincronizada con la pantalla actual ────── React.useEffect(() => { const ruta = rutaParaPantalla(flow, serviceView); if (ruta) nucleo.contenedor.router.navegar(ruta); // eslint-disable-next-line react-hooks/exhaustive-deps }, [flow, serviceView]); // ── atrás/adelante del navegador mueven la pantalla también ── React.useEffect(() => { return nucleo.contenedor.router.suscribir((rutaResuelta) => { const pantalla = pantallaParaRutaResuelta(rutaResuelta); if (!pantalla) return; setFlow(pantalla.flow); if (pantalla.serviceView !== undefined) setServiceView(pantalla.serviceView); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── restaurar desde el backend lo que Vita ya sabía del paciente ─ React.useEffect(() => { if (!perfilRemoto || perfilHidratado.current) return; perfilHidratado.current = true; if (perfilRemoto.personaVoz) setTweak('persona', perfilRemoto.personaVoz); if (perfilRemoto.onboardingCompletado) { if (perfilRemoto.respuestasOnboarding) { setProfile((p) => (Object.keys(p).length ? p : perfilRemoto.respuestasOnboarding)); } if (perfilRemoto.asis) setAsis((a) => a || perfilRemoto.asis); if (perfilRemoto.historiaClinica) setHcFormal((h) => h || perfilRemoto.historiaClinica); if (perfilRemoto.planCuidado) setPic((pc) => pc || perfilRemoto.planCuidado); const uc = perfilRemoto.ultimaConversacion; if (uc) { if (Array.isArray(uc.messages)) setMessages((m) => (m.length ? m : uc.messages)); if (Array.isArray(uc.docs)) setDocs((d) => (d.length ? d : uc.docs)); if (Array.isArray(uc.seguimiento)) setSeguimiento((s) => (s.length ? s : uc.seguimiento)); } startedRef.current = true; setDone(true); setTriageDone(true); setPicReady(!perfilRemoto.planCuidado); setProgress((p) => (p ? p : 100)); if (flow === 'comprobando' || flow === 'splash') setFlow('home'); } else if (flow === 'comprobando') { setFlow('splash'); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [perfilRemoto]); const conv = { voice, messages, profile, progress, loading, done, docs, send, attach: attachDoc, onFinish: finishTriage, turnoDeLlamada, personaId: persona.id, }; const toggleTheme = () => setTweak('dark', !tw.dark); const toggleTTS = () => { if (tw.tts) voice.stopSpeaking(); setTweak('tts', !tw.tts); }; const startFaceScan = (payload) => { setPendingAuth(payload || null); setFlow('facescan'); }; // Se llama con el `usuario` real del backend (login, registro, o el que // vuelve del escaneo facial de bienvenida tras un registro real). const onAuthed = (usuarioBackend) => { const nombreCompleto = usuarioBackend ? [usuarioBackend.nombre, usuarioBackend.apellido].filter(Boolean).join(' ') : null; if (nombreCompleto) setProfile(p => (p.nombre ? p : { ...p, nombre: nombreCompleto })); setFlow('splash'); }; // Pantallas que ocupan todo el lienzo, sin header ni navegación: son // previas a la sesión (login, onboarding) o inmersivas (videoconsulta). const SIN_SHELL = ['comprobando', 'auth', 'facescan', 'splash', 'choose', 'collect', 'asis', 'switchface', 'lock', 'done', 'telemed']; const NAV_DE_FLUJO = { home: 'home', chat: 'chat', care: 'care', history: 'history', services: 'services', profile: 'profile' }; const TITULO_DE_FLUJO = { home: 'Resumen de salud', chat: 'Conversación con Vita', care: 'Mi plan de hoy', history: 'Historia clínica', services: 'Servicios', profile: 'Mi perfil', notifs: 'Notificaciones', }; const usaShell = !SIN_SHELL.includes(flow); const pantallas = ( {flow === 'comprobando' && } {flow === 'auth' && setSecSheet(true)} />} {flow === 'facescan' && { // Registro real: la cuenta ya existe, esto sólo confirma y sigue. if (pendingAuth && pendingAuth.usuario) { onAuthed(pendingAuth.usuario); return; } // Login por reconocimiento facial: todavía no hay verificación // biométrica real en el backend — se vuelve al formulario en vez // de fingir una sesión que nadie validó de verdad. setFlow('auth'); }} onCancel={() => setFlow('auth')} />} {flow === 'splash' && setFlow('choose')} />} {flow === 'choose' && { setTweak('persona', id); // Se guarda ya mismo: si cierra aquí, Vita recuerda con qué voz la eligió. if (sesionEstado === 'autenticado') actualizarPerfilRemoto({ personaVoz: id }); }} onContinue={() => setFlow('chat')} onBack={() => setFlow('splash')} />} {flow === 'chat' && setSheet(true)} onBack={triageDone ? () => setFlow('home') : null} />} {flow === 'collect' && } {flow === 'asis' && } {flow === 'home' && (activeMember ? { setActiveId('titular'); }} onStartTriage={() => setFlow('chat')} onToggleTheme={toggleTheme} onOpenProfile={() => setFlow('profile')} onTalk={() => setFlow('chat')} /> : setFlow('chat')} onAddInfo={() => setAddSheet(true)} onOpenSeg={(it) => setSegItem(it)} careTasks={careTasks} careDone={careDone} onToggleCare={toggleCare} onOpenCare={() => setFlow('care')} postCita={postCitaItem} onOpenPostCita={() => setPostCitaSheet(true)} onDismissPostCita={() => setPostCitaDone(true)} notifCount={notifs.filter(nf => !nf.done).length} onOpenNotifs={() => setFlow('notifs')} onActivatePic={activatePic} onOpenPic={() => setPicSheet(true)} onDismissChangelog={() => setChangelog(null)} onShowHistory={() => setSheet(true)} onOpenHistory={() => setFlow('history')} onOpenProfile={() => setFlow('profile')} onOpenServices={() => { setServiceView(null); setFlow('services'); }} onShowSecurity={() => setSecSheet(true)} onToggleTheme={toggleTheme} />)} {flow === 'profile' && setAddMemberOpen(true)} onOpenData={() => setDataSheet(true)} onShowSecurity={() => setSecSheet(true)} onTalk={() => setFlow('chat')} onToggleTheme={toggleTheme} onLogout={reset} onOpenHome={() => setFlow('home')} onOpenHistory={() => setFlow('history')} onOpenProfile={() => setFlow('profile')} onOpenServices={() => { setServiceView(null); setFlow('services'); }} />} {flow === 'switchface' && { setPendingSwitch(null); setFlow('profile'); }} />} {flow === 'history' && setShareSheet(true)} onBack={() => setFlow('home')} onTalk={() => setFlow('chat')} onOpenSeg={(it) => setSegItem(it)} onShowSecurity={() => setSecSheet(true)} onToggleTheme={toggleTheme} onOpenHome={() => setFlow('home')} onOpenProfile={() => setFlow('profile')} onOpenServices={() => { setServiceView(null); setFlow('services'); }} />} {flow === 'services' && ( serviceView === 'citas' ? setServiceView(null)} onSchedule={(it) => { addToSeguimiento(it); setServiceView(null); setFlow('home'); }} onEnterCall={(cita) => { setTelemedCita(cita); setServiceView(null); setFlow('telemed'); }} /> : serviceView === 'nutricion' ? setServiceView(null)} onSchedule={(it) => { addToSeguimiento(it); setServiceView(null); setFlow('home'); }} /> : serviceView === 'psicologia' ? setServiceView(null)} onSchedule={addToSeguimiento} /> : serviceView === 'diagnostico' ? setServiceView(null)} onSchedule={(it) => { addToSeguimiento(it); }} /> : setServiceView(id)} onTalk={() => setFlow('chat')} onToggleTheme={toggleTheme} onOpenHome={() => setFlow('home')} onOpenHistory={() => setFlow('history')} onOpenProfile={() => setFlow('profile')} /> )} {flow === 'care' && setPostCitaSheet(true)} onToggle={toggleCare} onBack={() => setFlow('home')} onTalk={() => setFlow('chat')} onOpenHome={() => setFlow('home')} onOpenHistory={() => setFlow('history')} onOpenProfile={() => setFlow('profile')} onOpenServices={() => { setServiceView(null); setFlow('services'); }} />} {flow === 'notifs' && setFlow('home')} onSettings={() => setNotifSheet(true)} onSimulate={() => { setFlow('home'); setTimeout(simulateDay, 350); }} onLockPreview={() => setFlow('lock')} />} {flow === 'lock' && { setFlow('home'); if (nf) setTimeout(() => handleNotifAction(nf.actions && nf.actions[0] === 'done' ? 'view' : (nf.actions ? nf.actions[0] : 'view'), nf), 300); }} onAction={handleNotifAction} />} {flow === 'done' && setFlow('chat')} />} {flow === 'telemed' && items.forEach(addToSeguimiento)} onExit={() => { setTelemedCita(null); setPostCitaDone(false); setFlow('home'); }} />} ); const capas = ( {sheet && setSheet(false)} />} {secSheet && setSecSheet(false)} />} {segItem && setSegItem(null)} onAdvance={advanceSeg} />} {addSheet && setAddSheet(false)} onAdd={addInfo} />} {picSheet && setPicSheet(false)} />} {shareSheet && setShareSheet(false)} />} {addMemberOpen && setAddMemberOpen(false)} />} {dataSheet && setDataSheet(false)} />} {postCitaSheet && postCitaItem && setPostCitaSheet(false)} onResolve={resolvePostCita} />} {notifSheet && setNotifSettings(s => ({ ...s, [k]: v }))} onClose={() => setNotifSheet(false)} />} {/* push banner overlay — above everything inside the device */} {activeToast && triageDone && flow !== 'lock' && setActiveToast(null)} />} {reanalyzing && } ); return ( {usaShell ? ( !nf.done).length} pacienteNombre={activeMember ? activeMember.nombre : (nombreDeCuenta(usuarioSesion) || profile.nombre)} onNavegar={setFlow} onOpenNotifs={() => setFlow('notifs')} onToggleTheme={toggleTheme} onOpenProfile={() => setFlow('profile')} ancho={flow === 'chat' ? 'completo' : 'normal'}> {pantallas} ) : (
{pantallas}
)} {capas} setTweak('persona', v)} /> {voice.voiceState === 'no_disponible' && (
Vita no puede hablar. No hay voz de respaldo a propósito: antes caía a la voz robótica del navegador y el fallo pasaba desapercibido.
{voice.voiceReason || 'motivo desconocido'}
)} voice.speak('Hola, soy Vita. ¿Cómo te has sentido hoy? Cuéntame y arrancamos.', persona)} /> setTweak('waveStyle', v)} /> { if (!v) voice.stopSpeaking(); setTweak('tts', v); }} /> setTweak('dark', v)} />
); } Object.assign(window, { DoneScreen, ProfileSheet, VitaApp });