// Deus Cake — main app
const { useState, useEffect, useMemo, useRef, useCallback } = React;
const D = window.DEUS_DATA;
// "admin" (default, /admin/cotizador) ve precios y guarda directo en cotizaciones.
// "cliente" (público, /customer/cotizador) arma la misma configuración de
// pastel pero sin ver ningún precio, y solo envía una solicitud pendiente.
const MODE = (typeof window !== "undefined" && window.COTIZADOR_MODE) || "admin";
const IS_CLIENT = MODE === "cliente";
// === Default state
const blankTier = () => ({
flavor: "vainilla",
filling: "crema_pastelera",
tierType: "normal",
union: "buttercream",
colorId: "crema", colorName: "Crema", colorHex: "#F8EFD9",
pantone: "",
special: "",
});
const defaultState = () => ({
folio: "DC-" + Date.now().toString().slice(-6),
// Tipo de evento (Fase 4 — Agenda + Alertas): determina las alertas
// automáticas de seguimiento futuras (bautizo → 1er año, boda → baby
// shower/bautizo, etc.). "otro" cuando no aplica ninguna de las anteriores.
tipoEvento: "otro",
client: { code: "", name: "", phone: "", email: "" },
delivery: {
date: "", time: "",
locationName: "", address: "",
foreign: false, km: 0, lat: null, lng: null,
receiverName: "", receiverPhone: "",
},
cake: {
people: 15,
theme: "",
hydration: "ALTO",
covering: "buttercream",
sameForAll: true,
tiers: [blankTier()],
topper: { enabled: false, text: "", material: "", paper: false, laser: false, laserColor: "" },
figures: { fondant3d: false, arroz: false, impreso: false, relieve: false,
funkoQty: 0, detailQty: 0, cookieQty: 0, cookieDetailQty: 0 },
flowers: { fondant: false, natural: false, color: "", qty: 0 },
},
notes: "",
advance: 0,
});
// === Path utils for setting nested state
function setPath(obj, path, value) {
const keys = path.split(".");
const out = Array.isArray(obj) ? [...obj] : { ...obj };
let cur = out;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
cur[k] = Array.isArray(cur[k]) ? [...cur[k]] : { ...cur[k] };
cur = cur[k];
}
cur[keys[keys.length - 1]] = value;
return out;
}
function App() {
// Estado inicial: si hay un borrador sin terminar en este navegador (solo
// admin — ver notas abajo), lo retoma con TODO y su mismo folio, en vez de
// generar uno nuevo cada vez que se entra a la pantalla. El borrador se
// borra solo al dar "Nueva cotización"/"Limpiar" o al guardar con éxito,
// así que nunca "revive" una cotización ya cerrada.
const [s, setS] = useState(() => {
if (IS_CLIENT) return defaultState();
try {
const saved = localStorage.getItem("deuscake.draft");
if (saved) return JSON.parse(saved);
} catch (e) {}
return defaultState();
});
const [tab, setTab] = useState("resumen"); // resumen | cliente | cocina | reparto
const [view, setView] = useState("editor"); // editor | docs
// ====== Wizard ======
// `maxAlcanzado` permite regresar a pasos ya visitados con un clic en la
// barra, pero no saltarse hacia adelante sin validar lo de en medio.
const [paso, setPaso] = useState(1);
const [maxAlcanzado, setMaxAlcanzado] = useState(1);
const [erroresPaso, setErroresPaso] = useState([]);
const irAPaso = useCallback((n) => {
setPaso(n);
setErroresPaso([]);
setMaxAlcanzado(prev => Math.max(prev, n));
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const pasoSiguiente = useCallback(() => {
const faltan = validarPaso(paso, s);
if (faltan.length) { setErroresPaso(faltan); return; }
irAPaso(Math.min(paso + 1, WIZARD_STEPS.length));
}, [paso, s, irAPaso]);
const pasoAnterior = useCallback(() => irAPaso(Math.max(paso - 1, 1)), [paso, irAPaso]);
// Se incrementa cuando el catálogo de la BD llega, para forzar un re-render
// (D = window.DEUS_DATA se lee en cada render, pero React no sabe que cambió
// por sí solo ya que no es state — este contador se lo hace saber).
const [catalogVersion, setCatalogVersion] = useState(0);
// Autosave del borrador en curso (solo admin — el cotizador público
// siempre abre en blanco, cada visitante es alguien distinto).
useEffect(() => {
if (IS_CLIENT) return;
try { localStorage.setItem("deuscake.draft", JSON.stringify(s)); } catch (e) {}
}, [s]);
// Catálogo real desde "Catálogo de Opciones" del admin (sabores, rellenos,
// coberturas, tipos de piso, acabados, extras). Si falla, se queda con los
// valores de respaldo que ya trae data.js.
useEffect(() => {
if (!window.COTIZADOR_API_BASE) return;
fetch(window.COTIZADOR_API_BASE + "opciones", { method: "POST" })
.then(r => r.json())
.then(res => {
window.applyCotizadorCatalog(res && res.data);
setCatalogVersion(v => v + 1);
})
.catch(function (e) {
console.warn("No se pudo cargar el catálogo desde la BD, usando valores de respaldo.", e);
});
}, []);
// ====== Setter generalizado ======
const set = useCallback((path, value) => {
setS(prev => {
// Casos especiales
if (path === "cake.tiersCount") {
const n = Math.max(1, Math.min(7, Number(value) || 1));
const tiers = [...prev.cake.tiers];
while (tiers.length < n) tiers.push(blankTier());
while (tiers.length > n) tiers.pop();
// ajustar personas sugeridas si está en valor por defecto
let people = prev.cake.people;
const suggested = D.servingsByTier[n];
if (suggested && (people === D.servingsByTier[prev.cake.tiers.length] || people === 0)) {
people = suggested;
}
return { ...prev, cake: { ...prev.cake, tiers, people } };
}
if (path === "cake.baseFlavor") {
return { ...prev, cake: { ...prev.cake, tiers: prev.cake.tiers.map(t => ({ ...t, flavor: value })) } };
}
if (path === "cake.baseFilling") {
return { ...prev, cake: { ...prev.cake, tiers: prev.cake.tiers.map(t => ({ ...t, filling: value })) } };
}
return setPath(prev, path, value);
});
}, []);
// ====== Totales ======
const totals = useMemo(() => {
const people = Math.max(1, Number(s.cake.people) || 0);
const perPerson = D.pricePerPerson(people);
const cakeBase = people * perPerson;
// Sabores/rellenos especiales: cuenta únicos
const specialFlavors = new Set();
const specialFillings = new Set();
s.cake.tiers.forEach(t => {
const f = D.flavors.find(x => x.id === t.flavor);
const r = D.fillings.find(x => x.id === t.filling);
if (f?.extra) specialFlavors.add(t.flavor);
if (r?.extra) specialFillings.add(t.filling);
});
const specialCount = specialFlavors.size + specialFillings.size;
const specialPct = specialCount > 0 ? D.SPECIAL_FLAVOR_PCT : 0;
const specialSurcharge = cakeBase * specialPct;
// Extras
const items = [];
if (s.cake.topper.enabled) items.push({ name: "Cake topper", qty: 1, price: D.extras.cake_topper.price });
if (s.cake.figures.funkoQty > 0) items.push({ name: "Figuras tipo Funko", qty: s.cake.figures.funkoQty, price: D.extras.figura_funko.price });
if (s.cake.figures.detailQty > 0) items.push({ name: "Figuras detalladas", qty: s.cake.figures.detailQty, price: D.extras.figura_detalle.price });
if (s.cake.figures.cookieQty > 0) items.push({ name: "Galletas royal icing", qty: s.cake.figures.cookieQty, price: D.extras.galleta_simple.price });
if (s.cake.figures.cookieDetailQty > 0) items.push({ name: "Galletas royal icing detalladas", qty: s.cake.figures.cookieDetailQty, price: D.extras.galleta_detalle.price });
if (s.cake.flowers.qty > 0) items.push({ name: "Flores decorativas", qty: s.cake.flowers.qty, price: D.extras.flor.price });
const extrasSum = items.reduce((sum, x) => sum + x.qty * x.price, 0);
const extrasCount = items.reduce((sum, x) => sum + x.qty, 0);
// Envío
const shipping = D.computeShipping(Number(s.delivery.km) || 0);
const subtotal = cakeBase + specialSurcharge + extrasSum + shipping;
const iva = subtotal * D.IVA_RATE;
const total = subtotal + iva;
// Nunca mostrar saldo negativo: si un JSON importado trae un anticipo
// viejo mayor al total recalculado, se recorta aquí también (el input
// ya lo evita al escribir, pero un import no pasa por el onChange).
const advance = Math.max(0, Math.min(Number(s.advance) || 0, total));
const balance = total - advance;
return {
perPerson, cakeBase,
specialPct, specialSurcharge, specialCount,
items, extrasSum, extrasCount,
shipping, subtotal, iva, total, advance, balance,
};
}, [s]);
// Verificación con el servidor (Fase 2): el precio real lo calcula PHP con
// los mismos datos del catálogo — aquí solo se compara en segundo plano para
// detectar si el cálculo del navegador se desincroniza. No bloquea nada;
// el servidor será quien mande cuando se conecte el guardado (Fase 4).
useEffect(() => {
if (!window.COTIZADOR_API_BASE || IS_CLIENT) return; // no hay /calcular público, y el cliente no ve precio
const timer = setTimeout(() => {
const payload = {
people: s.cake.people,
tiers: s.cake.tiers.map(t => ({ flavor: t.flavor, filling: t.filling })),
extras: {
topper: s.cake.topper.enabled,
funkoQty: s.cake.figures.funkoQty,
detailQty: s.cake.figures.detailQty,
cookieQty: s.cake.figures.cookieQty,
cookieDetailQty: s.cake.figures.cookieDetailQty,
flowerQty: s.cake.flowers.qty,
},
km: s.delivery.km,
advance: s.advance,
};
fetch(window.COTIZADOR_API_BASE + "calcular", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(r => r.json())
.then(res => {
const serverTotal = res && res.data ? Number(res.data.total) : null;
if (serverTotal == null) return;
const diff = Math.abs(serverTotal - totals.total);
if (diff > 0.01) {
console.warn("[Cotizador] Precio del servidor distinto al del navegador:", { servidor: serverTotal, navegador: totals.total });
} else {
console.log("[Cotizador] Precio verificado con el servidor:", serverTotal);
}
})
.catch(function () {});
}, 600);
return () => clearTimeout(timer);
}, [s, totals.total]);
// ====== Estado enriquecido para los documentos ======
const stateForDocs = useMemo(() => {
const totalH = s.cake.tiers.length;
const baseTier = s.cake.tiers[0];
const baseFlavorColor = D.flavors.find(f => f.id === baseTier?.flavor)?.color || "#F4E4BC";
const baseTierColor = baseTier?.colorHex || "#FFFFFF";
const tiersWithColors = s.cake.tiers.map(t => ({
...t,
flavorColor: D.flavors.find(f => f.id === t.flavor)?.color || "#F4E4BC",
color: t.colorHex,
}));
const preview = (