/* global React, RadioIcon, RadioBottomNav */ // Yomee — Radiografía dashboard SHELL. // Header + background + bottom nav. No content cards yet (added in later prompts). // // Profile is driven by a single `profile` prop. The 4 profiles share an // identical header layout but swap their accent color, user pill, profile // pill, and dummy "first name" / initials. const { useState: useStateRG } = React; // ──────────────────────────────────────────────── // Profile palette + dummy header data // (per spec — overrides whatever lives in RadiografiaData.jsx so the shell // reads cleanly on its own.) // ──────────────────────────────────────────────── // Header homologation (May 2026): // All archetypes now share an IDENTICAL top card. The only thing that // changes between variants is the archetype pill's background color // (per-archetype accent token from the Design System). The card // background itself is always Yomee brand green for every archetype. // // `color` here is the archetype-pill color (NOT the card background). // The legacy per-archetype card-color field has been removed — see // RADIO_CARD_BG below. // // Mapping rationale: // • Totalero → --yo-mint-700 (#37B78B — energetic brand green) // • Parcialero → --yo-orange-soft (warm "in-between" amber) // • Mínimero → --yo-coral (alert / cost — small payment risk) // • Reiniciador → --yo-lavender (cycle / fresh start) // // Note on spec ↔ design system reconciliation: the brief listed // archetype names "Totalero / Parcialero / Mínimero / Reiniciador" and // `#2BAE80` for the green. The codebase shipped with a different fourth // archetype set (`optimizador / escalador`) and the closest design-system // green is `--yo-mint-700` (#37B78B). I've kept the existing archetype // keys to avoid breaking every other screen, mapped the new pill colors // onto them, and used the actual brand token instead of hardcoding // `#2BAE80` — the brief was explicit about preferring the token. const RADIO_CARD_BG = "var(--yo-mint-700)"; // brand green for top card const RADIO_CARD_RADIUS = "0 0 var(--radius-card) var(--radius-card)"; // Profile-icon background colors — kept in lockstep with PROFILE_CHIP in // ProfileScreen.jsx so the dashboard's top-card avatar visually matches // the IdentityCard avatar inside Mi perfil. If either table changes, // update both. const PROFILE_CHIP_BG = { totalero: "#1a6b3f", optimizador: "#185fa5", escalador: "#7c2d12", reiniciador: "#4c1d95", }; const RADIO_HEADER = { totalero: { color: "var(--yo-mint-700)", initials: "AR", firstName: "Ana R.", pillLabel: "Totalero" }, optimizador: { color: "var(--yo-orange-soft)", initials: "LM", firstName: "Luis M.", pillLabel: "Parcialero" }, escalador: { color: "var(--yo-coral)", initials: "CV", firstName: "Carlos V.", pillLabel: "Mínimero" }, reiniciador: { color: "var(--yo-lavender)", initials: "SP", firstName: "Sofía P.", pillLabel: "Reiniciador" }, }; // Soft warm grey body background — pairs with the cream design system but // reads as "page" rather than "card". Used wherever --color-background-tertiary // is referenced. const BODY_BG = "rgb(238, 235, 230)"; // Secondary text color across body cards. const SECONDARY_FG = "#6b6b6b"; // ──────────────────────────────────────────────── // Hero config per archetype. // // Static parts (eyebrow, amount color, stat labels) are fixed per archetype. // Dynamic parts — amount, subline, scope tag, stat values — are derived // live from `uploadedBanks` via buildHeroView() below. // // `partialSuffix` is the per-archetype tail shown after the bank list when // uploads are incomplete. On a complete upload the tail is replaced with // `análisis completo`. // // `perBank` holds the contribution each bank makes to the hero amount // (interest / overpayment / savings, depending on archetype). Summed across // `uploadedBanks` to produce the live amount. // ──────────────────────────────────────────────── const RADIO_HERO = { totalero: { eyebrow: "Puedes ahorrar hasta", amountColor: "#1a6b3f", // Totalero subline is opportunity-framed; partial state references // the limited data set, complete state replaces with `análisis completo`. sublinePrefix: "optimizando con los datos de", partialSuffix: "el ahorro puede ser mayor", perBank: { BBVA: 11400, Nu: 4200, Banorte: 3000 }, healthByCount: { 1: "82/100", 2: "85/100", 3: "88/100" }, healthColor: "#1a6b3f", stat2Label: "Costo anual de tarjetas", stat2Color: "#c97a00", perBankStat2: { BBVA: 1200, Nu: 480, Banorte: 360 }, }, optimizador: { eyebrow: "Pagas de más cada año", amountColor: "#c0392b", sublinePrefix: "solo con los datos de", partialSuffix: "puede ser más", perBank: { BBVA: 14400, Nu: 6800, Banorte: 4200 }, healthByCount: { 1: "58/100", 2: "54/100", 3: "50/100" }, healthColor: "#c97a00", stat2Label: "Intereses al año", stat2Color: "#c0392b", }, escalador: { eyebrow: "Intereses detectados este año", amountColor: "#c0392b", sublinePrefix: "con los datos de", partialSuffix: "puede ser más alto", perBank: { BBVA: 38400, Nu: 14200, Banorte: 9600 }, healthByCount: { 1: "31/100", 2: "28/100", 3: "24/100" }, healthColor: "#c0392b", stat2Label: "Intereses al año", stat2Color: "#c0392b", }, reiniciador: { eyebrow: "Intereses al año detectados", amountColor: "#c0392b", sublinePrefix: "con los datos de", partialSuffix: "el ciclo puede ser más amplio", perBank: { BBVA: 21600, Nu: 8400, Banorte: 5200 }, healthByCount: { 1: "44/100", 2: "41/100", 3: "38/100" }, healthColor: "#c97a00", stat2Label: "Intereses al año", stat2Color: "#c0392b", }, }; // Natural-language es-MX list of bank names: [A] → "A", [A,B] → "A y B", // [A,B,C] → "A, B y C". function formatBankList(names) { if (!names || names.length === 0) return ""; if (names.length === 1) return names[0]; if (names.length === 2) return `${names[0]} y ${names[1]}`; const head = names.slice(0, -1).join(", "); return `${head} y ${names[names.length - 1]}`; } function formatCurrency(n) { return `$${Math.round(n).toLocaleString("en-US")}`; } // Build the live hero view (amount, subline, tag, stats) from the shared // upload state. `uploadedBankNames` is the ordered list of display names, // `totalExpected` is the user's reportedCount. function buildHeroView(profile, uploadedBankNames, totalExpected) { const cfg = RADIO_HERO[profile] || RADIO_HERO.totalero; const count = uploadedBankNames.length; // ── Empty state ───────────────────────────────────────────────── if (count === 0) { return { eyebrow: cfg.eyebrow, amountColor: cfg.amountColor, amountText: "—", isEmpty: true, subline: "Carga tu primer estado de cuenta para ver tu análisis.", scopeWarning: null, stat1: { label: "Salud financiera", value: "—/100", color: cfg.healthColor }, stat2: { label: cfg.stat2Label, value: "$—", color: cfg.stat2Color }, }; } const isComplete = count >= totalExpected; const bankList = formatBankList(uploadedBankNames); // ── Amount: sum per-bank contributions across uploaded banks ──── const total = uploadedBankNames.reduce( (sum, n) => sum + (cfg.perBank[n] || 0), 0, ); // ── Subline: " " ─────────────────── const suffix = isComplete ? "análisis completo" : cfg.partialSuffix; const subline = `${cfg.sublinePrefix} ${bankList} — ${suffix}`; // ── Scope tag: hidden when complete; otherwise "Basado en …" ──── let scopeWarning = null; if (!isComplete) { if (count === 1) scopeWarning = `Basado en ${bankList} únicamente`; else scopeWarning = `Basado en ${bankList}`; } // ── Stat row ──────────────────────────────────────────────────── const healthValue = cfg.healthByCount[Math.min(count, 3)] || cfg.healthByCount[1]; // Totalero stat2 is "Costo anual de tarjetas" — its own per-bank table. // For the rest, stat2 mirrors the hero amount (Intereses al año). let stat2Value; if (cfg.perBankStat2) { const s = uploadedBankNames.reduce( (sum, n) => sum + (cfg.perBankStat2[n] || 0), 0, ); stat2Value = formatCurrency(s); } else { stat2Value = formatCurrency(total); } return { eyebrow: cfg.eyebrow, amountColor: cfg.amountColor, amountText: formatCurrency(total), isEmpty: false, subline, scopeWarning, stat1: { label: "Salud financiera", value: healthValue, color: cfg.healthColor }, stat2: { label: cfg.stat2Label, value: stat2Value, color: cfg.stat2Color }, }; } // ──────────────────────────────────────────────── // Scenarios (BBVA-only, BBVA+Nu, stale-month) // // NOTE — the Mayo 2026 statements upload progress card no longer reads // `monthLabel`, `pct`, `pctLabel`, or `banks` from this table. Those four // fields are now derived live from the user's actual upload state // (cardsAnswer + uploadedFiles). The remaining fields below — `cta` // (legacy Pocopin nudge label) and `scopeWarning` (hero card chip) — // still drive the rest of the dashboard. // ──────────────────────────────────────────────── const RADIO_SCENARIOS_LOCAL = { A: { monthLabel: "Mayo 2026", pct: 50, pctLabel: "1 de 2 bancos", banks: [ { name: "BBVA", done: true }, { name: "Nu", done: false }, ], cta: "Subir estado de Nu", scopeWarning: "Basado en BBVA únicamente", }, B: { monthLabel: "Mayo 2026", pct: 67, pctLabel: "2 de 3 bancos", banks: [ { name: "BBVA", done: true }, { name: "Nu", done: true }, { name: "Banorte", done: false }, ], cta: "Subir estado de Banorte", scopeWarning: "Basado en BBVA y Nu", }, C: { monthLabel: "Septiembre 2025 (desactualizado)", pct: 100, pctLabel: "Mes anterior", banks: [ { name: "BBVA", done: true }, { name: "Nu", done: true }, ], cta: "Actualizar con octubre 2025", scopeWarning: null, }, }; // ──────────────────────────────────────────────── // Hero card // // Fully data-driven view over `view` (built upstream by buildHeroView). // Renders: // - eyebrow (fixed per archetype) // - amount ("—" in empty state) // - subline (empty-state copy or " ") // - scope warning tag (hidden when empty or complete) // - empty-state primary CTA (only when no statements uploaded) // - stats row (placeholders in empty state) // ──────────────────────────────────────────────── function HeroCard({ view, blurred, onGoToUpload }) { return (
{/* Eyebrow (always shown — preserves archetype tone in empty state) */}
{view.eyebrow}
{/* Amount row */}
{view.amountText} /año
{/* Subline */}
{view.subline}
{/* Scope warning chip — hidden when empty or complete */} {view.scopeWarning && (
{view.scopeWarning}
)} {/* Empty-state primary CTA — styled to match the legacy "Subir estado de Nu" CTA (green #1a6b3f, body 13/600, no caps, chevron-right icon). Only rendered when zero statements. */} {view.isEmpty && ( )} {/* Divider — hidden in empty state (CTA already separates above) */} {!view.isEmpty && (
)} {/* Stats row */}
); } function Stat({ label, value, color, blurred }) { // Don't blur "82/100" style scores — only currency. The empty-state // placeholders (`$—`, `—/100`) carry no real number, so don't blur them // either; they read as missing data, not hidden data. const isCurrency = /^\$/.test(value); const hasRealNumber = /\d/.test(value); return (
{label} {value}
); } // ──────────────────────────────────────────────── // Progress block // ──────────────────────────────────────────────── // Map a parsed bank key (as stored in UploadScreen file rows) to the // display name shown on the chip. const BANK_DISPLAY_NAME = { bbva: "BBVA", nu: "Nu", banorte: "Banorte", }; // Mirror of UploadScreen's UPLOAD_SEQUENCE — the simulated next-file // pattern used everywhere a tap appends to uploadedFiles. Kept as a // local copy here (not imported) since UploadScreen's constant is // module-private; the brief says to reuse the existing simulated upload // pattern rather than introduce a new mechanism. const RG_UPLOAD_SEQUENCE = [ { name: "Estado_cta_BBVA_enero.pdf", bank: "bbva" }, { name: "Estado_cta_NU_enero.pdf", bank: "nu" }, { name: "Estado_cta_Banorte_enero.pdf", bank: "banorte" }, ]; // Map cardsAnswer (questionnaire response) to the expected statements // total. "3 o más" defaults to 3 in the prototype, per spec. function totalExpectedFromCardsAnswer(cardsAnswer) { if (cardsAnswer === "2 tarjetas") return 2; if (cardsAnswer === "3 o más") return 3; // "1 tarjeta" or null/skipped → 1 return 1; } function ProgressCard({ scenario, cardsAnswer, uploadedFiles, extraDeclaredCount = 0, onGoToUpload, onAgregarTap, onDismissPendingSlot, onPendingChipTap }) { const data = RADIO_SCENARIOS_LOCAL[scenario] || RADIO_SCENARIOS_LOCAL.A; // Live-derived state. The numerator counts every uploaded statement. // The denominator is sourced from the user's cards answer (per spec), // minus any pending positions the user has manually dismissed via the // × on a grey chip. Floored at 0 (defensive). const reportedRaw = totalExpectedFromCardsAnswer(cardsAnswer); const reported = Math.max(0, reportedRaw - extraDeclaredCount); const uploaded = uploadedFiles || []; const uploadedCount = uploaded.length; // Total expected — at minimum the (reduced) reported count, but never // less than the number actually uploaded (defensive: a user may upload // more banks than they originally reported; everything they uploaded // must still render). const total = Math.max(reported, uploadedCount); const isComplete = uploadedCount >= total; // Pending = positions in [uploadedCount, total). A × appears on a grey // chip iff there is at least one pending position that can be removed // without going below uploadedCount — i.e. total > uploadedCount. // The last remaining pending position can always be dismissed (it // resolves the bar to 100%). const canDismissPending = total > uploadedCount && !!onDismissPendingSlot; // Chip list: one chip per uploaded statement (in upload order), then // `Banco N` placeholders for every remaining position through `total`. const chips = []; uploaded.forEach((f, i) => { chips.push({ key: `known-${i}`, label: BANK_DISPLAY_NAME[f.bank] || f.bank, done: true, }); }); for (let i = uploadedCount; i < total; i++) { chips.push({ key: `placeholder-${i}`, label: `Banco ${i + 1}`, done: false, dismissable: canDismissPending, }); } // Progress bar fill — exact percentage so 1/3 reads 33.33% etc. const pct = total > 0 ? (uploadedCount / total) * 100 : 0; // Counter copy: "X de Y bancos". const counterLabel = `${uploadedCount} de ${total} bancos`; return (
{/* Month + pct */}
{data.monthLabel} {counterLabel}
{/* Bar */}
{/* Bank chips */}
{chips.map((c) => ( ))} {uploadedCount >= reported && uploadedCount >= totalExpectedFromCardsAnswer(cardsAnswer) && ( )}
{/* CTA row — hidden when uploads are complete. Same visual style as the legacy "Subir estado de Nu" CTA: green #1a6b3f, body font, 13px / 600, no caps, no trailing arrow, chevron-right icon. */} {!isComplete && ( )}
); } function BankChip({ name, done, onTap, onDismiss }) { if (done) { return ( {name} ); } // Placeholder ("Banco N") chip — muted/disabled variant so it reads as // a pending position rather than a known-but-not-uploaded bank. // Tapping the chip body opens the file picker (onTap). When // `onDismiss` is provided, a circular × badge sits at the trailing // edge inside the chip boundary so the user can retract this declared // position (reduces totalExpected by 1). The × stops propagation so // tapping it does NOT also fire onTap / open the picker. const interactive = !!onTap; return ( { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onTap(); } } : undefined} style={{ display: "inline-flex", alignItems: "center", gap: 6, // Trailing padding bumped to 6px when × is present so the 18px // circle has breathing room and never clips the chip boundary. padding: onDismiss ? "3px 6px 3px 10px" : "4px 10px", borderRadius: 999, background: "transparent", border: "1px dashed rgba(20,32,28,0.22)", fontFamily: "var(--font-body)", fontSize: 12, fontWeight: 500, color: "#9aa0a6", letterSpacing: "0.005em", cursor: interactive ? "pointer" : "default", }} > {name} {onDismiss && ( )} ); } // ──────────────────────────────────────────────── // Pocopin nudge data per profile × scenario // ──────────────────────────────────────────────── const POCOPIN_BY_PROFILE = { totalero: { A: { msg: "Tu análisis de Nu está pendiente. Sin él, tu radiografía solo refleja parte de tu situación real.", bold: "Tu análisis de Nu está pendiente." }, B: { msg: "Falta Banorte para completar tu panorama. Los insights que ves son con base en BBVA y Nu.", bold: "Falta Banorte para completar tu panorama." }, C: { msg: "Tu radiografía es del mes pasado. Sube tu estado de octubre para tener el análisis más reciente.", bold: "Tu radiografía es del mes pasado." }, }, optimizador: { A: { msg: "Sin tu estado de Nu no puedo ver el cuadro completo. Tu costo real de deuda puede ser mayor.", bold: "Sin tu estado de Nu no puedo ver el cuadro completo." }, B: { msg: "Falta Banorte. Si tienes saldo ahí, el cálculo de intereses está incompleto.", bold: "Falta Banorte." }, C: { msg: "Estos datos son del mes pasado. Sube octubre para ver si tu situación mejoró.", bold: "Estos datos son del mes pasado." }, }, escalador: { A: { msg: "Si tienes deuda en Nu también, el número real de intereses es mayor al que ves aquí.", bold: "Si tienes deuda en Nu también, el número real es mayor." }, B: { msg: "Banorte pendiente. Con 3 tarjetas en deuda el plan de consolidación cambia.", bold: "Banorte pendiente." }, C: { msg: "El análisis es del mes pasado. Tu deuda puede haber crecido — sube el estado de octubre.", bold: "El análisis es del mes pasado." }, }, reiniciador: { A: { msg: "Sin Nu no puedo ver el ciclo completo. El patrón real puede ser diferente al que muestro.", bold: "Sin Nu no puedo ver el ciclo completo." }, B: { msg: "Con Banorte pendiente, el análisis del ciclo está incompleto. El plan de ahorro puede cambiar.", bold: "Con Banorte pendiente, el análisis del ciclo está incompleto." }, C: { msg: "El análisis es del mes pasado. Sube octubre para ver si el ciclo continúa o se interrumpió.", bold: "El análisis es del mes pasado." }, }, }; // ──────────────────────────────────────────────── // First step data per profile // ──────────────────────────────────────────────── const FIRST_STEP_BY_PROFILE = { totalero: { title: "Reclama la condonación de tu anualidad BBVA", subtitle: "8 meses cumpliendo el requisito sin reclamarlo.", saving: "Ahorro inmediato: $1,200", cta: "Ver cómo solicitarlo", }, optimizador: { title: "Cambia a tarjeta con CAT 28%", subtitle: "Solo con BBVA detectado. Puede haber más opciones cuando agregues tus otros bancos.", saving: "Ahorro estimado: $9,600/año", cta: "Ver comparativa", }, escalador: { title: "Consolida tu deuda en un crédito al 24%", subtitle: "Análisis basado en BBVA únicamente. Al agregar tus otros estados el plan puede mejorar.", saving: "Ahorro estimado: $47,000", cta: "Ver opciones", }, reiniciador: { title: "El patrón que repites — y cómo romperlo", subtitle: "Detectado en BBVA. Con todos tus estados el análisis del ciclo será más preciso.", saving: "El problema está en el flujo mensual", cta: "Ver mi historial", }, }; const FIRST_STEP_SCOPE = { A: "Estimado con BBVA", B: "Estimado con BBVA y Nu", C: null, }; // ──────────────────────────────────────────────── // Pocopin nudge card // ──────────────────────────────────────────────── function PocopinCard({ profile, scenario, ctaLabel, isComplete, hasPendingPositions, onGoToUpload }) { // When the upload is complete, the "missing bank" pocopin tip must hide // entirely — there's nothing pending. The C (stale-month) scenario is // about outdated data, not missing banks, so it still renders. if (isComplete && scenario !== "C") return null; // When there are pending positions (Banco N placeholders) and the tip // would otherwise reference a specific missing bank, switch to a // generic copy that does not name a bank. Applies to scenarios A and B. const useGenericCopy = hasPendingPositions && scenario !== "C"; let title, body, cta, boldPhrase; if (useGenericCopy) { title = "Sin todos tus estados de cuenta no puedo ver el ciclo completo."; body = "El patrón real puede ser diferente al que muestro."; cta = "Completar mi radiografía"; boldPhrase = title; } else { const data = (POCOPIN_BY_PROFILE[profile] || POCOPIN_BY_PROFILE.totalero)[scenario] || POCOPIN_BY_PROFILE[profile].A; title = null; body = data.msg; boldPhrase = data.bold; cta = ctaLabel; } // Render the message with the bold key phrase inlined. const fullMsg = title ? `${title} ${body}` : body; const idx = fullMsg.indexOf(boldPhrase); let msgNode; if (idx === -1) { msgNode = fullMsg; } else { msgNode = ( <> {fullMsg.slice(0, idx)} {boldPhrase} {fullMsg.slice(idx + boldPhrase.length)} ); } return (
POCOPIN
{msgNode}
); } // ──────────────────────────────────────────────── // First step card // ──────────────────────────────────────────────── // Build the "Estimado con ..." chip text from the actual list of uploaded // banks. Falls back to the legacy scenario-keyed table when no upload // state has been threaded through (Tweaks-only navigation, demos). function buildEstimadoText(uploadedBankNames) { if (!uploadedBankNames || uploadedBankNames.length === 0) return null; if (uploadedBankNames.length === 1) return `Estimado con ${uploadedBankNames[0]}`; if (uploadedBankNames.length === 2) { return `Estimado con ${uploadedBankNames[0]} + ${uploadedBankNames[1]}`; } const head = uploadedBankNames.slice(0, -1).join(", "); const last = uploadedBankNames[uploadedBankNames.length - 1]; return `Estimado con ${head} + ${last}`; } function FirstStepCard({ profile, scenario, uploadedBankNames, isComplete }) { const data = FIRST_STEP_BY_PROFILE[profile] || FIRST_STEP_BY_PROFILE.totalero; let scopeText; if (isComplete) { scopeText = null; } else if (uploadedBankNames && uploadedBankNames.length > 0) { scopeText = buildEstimadoText(uploadedBankNames); } else { scopeText = FIRST_STEP_SCOPE[scenario]; } return (
{/* Header row */}
TU PRIMER PASO
{/* Body */}
{data.title}
{data.subtitle}
{data.saving}
{scopeText && (
{scopeText}
)}
{/* CTA row */}
); } // ──────────────────────────────────────────────── // Insights data per profile // ──────────────────────────────────────────────── const INSIGHTS_BY_PROFILE = { totalero: [ { icon: "plane", iconBg: "#e6f3ee", iconStroke: "#1a6b3f", title: "Beneficios de viaje sin usar", meta: "6 vuelos · $4,800/año", badge: { label: "Más por lo mismo", kind: "save" }, detail: "Tu perfil califica para tarjeta con salas VIP + seguro. $4,800/año en beneficios sin aprovechar.", cta: "Ver opciones", scoped: true }, { icon: "coffee", iconBg: "#fef3e2", iconStroke: "#c97a00", title: "Gastos hormiga: cafeterías", meta: "$2,340 · últimos 3 meses", badge: { label: "Oportunidad", kind: "warn" }, detail: "4–5 visitas/semana sin cashback. ~$700/año recuperables con otra tarjeta.", cta: "Ver tarjetas", scoped: true }, ], optimizador: [ { icon: "split", iconBg: "#fef3e2", iconStroke: "#c97a00", title: "Patrón de pago parcial", meta: "Últimos 3 meses", badge: { label: "Costo oculto", kind: "warn" }, detail: "Pagas 40–70% del total. En 12 meses = 2.3 meses de sueldo perdidos.", cta: "Ver plan", scoped: true }, { icon: "tag", iconBg: "#e6f3ee", iconStroke: "#1a6b3f", title: "MSI disponibles en tus tiendas", meta: "Compras +$3k detectadas", badge: { label: "Usar a tu favor", kind: "save" }, detail: "El 70% de las tiendas donde compras ofrecen MSI que no aprovechas.", cta: "Ver tiendas", scoped: false }, ], escalador: [ { icon: "alert", iconBg: "#fde2e2", iconStroke: "#c0392b", title: "Solo el mínimo — tu deuda no baja", meta: "Detectado en BBVA", badge: { label: "Urgente", kind: "risk" }, detail: "Pagando el mínimo tu deuda tarda 9 años en liquidarse.", cta: "Ver simulación", scoped: true }, { icon: "subscriptions", iconBg: "#fef3e2", iconStroke: "#c97a00", title: "Suscripciones activas mientras debes", meta: "11 activas · $2,640/mes", badge: { label: "Liberar flujo", kind: "warn" }, detail: "Cancelar las que no usas libera $1,100/mes hacia tu deuda.", cta: "Ver suscripciones", scoped: false }, ], reiniciador: [ { icon: "calendar", iconBg: "#fef3e2", iconStroke: "#c97a00", title: "Optimiza tu fecha de corte", meta: "Patrón detectado en BBVA", badge: { label: "Optimizar", kind: "warn" }, detail: "Mover gastos 6 días después del corte da 28 días extra sin intereses.", cta: "Ver cómo", scoped: true }, { icon: "anchor", iconBg: "#e6f3ee", iconStroke: "#1a6b3f", title: "Ahorro automático como ancla", meta: "Sin ahorro activo detectado", badge: { label: "Romper el ciclo", kind: "save" }, detail: "$500/mes automáticos el día de nómina crean el colchón que rompe el ciclo.", cta: "Ver cómo", scoped: false }, ], }; const BADGE_STYLE = { save: { bg: "#d1fae5", color: "#065f46" }, warn: { bg: "#fef3c7", color: "#92400e" }, risk: { bg: "#fee2e2", color: "#991b1b" }, }; const INSIGHT_SCOPE = { A: "Solo BBVA", B: "Solo BBVA y Nu", C: null, }; function Badge({ kind, children }) { const s = BADGE_STYLE[kind] || BADGE_STYLE.warn; return ( {children} ); } function ScopeChip({ children }) { return ( {children} ); } function InsightCard({ insight, scopeText }) { const [expanded, setExpanded] = useStateRG(false); return (
{expanded && (
{insight.detail}
)}
); } function MoreOpportunities({ profile, scenario, uploadedBankNames, isComplete }) { const [open, setOpen] = useStateRG(false); const list = INSIGHTS_BY_PROFILE[profile] || INSIGHTS_BY_PROFILE.totalero; let scopeText; if (isComplete) { scopeText = null; } else if (uploadedBankNames && uploadedBankNames.length > 0) { if (uploadedBankNames.length === 1) { scopeText = `Solo ${uploadedBankNames[0]}`; } else { const head = uploadedBankNames.slice(0, -1).join(", "); const last = uploadedBankNames[uploadedBankNames.length - 1]; scopeText = `Solo ${head} y ${last}`; } } else { scopeText = INSIGHT_SCOPE[scenario]; } return (
{open && list.map((ins, i) => ( ))}
); } // ──────────────────────────────────────────────── // Header // ──────────────────────────────────────────────── function RadioHeader({ profile = "totalero", amountsBlurred, onToggleBlur, onOpenProfile }) { const baseData = RADIO_HEADER[profile] || RADIO_HEADER.totalero; // Read the active persona's name reactively from the global context. // Switching persona via the Demo pill must update the user pill name + // avatar initials instantly without remount. const yomeeUser = (window.useYomeeUser && window.useYomeeUser()) || {}; const userName = yomeeUser.name; // If a runtime userName is supplied (from the active persona), // override the profile's dummy firstName + initials. First name is the // first space-separated token; initials are the first two letters of the // first name, uppercased. const data = React.useMemo(() => { const trimmed = (userName || "").trim(); if (!trimmed) return baseData; const first = trimmed.split(" ")[0]; return { ...baseData, firstName: first, initials: first.slice(0, 2).toUpperCase(), }; }, [baseData, userName]); // Per Dashboard Homologation (May 2026): // The top card is now visually identical across every archetype. The // ONLY thing that changes is the archetype pill's background color // (data.color). The card's own background is the brand green. // // Structure (top → bottom): // Row 1 yomee logo (left) · profile icon (right) // [Gratis upgrade chip is FEATURE-FLAGGED OFF — see below] // Row 2 "Tu radiografía" (headline, Geist Mono — display family // stands in for Poppins from the brief; brief said use the // Design System token, and Geist Mono is the display token) // Row 3 Archetype pill — bg = data.color, text always #FFFFFF // // Bottom corners use --radius-card (28px). Top corners are flush. // Eye toggle (amount blur) was previously in this header; it has been // moved into the hero card for the homologated design so the top card // stays focused on identity. `amountsBlurred` / `onToggleBlur` props // are kept for back-compat but no longer rendered here. void amountsBlurred; void onToggleBlur; // Feature flag for the upgrade ("Gratis") badge. Hidden in the // homologated design but kept in source per the brief — flip to true // to re-enable when the upgrade flow ships. const SHOW_UPGRADE_BADGE = false; return (
{/* ── Row 1: yomee logo (left) · profile icon (right) ───────── */}
{/* Left: yomee wordmark logo — 200% of previous size (was 22px → now 44px). Card vertical padding bumped above so the larger mark doesn't crowd the headline block. Position + aspect ratio preserved. */} yomee {/* Right: profile icon — taps open Mi perfil */}
{/* Gratis upgrade badge — feature-flagged off for now. Kept inline (not commented out) so the flag is the only switch needed to bring it back. */} {SHOW_UPGRADE_BADGE && ( )} {/* Profile icon — visually unified with the Perfil module's identity-card avatar (PFAvatar). Same silhouette (filled circle, no border), same fill (per-archetype PROFILE_CHIP.bg from ProfileScreen.jsx), same typography (var(--font-mono), weight 700, white initials, 0.02em tracking). Size preserved at 36px per spec — only the styling is unified, not the size. */}
{/* ── Row 2: greeting + headline ──────────────────────────── */}
{/* Greeting — Onest Medium, one step below the headline. Falls back to "Hola:" when firstName is unavailable. The colon is intentional and part of the spec'd copy. */}
{data.firstName ? `Hola ${data.firstName}:` : "Hola:"}

