// Form sections — receive `s` (state) and `set` updater functions
const { useRef, useEffect } = React;
// "admin" (default) ve precios y guarda directo; "cliente" (formulario
// público /customer/cotizador) arma la misma configuración de pastel pero
// sin ver ningún precio — solo envía la solicitud para que el vendedor cotice.
const MODE = (typeof window !== "undefined" && window.COTIZADOR_MODE) || "admin";
const IS_CLIENT = MODE === "cliente";
const Field = ({ label, children, hint, full }) => (
);
const Input = (props) => ;
const TextArea = (props) => ;
// Solo dígitos + separadores típicos de teléfono (espacio, guión, paréntesis,
// +) — para que no se cuele texto en un campo que en realidad se usa para
// marcar o para buscar por número (LeadModel/ClienteModel resuelven clientes
// por teléfono, así que un teléfono con letras rompe esa búsqueda).
const soloTelefono = (v) => (v || "").replace(/[^0-9+()\-\s]/g, "").slice(0, 20);
// Clamp genérico para inputs numéricos — evita negativos y, si se da `max`,
// evita que se pase de un tope (ej. el anticipo no puede superar el total).
const clampNum = (v, min, max) => {
if (v === "" || v === null || v === undefined) return "";
let n = Number(v);
if (Number.isNaN(n)) return "";
if (min !== undefined) n = Math.max(min, n);
if (max !== undefined) n = Math.min(max, n);
return n;
};
const Select = ({ options, ...props }) => (
);
function Toggle({ checked, onChange, label }) {
return (
);
}
function Chips({ value, onChange, options, allowEmpty }) {
return (
{options.map(o => {
const active = value === o.id;
return (
);
})}
);
}
// ========== Wizard ==========
// El formulario completo era una sola columna larguísima: el cliente tenía que
// hacer scroll por seis tarjetas antes de poder enviar. Se parte en pasos, con
// el mismo patrón visual del wizard de Rizz (bolita numerada + título + guía).
const WIZARD_STEPS = [
{ id: 1, label: "Cliente", hint: "Datos de contacto" },
{ id: 2, label: "Entrega", hint: "Fecha y ubicación" },
{ id: 3, label: "Pastel", hint: "Pisos y sabores" },
{ id: 4, label: "Decoración", hint: "Extras y figuras" },
{ id: 5, label: "Resumen", hint: IS_CLIENT ? "Enviar solicitud" : "Guardar cotización" },
];
// Qué falta para poder avanzar. Se valida al intentar pasar de paso y no al
// escribir, para no llenar de rojo un formulario que apenas se está llenando.
function validarPaso(paso, s) {
const faltan = [];
if (paso === 1) {
if (!(s.client.name || "").trim()) faltan.push("el nombre del solicitante");
if (!(s.client.phone || "").trim()) faltan.push("el teléfono de contacto");
}
if (paso === 2) {
if (!s.delivery.date) faltan.push("la fecha de entrega");
if (!(s.delivery.address || "").trim()) faltan.push("la dirección de entrega");
// Sin pin no hay km, y sin km el envío se cobraría en $0.
if (!s.delivery.lat || !s.delivery.lng) faltan.push("ubicar el punto en el mapa");
}
if (paso === 3) {
if (!(Number(s.cake.people) > 0)) faltan.push("el número de personas");
}
return faltan;
}
function WizardNav({ current, maxAlcanzado, onGo }) {
return (
{WIZARD_STEPS.map(st => {
const hecho = st.id < maxAlcanzado;
const activo = st.id === current;
const navegable = st.id <= maxAlcanzado;
return (
);
})}
);
}
function WizardFooter({ current, onPrev, onNext, errores }) {
const esUltimo = current === WIZARD_STEPS.length;
return (
{errores.length > 0 && (
Para continuar falta {errores.join(", ").replace(/,([^,]*)$/, " y$1")}.
)}
Paso {current} de {WIZARD_STEPS.length}
{!esUltimo && (
)}
);
}
// Selector de color libre (Huebee, ya incluido en el tema Rizz). Complementa
// a la paleta con nombre: cuando el cliente trae un color que no está en la
// lista, aquí se elige el tono exacto y se guarda el HEX.
function ColorPickerHuebee({ value, onChange }) {
const inputRef = useRef(null);
const hueRef = useRef(null);
// La instancia de Huebee se crea una sola vez, pero `onChange` cambia en
// cada render (depende del piso): si se capturara en el closure inicial,
// los cambios se escribirían siempre en el primer piso.
const cbRef = useRef(onChange);
cbRef.current = onChange;
useEffect(() => {
if (!inputRef.current || typeof Huebee === "undefined") return;
const hb = new Huebee(inputRef.current, {
notation: "hex",
saturations: 3,
shades: 6,
hues: 12,
setText: false, // el input lo controla React, no Huebee
setBGColor: false,
});
hb.on("change", (color) => { if (cbRef.current) cbRef.current(color); });
hueRef.current = hb;
return () => { if (hueRef.current && hueRef.current.destroy) hueRef.current.destroy(); };
}, []);
if (typeof Huebee === "undefined") return null;
return (
);
}
// ========== Cliente ==========
function ClienteForm({ s, set, onSave, onClear }) {
return (
{/* "Guardar por folio" es un atajo local (localStorage) del propio
equipo del vendedor — no aplica en el cotizador público: el
visitante no tiene sesión ni folios previos en ese navegador. */}
{!IS_CLIENT && }
}>
);
}
// Panel "Combinaciones de teclas" de Google.
//
// Se inspeccionó el DOM real (no se reproduce en navegadores sin composición):
// al arrastrar dentro del mapa, Google inyecta el panel como un
HERMANO
// del mapa, hijo directo de nuestro contenedor .delivery-map. Lo vuelve a
// crear en cada interacción, así que no basta con quitarlo una vez.
//
// Por eso se observa SOLO la lista de hijos directos del contenedor propio
// (childList sin subtree) y nunca se toca el primer hijo, que es el mapa.
// Un intento anterior recorría el árbol completo ocultando cualquier nodo con
// ese texto y congelaba el mapa: el texto del pie vive dentro de la misma
// rama que la capa interactiva. No volver a hacer eso.
//
// El link del pie sí es un