/* App entry — login gate + router. Sesión por email + password (POST /auth/login) * con token persistido en localStorage. Modo demo opcional (sin backend) cuando * el usuario clickea "Modo demo" en login. */ const ACCENTS = { vital: { color: "#16a34a", soft: "rgba(22, 163, 74, 0.14)", fg: "#ffffff", label: "Vital green" }, ember: { color: "#ea580c", soft: "rgba(234, 88, 12, 0.14)", fg: "#ffffff", label: "Ember orange" }, ocean: { color: "#0284c7", soft: "rgba(2, 132, 199, 0.14)", fg: "#ffffff", label: "Ocean blue" }, iron: { color: "#dc2626", soft: "rgba(220, 38, 38, 0.14)", fg: "#ffffff", label: "Iron red" }, mineral: { color: "#0891b2", soft: "rgba(8, 145, 178, 0.14)", fg: "#ffffff", label: "Mineral teal" }, mono: { color: "#1a1a1a", soft: "rgba(0, 0, 0, 0.08)", fg: "#ffffff", label: "Mono" }, }; const FONT_FAMILIES = { system: { sans: "'Geist', 'Inter', system-ui, sans-serif", display: "'Fraunces', serif", label: "Sans + Serif" }, mono: { sans: "'JetBrains Mono', ui-monospace, monospace", display: "'JetBrains Mono', monospace", label: "Mono everywhere" }, serif: { sans: "'IBM Plex Serif', Georgia, serif", display: "'Fraunces', serif", label: "Editorial serif" }, }; const PREFS_KEY = "api_fit_prefs"; const DEFAULTS = { // "auto" sigue al SO (prefers-color-scheme); "dark" / "light" lo fijan. // Default auto — el brief §10 prefiere oscuro pero respetamos la // preferencia del usuario primero. theme: "auto", density: "compact", dataState: "full", accent: "ocean", fontFamily: "system", showDisclaimer: true, }; // Helper: tema efectivo (lo que se setea en data-theme). function resolveTheme(pref) { if (pref === "dark" || pref === "light") return pref; // auto / cualquier otro if (typeof window !== "undefined" && window.matchMedia) { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } return "dark"; } function loadPrefs() { try { const raw = localStorage.getItem(PREFS_KEY); if (raw) return { ...DEFAULTS, ...JSON.parse(raw) }; } catch (_) {} return DEFAULTS; } function savePrefs(p) { try { localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } catch (_) {} } function App() { // boot status: 'checking' | 'login' | 'authed' | 'demo' const [boot, setBoot] = React.useState("checking"); const [user, setUser] = React.useState(() => API.getUser()); const [tweaks, setTweaks] = React.useState(loadPrefs); const setTweak = (k, v) => setTweaks((p) => { const next = (typeof k === "object") ? { ...p, ...k } : { ...p, [k]: v }; savePrefs(next); return next; }); const [route, setRoute] = React.useState("today"); const [toast, setToast] = React.useState(null); // 401 desde cualquier parte → kick a login. React.useEffect(() => { API.on("unauthorized", () => { // En modo demo no hay sesión, así que ignoramos. if (API.demoMode()) return; setToast({ kind: "bad", msg: "Sesión expirada. Volvé a iniciar sesión." }); API.clearToken(); API.clearUser(); setUser(null); setBoot("login"); }); API.on("error", (e) => { if (e.network) { // No hagas spam: solo mostralo una vez por minuto. if (!window.__af_last_net_toast || Date.now() - window.__af_last_net_toast > 60_000) { window.__af_last_net_toast = Date.now(); setToast({ kind: "bad", msg: "El backend no responde. Datos de muestra en pantalla." }); } } }); }, []); // Boot: decide si vamos directo a la app o al login. React.useEffect(() => { let alive = true; (async () => { // 1) Demo mode → entrar con MOCKs sin tocar backend. if (API.demoMode()) { if (alive) setBoot("demo"); return; } // 2) Token presente → validar con /auth/me. if (API.getToken()) { try { const me = await API.authMe(); if (!alive) return; API.setUser(me); setUser(me); setBoot("authed"); return; } catch (e) { if (e.status === 401) { API.clearToken(); API.clearUser(); if (alive) setBoot("login"); return; } // Si el backend está caído, no perdemos la sesión: dejamos al usuario entrar // y los componentes caerán a MOCK silenciosamente. El usuario ya tiene un // user persistido localmente. if (e.network) { if (alive) { setBoot("authed"); setToast({ kind: "bad", msg: "El backend no responde. Datos de muestra en pantalla." }); } return; } } } // 3) Sin token → ir a login. Login.jsx llama a /auth/has_users para decidir // si arranca en modo register (primera ejecución) o login normal. if (alive) setBoot("login"); })(); return () => { alive = false; }; }, []); // Permitir a otras pantallas redirigir vía evento (p.ej. CTA "Configurar LLM") React.useEffect(() => { const onRoute = (e) => setRoute(e.detail); window.addEventListener("af-route", onRoute); return () => window.removeEventListener("af-route", onRoute); }, []); // Aplicar tema, densidad, acento, tipografía a . Cuando el usuario // elige "auto" además escuchamos prefers-color-scheme para reaccionar en // vivo a cambios del SO (modo nocturno automático, switch manual del // usuario en Ajustes, etc.). React.useEffect(() => { const apply = () => { document.documentElement.dataset.theme = resolveTheme(tweaks.theme); }; apply(); if (tweaks.theme === "auto" && typeof window !== "undefined" && window.matchMedia) { const mq = window.matchMedia("(prefers-color-scheme: dark)"); mq.addEventListener("change", apply); return () => mq.removeEventListener("change", apply); } }, [tweaks.theme]); React.useEffect(() => { document.documentElement.dataset.density = tweaks.density; const a = ACCENTS[tweaks.accent] || ACCENTS.vital; document.documentElement.style.setProperty("--accent", a.color); document.documentElement.style.setProperty("--accent-soft", a.soft); document.documentElement.style.setProperty("--accent-fg", a.fg); const f = FONT_FAMILIES[tweaks.fontFamily] || FONT_FAMILIES.system; document.documentElement.style.setProperty("--font-sans", f.sans); document.documentElement.style.setProperty("--font-display", f.display); }, [tweaks.density, tweaks.accent, tweaks.fontFamily]); // Toast auto-dismiss React.useEffect(() => { if (!toast) return; const t = setTimeout(() => setToast(null), 4500); return () => clearTimeout(t); }, [toast]); if (boot === "checking") { return (
verificando sesión…
); } if (boot === "login") { return ( { setUser(API.getUser()); setBoot("authed"); setRoute("today"); }} onRegisteredFirstTime={() => { setUser(API.getUser()); setBoot("authed"); setRoute("onboarding"); }} /> ); } const inDemo = boot === "demo" || API.demoMode(); const exitDemo = () => { API.setDemoMode(false); setBoot("login"); }; const handleLogout = async () => { try { await API.authLogout(); } catch (_) {} setUser(null); setBoot("login"); setRoute("today"); }; const titles = { today: { t: "Hoy", s: subtitle() }, metrics: { t: "Métricas", s: "Tendencias · histórico" }, activities: { t: "Actividades", s: "Tus sincronizaciones recientes" }, agents: { t: "Coach", s: "Coach · Nutricionista · Médico" }, plans: { t: "Planes", s: "Entrenamiento · Nutrición" }, knowledge: { t: "Biblioteca", s: "Libros y apuntes que cita el coach" }, settings: { t: "Configuración", s: "Proveedores · LLM · Privacidad" }, }; if (route === "onboarding") { return setRoute("today")} />; } const userLabel = inDemo ? "Demo" : (user?.name || user?.email || "usuario"); return (
setTweak("density", v)} actions={
{inDemo && ( DEMO )} {userLabel} } title={ tweaks.theme === "auto" ? `Tema: Auto (sigue al SO · actualmente ${resolveTheme("auto")})` : `Tema: ${tweaks.theme === "dark" ? "Oscuro" : "Claro"} (fijo)` } onClick={() => setTweak("theme", tweaks.theme === "auto" ? "light" : tweaks.theme === "light" ? "dark" : "auto" )} > {tweaks.theme === "auto" ? "Auto" : tweaks.theme === "dark" ? "Oscuro" : "Claro"} {inDemo ? ( Salir de demo ) : ( Logout )}
} />
{route === "today" && } {route === "metrics" && } {route === "activities" && } {route === "agents" && } {route === "plans" && } {route === "knowledge" && } {route === "settings" && }
{/* Floating preferences panel — auto-open con keyboard shortcut Cmd+. */} {toast && (
{toast.msg}
)}
); } function subtitle(suffix) { const d = new Date(); const days = ["Dom","Lun","Mar","Mié","Jue","Vie","Sáb"]; const months = ["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"]; const head = `${days[d.getDay()]} ${String(d.getDate()).padStart(2,"0")} ${months[d.getMonth()]}`; return suffix ? `${head} · ${suffix}` : head; } /* Panel flotante de preferencias — versión simplificada del TweaksPanel * (sin postMessage al host). Activa con icono ⚙ flotante. */ const PrefsPanel = ({ tweaks, setTweak, setRoute, onLogout, inDemo, exitDemo }) => { const [open, setOpen] = React.useState(false); React.useEffect(() => { const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key === ".") { e.preventDefault(); setOpen((v) => !v); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); return ( <> {open && (
APARIENCIA
setTweak("theme", v)} options={[ { value: "auto", label: "Auto" }, { value: "light", label: "Claro" }, { value: "dark", label: "Oscuro" }, ]} /> setTweak("density", v)} options={[ { value: "compact", label: "Compact" }, { value: "comfortable", label: "Normal" }, { value: "detailed", label: "Detail" }, ]} />
DATOS (DEMO)
setTweak("dataState", v)} options={[ { value: "full", label: "Lleno" }, { value: "partial", label: "Parcial" }, { value: "empty", label: "Vacío" }, ]} />
{inDemo ? ( ) : ( )}
)} ); }; const PrefRow = ({ label, children }) => (
{label} {children}
); const Seg = ({ value, onChange, options }) => (
{options.map((o) => ( ))}
); const selectStyle = { padding: "4px 8px", fontSize: 11.5, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: 5, color: "var(--text)", outline: "none", }; const ghostBtn = { padding: "6px 8px", textAlign: "left", background: "transparent", border: "none", color: "var(--text-2)", fontSize: 12, cursor: "pointer", borderRadius: 4, }; ReactDOM.createRoot(document.getElementById("root")).render();