Tu radiografía

{/* ── Row 3: archetype pill ───────────────────────────────── */}
{data.pillLabel}
); } // ──────────────────────────────────────────────── // "+ Agregar" inline upload modal // // Centered modal sheet (not full-screen) used when the user taps the // "+ Agregar" pill on the ProgressCard. Reuses the same visual language // as UploadScreen — same dashed/solid upload area, same body copy, same // primary/secondary CTA stack — but lives inside the dashboard rather // than navigating to a separate screen. // // Stages: // "upload" → static upload area + file row (when staged) // "procesando" → spinner + "Procesando archivo…" // "eliminando" → spinner + "Eliminando archivo de forma segura…" // "actualizando" → spinner + "Actualizando tu radiografía…" // // The primary CTA is disabled until a file has been staged. Tapping the // secondary CTA dismisses the modal without modifying state. // ──────────────────────────────────────────────── function AgregarUploadModal({ visible, stage, file, simLoading, onSimulateUpload, onClearFile, onSkip, onBackdrop, onConfirm, }) { const isUploadStage = stage === "upload"; const ctaEnabled = isUploadStage && !!file && !simLoading; const stageCopy = { procesando: "Procesando archivo…", eliminando: "Eliminando archivo de forma segura…", actualizando: "Actualizando tu radiografía…", }; return (
{/* Backdrop */}
{/* Bottom sheet — same chrome as TotaleroSingleStatementSheet */}
{/* Handle bar */}
{/* Mascot — matches the stopper sheet */} {/* Title */}
Agregar estado de cuenta
{/* Body */}

