/* ============================================================ CART — botón flotante + cajón con checkout en pasos + PSE + WhatsApp ============================================================ */ const { PAYMENTS: _PAYMENTS, WA: _WA, GOOGLE_MAPS_API_KEY: _GKEY, DELIVERY_FEE: _DFEE, DELIVERY_SURCHARGE_KM: _DSUR, DELIVERY_SURCHARGE_MIN_KM: _DMIN, DELIVERY_SURCHARGE_MAX_KM: _DMAX, FREE_DELIVERY_MIN: _DFREE, RESTAURANT_LOCATION: _RLOC } = window; /* ---- Carga (una sola vez) del SDK de Google Maps Places ---- */ function loadGoogleMaps(key) { if (window.google && window.google.maps && window.google.maps.places) return Promise.resolve(); if (window.__gmapsPromise) return window.__gmapsPromise; window.__gmapsPromise = new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=places&language=es®ion=CO`; s.async = true; s.onload = resolve; s.onerror = () => reject(new Error("No se pudo cargar Google Maps")); document.head.appendChild(s); }); return window.__gmapsPromise; } /* ---- Autocompletado gratuito (OpenStreetMap · Nominatim), sin clave ---- */ function osmLabel(x) { const a = x.address || {}; const road = [a.road, a.house_number].filter(Boolean).join(" "); const area = a.neighbourhood || a.suburb || a.quarter || a.residential || a.city_district || a.borough; const city = a.city || a.town || a.municipality || a.county; const parts = [road || a.name || x.name, area, city].filter(Boolean); const uniq = parts.filter((v, i) => parts.indexOf(v) === i); return uniq.join(", "); } async function osmSearch(q) { // viewbox de Bogotá para sesgar resultados cercanos (sin excluir otras ciudades) const url = "https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1" + "&limit=6&countrycodes=co&accept-language=es" + "&viewbox=-74.25,4.85,-73.99,4.48" + "&q=" + encodeURIComponent(q); const r = await fetch(url); if (!r.ok) throw new Error("net"); const data = await r.json(); const list = (Array.isArray(data) ? data : []) .map(x => ({ label: osmLabel(x), lat: parseFloat(x.lat), lon: parseFloat(x.lon) })) .filter(x => x.label); const seen = new Set(); const out = []; for (const item of list) { if (!seen.has(item.label)) { seen.add(item.label); out.push(item); } } return out.slice(0, 6); } /* ---- Campo de dirección con autocompletado ---- */ function AddressField({ value, onChange, error }) { const ref = useRef(null); // --- Ruta 1: Google Maps (solo si hay clave) --- useEffect(() => { if (!_GKEY || !ref.current) return; let ac; loadGoogleMaps(_GKEY).then(() => { if (!ref.current || !window.google) return; ac = new window.google.maps.places.Autocomplete(ref.current, { componentRestrictions: { country: "co" }, fields: ["formatted_address", "name", "geometry"], types: ["address"], }); ac.addListener("place_changed", () => { const p = ac.getPlace(); const addr = p.formatted_address || p.name || ref.current.value; const geo = (p.geometry && p.geometry.location) ? { lat: p.geometry.location.lat(), lng: p.geometry.location.lng() } : null; onChange(addr, geo); }); }).catch(() => {}); }, []); // --- Ruta 2 (por defecto): OpenStreetMap, gratis y sin clave --- const [sugs, setSugs] = useState([]); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [failed, setFailed] = useState(false); const tRef = useRef(null); const seqRef = useRef(0); function handleType(v) { onChange(v, null); // limpia la geolocalización al escribir manualmente if (_GKEY) return; // Google maneja sus propias sugerencias if (tRef.current) clearTimeout(tRef.current); const q = v.trim(); if (q.length < 4) { setSugs([]); setOpen(false); return; } setLoading(true); setOpen(true); setFailed(false); const seq = ++seqRef.current; tRef.current = setTimeout(() => { osmSearch(q) .then(list => { if (seq !== seqRef.current) return; setSugs(list); setLoading(false); }) .catch(() => { if (seq !== seqRef.current) return; setSugs([]); setLoading(false); setFailed(true); }); }, 450); } function pick(s) { onChange(s.label, { lat: s.lat, lng: s.lon }); setSugs([]); setOpen(false); } const showList = !_GKEY && open && (loading || sugs.length > 0); return ( ); } function cartCount(cart) { return cart.reduce((s, l) => s + l.qty, 0); } function cartSubtotal(cart) { return cart.reduce((s, l) => s + l.unitPrice * l.qty, 0); } function cartRanged(cart) { return cart.some(l => l.ranged && !l.size); } // Opciones de tamaño para platos con rango de precio (por peso, en gramos) const SIZES = [ { id: "pequeno", idx: 0 }, { id: "mediano", idx: 1 }, { id: "grande", idx: 2 }, ]; function sizeWeight(line, sizeId) { if (line.weights && line.weights.length === 3) { const s = SIZES.find(x => x.id === sizeId); return line.weights[s.idx]; } return null; } function sizePrice(line, sizeId) { const lo = (line.from != null ? line.from : line.to) || 0; const hi = (line.to != null ? line.to : line.from) || 0; if (sizeId === "pequeno") return lo; if (sizeId === "grande") return hi; return Math.round(((lo + hi) / 2) / 1000) * 1000; // mediano } function sizeLabel(line, sizeId) { const w = sizeWeight(line, sizeId); return w ? (w + " g") : ({ pequeno: "Pequeño", mediano: "Mediano", grande: "Grande" }[sizeId]); } /* ---- Botón flotante ---- */ function FloatingCart({ cart, onOpen }) { const n = cartCount(cart); if (!n) return null; return ( ); } /* ---- Línea del carrito ---- */ function CartLine({ line, onQty, onRemove, onSize }) { return (
{line.name}
{line.rice && {line.rice}} {line.drink && {line.drink}} {line.notes && “{line.notes}”}
{line.ranged && ( )}
{fmt(line.unitPrice * line.qty)}{line.ranged && !line.size ? "+" : ""}
); } /* ---- PSE simulado ---- */ function PseModal({ total, onClose, onApproved }) { const banks = ["Bancolombia", "Davivienda", "BBVA", "Banco de Bogotá", "Nequi", "Banco Caja Social"]; const [bank, setBank] = useState(""); const [stage, setStage] = useState("select"); // select | processing | done useEffect(() => { if (stage === "processing") { const t = setTimeout(() => setStage("done"), 1800); return () => clearTimeout(t); } if (stage === "done") { const t = setTimeout(onApproved, 1300); return () => clearTimeout(t); } }, [stage]); return (
e.stopPropagation()}>
PSEPagos Seguros en Línea
{stage === "select" && <>

Vas a pagar {fmt(total)}

Selecciona tu banco
{banks.map(b => ( ))}
} {stage === "processing" &&

Conectando con {bank}…

Simulación de pasarela PSE
} {stage === "done" &&

¡Pago aprobado!

Enviando tu pedido…
}
); } /* ---- Cajón del carrito ---- */ function CartDrawer({ open, cart, setCart, onClose, onScrollTo, isOpen, store }) { const [step, setStep] = useState(0); const [mode, setMode] = useState("domicilio"); // domicilio | recoger const [form, setForm] = useState({ name: "", phone: "", address: "", details: "", addressGeo: null }); const [pay, setPay] = useState("pse"); const [pse, setPse] = useState(false); const [tried, setTried] = useState(false); // marca intento de continuar para mostrar errores const sub = cartSubtotal(cart); const ranged = cartRanged(cart); const distanceKm = (_RLOC.lat != null && form.addressGeo) ? haversineKm(_RLOC.lat, _RLOC.lng, form.addressGeo.lat, form.addressGeo.lng) : null; function computeDelivery() { if (sub >= _DFREE) return 0; // envío gratis por monto mínimo if (distanceKm != null && distanceKm >= _DMIN && distanceKm < _DMAX) return _DFEE + _DSUR; // recargo silencioso por distancia return _DFEE; } const delivery = mode === "domicilio" ? computeDelivery() : 0; const total = sub + delivery; const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const errName = isValidName(form.name) ? "" : "Ingresa un nombre válido (solo letras)."; const errPhone = isValidPhone(form.phone) ? "" : "Ingresa un teléfono válido (7 a 15 dígitos)."; const errAddr = (mode === "recoger" || form.address.trim().length >= 6) ? "" : "Ingresa una dirección de entrega."; const validStep1 = !errName && !errPhone && !errAddr; function setQty(uid, q) { setCart(c => c.map(l => l.uid === uid ? { ...l, qty: q } : l)); } function remove(uid) { setCart(c => c.filter(l => l.uid !== uid)); } function setSize(uid, sizeId) { setCart(c => c.map(l => { if (l.uid !== uid) return l; const price = sizePrice(l, sizeId) + (l.drinkExtra || 0); return { ...l, size: sizeId, unitPrice: price }; })); } function buildMsg() { const L = ["*PEDIDO — Dos Océanos*", "—————————————"]; cart.forEach((l, i) => { L.push(`${i + 1}) ${l.name} ×${l.qty}`); if (l.rice) L.push(` • ${l.rice}`); if (l.drink) L.push(` • ${l.drink}`); if (l.ranged) L.push(` • Tamaño: ${l.size ? sizeLabel(l, l.size) : "por confirmar"}`); if (l.notes) L.push(` • Nota: ${l.notes}`); L.push(` ${fmt(l.unitPrice * l.qty)}${l.ranged ? " (según tamaño)" : ""}`); }); L.push("—————————————"); L.push(`Subtotal: ${fmt(sub)}${ranged ? "+" : ""}`); if (mode === "domicilio") L.push(`Domicilio: ${delivery === 0 ? "Gratis" : fmt(delivery)}`); L.push(`*Total estimado: ${fmt(total)}${ranged ? "+" : ""}*`); L.push("—————————————"); L.push(mode === "domicilio" ? "Entrega: Domicilio" : "Entrega: Recoger en el local"); L.push(`Cliente: ${form.name}`); L.push(`Tel: ${form.phone}`); if (mode === "domicilio") { L.push(`Dirección: ${form.address}`); if (form.details.trim()) L.push(`Indicaciones: ${form.details}`); } const pm = _PAYMENTS.find(p => p.id === pay); L.push(`Pago: ${pm ? pm.label : pay}${pay === "pse" ? " (aprobado ✓)" : ""}`); return encodeURIComponent(L.join("\n")); } function sendWhatsApp() { window.open(`https://wa.me/${_WA}?text=${buildMsg()}`, "_blank"); setCart([]); setStep(0); onClose(); } function onPay() { if (pay === "pse") setPse(true); else sendWhatsApp(); } const steps = ["Tu pedido", "Entrega", "Pago"]; return ( <>
{pse && setPse(false)} onApproved={() => { setPse(false); sendWhatsApp(); }} />} ); } Object.assign(window, { FloatingCart, CartDrawer, cartCount, cartSubtotal });