const { useState, useMemo, useRef, useEffect } = React; // ---------- Inputs ---------- function NumberField({ label, value, onChange, suffix, prefix, hint, step = 1, min = 0, error }) { return ( ); } // Slider compacto para porcentajes (0-100) function PctRow({ label, value, onChange, derivedAmount, accent }) { const { fmtMXN } = window.CalcLogic; return (
{label} {fmtMXN(derivedAmount)}
onChange(+e.target.value)} className="pctrow__slider" style={accent ? { "--track": accent } : undefined} />
onChange(+e.target.value)} className="pctrow__input" /> %
); } // ---------- KPI cards ---------- function KPI({ label, sublabel, value, sub, tone = "neutral", big = false }) { return (
{label}
{sublabel &&
{sublabel}
}
{value}
{sub &&
{sub}
}
); } // ---------- Donut de distribución ---------- function DistributionDonut({ enganche, mensualidades, entrega, precio }) { const { fmtPct } = window.CalcLogic; const total = enganche + mensualidades + entrega || 1; const segs = [ { v: enganche, color: "#2B6CB0", label: "Enganche" }, { v: mensualidades, color: "#2F855A", label: "Mensualidades" }, { v: entrega, color: "#C05621", label: "Contra entrega" }, ]; const r = 56, c = 70; let acc = 0; const circ = 2 * Math.PI * r; return (
{segs.map((s, i) => { const frac = s.v / total; const dash = frac * circ; const offset = -acc * circ; acc += frac; return ( ); })}
{segs.map((s, i) => (
{s.label} {fmtPct(precio > 0 ? (s.v / precio) * 100 : 0, 1)}
))}
); } // ---------- Inline styles (production-safe; do not depend on styles.css) ---------- // These rules are injected directly into by the component, so they ship // with the JS bundle and survive when the component is mounted in production // without the external stylesheet. const INLINE_STYLES = ` /* === KPI hover animations (Proyección a 10 años) === */ .kpis--4 .kpi { transition: transform 380ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 380ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 380ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 380ms cubic-bezier(0.2, 0.8, 0.2, 1); border: 1px solid var(--line, #e5e1d8); will-change: transform; cursor: default; } .kpis--4 .kpi:hover { transform: translateY(-2px) scale(1.015); box-shadow: 0 1px 2px rgba(20, 16, 10, 0.04), 0 18px 40px -18px rgba(20, 16, 10, 0.18), 0 8px 16px -12px rgba(20, 16, 10, 0.10); border-color: var(--accent, #8a6a3a); background: var(--bg, #fbfaf7); } .kpis--4 .kpi:hover .kpi__value { color: var(--accent, #8a6a3a); } .kpis--4 .kpi--accent:hover .kpi__value { color: var(--accent, #8a6a3a); } .kpis--4 .kpi--positive:hover .kpi__value { color: var(--pos, #2f7a4f); } .kpis--4 .kpi--negative:hover .kpi__value { color: var(--neg, #b04545); } @media (prefers-reduced-motion: reduce) { .kpis--4 .kpi { transition: none; } .kpis--4 .kpi:hover { transform: none; } } /* === PDF download button === */ .pdf-btn-wrap { display: flex; justify-content: center; margin: 24px 32px 8px; } .pdf-btn { appearance: none; border: 1px solid var(--line, #e5e1d8); background: var(--bg-elev, #fff); color: var(--ink, #1a1612); font: inherit; font-size: 13px; font-weight: 500; letter-spacing: 0.01em; padding: 11px 22px; border-radius: 999px; cursor: pointer; display: inline-flex; align-items: center; gap: 10px; transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 200ms, border-color 200ms, color 200ms, box-shadow 200ms; box-shadow: 0 1px 2px rgba(20, 16, 10, 0.04); } .pdf-btn:hover { background: var(--ink, #1a1612); color: var(--bg, #fbfaf7); border-color: var(--ink, #1a1612); transform: translateY(-1px); box-shadow: 0 6px 16px -8px rgba(20, 16, 10, 0.35); } .pdf-btn:active { transform: translateY(0); } .pdf-btn:disabled { opacity: 0.6; cursor: progress; transform: none; } .pdf-btn__icon { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } /* === Print styles === */ @media print { @page { size: Letter; margin: 14mm 12mm; } html, body { background: #fff !important; color: #000 !important; -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } * { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } /* Hide non-report chrome */ .header__actions, .pdf-btn-wrap, .balance__fix, .panel--inputs, .roi-inputs, [data-no-print], /* Tweaks panel and its host */ .tweaks-panel, .tweaks-fab, [class*="tweaks"] { display: none !important; } body > div:last-of-type:empty { display: none !important; } .layout { display: block !important; grid-template-columns: none !important; gap: 0 !important; } .panel--results { box-shadow: none !important; border: none !important; padding: 0 !important; } .header { border-bottom: 1px solid #999 !important; padding: 0 0 10px !important; margin-bottom: 14px !important; page-break-after: avoid; } .results__headline, .roi-results__headline { page-break-after: avoid; } .kpis, .kpis--4 { page-break-inside: avoid; } .kpi { border: 1px solid #ccc !important; background: #fafaf7 !important; box-shadow: none !important; transform: none !important; page-break-inside: avoid; } /* Force major sections onto new pages */ .roi-results { page-break-before: always; } .schedule, .chart, .compare, .selfpay, .report { page-break-inside: avoid; margin-top: 14px !important; } .schedule__scroll { overflow: visible !important; } .schedule__table, .report__table { width: 100% !important; font-size: 11px !important; } .disclaimer { page-break-inside: avoid; margin: 18px 0 0 !important; } /* Ensure legible base type */ body, .results__lead, .compare__note, .form__note, .field__hint, .kpi__sub, .kpi__sublabel, .roi-inputs__sub { font-size: 11px !important; } .kpi__value { font-size: 22px !important; } /* Solid colors instead of OKLCH for old print pipelines */ .accent { color: #8a6a3a !important; } } `; function InlineStyles() { React.useEffect(() => { const id = "az-calc-inline-styles"; if (document.getElementById(id)) return; const el = document.createElement("style"); el.id = id; el.textContent = INLINE_STYLES; document.head.appendChild(el); return () => { /* keep on unmount; safe for SSR/hydration cycles */ }; }, []); return null; } // ---------- PDF download (jsPDF + html2canvas via CDN, lazy-loaded) ---------- const PDF_DEPS = { html2canvas: "https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js", jspdf: "https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js", }; function loadScript(src) { return new Promise((resolve, reject) => { const existing = document.querySelector(`script[data-src="${src}"]`); if (existing) { if (existing.dataset.loaded === "1") return resolve(); existing.addEventListener("load", () => resolve()); existing.addEventListener("error", () => reject(new Error("Failed to load " + src))); return; } const s = document.createElement("script"); s.src = src; s.async = true; s.dataset.src = src; s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); }); s.addEventListener("error", () => reject(new Error("Failed to load " + src))); document.head.appendChild(s); }); } async function ensurePdfLibs() { if (!window.html2canvas) await loadScript(PDF_DEPS.html2canvas); if (!(window.jspdf && window.jspdf.jsPDF) && !window.jsPDF) await loadScript(PDF_DEPS.jspdf); } async function downloadReportPDF(targetSelector = ".panel--results", filename = "reporte-inversion.pdf") { await ensurePdfLibs(); const target = document.querySelector(targetSelector); if (!target) throw new Error("No se encontró el reporte para exportar"); // Hide elements marked no-print before capture const hidden = []; document.querySelectorAll(".pdf-btn-wrap, [data-no-print]").forEach((el) => { hidden.push([el, el.style.display]); el.style.display = "none"; }); const canvas = await window.html2canvas(target, { scale: 2, useCORS: true, backgroundColor: "#ffffff", windowWidth: target.scrollWidth, }); hidden.forEach(([el, prev]) => { el.style.display = prev; }); const jsPDFCtor = (window.jspdf && window.jspdf.jsPDF) || window.jsPDF; const pdf = new jsPDFCtor({ orientation: "portrait", unit: "pt", format: "letter" }); const pageW = pdf.internal.pageSize.getWidth(); const pageH = pdf.internal.pageSize.getHeight(); const margin = 24; const usableW = pageW - margin * 2; const imgW = usableW; const imgH = (canvas.height * imgW) / canvas.width; // Slice the tall canvas into page-sized chunks const pageContentH = pageH - margin * 2; const pageHeightInCanvas = (pageContentH * canvas.width) / imgW; // px of source per page let renderedH = 0; let pageIdx = 0; while (renderedH < canvas.height) { const sliceH = Math.min(pageHeightInCanvas, canvas.height - renderedH); const slice = document.createElement("canvas"); slice.width = canvas.width; slice.height = sliceH; const ctx = slice.getContext("2d"); ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, slice.width, slice.height); ctx.drawImage(canvas, 0, renderedH, canvas.width, sliceH, 0, 0, canvas.width, sliceH); const imgData = slice.toDataURL("image/jpeg", 0.92); if (pageIdx > 0) pdf.addPage(); const sliceImgH = (sliceH * imgW) / canvas.width; pdf.addImage(imgData, "JPEG", margin, margin, imgW, sliceImgH, undefined, "FAST"); renderedH += sliceH; pageIdx += 1; } pdf.save(filename); } function PdfDownloadButton({ targetSelector = ".panel--results", filename = "reporte-inversion.pdf", label = "Descargar reporte PDF" }) { const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const onClick = async () => { setErr(null); setBusy(true); try { await downloadReportPDF(targetSelector, filename); } catch (e) { console.error(e); setErr("No se pudo generar el PDF. Revisa la consola."); } finally { setBusy(false); } }; return (
{err && {err}}
); } Object.assign(window, { NumberField, PctRow, KPI, DistributionDonut, InlineStyles, PdfDownloadButton, downloadReportPDF });