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