// ───────────────────────────────────────────────────────────────────── // Yomee — Market Comparison Card // // Renders one comparison row per entry in `uploadedBankNames` (the SAME // derived list ProgressCard uses) and pads with `Banco N` placeholder // rows up to `totalExpected` (denominator from cardsAnswer). When the // user uploads another statement, App.jsx → setUploadedFiles re-renders // RadiografiaScreen, which recomputes uploadedBankNames; this card and // ProgressCard both re-render off the same derivation. No new state. // // Profile-aware recommendations: // • totalero → annual fee waiver + benefits over rate // • parcialero → CAT reduction; 2+ banks → consolidation block // • minimero → CAT reduction; 2+ banks → consolidation block // • reiniciador → cost reduction per cycle // // Visual rules (per spec): // • savings pill = #d1fae5 / #065f46 // • pending pill = #fef3c7 / #92400e // • optimal pill = neutral (background-secondary) // • user CAT/rate = #c0392b when > 40%, else neutral // • alt values = always green (#1a6b3f) // • source line = tertiary text, 10px // ───────────────────────────────────────────────────────────────────── const MC_RG = (typeof React !== "undefined" ? React : window.React); const useStateMC = MC_RG.useState; // ── Tokens ─────────────────────────────────────────────────────────── const MC_TOKENS = { pillSavingsBg: "#d1fae5", pillSavingsFg: "#065f46", pillPendingBg: "#fef3c7", pillPendingFg: "#92400e", pillOptimalBg: "rgba(20,32,28,0.06)", pillOptimalFg: "#4a5752", redCost: "#c0392b", green: "#1a6b3f", greenSoft: "#e8f3ec", ink: "#1f2937", inkMuted: "#5a6b66", tertiary: "#8a948f", border: "rgba(20,32,28,0.08)", surface: "#ffffff", amber: "#f59e0b", }; // ── Bank metadata: badge color + display letter ───────────────────── // Mirrors UploadScreen BANKS table; kept here so this card is self- // contained and can render badges without importing UploadScreen. const MC_BANK_META = { BBVA: { color: "#004A9F", letter: "B" }, Nu: { color: "#7B2D8B", letter: "N" }, Banorte: { color: "#E30613", letter: "B" }, }; // ── Per-bank "current card" snapshot (CAT, anualidad, tasa). // These are the user-side values shown in the comparison grid. Kept as // a small lookup so the card is data-driven, not hard-coded per row. // ───────────────────────────────────────────────────────────────────── const MC_USER_CARD = { BBVA: { cardName: "BBVA Azul", cat: 52.4, anualidad: 690, tasa: 45.8, }, Nu: { cardName: "Nu Tarjeta de Crédito", cat: 58.6, anualidad: 0, tasa: 50.2, }, Banorte: { cardName: "Banorte Clásica", cat: 48.9, anualidad: 580, tasa: 42.1, }, }; // ── Best market alternative per current card. // `optimal: true` means the user's current card is already the best // match → show neutral "Tu tarjeta es óptima" treatment instead of an // upgrade recommendation. // ───────────────────────────────────────────────────────────────────── const MC_ALTERNATIVES = { BBVA: { altName: "Hey, Banco — Tarjeta Hey", cat: 38.2, anualidad: 0, tasa: 32.5, annualSaving: 1280, rationale: "Sin anualidad y CAT 14 puntos menor. Misma red Visa, aceptación equivalente.", optimal: false, }, Nu: { altName: null, cat: null, anualidad: null, tasa: null, annualSaving: 0, rationale: "Tu Nu ya está entre las opciones de menor costo del mercado para tu perfil. Mantener.", optimal: true, }, Banorte: { altName: "Stori — Tarjeta Stori", cat: 36.1, anualidad: 0, tasa: 29.4, annualSaving: 940, rationale: "Sin anualidad y CAT 13 puntos menor. Buena opción para tu nivel de uso.", optimal: false, }, }; // ── Profile-aware recommendation framing. // Adjusts the rationale's emphasis without changing the underlying // alternatives (the market doesn't change with profile). // ───────────────────────────────────────────────────────────────────── const MC_PROFILE_FRAMING = { totalero: { angle: "Sin anualidad y beneficios sin redimir", verb: "Recuperar", }, optimizador: { angle: "Reducir CAT y costos fijos", verb: "Ahorrar", }, parcialero: { angle: "Reducir CAT sobre tu saldo revolvente", verb: "Ahorrar en intereses", }, minimero: { angle: "Reducir CAT — clave si pagas mínimos", verb: "Ahorrar en intereses", }, escalador: { angle: "Reducir CAT sobre tu saldo revolvente", verb: "Ahorrar en intereses", }, reiniciador: { angle: "Reducir el costo por ciclo", verb: "Ahorrar por ciclo", }, }; // ── Helpers ────────────────────────────────────────────────────────── function fmtMXN(n) { if (n == null) return "—"; return "$" + n.toLocaleString("es-MX"); } function fmtPct(n) { if (n == null) return "—"; return n.toFixed(1).replace(/\.0$/, "") + "%"; } function fmtAnualidad(n) { if (n == null) return "—"; return n === 0 ? "Sin anualidad" : "$" + n.toLocaleString("es-MX"); } // Spec: user CAT and tasa values render in red when > 40%, neutral // otherwise. function userValueColor(n) { return n > 40 ? MC_TOKENS.redCost : MC_TOKENS.ink; } // Profiles that carry active revolving debt (drives the consolidation // recommendation block when the user has 2+ uploaded banks). function profileHasActiveDebt(profile) { return profile === "parcialero" || profile === "minimero" || profile === "escalador"; } // `sendPrompt()` shim — the brief asks the recommendation CTA to call // sendPrompt(). The host app doesn't define one yet, so we fall back // to console + a postMessage the host can listen for later. function sendPrompt(prompt) { if (typeof window.sendPrompt === "function" && window.sendPrompt !== sendPrompt) { return window.sendPrompt(prompt); } try { window.parent.postMessage({ type: "yomee_send_prompt", prompt }, "*"); } catch (_) { /* noop */ } // eslint-disable-next-line no-console console.log("[sendPrompt]", prompt); } // ───────────────────────────────────────────────────────────────────── // Pills // ───────────────────────────────────────────────────────────────────── function MCPill({ variant, children }) { const palette = { savings: { bg: MC_TOKENS.pillSavingsBg, fg: MC_TOKENS.pillSavingsFg }, pending: { bg: MC_TOKENS.pillPendingBg, fg: MC_TOKENS.pillPendingFg }, optimal: { bg: MC_TOKENS.pillOptimalBg, fg: MC_TOKENS.pillOptimalFg }, }[variant] || { bg: MC_TOKENS.pillOptimalBg, fg: MC_TOKENS.pillOptimalFg }; return ( {children} ); } // ───────────────────────────────────────────────────────────────────── // Bank badge (mirrors UploadScreen visual) // ───────────────────────────────────────────────────────────────────── function MCBankBadge({ name, dimmed }) { const meta = MC_BANK_META[name] || { color: "#9ca3af", letter: "?" }; return (
{meta.letter}
); } // Placeholder badge for "Banco N" (declared but not uploaded) rows — // neutral grey, dashed ring to read as a pending position. function MCPlaceholderBadge() { return (
?
); } // ───────────────────────────────────────────────────────────────────── // Comparison grid (label | tu tarjeta | mejor opción) // ───────────────────────────────────────────────────────────────────── function MCGrid({ user, alt }) { const rows = [ { label: "CAT", userVal: fmtPct(user.cat), userColor: userValueColor(user.cat), altVal: fmtPct(alt.cat), }, { label: "Anualidad", userVal: fmtAnualidad(user.anualidad), // Anualidad isn't a percentage; only color when it's a non-zero // cost (always neutral if 0, red if > 0 to signal it's a cost). userColor: user.anualidad > 0 ? MC_TOKENS.redCost : MC_TOKENS.ink, altVal: fmtAnualidad(alt.anualidad), }, { label: "Tasa de interés", userVal: fmtPct(user.tasa), userColor: userValueColor(user.tasa), altVal: fmtPct(alt.tasa), }, ]; return (
{/* Header row */}   Tu tarjeta Mejor opción {rows.map((r) => ( {r.label} {r.userVal} {r.altVal} ))}
); } const mcGridHead = { fontFamily: "var(--font-mono)", fontSize: 9, fontWeight: 500, letterSpacing: "0.10em", textTransform: "uppercase", color: MC_TOKENS.tertiary, }; const mcGridLabel = { fontFamily: "var(--font-body)", fontSize: 12, fontWeight: 500, color: MC_TOKENS.inkMuted, letterSpacing: "0.005em", }; const mcGridValue = { fontFamily: "var(--font-body)", fontSize: 13, fontWeight: 600, letterSpacing: "0.005em", textAlign: "left", }; // ───────────────────────────────────────────────────────────────────── // Recommendation block (per-row, post-grid) // ───────────────────────────────────────────────────────────────────── function MCRecommendation({ bankName, user, alt, profile }) { const framing = MC_PROFILE_FRAMING[profile] || MC_PROFILE_FRAMING.totalero; // Optimal-state: neutral explanation, no CTA. if (alt.optimal) { return (
Tu tarjeta es óptima.{" "} {alt.rationale}
); } // Profile-framed rationale: prefix the angle, then the rationale. const framedRationale = `${framing.angle}. ${alt.rationale}`; return (
{alt.altName} {fmtMXN(alt.annualSaving)}/año
{framedRationale}
); } // ───────────────────────────────────────────────────────────────────── // Pending row body — shown when the bank position is declared but the // user hasn't uploaded its statement yet. Replaces the grid with an // inline "Subir estado" CTA. // ───────────────────────────────────────────────────────────────────── function MCPendingBody({ onGoToUpload }) { return (
Sube el estado de cuenta para comparar esta tarjeta con las mejores alternativas del mercado.
); } // ───────────────────────────────────────────────────────────────────── // Single comparison row (collapsible) // ───────────────────────────────────────────────────────────────────── function MCRow({ bankName, isPending, isFirst, profile, onGoToUpload, defaultOpen }) { const [open, setOpen] = useStateMC(!!defaultOpen); // ── Collapsed-row data resolution ── const user = !isPending ? MC_USER_CARD[bankName] : null; const alt = !isPending ? MC_ALTERNATIVES[bankName] : null; // Pill on the right edge of the collapsed row. let pill; if (isPending) { pill = Subir estado; } else if (alt && alt.optimal) { pill = Óptima; } else if (alt) { pill = Ahorra {fmtMXN(alt.annualSaving)}; } const titleText = isPending ? bankName /* "Banco 2" / "Banco 3" */ : (user ? user.cardName : bankName); const subText = isPending ? "Pendiente de subir" : bankName; return (
{isPending && (
)} {!isPending && open && (
)}
); } // ───────────────────────────────────────────────────────────────────── // Consolidation block (parcialero / minimero / escalador with 2+ banks) // ───────────────────────────────────────────────────────────────────── function MCConsolidationBlock({ uploadedBankNames }) { // Estimated saving — illustrative; combines the per-bank annual // savings since consolidating typically captures most of them. const estSaving = uploadedBankNames.reduce((sum, n) => { const alt = MC_ALTERNATIVES[n]; return sum + (alt && !alt.optimal ? alt.annualSaving : 0); }, 0); return (
Consolidación recomendada
Unifica tu deuda en una sola tarjeta ~{fmtMXN(estSaving)}/año
Pasar tus saldos a la tarjeta de menor CAT reduce intereses totales y simplifica un solo pago al mes.
); } // ───────────────────────────────────────────────────────────────────── // Public component // ───────────────────────────────────────────────────────────────────── // // Props (all derived in the parent from the SAME app-level state that // drives ProgressCard — uploadedFiles + cardsAnswer): // // uploadedBankNames : string[] display names, in upload order // totalExpected : number denominator (from cardsAnswer) // profile : string "totalero" | "parcialero" | … // onGoToUpload : ()=>void navigates to UploadScreen // // The card renders one row per uploaded bank, then pads with anonymous // "Banco N" pending rows up to totalExpected. When uploadedFiles // changes, the parent re-renders → both this card and ProgressCard // re-derive from the same source. No internal cache, no duplicated // state. // ───────────────────────────────────────────────────────────────────── function MarketComparisonCard({ uploadedBankNames = [], totalExpected = 0, profile = "totalero", onGoToUpload = () => {}, }) { // Pad to total — but never less than the count actually uploaded. const total = Math.max(totalExpected, uploadedBankNames.length); // Consolidation block precondition. const showConsolidation = profileHasActiveDebt(profile) && uploadedBankNames.length >= 2; // Default-open behavior: open the FIRST uploaded row so the card has // visible content on first paint. Pending rows stay collapsed (their // body is the upload CTA, which the row chrome already surfaces). return (
{/* Header */}
Comparación de mercado
Tus tarjetas vs. las mejores alternativas
Comparamos cada una de tus tarjetas con las mejores opciones disponibles en México hoy.
{/* Optional consolidation recommendation (debt profiles, 2+ banks) */} {showConsolidation && ( )} {/* Rows — one per uploaded bank, then pending placeholders */}
{uploadedBankNames.map((name, i) => ( ))} {Array.from({ length: Math.max(0, total - uploadedBankNames.length) }).map((_, i) => { const positionIndex = uploadedBankNames.length + i + 1; return ( ); })} {/* Always-visible "Agregar otro banco" row — escape hatch for users with more banks than they declared in cardsAnswer. */}
{/* Source footer */}
Fuente: CONDUSEF · CNBV — datos públicos de tarifas y CAT promedio.
); } // ───────────────────────────────────────────────────────────────── // MCAddBankRow — always-visible affordance for users whose actual // bank count exceeds the declared cardsAnswer total. Sits below all // uploaded + pending rows and above the source footer. // // Visual treatment per spec: // • dashed border (MC_TOKENS.border) // • icon + text in MC_TOKENS.tertiary // • background MC_TOKENS.pillOptimalBg // • height + horizontal padding of an MCRow collapsed row // (12px vertical, 0 horizontal — content lives in the card's // own 14px horizontal padding; badge column = 28px to align with // MCBankBadge / MCPlaceholderBadge) // ───────────────────────────────────────────────────────────────── function MCAddBankRow({ onGoToUpload }) { return ( ); } // Export to window so RadiografiaScreen.jsx (separate Babel scope) can // see it. Same pattern every other component file in the project uses. window.MarketComparisonCard = MarketComparisonCard;