const { useEffect, useMemo, useState } = React; function useConfig() { const [cfg, setCfg] = useState(null); useEffect(() => { fetch('config.json', { cache: 'no-store' }) .then(r => r.json()) .then(setCfg); }, []); return cfg; } function normalizeText(str) { return (str || "") .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/\s+/g, " ") .trim(); } // Carga invitados.json (cacheado en memoria) y permite buscar por nombre. function useTicketsDirectory() { const [dir, setDir] = useState({ status: "idle", data: null, error: null }); useEffect(() => { let cancelled = false; (async () => { try { setDir({ status: "loading", data: null, error: null }); const resp = await fetch('invitados.json', { cache: 'no-store' }); if (!resp.ok) throw new Error('No se pudo cargar invitados.json'); const json = await resp.json(); if (!cancelled) setDir({ status: "ready", data: Array.isArray(json) ? json : [], error: null }); } catch (e) { if (!cancelled) setDir({ status: "error", data: null, error: e }); } })(); return () => { cancelled = true; }; }, []); const findByName = (name) => { if (!dir.data) return null; const key = normalizeText(name); if (!key) return null; // 1) Match exacto normalizado const exact = dir.data.find(r => normalizeText(r.nombre) === key); if (exact) return exact; // 2) Match "contiene" por si el invitado escribe apellidos incompletos //const partial = dir.data.find(r => normalizeText(r.nombre).includes(key)); //return partial || null; }; return { ...dir, findByName }; } function formatDateLong(iso) { const date = new Date(iso); const fmt = new Intl.DateTimeFormat('es-MX', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric', hour: undefined, minute: undefined, timeZoneName: undefined }); return fmt.format(date); } function Countdown({ iso }) { const [now, setNow] = useState(() => new Date()); useEffect(() => { const id = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(id); }, []); const target = useMemo(() => new Date(iso), [iso]); const diff = Math.max(0, target - now); const s = Math.floor(diff / 1000); const days = Math.floor(s / 86400); const hours = Math.floor((s % 86400) / 3600); const minutes = Math.floor((s % 3600) / 60); const seconds = s % 60; return (
{[["Días", days], ["Horas", hours], ["Min", minutes], ["Seg", seconds]].map(([label, val]) => (
{String(val).padStart(2,'0')}
{label}
))}
); } function GoogleMapLink({ lat, lng, children }) { const href = `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`; return {children} } function RSVPForm({ cfg, ticketResult }) { const [name, setName] = useState(""); const [assist, setAssist] = useState("Sí"); const [notes, setNotes] = useState(""); const [guests, setGuests] = useState(0); useEffect(() => { if (ticketResult) { setGuests(ticketResult.adultos + ticketResult.ninos); } else { setGuests(0); } }, [ticketResult]); const message = useMemo(() => { return `Hola, soy ${name || "(tu nombre)"}.\n` + `Confirmación: ${assist}.` + `\nLugar(es): Ceremonia ${cfg.event.ceremony.time}, Recepción ${cfg.event.reception.time}.` + `\nAcompañantes: ${guests}.` + (notes ? `\nNotas: ${notes}` : ""); }, [name, guests, assist, notes, cfg]); const waLink = useMemo(() => { const base = "https://wa.me/"; const phone = cfg.rsvp.whatsapp_phone_e164.replace("+",""); const text = encodeURIComponent(message); return `${base}${phone}?text=${text}`; }, [message, cfg]); const mailto = useMemo(() => { const subject = encodeURIComponent("Confirmación asistencia boda"); const body = encodeURIComponent(message); return `mailto:${cfg.rsvp.email}?subject=${subject}&body=${body}`; }, [message, cfg]); function handleSubmit(e) { e.preventDefault(); window.open(waLink, "_blank", "noopener"); } return (
setName(e.target.value)} required className="w-full rounded-xl border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-800"/>
setGuests(parseInt(e.target.value||"0"))} readOnly className="w-full rounded-xl border border-gray-300 px-3 py-2"/>