// rsvp.jsx — Confirmación personalizada + seguridad del enlace. // Exporta: RSVP, AccessGate, useSecurity. const { T: T3 } = window; // —————————————————————————— SEGURIDAD DEL ENLACE —————————————————————————— // Estrategia (la más eficiente sin servidor propio): // 1) Cada enlace lleva ?id=INVxxx&k=TOKEN. Sin el token correcto, no abre. // 2) En la 1ª apertura se "vincula" el enlace a este dispositivo (localStorage). // 3) Se registra cada apertura en la hoja (id + huella de dispositivo + hora). // Si el mismo id se abre desde OTRO dispositivo → el Apps Script marca // "POSIBLE REENVÍO" y te envía un correo de alerta. Ver la guía incluida. function getDeviceId() { let d = localStorage.getItem('dd_device'); if (!d) { d = 'dev_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); localStorage.setItem('dd_device', d); } return d; } function post(payload) { const url = window.CONFIG.WEBHOOK_URL; const body = JSON.stringify({ ...payload, secret: window.CONFIG.formSecret }); if (!url) { console.log('[RSVP modo demo] payload →', JSON.parse(body)); return Promise.resolve({ demo: true }); } return fetch(url, { method: 'POST', mode: 'no-cors', headers: { 'Content-Type': 'text/plain;charset=utf-8' }, body }) .then(() => ({ ok: true })); } function useSecurity(invite) { React.useEffect(() => { if (!invite || invite.status !== 'ok') return; const id = invite.id, dev = getDeviceId(); const claimKey = 'dd_claim_' + id; const firstDevice = localStorage.getItem(claimKey); const isNewDeviceForThisLink = firstDevice && firstDevice !== dev; // raro en el mismo equipo if (!firstDevice) localStorage.setItem(claimKey, dev); // registra la apertura (el servidor decide si es reenvío comparando dispositivos) post({ type: 'apertura', id, nombre: invite.nombre, deviceId: dev, nuevoDispositivoLocal: !!isNewDeviceForThisLink, ua: navigator.userAgent, referrer: document.referrer || '', ts: new Date().toISOString(), }).catch(() => {}); }, [invite && invite.id, invite && invite.status]); } // —————————————————————————— PUERTA DE ACCESO —————————————————————————— function AccessGate({ invite, children }) { if (invite.status === 'ok' || invite.status === 'none') return children; // denied / invalid → pantalla de bloqueo const denied = invite.status === 'denied'; return (
Invitación personal

{denied ? 'Este enlace no es válido' : 'No encontramos tu invitación'}

Cada invitación de Diana & Daniel es única y personal. Por favor abre el enlace completo que recibiste, o escríbenos para reenviártelo.

Diana & Daniel · 07·11·2026
); } // —————————————————————————— FORMULARIO RSVP —————————————————————————— function Field({ label, children }) { return ( ); } function RSVP({ invite }) { const isOk = invite.status === 'ok'; const demo = !isOk || invite.demo; const nombre = isOk ? invite.nombre : 'Nuestros invitados'; const maxPases = isOk ? invite.pases : 2; const id = isOk ? invite.id : 'DEMO'; const acomp = isOk && invite.acomp && invite.acomp !== 'TBD' ? invite.acomp : null; const storeKey = 'dd_rsvp_' + id; const prev = React.useMemo(() => { try { return JSON.parse(localStorage.getItem(storeKey) || 'null'); } catch { return null; } }, [storeKey]); const [confirmName, setConfirmName] = React.useState(prev?.nombre || (isOk && !invite.demo ? invite.nombre : '')); const [asiste, setAsiste] = React.useState(prev?.asiste ?? null); // true | false | null const [pases, setPases] = React.useState(prev?.pases || maxPases); const [mensaje, setMensaje] = React.useState(prev?.mensaje || ''); const [state, setState] = React.useState(prev ? 'done' : 'idle'); // idle | sending | done | error const inputStyle = { width: '100%', background: T3.bg, border: `1px solid ${T3.line}`, color: T3.ink, padding: '14px 16px', fontFamily: T3.micro, fontSize: 16, borderRadius: 2, outline: 'none', transition: 'border-color .3s', }; const submit = async (e) => { e.preventDefault(); if (!confirmName.trim() || asiste === null) return; setState('sending'); const payload = { type: 'rsvp', id, nombreConfirmacion: confirmName.trim(), estatus: asiste ? 'Asiste' : 'No asiste', pasesConfirmados: asiste ? pases : 0, mensaje: mensaje.trim(), deviceId: getDeviceId(), ts: new Date().toISOString(), }; try { await post(payload); localStorage.setItem(storeKey, JSON.stringify({ nombre: confirmName.trim(), asiste, pases, mensaje })); setState('done'); } catch { setState('error'); } }; // — estado de agradecimiento — if (state === 'done') { const yes = (prev?.asiste ?? asiste) === true; return (
{yes ? 'Confirmación recibida' : 'Gracias por avisarnos'}

{yes ? '¡Nos vemos en Oaxaca!' : 'Te vamos a extrañar'}

{yes ? `Gracias, ${(prev?.nombre || confirmName).split(' ')[0]}. Guardamos tu lugar con mucho cariño y contamos los días para celebrar contigo.` : 'Lamentamos que no puedas acompañarnos. Gracias por tomarte un momento para avisarnos; estarás en nuestros pensamientos ese día.'}

); } return (
Confirma tu asistencia

¡Hola, {nombre}!

{isOk ? (acomp ? <>Reservamos un lugar para ti y {acomp}. Confírmanos antes del 15 de septiembre. : maxPases === 1 ? <>Reservamos un lugar a tu nombre. Confírmanos antes del 15 de septiembre. : <>Tenemos {maxPases} lugares reservados a tu nombre. Confírmanos antes del 15 de septiembre.) : 'Vista previa del formulario. Comparte un enlace personal (con ?id=…) para activar el saludo y los pases de cada invitado.'}

setConfirmName(e.target.value)} placeholder="Tu nombre completo" required onFocus={(e) => e.target.style.borderColor = T3.accent} onBlur={(e) => e.target.style.borderColor = T3.line} />
{[[true, 'Sí, asistiré'], [false, 'No podré asistir']].map(([val, txt]) => { const active = asiste === val; return ( ); })}
{maxPases === 1 ? ( /* invitación individual: sin dropdown, confirmación fija */
{isOk ? `${nombre} · 1 persona` : '1 persona'}
Esta invitación es individual
) : ( /* invitación para dos: opciones con nombres reales */
{acomp ? `Tu invitación incluye un lugar para ${acomp}` : 'Tu invitación incluye un lugar para tu acompañante'}
)}