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