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 (
);
}
function Section({ id, title, children }) {
return (
);
}
function App() {
const cfg = useConfig();
const ticketsDir = useTicketsDirectory();
const [ticketName, setTicketName] = useState("");
const [ticketResult, setTicketResult] = useState(null);
const [ticketMsg, setTicketMsg] = useState("");
const runTicketSearch = () => {
const name = ticketName;
if (ticketsDir.status === "loading") {
setTicketResult(null);
setTicketMsg("Cargando lista...");
return;
}
if (ticketsDir.status === "error") {
setTicketResult(null);
setTicketMsg("No se pudo cargar la lista de invitados.");
return;
}
const found = ticketsDir.findByName(name);
if (!found) {
setTicketResult(null);
setTicketMsg("No encontramos tu nombre. Verifica la escritura.");
return;
}
const adultos = Number(found.adultos ?? 0);
const ninos = Number(found.ninos ?? 0);
setTicketResult({ nombre: found.nombre, adultos, ninos });
setTicketMsg("");
};
if (!cfg) return Cargando…
;
const dateLong = formatDateLong(cfg.date_iso);
const year = new Date(cfg.date_iso).getFullYear();
return (
{/* Header */}
{/* Hero */}
{cfg.couple.bride} & {cfg.couple.groom}
¡Nos casamos!
{/* Historia */}
{/* FotoMedio */}
{/* Detalles de eventos */}
Nos complace invitarte a ser parte de nuestra historia
{[
{ key: "ceremony", icon: "💒" },
{ key: "reception", icon: "🎉" }
].map(({key, icon}) => {
const ev = cfg.event[key];
return (
{icon} {ev.title}
{ev.place_name}
{ev.address_hint}
Hora: {ev.time}
Ver en Google Maps
);
})}
💃DressCode🕺
{cfg.event.dress_code}
{/* FotosEnMedio */}
{/* Mesa de regalos */}
{/* Boletos */}
Con mucho cariño hemos reservado
{/* Buscador de boletos por invitado */}
{ticketMsg &&
{ticketMsg}
}
{ticketResult && (
{ticketResult.nombre}
{ticketResult.adultos} adulto(s) · {ticketResult.ninos} niño(s)
)}
{cfg.event.tickets_label}
{cfg.event.tickets_label2}
{/* Confirmación */}
{/* Contacto */}
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render();