/* Onboarding flow — 5 steps */ const Onboarding = ({ onExit }) => { const [step, setStep] = React.useState(0); const [authState, setAuthState] = React.useState("idle"); // idle | mfa | syncing | done const [bleState, setBleState] = React.useState("idle"); // idle | scanning | results | configuring | active React.useEffect(() => { if (authState === "syncing") { let n = 0; const i = setInterval(() => { n += 4; setSyncProgress(Math.min(87, n)); if (n >= 87) clearInterval(i); }, 80); return () => clearInterval(i); } }, [authState]); const [syncProgress, setSyncProgress] = React.useState(12); return (
{/* Header */}
{Array.from({ length: 5 }).map((_, i) => (
))}
paso {step + 1} de 5 Salir
{step === 0 && setStep(1)} />} {step === 1 && setStep(2)} />} {step === 2 && ( setStep(3)} /> )} {step === 3 && setStep(4)} />} {step === 4 && }
); }; const StepWelcome = ({ onNext }) => (
BIENVENIDO A API_FIT

Tus datos. Tu casa. Una IA que ve el cuadro completo.

FtiMind corre en tu NAS, Raspberry Pi o computador. Lee tus relojes y básculas, lo guarda en una base local privada y te asesora con coach, nutricionista y médico que conocen tu historia completa.
}>Empezar
~5 minutos · podés saltar pasos
QUÉ VAMOS A HACER
{[ { n: "01", l: "Confirmar tu cuenta", d: "La sesión que iniciaste está lista" }, { n: "02", l: "Conectar tu primer dispositivo", d: "Garmin, Eufy, otros vienen pronto" }, { n: "03", l: "Configurar LLM (opcional)", d: "Sin esto: solo dashboards. Con esto: coach completo." }, ].map((s, i) => (
{s.n}
{s.l}
{s.d}
))}
Todos tus datos viven en tu máquina. Solo lo que envíes al LLM sale.
); const StepToken = ({ onNext }) => { // Tras el cambio a auth por email+password, este paso ya no pide token: el usuario // ya está autenticado al llegar acá (pasó por Login.jsx). Mostramos su sesión. const user = API.getUser() || {}; const inDemo = API.demoMode(); return (
PASO 1 · TU CUENTA

{inDemo ? "Estás en modo demo." : "Tu cuenta está lista."}

{inDemo ? "Vas a navegar la app con datos de muestra. Para conectar Garmin/Eufy de verdad, salí del modo demo desde el topbar." : "Tus credenciales viven en la base local de FtiMind. El token de sesión expira automáticamente y se renueva al iniciar sesión."}
SESIÓN ACTUAL
{user.name || (inDemo ? "Usuario demo" : "—")}
{user.email || "—"}
}>Continuar
); }; const StepProvider = ({ authState, setAuthState, bleState, setBleState, syncProgress, onNext }) => { const [picked, setPicked] = React.useState(null); const [providers, setProviders] = React.useState(null); React.useEffect(() => { if (API.demoMode()) return; let alive = true; (async () => { try { const data = await API.listProviders(); const list = Array.isArray(data) ? data : (data?.providers || []); if (alive) setProviders(list); } catch (_) { /* fallback al hardcoded */ } })(); return () => { alive = false; }; }, []); if (picked === "garmin") return setPicked(null)} onDone={onNext} progress={syncProgress} />; if (picked === "eufy") return setPicked(null)} onDone={onNext} />; // Si tenemos providers reales del backend los usamos para decidir disponibilidad, // si no, hardcodeamos los 4 conocidos para que la UI no se rompa. const known = providers ? providers.map(p => (typeof p === "string" ? p : p.name)).filter(Boolean).map(n => n.toLowerCase()) : null; const has = (n) => known ? known.includes(n) : (n === "garmin" || n === "eufy"); return (
PASO 2 · CONECTÁ TU PRIMER DISPOSITIVO

¿Qué tenés a mano?

