// ─────────────────────────────────────────────────────────────────────
// 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 (
);
}
// 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;