);
}
/* ============================================================
helpers de form
============================================================ */
function Field({ label, opt, hint, children, full }) {
return (
);
}
/* ============================================================
PASO 1 — cuenta + servicios à la carte + promos
============================================================ */
function StepServices({ data, set, totals, toggleSvc, focus, setFocus }) {
const toggle = (s) => {if (!s.base) toggleSvc(s.id);};
const toGo400 = Math.max(0, 400 - totals.gross);
const toGo700 = Math.max(0, 700 - totals.gross);
const activePkg = findPkg(data.services);
return (
Bienvenida, Miss XV VIP✨
Aquí empieza tu historia. Elige y personaliza tu invitación paso a paso, a tu manera, con una vista previa en vivo mientras decides. Al confirmar tu orden, nuestro equipo produce la versión final —fondos, estilos y animación de intro— y te la entrega en 1 a 3 días. Nosotros nos encargamos del resto. ♡
Empieza con un paquete — o personaliza abajo
{PKGS.map((p) => {
const active = pkgActive(data.services, p);
const t = p.price;
const list = pkgListValue(p);
const save = list - t;
return (
);
})}
{activePkg ?
Paquete {activePkg.name} seleccionado · {activePkg.items.length + 1} {activePkg.items.length + 1 === 1 ? 'servicio' : 'servicios'} por {USD(activePkg.price)} USD. Agrega o quita abajo para personalizarlo.
:
{totals.freeQS ?
<>¡Desbloqueado! QR-VIP y Seating GRATIS en tu orden.> :
<>Agrega {USD(toGo400)} más y llévate QR-VIP + Seating gratis ($400+).>}
{!totals.freeQS &&
}
{totals.freeSlide ?
<>¡Desbloqueado! VIP Slideshow GRATIS en tu orden.> :
<>Llega a $700 y el VIP Slideshow es gratis — te faltan {USD(toGo700)}.>}
{!totals.freeSlide &&
}
}
O personaliza servicio por servicio — toca para ver el demo, + para agregar
);
}
/* secciones cuyo contenido se arma después en el panel (no se captura en la compra) */
const SEC_PANEL_NOTE = {
itinerary: 'Arma el programa del día (horarios y momentos) en tu panel.',
gallery: 'Sube tus fotos (hasta 6) desde tu panel después de comprar.',
music: 'Elige la pista de la biblioteca o sube la tuya en tu panel.',
qrvip: 'El álbum colaborativo se activa y configura en tu panel.',
video_intro: 'La animación de apertura es preparada por nuestro equipo según el estilo de tu evento.'
};
function SecInlineFields({ id, data, set }) {
if (id === 'reception') return (
);
if (id === 'registry') return (
);
if (id === 'corte') return (
);
if (id === 'dress_code') return (
set({ dressCode: e.target.value })} />);
if (id === 'rsvp') return (
set({ rsvpDeadline: e.target.value })} />);
if (id === 'hero') return (
set({ welcome: e.target.value })} />);
if (id === 'map') return (
set({ recepMaps: e.target.value })} />);
if (SEC_PANEL_NOTE[id]) return (
{pendingCount} {pendingCount === 1 ? 'sección encendida' : 'secciones encendidas'} con datos pendientes — los podrás completar o actualizar después en tu panel. No bloquea tu compra.
Elige tu método de pago para completar la orden. Nuestro equipo preparará tu invitación en 1 a 3 días hábiles.
Sesión iniciada{data.cEmail ? <> como {data.cEmail}> : ''}. Tu progreso está guardado — vuelve a tu panel de RSVP, invitados y álbum QR cuando quieras.
Elige tu método de pago
{isCard &&
<>
Subtotal{USD2(orderTotal)}
Comisión de procesamiento{USD2(fee)}
Total con tarjeta{USD2(charge)} USD
Las transferencias (Zelle/Venmo) no tienen comisión y mantienen el total en {USD2(orderTotal)}.
Te llevamos al checkout seguro de Stripe para completar tu pago. No guardamos los datos de tu tarjeta.
{isCard ?
<>Gracias{data.cName ? `, ${data.cName.split(' ')[0]}` : ''}. Tu pago de {USD2(paidAmount)} USD con tarjeta quedó confirmado. Nuestro equipo comenzará a preparar tu invitación y te la enviamos por WhatsApp{data.cPhone ? <> al {data.cPhone}> : ''} en 1 a 3 días hábiles.> :
<>Gracias{data.cName ? `, ${data.cName.split(' ')[0]}` : ''}. Registramos tu orden con {payLabel} como método de pago. Sigue los pasos de abajo para que tu orden avance.>}
{[`Envía tu pago por ${payLabel} si todavía no lo hiciste`,
'Empieza el formulario de tu invitación (botón de arriba)',
'Verificamos tu pago y te confirmamos por WhatsApp',
'Preparamos y entregamos tu invitación en 1 a 3 días'].map((t, i) =>
{i + 1}{t}
)}
}
{isCard &&
Recibirás el acceso a tu panel {data.cEmail ? <>en {data.cEmail}> : ''} para ver el avance, tus RSVP y tu álbum QR.
}
{pend.length > 0 &&
{isCard ? <>Datos que faltan para que quede perfecta> : <>Datos pendientes de tu invitación>}
{missing.map((m) =>
{m.label}
)}
}
);
}
/* ============================================================
APP
============================================================ */
const DRAFT_KEY = 'vipinv_wizard_draft';
// TOS-ORD-013: id de sesion por pestana (sessionStorage). El draft se restaura SOLO si su sid
// coincide con el de esta sesion -> una visita/persona distinta en el mismo navegador no
// autocompleta el borrador de otra. Refresh de la misma pestana si resume (sessionStorage persiste).
function wizSid() {
try {
let s = sessionStorage.getItem('vipinv_wizard_sid');
if (!s) { s = Date.now().toString(36) + Math.random().toString(36).slice(2); sessionStorage.setItem('vipinv_wizard_sid', s); }
return s;
} catch (e) { return ''; }
}
const DEFAULT_DATA = {
services: new Set(),
honoree: '', date: '', time: '20:00', dateMode: 'exact', dateMY: '', venue: '', city: '', mom: '', dad: '', padrinos: '',
theme: 'esmeralda', style: 'clasico', orderId: genFolio(),
sections: { video_intro: true, hero: true, countdown: true, parents: true, ceremony: true, reception: true, itinerary: false, dress_code: true, registry: false, corte: false, gallery: false, music: false, qrvip: true, rsvp: true, guestbook: true, thanks: true, map: true },
recepAddr: '', recepTime: '', recepMaps: '', registry: '', corteNames: '', dressCode: '', welcome: '', rsvpDeadline: '', lang: 'es',
cName: '', cEmail: '', cPass: '', cPhone: '', receiptName: '', receiptData: '',
auth: false, authMethod: null, payMethod: null
};
function loadDraft() {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return null;
const p = JSON.parse(raw);
if (!p || !p.data) return null;
if (p.sid && p.sid !== wizSid()) return null; // TOS-ORD-013: borrador de otra sesion/persona -> no autocompletar
const merged = { ...DEFAULT_DATA, ...p.data, sections: { ...DEFAULT_DATA.sections, ...(p.data.sections || {}) } };
merged.services = new Set(Array.isArray(p.data.services) ? p.data.services : []);
return { step: typeof p.step === 'number' ? p.step : 0, data: merged };
} catch (e) {return null;}
}
function App() {
const draft = useMemo(() => loadDraft(), []);
const [step, setStep] = useState(draft ? draft.step : 0);
const [paid, setPaid] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const submittingRef = useRef(false);
const [showPrev, setShowPrev] = useState(false);
const [focus, setFocus] = useState('historia');
const [saved, setSaved] = useState(false);
const [data, setData] = useState(draft ? draft.data : DEFAULT_DATA);
const set = (patch) => setData((d) => ({ ...d, ...patch }));
const toggleSec = (id) => setData((d) => ({ ...d, sections: { ...d.sections, [id]: !d.sections[id] } }));
const toggleSvc = (id) => setData((d) => {const s = new Set(d.services);s.has(id) ? s.delete(id) : s.add(id);return { ...d, services: s };});
// auto-guardado en localStorage (solo este navegador)
const mounted = useRef(false);
useEffect(() => {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ step, sid: wizSid(), data: { ...data, services: [...data.services] } }));
} catch (e) {}
if (mounted.current) {
setSaved(true);
const t = setTimeout(() => setSaved(false), 1900);
return () => clearTimeout(t);
}
mounted.current = true;
}, [data, step]);
const order = useMemo(() => orderState(data.services), [data.services]);
const totals = order;
const activePkg = order.pkg;
const orderTotal = order.total + bilingualFee(data.lang);
const last = STEPS.length - 1;
const phoneOk = data.cPhone.replace(/\D/g, '').length >= 8;
const payChargeNow = data.payMethod === 'card' ? cardCharge(orderTotal) : orderTotal;
const canNext =
step === 0 ? true /* RMR-635 / TOS-WIZARD-025: Paso 1 SIN gate de cuenta (restaura WIZARD-017). AccountGate opcional; guest avanza. NO revertir a !!data.auth en rebuilds. */ :
step === 1 ? !!data.honoree.trim() :
step === last ? data.payMethod === 'card' ? true : !!(data.payMethod && data.cName.trim() && phoneOk) :
true;
const next = () => {
if (step < last) { setStep(step + 1); return; }
if (submitting || submittingRef.current) return; // guard anti doble-cobro (ref sync + state)
// Paso Pago: si el backend expuso el hook, delegamos la creación de orden +
// cobro real (Stripe / Zelle / Venmo). El shim llama onDone() cuando toca.
if (typeof window.__onOrderSubmit === 'function') {
submittingRef.current = true;
setSubmitError('');
setSubmitting(true);
// red de seguridad: si el backend no resuelve ni redirige, re-habilita el botón
const safety = setTimeout(() => { submittingRef.current = false; setSubmitting(false); setSubmitError('Esto está tardando más de lo normal. Revisa tu conexión e inténtalo de nuevo.'); }, 20000);
try {
window.__onOrderSubmit({
services: Array.from(data.services),
honoree: data.honoree,
date: data.dateMode === 'exact' ? data.date : '',
venue: data.venue,
city: data.city,
theme: data.theme,
lang: data.lang,
payMethod: data.payMethod,
cName: data.cName,
cEmail: data.cEmail,
cPhone: data.cPhone,
orderId: data.orderId,
total: orderTotal,
onDone: () => { clearTimeout(safety); submittingRef.current = false; try { localStorage.removeItem(DRAFT_KEY); } catch (e) {} setSubmitting(false); setPaid(true); },
});
} catch (e) {
clearTimeout(safety);
submittingRef.current = false;
setSubmitting(false);
setSubmitError('No pudimos procesar tu orden. Intenta de nuevo.');
}
return;
}
setPaid(true); // fallback demo (sin backend)
};
const goTo = (i) => {setStep(i);setPaid(false);};
const mode = 'video';
const showDemos = step === 0 && !paid;
return (