Podés conectar más después. Para el MVP soportamos Garmin + Eufy. Otros vienen pronto.
setPicked("garmin")} /> setPicked("eufy")} />
Saltar este paso
); }; const ProviderPick = ({ name, desc, caps, mode, available, onClick }) => ( ); const GarminAuth = ({ state, setState, onBack, onDone, progress }) => { const [garminEmail, setGarminEmail] = React.useState(""); const [garminPassword, setGarminPassword] = React.useState(""); const [mfaCode, setMfaCode] = React.useState(""); const [authError, setAuthError] = React.useState(null); const startGarmin = async () => { setAuthError(null); if (API.demoMode()) { setState("mfa"); return; } try { const res = await API.startAuth("garmin", { style: "interactive", credentials: { email: garminEmail, password: garminPassword }, }); // Backend devuelve { status: "logged_in" | "awaiting_mfa" | "error" | ... }. if (res?.status === "logged_in" || res?.status === "ok" || res?.mfa_required === false) { setState("syncing"); } else if (res?.status === "error") { setAuthError(res.error || "No se pudo iniciar sesión con Garmin."); } else { setState("mfa"); } } catch (e) { if (e.network) { setState("mfa"); return; } // demo path setAuthError(e.detail?.detail || e.message || "No se pudo iniciar sesión con Garmin."); } }; const submitMfa = async () => { setAuthError(null); if (API.demoMode()) { setState("syncing"); return; } try { await API.submitMfa("garmin", { code: mfaCode }); setState("syncing"); } catch (e) { if (e.network) { setState("syncing"); return; } setAuthError(e.detail?.detail || e.message || "Código inválido."); } }; return (
CONECTAR GARMIN
{authError && (
Error: {authError}
)} {state === "idle" && ( <>

Iniciá sesión con tu cuenta Garmin Connect.

setGarminEmail(e.target.value)} placeholder="email@dominio.com" style={{ ...fieldStyle3 }} /> setGarminPassword(e.target.value)} placeholder="contraseña" style={{ ...fieldStyle3 }} />
Iniciar sesión )} {state === "mfa" && ( <>

Código de verificación.

Te enviamos un código a tu email. Expira en 04:42.
setMfaCode(e.target.value)} placeholder="••••••" maxLength={6} style={{ ...fieldStyle3, fontFamily: "var(--font-mono)", fontSize: 22, letterSpacing: "0.5em", textAlign: "center" }} />
Reenviar código Verificar
)} {state === "syncing" && ( <>

Sincronizando tu historial...

Bajando actividades, sueño y métricas diarias. Podés cerrar esto, sigue en background.
{progress}/87 actividades ~{Math.max(0, 87 - progress) * 2}s restantes
Continuar (sigue en background)
)}
); }; // Modelos soportados por eufylife-ble-client. La key es el `model_id` que // el backend espera en `configure`; el value es el nombre comercial. const EUFY_MODELS = [ { id: "eufy T9120", name: "Smart Scale A1" }, { id: "eufy T9130", name: "Smart Scale C20" }, { id: "eufy T9140", name: "Smart Scale" }, { id: "eufy T9146", name: "Smart Scale C1" }, { id: "eufy T9147", name: "Smart Scale P1" }, { id: "eufy T9148", name: "Smart Scale P2" }, { id: "eufy T9149", name: "Smart Scale P2 Pro" }, { id: "eufy T9150", name: "Smart Scale P3" }, ]; const _eufyModelLabel = (modelId) => { const hit = EUFY_MODELS.find((m) => m.id === modelId); return hit ? `${hit.id.replace("eufy ", "")} · ${hit.name}` : (modelId || "modelo desconocido"); }; const EufyPair = ({ state, setState, onBack, onDone }) => { const [discovered, setDiscovered] = React.useState([]); const [selectedAddress, setSelectedAddress] = React.useState(null); const [modelId, setModelId] = React.useState("eufy T9146"); // C1 = T9146; default sensato const [mode, setMode] = React.useState("local_ble"); const [manualAddress, setManualAddress] = React.useState(""); const [manualMode, setManualMode] = React.useState(false); const [sex, setSex] = React.useState("male"); const [age, setAge] = React.useState(32); const [heightCm, setHeightCm] = React.useState(178); const [pairError, setPairError] = React.useState(null); const [submitting, setSubmitting] = React.useState(false); const [includeAll, setIncludeAll] = React.useState(false); const startScan = async (allFlag = includeAll) => { setPairError(null); setDiscovered([]); setSelectedAddress(null); setManualMode(false); setState("scanning"); if (API.demoMode()) { // Modo demo: simulamos un C1 detectado. setTimeout(() => { setDiscovered([{ address: "CF:35:1A:B8:42:7D", name: "eufy T9146 Scale", rssi: -52, model_guess: "eufy T9146" }]); setSelectedAddress("CF:35:1A:B8:42:7D"); setModelId("eufy T9146"); setState("results"); }, 1800); return; } try { const res = await API.extras("eufy", "discover", { seconds: 10, include_all: allFlag }); const found = Array.isArray(res?.devices) ? res.devices : []; setDiscovered(found); if (found.length > 0) { setSelectedAddress(found[0].address); if (found[0].model_guess) setModelId(found[0].model_guess); } setState("results"); } catch (e) { if (e.network) { setPairError("No hay conexión con el backend. Probá levantar el server o usar modo demo."); setState("idle"); return; } const msg = e.detail?.detail || e.message || "El escaneo BLE falló."; // En macOS la causa más común es que el host no le dio permisos de // Bluetooth al proceso uvicorn → bleak devuelve un error específico. const hint = /permission|authoriz|bluetooth.*not.*on|adapter|hci/i.test(msg) ? " · Pista: probá con `address` manual o asegurate que el host (Linux: /dev/hci0; macOS: permisos BT) tiene BLE habilitado." : ""; setPairError(`${msg}${hint}`); setState("idle"); } }; const confirmPair = async () => { setPairError(null); setSubmitting(true); try { const address = manualMode ? manualAddress.trim() : selectedAddress; if (!address) { setPairError("Falta la dirección BLE de la báscula."); return; } // Validación mínima. Aceptamos dos formatos: // - MAC clásica (Linux/Windows): AA:BB:CC:DD:EE:FF // - UUID CoreBluetooth (macOS): 12345678-1234-1234-1234-123456789ABC // En macOS la MAC real está oculta y bleak entrega un UUID per-app. const isMac = /^([0-9a-f]{2}[:\-]){5}[0-9a-f]{2}$/i.test(address); const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(address); if (!isMac && !isUuid) { setPairError("La dirección no parece válida. Acepto MAC (AA:BB:CC:DD:EE:FF) o UUID CoreBluetooth (8-4-4-4-12 hex con guiones)."); return; } if (API.demoMode()) { onDone(); return; } const payload = { address, mode, model_id: modelId, sex, age: Number(age), height_cm: Number(heightCm), }; await API.extras("eufy", "configure", payload); try { await API.startListener("eufy"); } catch (le) { // configure ok pero el listener no arrancó (BLE no disponible en el // host). Mostramos el error pero seguimos: la config quedó persistida // y el usuario puede reintentar desde Configuración → "Reiniciar listener". const msg = le.detail?.detail || le.message || "El listener BLE no pudo arrancar."; setPairError(`Configuración guardada, pero el listener falló: ${msg}. Reiniciá desde Configuración cuando el BLE esté disponible.`); return; } onDone(); } catch (e) { if (e.network) { onDone(); return; } setPairError(e.detail?.detail || e.message || "No se pudo guardar la configuración Eufy."); } finally { setSubmitting(false); } }; return (
EMPAREJAR EUFY · BLE LOCAL
{pairError && (
Error: {pairError}
)} {state === "idle" && ( <>

Despertá la báscula y subite encima.

Vamos a escanear BLE durante ~10 segundos. La báscula se anuncia mientras alguien está sobre ella o justo después de pisarla. Si no aparece, podés ver todos los devices BLE o ingresar la dirección a mano.
startScan(false)} icon={}>Iniciar escaneo { setIncludeAll(true); startScan(true); }}>Ver todos los BLE { setManualMode(true); setState("results"); }}>Ingresar manualmente
macOS oculta la MAC real de los devices BLE y la reemplaza por un UUID aleatorio, por eso a veces el filtro Eufy/Anker no encuentra la báscula. En ese caso usá "Ver todos los BLE" y elegila por nombre o RSSI más fuerte mientras estás parado encima.
)} {state === "scanning" && (
Escaneando dispositivos BLE...
timeout 10s · subite a la báscula ahora
)} {state === "results" && ( <> {!manualMode && discovered.length === 0 && ( <>

{includeAll ? "El scan no detectó ningún BLE." : "No encontré básculas Eufy con el filtro."}

{includeAll ? "Asegurate que el host tiene Bluetooth encendido y permisos BLE concedidos al proceso. En macOS: Configuración → Privacidad → Bluetooth." : "En macOS la MAC real está oculta, así que el filtro Eufy/Anker puede fallar aunque la báscula esté presente. Probá \"Ver todos los BLE\" para listar todo lo que hay alrededor."}
{ setIncludeAll(false); setState("idle"); }}>Volver a escanear {!includeAll && ( { setIncludeAll(true); startScan(true); }}> Ver todos los BLE )} setManualMode(true)}>Ingresar manualmente
)} {!manualMode && discovered.length > 0 && ( <>

{discovered.length === 1 ? "Encontré 1 báscula. Confirmá:" : `Encontré ${discovered.length} bácsulas. Elegí cuál:`}

{discovered.map((d, i) => { const isSel = selectedAddress === d.address; const tone = d.rssi == null ? "default" : d.rssi >= -60 ? "good" : d.rssi >= -75 ? "warn" : "bad"; return (
{ setSelectedAddress(d.address); if (d.model_guess) setModelId(d.model_guess); }} style={{ padding: 14, display: "flex", alignItems: "center", gap: 14, cursor: "pointer", borderBottom: i === discovered.length - 1 ? "none" : "1px solid var(--line-soft)", background: isSel ? "color-mix(in srgb, var(--accent) 6%, transparent)" : "transparent", }}>
{d.name || "Eufy Scale"}
{d.address} · RSSI {d.rssi ?? "?"} dBm{d.model_guess ? ` · ${d.model_guess.replace("eufy ", "")}` : ""}
{d.rssi == null ? "?" : d.rssi >= -60 ? "fuerte" : d.rssi >= -75 ? "ok" : "débil"}
); })}
setManualMode(true)}>¿No es ninguna? Ingresar manualmente
)} {manualMode && ( <>

Ingresá la dirección de la báscula

Formato: AA:BB:CC:DD:EE:FF. La encontrás en la app de Eufy o pisando "i" sobre el dispositivo en una app BLE genérica.
setManualAddress(e.target.value)} placeholder="AA:BB:CC:DD:EE:FF" style={{ ...fieldStyle3, fontFamily: "var(--font-mono)", letterSpacing: "0.05em" }} />
{ setManualMode(false); }}>Volver a la lista escaneada
)}
MODELO
Para la Eufy Smart Scale C1 elegí T9146. La selección define cómo se decodifica el payload BLE.
MODO DE LECTURA
DATOS PARA BMR/BMI
setAge(e.target.value)} style={fieldStyle3} /> setHeightCm(e.target.value)} style={fieldStyle3} />
setState("idle")}>Volver a escanear {submitting ? "Guardando…" : "Confirmar y arrancar listener"}
)}
); }; const RadioCard = ({ l, d, picked }) => ( ); const StepLLM = ({ onNext }) => { const [provider, setProvider] = React.useState("OpenAI"); const [model, setModel] = React.useState("gpt-4o-mini"); const [apiKey, setApiKey] = React.useState(""); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const saveAndContinue = async () => { if (!apiKey || apiKey.length < 6) { // Sin API key, simplemente saltar onNext(); return; } if (API.demoMode()) { onNext(); return; } setSaving(true); setError(null); try { await API.setLLMConfig({ provider: provider.toLowerCase(), default_model: model, api_key: apiKey, }); onNext(); } catch (e) { if (e.network) { onNext(); return; } setError(e.detail?.detail || e.message || "No se pudo guardar la configuración LLM."); } finally { setSaving(false); } }; return (
PASO 3 · LLM (OPCIONAL)

¿Activamos al coach ahora?

Sin LLM: dashboards, métricas y planes manuales. Con LLM: coach, nutricionista y médico que cruzan tus datos. Podés saltarlo y configurarlo después.
{error && (
Error: {error}
)}
setApiKey(e.target.value)} placeholder="sk-..." style={fieldStyle3} />
El provider procesará tus datos en sus servidores. Vas a poder activar "Anonimizar" después.
Saltar por ahora {saving ? "Guardando…" : "Guardar y continuar"}
); }; /* StepDone — saluda con el nombre/email real del usuario y describe qué pasa * después en términos genéricos. Antes hardcodeaba "Daniel" + "87 actividades" * a TODOS los usuarios. */ const StepDone = ({ onExit }) => { const user = (typeof API !== "undefined" && API.getUser) ? API.getUser() : null; const greeting = (() => { if (!user) return "Todo listo."; const name = user.name || user.full_name; if (name && name.trim()) return `Todo listo, ${name.trim().split(/\s+/)[0]}.`; if (user.email) return `Todo listo, ${user.email.split("@")[0]}.`; return "Todo listo."; })(); return (

{greeting}

Las cuentas que conectaste se están sincronizando en background.
En unos minutos verás tus datos en el dashboard.
}>Ir al dashboard
); }; const fieldStyle3 = { width: "100%", padding: "10px 12px", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-2)", fontSize: 13, color: "var(--text)", outline: "none", marginTop: 4, }; window.Onboarding = Onboarding; // Expose EufyPair so screen-settings.jsx can render it inline en su ConnectModal // y evitar el redirect a /onboarding (que rompía el flujo modal). window.EufyPair = EufyPair; window.fieldStyle3 = fieldStyle3;