Sube otro estado de cuenta para enriquecer tu radiografía con más datos.

{/* Body content — varies by stage */}
{isUploadStage ? ( {/* Simulated upload area — tap appends next file in RG_UPLOAD_SEQUENCE. No file picker. Mirrors the UploadArea component on the upload screen. */} {/* Staged file row — mirrors UploadScreen FileRow */} {file && (
{file.name}
)}
) : ( // Processing — spinner + label + step dots, replaces upload area.
{stageCopy[stage] || "Procesando…"}
{["procesando", "eliminando", "actualizando"].map((s, i) => { const order = ["procesando", "eliminando", "actualizando"]; const idx = order.indexOf(stage); const isDone = i < idx; const isCurrent = i === idx; return ( ); })}
)}
{/* CTAs — same stack pattern as the stopper sheet. Hidden during processing so the modal is non-dismissable mid-flow. */} {isUploadStage && (
)}
); } // ──────────────────────────────────────────────── // Screen // ──────────────────────────────────────────────── function RadiografiaScreen({ profile = "totalero", scenario = "A", cardsAnswer = null, uploadedFiles = [], setFiles, onGoToUpload, onOpenProfile }) { const [amountsBlurred, setAmountsBlurred] = useStateRG(false); // ── "+ Agregar" inline upload modal state ───────────────────────── // Tapping "+ Agregar" on the ProgressCard opens an in-place upload // modal (mirrors UploadScreen's UploadArea) instead of full-screen // navigating away. The modal walks through 4 stages: // "upload" → user picks a file (primary CTA stays disabled // until a file is staged; secondary "Omitir" closes) // "procesando" → simulated parse (~900ms) // "eliminando" → simulated secure delete (~900ms) // "actualizando" → dashboard re-derive (~900ms) // After "actualizando" finishes, we append the next entry from // RG_UPLOAD_SEQUENCE to uploadedFiles via the same `setFiles` setter // UploadScreen / pending-chip taps already use, then dismiss the modal. const [agregarOpen, setAgregarOpen] = useStateRG(false); const [agregarVisible, setAgregarVisible] = useStateRG(false); const [agregarStage, setAgregarStage] = useStateRG("upload"); const [agregarFile, setAgregarFile] = useStateRG(null); // Simulated tap-to-upload loading flag — mirrors UploadScreen's // UploadArea spinner pattern. Tap → 300ms spinner → file row appears. const [agregarSimLoading, setAgregarSimLoading] = useStateRG(false); const openAgregar = () => { setAgregarStage("upload"); setAgregarFile(null); setAgregarSimLoading(false); setAgregarOpen(true); setTimeout(() => setAgregarVisible(true), 16); }; const closeAgregar = () => { setAgregarVisible(false); setTimeout(() => { setAgregarOpen(false); setAgregarStage("upload"); setAgregarFile(null); setAgregarSimLoading(false); }, 220); }; // Simulated upload — appends the next entry from RG_UPLOAD_SEQUENCE // as the staged file (mirroring the upload screen, which never opens // a real file picker either). const handleSimulateAgregarUpload = () => { if (agregarFile || agregarSimLoading) return; setAgregarSimLoading(true); setTimeout(() => { const idx = (uploadedFiles || []).length; const next = RG_UPLOAD_SEQUENCE[idx] || { name: `Estado_cta_extra_${idx + 1}.pdf`, bank: "bbva", }; setAgregarFile(next); setAgregarSimLoading(false); }, 300); }; // Number of declared bank positions the user has manually dismissed // via the × on a grey/pending chip. Each tap reduces totalExpected by 1 // (i.e. retracts one declared bank that was never uploaded), which // recalculates the progress percentage immediately. Every consumer of // totalExpected (ProgressCard, MarketComparisonCard) re-derives off the // updated value with no additional sync. const [extraDeclaredCount, setExtraDeclaredCount] = useStateRG(0); const handleDismissPendingSlot = () => setExtraDeclaredCount((n) => n + 1); // Hidden file input — tapping a pending grey chip opens this picker // instead of full-screen-navigating to UploadScreen. On change we // append the next entry from RG_UPLOAD_SEQUENCE to uploadedFiles via // the same `setFiles` setter UploadScreen already uses (passed down // from App.jsx). The actual file the user picks isn't parsed in this // prototype — we just consume the tap and advance the simulated // sequence, matching UploadScreen's behaviour exactly. const fileInputRef = React.useRef(null); const handlePendingChipTap = () => { if (fileInputRef.current) { // Reset value so picking the same file twice still fires onChange. fileInputRef.current.value = ""; fileInputRef.current.click(); } }; const handleFileInputChange = (e) => { const picked = e.target.files && e.target.files[0]; if (!picked) return; if (typeof setFiles === "function") { setFiles((prev) => { const next = RG_UPLOAD_SEQUENCE[prev.length]; return next ? [...prev, next] : prev; }); } // Clear so the next pick re-fires onChange. e.target.value = ""; }; const accent = (RADIO_HEADER[profile] || RADIO_HEADER.totalero).color; const scen = RADIO_SCENARIOS_LOCAL[scenario] || RADIO_SCENARIOS_LOCAL.A; // ── Live upload state — single source of truth shared by ProgressCard, // PocopinCard, FirstStepCard, and MoreOpportunities. Everything below // (counter, chips, progress %, CTA visibility, Pocopin copy, scope tags) // is derived from this object. Nothing is hardcoded. const totalExpected = Math.max( 0, totalExpectedFromCardsAnswer(cardsAnswer) - extraDeclaredCount, ); const uploadedBankNames = (uploadedFiles || []).map( (f) => BANK_DISPLAY_NAME[f.bank] || f.bank, ); const uploadedCount = uploadedBankNames.length; const isComplete = uploadedCount >= totalExpected; const hasPendingPositions = uploadedCount < totalExpected; return (
setAmountsBlurred((v) => !v)} onOpenProfile={onOpenProfile} /> {/* Scrollable content area — empty for now. Cards land here next. */}
{/* Hidden file input — triggered when the user taps a pending grey chip. Kept rendered (not portal'd) at the screen root so its lifecycle matches the screen's. */} {/* Market Comparison — inserted between FirstStep and Progress per spec. Derives every row from the SAME uploadedBankNames / totalExpected derivation that ProgressCard uses (see lines ~1560 above). When uploadedFiles changes, both this card and ProgressCard re-render off the same source — no new state. */} {window.MarketComparisonCard && ( )}
{/* Bottom nav hidden on Radiografía dashboard for all 4 profiles per design spec — kept mounted (display:none) so it can be re-enabled later without rebuilding it. */} {/* "+ Agregar" inline upload modal — appears as a centered card with a 40% backdrop. Reuses the upload-area visual language from UploadScreen and walks through processing → deletion → dashboard update before dismissing back to the dashboard. */} {agregarOpen && ( setAgregarFile(null)} onSkip={closeAgregar} onBackdrop={closeAgregar} onConfirm={() => { // Walk through the 3 simulated stages, then append the staged // file and close. Total runtime ≈ 2.7s. const staged = agregarFile; setAgregarStage("procesando"); setTimeout(() => setAgregarStage("eliminando"), 900); setTimeout(() => setAgregarStage("actualizando"), 1800); setTimeout(() => { if (typeof setFiles === "function" && staged) { setFiles((prev) => [...prev, staged]); } closeAgregar(); }, 2700); }} /> )}
); } window.RadiografiaScreen = RadiografiaScreen; window.RADIO_HEADER = RADIO_HEADER;