// 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 = ( ); return { folio: s.folio, client: s.client, delivery: s.delivery, cake: { ...s.cake, preview }, extras: { items: totals.items }, notes: s.notes, }; }, [s, totals]); // ====== Guardar cliente (por folio) ====== // Guarda con clave = folio de ESTA cotización, en el localStorage de este // navegador (no en la BD). El botón de "Recuperar por folio" se retiró: la // recuperación real de una cotización se hace desde /crm/cotizaciones, que // sí lee de la base de datos y no depende del navegador que se use. const leerDirectorio = () => { // localStorage puede traer JSON roto de versiones anteriores; si revienta // aquí, se caía toda la pantalla de "Recuperar". try { return JSON.parse(localStorage.getItem("deuscake.clients") || "{}") || {}; } catch (e) { return {}; } }; const saveClient = () => { const key = (s.folio || "").trim().toUpperCase(); if (!key) { alert("No hay folio para guardar."); return; } const dir = leerDirectorio(); dir[key] = { client: s.client, delivery: { ...s.delivery, date: "", time: "" }, // dirección sí, fecha/hora no savedAt: new Date().toISOString(), }; try { localStorage.setItem("deuscake.clients", JSON.stringify(dir)); alert(`Cliente "${s.client.name || key}" guardado en este equipo.`); } catch (e) { alert("No se pudo guardar en este navegador (almacenamiento lleno o bloqueado)."); } }; const clearAll = () => { try { localStorage.removeItem("deuscake.draft"); } catch (e) {} setS(defaultState()); setCotizacionId(null); setFotoReferencia(null); setTab("resumen"); setView("editor"); // Empezar de cero también reinicia el wizard: si no, quedabas parado en el // paso 5 de una cotización que ya se borró. setPaso(1); setMaxAlcanzado(1); setErroresPaso([]); }; // ====== Precargar una solicitud del cliente (solo admin) ====== // La lista y el botón "Abrir en Cotizador" viven en /admin/cotizador-solicitudes // (pantalla normal del admin, dentro del layout Dore). Ese botón abre esta // herramienta con ?solicitud=ID en la URL; aquí solo se detecta ese parámetro // al montar y se precarga la configuración completa que mandó el cliente. const [loadingSolicitud, setLoadingSolicitud] = useState(false); useEffect(() => { if (IS_CLIENT || !window.COTIZADOR_API_BASE) return; const idParam = new URLSearchParams(window.location.search).get("solicitud"); if (!idParam) return; setLoadingSolicitud(true); const body = new FormData(); body.append("id", idParam); fetch(window.COTIZADOR_API_BASE + "solicitud", { method: "POST", body }) .then(r => r.json()) .then(res => { setLoadingSolicitud(false); if (res && res.status === "success" && res.data) { setS(prev => ({ ...defaultState(), ...res.data })); // Ya viene todo capturado: se abren los 5 pasos para poder revisar // cualquiera sin tener que ir avanzando de uno en uno. setMaxAlcanzado(WIZARD_STEPS.length); } else { alert((res && res.message) || "No se pudo cargar la solicitud."); } }) .catch(() => { setLoadingSolicitud(false); alert("Error de conexión al cargar la solicitud."); }); }, []); // ====== Ver/reimprimir una cotización ya guardada ====== // Desde /crm/cotizaciones el link "ver" abre esta misma herramienta con // ?cotizacion=ID; aquí se detecta al montar, se precarga el estado completo // guardado en su momento y se salta directo a Documentos > Cotización // (mismo documento con logo/folio que ya se veía en mariaschocolate). useEffect(() => { if (IS_CLIENT || !window.COTIZADOR_API_BASE) return; const idParam = new URLSearchParams(window.location.search).get("cotizacion"); if (!idParam) return; setLoadingSolicitud(true); const body = new FormData(); body.append("id", idParam); fetch(window.COTIZADOR_API_BASE + "ver", { method: "POST", body }) .then(r => r.json()) .then(res => { setLoadingSolicitud(false); if (res && res.status === "success" && res.data) { setS(prev => ({ ...defaultState(), ...res.data })); setMaxAlcanzado(WIZARD_STEPS.length); // Guarda con qué cotización de la BD queda vinculada esta edición, // para que "Guardar cotización en el sistema" actualice esta misma // fila en vez de crear una duplicada. setCotizacionId(res.id || Number(idParam) || null); setTab("resumen"); setView("docs"); } else { alert((res && res.message) || "No se pudo cargar la cotización."); } }) .catch(() => { setLoadingSolicitud(false); alert("Error de conexión al cargar la cotización."); }); }, []); // ====== Guardar cotización en la BD (Fase 4) ====== // El servidor recalcula el total con calcularCotizacionPrecio() antes de // guardar, así que el precio que queda en la BD siempre es el autorizado // por el catálogo vigente, no el que trae el navegador. const [saving, setSaving] = useState(false); const [saveResult, setSaveResult] = useState(null); const [saveError, setSaveError] = useState(null); // Id de la cotización que se está EDITANDO (se llenó al abrir con // ?cotizacion=ID). Se guarda aparte de `s` a propósito: si viviera dentro // de `s` se colaría en "Exportar JSON" y, al importar ese archivo después, // el próximo "Guardar" pisaría por error la cotización original en vez de // crear una nueva. const [cotizacionId, setCotizacionId] = useState(null); // Foto de referencia (solo modo cliente, slide 7: "Captura datos... foto // referencia"). Se guarda aparte de `s` porque un File no se puede meter // en JSON.stringify — se sube en una segunda petición (multipart) una vez // que solicitar() ya creó el lead y sabemos su id. const [fotoReferencia, setFotoReferencia] = useState(null); const saveCotizacion = () => { if (!window.COTIZADOR_API_BASE) return; if (!s.client.name && !s.client.code && !s.client.phone) { alert("Captura al menos tu nombre o teléfono antes de enviar."); return; } setSaving(true); setSaveError(null); const payload = (!IS_CLIENT && cotizacionId) ? { ...s, cotizacion_id: cotizacionId } : s; fetch(window.COTIZADOR_API_BASE + (IS_CLIENT ? "solicitar" : "guardar"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) .then(r => r.json()) .then(res => { setSaving(false); if (res && res.status === "success") { setSaveResult(res.data); if (!s.folio || s.folio !== res.data.folio) set("folio", res.data.folio); // A partir de aquí, si se vuelve a dar "Guardar" sin recargar la // página, se sigue actualizando esta misma cotización (no crea otra). if (!IS_CLIENT && res.data.id) setCotizacionId(res.data.id); // Ya se guardó en la BD — se borra el borrador local para que la // próxima vez que se abra el cotizador no "reviva" esta cotización // ya cerrada (admin). El cliente público nunca usa este borrador. if (!IS_CLIENT) { try { localStorage.removeItem("deuscake.draft"); } catch (e) {} } // Modo cliente: si adjuntó una foto de referencia, se sube aparte // (multipart) ligada al lead que se acaba de crear. if (IS_CLIENT && fotoReferencia && res.data.lead_id) { const fd = new FormData(); fd.append("lead_id", res.data.lead_id); fd.append("archivo", fotoReferencia); fetch(window.COTIZADOR_API_BASE + "foto-referencia", { method: "POST", body: fd }).catch(() => {}); setFotoReferencia(null); } } else { setSaveError((res && res.message) || "No se pudo guardar la cotización."); } }) .catch(() => { setSaving(false); setSaveError("Error de conexión al guardar la cotización."); }); }; // ====== Importar / exportar JSON ====== const fileRef = useRef(null); const exportJson = () => { const blob = new Blob([JSON.stringify(s, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `cotizacion-${s.folio}-${(s.client.name || "cliente").replace(/\s+/g, "_")}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const importJson = (e) => { const f = e.target.files?.[0]; if (!f) return; const reader = new FileReader(); reader.onload = () => { try { const parsed = JSON.parse(reader.result); if (parsed && parsed.cake && parsed.client) { setS(parsed); setMaxAlcanzado(WIZARD_STEPS.length); // Un JSON importado es un punto de partida nuevo, no la misma fila // de la BD que se estuviera editando — si no se limpia, "Guardar" // pisaría esa otra cotización por error. setCotizacionId(null); alert("Cotización cargada."); } else alert("Archivo no válido."); } catch (err) { alert("No se pudo leer el archivo."); } }; reader.readAsText(f); e.target.value = ""; }; // ====== Imprimir / PDF ====== const onPrint = () => { document.body.classList.add("printing-" + tab); setTimeout(() => { window.print(); setTimeout(() => document.body.classList.remove("printing-" + tab), 500); }, 100); }; const onPdf = onPrint; // mismo flujo: el navegador permite "Guardar como PDF" // ====== Switch a docs ====== const goToTab = (t) => { setTab(t); setView("docs"); setTimeout(() => { document.querySelector(".docs-stack")?.scrollTo({ top: 0, behavior: "smooth" }); }, 50); }; // ====== Preview principal (sidebar) ====== const sidebarPreview = useMemo(() => { const tiersWithColors = s.cake.tiers.map(t => ({ flavorColor: D.flavors.find(f => f.id === t.flavor)?.color || "#F4E4BC", color: t.colorHex, tierType: t.tierType, })); return ; }, [s.cake.tiers]); return (
fileRef.current?.click()} onClear={clearAll} mode={MODE} /> {loadingSolicitud && !IS_CLIENT && (
Cargando cotización…
)} {(view === "editor" || IS_CLIENT) ? (
{paso === 1 && ( )} {paso === 2 && } {paso === 3 && ( <> )} {paso === 4 && } {paso === 5 && ( { setView("docs"); setTimeout(onPrint, 200); }} onPdf={() => { setView("docs"); setTimeout(onPdf, 200); }} onTab={goToTab} onSave={saveCotizacion} saving={saving} saveResult={saveResult} saveError={saveError} editando={!!cotizacionId} onFotoChange={setFotoReferencia} fotoNombre={fotoReferencia?.name} /> )}
) : ( )}
); } function TopBar({ folio, view, setView, onExport, onImport, onClear, mode }) { const isClient = mode === "cliente"; return (
Deus Cake
{isClient ? "SOLICITAR COTIZACIÓN" : "COTIZADOR"} en línea
{!isClient && Folio · {folio}} {!isClient && (
)} {!isClient && } {!isClient && }
); } function DocsView({ tab, setTab, setView, s, totals, onPrint }) { return (
{tab === "resumen" && } {tab === "cliente" && } {tab === "cocina" && } {tab === "reparto" && }
); } ReactDOM.createRoot(document.getElementById("root")).render();