/* ============================================================
Miss XV VIP — Order Flow (order-wizard.jsx)
À la carte en USD · 5 pasos · preview + video demos en vivo
Solo capa visual/UX. Sin backend real.
============================================================ */
const { useState, useMemo, useEffect, useRef } = React;
/* ---------- helpers de cliente (sin backend) ---------- */
// Copia texto al portapapeles con fallback a un input temporal + execCommand.
async function copyText(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (e) { /* cae al fallback */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus(); ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (e) { return false; }
}
// Lee un File como data URL (para guardarlo en el borrador, sin servidor).
function fileToDataURL(file, cb) {
const r = new FileReader();
r.onload = (e) => cb(e.target.result);
r.readAsDataURL(file);
}
// Botón "Copiar" con confirmación visual breve.
function CopyChip({ text, label = 'Copiar' }) {
const [done, setDone] = useState(false);
const onCopy = async (e) => {
e.preventDefault();
e.stopPropagation();
const ok = await copyText(String(text));
if (ok) { setDone(true); setTimeout(() => setDone(false), 1500); }
};
return (
{done ? '¡Copiado!' : label}
);
}
/* ---------- icons ---------- */
const P = {
check: 'M20 6L9 17l-5-5', chevright: 'M9 18l6-6-6-6', chevleft: 'M15 18l-6-6 6-6',
x: 'M18 6L6 18M6 6l12 12', plus: 'M12 5v14M5 12h14',
eye: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM12 15a3 3 0 100-6 3 3 0 000 6z',
play: 'M6 4l14 8-14 8V4z', pause: 'M6 4h4v16H6zM14 4h4v16h-4z',
sparkle: 'M12 2l1.8 5.5L19.5 9l-5.7 1.5L12 16l-1.8-5.5L4.5 9l5.7-1.5z',
star: 'M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 5.82 22 7 14.14l-5-4.87 6.91-1.01z',
qr: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h2v2h-2zM18 14h3v3h-3zM14 18h2v3h-2zM18 19h3v2h-3z',
image: 'M19 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5a2 2 0 00-2-2zM8.5 10a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM21 15l-5-5L5 21',
film: 'M3 3h18v18H3zM7 3v18M17 3v18M3 8h4M3 16h4M17 8h4M17 16h4M3 12h18',
users: 'M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M8.5 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75',
user: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z',
calendar: 'M19 4H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zM16 2v4M8 2v4M3 10h18',
clock: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 6v6l4 2',
mappin: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0zM12 13a3 3 0 100-6 3 3 0 000 6z',
palette: 'M12 22a10 10 0 110-20 10 10 0 0110 8 4 4 0 01-4 4h-2a2 2 0 00-1 4 2 2 0 01-3 2zM6.5 12a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM10.5 8a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM15.5 8a1.5 1.5 0 100-3 1.5 1.5 0 000 3z',
heart: 'M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z',
edit: 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.121 2.121 0 113 3L12 15l-4 1 1-4 9.5-9.5z',
seat: 'M3 18v-6a3 3 0 013-3h12a3 3 0 013 3v6M3 18h18M5 18v2M19 18v2M6 9V7a3 3 0 013-3h6a3 3 0 013 3v2',
lock: 'M19 11H5a2 2 0 00-2 2v7a2 2 0 002 2h14a2 2 0 002-2v-7a2 2 0 00-2-2zM7 11V7a5 5 0 0110 0v4',
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
gift: 'M20 12v10H4V12M2 7h20v5H2zM12 22V7M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z',
zap: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z',
crown: 'M3 18l2-11 5 6 2-8 2 8 5-6 2 11z',
message: 'M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z',
globe: 'M12 22a10 10 0 100-20 10 10 0 000 20zM2 12h20M12 2a15 15 0 010 20 15 15 0 010-20z',
shirt: 'M16 3l4 3-3 3-1-1v11H8V8L7 9 4 6l4-3 2 2h4z',
ticket: 'M3 8a2 2 0 012-2h14a2 2 0 012 2v2a2 2 0 000 4v2a2 2 0 01-2 2H5a2 2 0 01-2-2v-2a2 2 0 000-4zM12 6v2M12 11v2M12 16v2',
music: 'M9 18V5l12-2v13M9 18a3 3 0 11-6 0 3 3 0 016 0zM21 16a3 3 0 11-6 0 3 3 0 016 0z',
mail: 'M4 4h16a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2V6a2 2 0 012-2zM22 6l-10 7L2 6',
info: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 8h.01M11 12h1v4h1'
};
function Ic({ n, s = 16, style, cls }) {
return (
);
}
function GoogleG({ s = 18 }) {
return (
);
}
/* ---------- catálogo real (USD) ---------- */
const SERVICES = [
{ id: 'invitacion', base: true, name: 'Invitación Digital VIP Original', short: 'Tu invitación base, personalizada', price: 100, icon: 'sparkle', tint: '#f5c945', badge: 'Invitación', demoUrl: 'https://www.missxvvip.com/inv/xv-maria-fernanda-valdes', desc: 'La invitación digital animada y personalizada a tu medida, siempre incluida en tu orden.' },
{ id: 'signature', name: 'Signature', short: 'Invitación diseñada a tu medida', price: 200, icon: 'palette', tint: '#d4a3ff', badge: 'Signature', desc: 'Diseñamos tu invitación a tu medida, con tus colores, temática y estilo.' },
{ id: 'savedate', name: 'Anuncia tu Fecha', short: 'Save the Date', price: 150, icon: 'calendar', tint: '#b347ff', badge: 'Save the Date', desc: 'Video animado para anunciar tu fecha y crear expectativa entre tus invitados.' },
{ id: 'historia', name: 'Cuenta tu Historia', short: 'Video Cronológico', price: 300, popular: true, icon: 'film', tint: '#fb923c', badge: 'Tu Historia', desc: 'Hasta 80 fotos, editadas individualmente · Video cronológico que narra tu historia con fotos y momentos clave.' },
{ id: 'qrvip', name: 'Vive tu Fiesta', short: 'QR álbum digital', price: 50, icon: 'qr', tint: '#22d34a', badge: 'QR-VIP', desc: 'Álbum colaborativo y photo wall en vivo: tus invitados suben fotos con un QR.' },
{ id: 'seating', name: 'Organizador de Mesas', short: 'Mapa de mesas digital', price: 50, icon: 'seat', tint: '#3b8bd6', badge: 'Mesas', desc: 'Organizador de mesas visual para acomodar tus mesas e invitados sin estrés.' },
{ id: 'bannerfisico', name: 'Roll-Up Banner', short: 'Diseño + impresión + envío + stand', price: 200, icon: 'image', tint: '#60a5fa', badge: 'Banner', desc: 'Roll-Up Banner con diseño, impresión, envío y stand.' },
{ id: 'slideshow', name: 'VIP Slideshow', short: 'Hasta 25 fotos', price: 100, was: 300, icon: 'image', tint: '#ec4899', badge: 'Slideshow', desc: 'Video slideshow con música y transiciones elegantes, máximo 25 fotos.' },
{ id: 'digitaldesign', name: 'Paquete de Diseño Digital', short: 'Postcard QR + número de mesa + arte para mesas', price: 50, icon: 'edit', tint: '#14b8a6', badge: 'Diseño', desc: 'Tres artes digitales coordinadas para QR y mesas: postcard/flyer QR, número de mesa y arte complementario para mesas.' }];
const PROMO_INVITACIONES_VIDEO = '/orden/lite/assets/videos/promo-invitaciones-digitales-miss-xv-vip-voiceoff.mp4';
/* Bilingüe viene incluido en la invitación. */
const bilingualFee = () => 0;
const THEME_PALETTES = {
'princesa-disney': { mood: 'Princesa · cuento', bg: ['#2b1835', '#100817'], text: '#fff4fb', accent: '#f3b5d7', dots: ['#6f3a83', '#f3b5d7', '#fff4fb'] },
natural: { mood: 'Natural · encantado', bg: ['#16341f', '#0a1d11'], text: '#fff8e6', accent: '#e8c76b', dots: ['#1f5a33', '#e8c76b', '#fff8e6'] },
elegante: { mood: 'Elegante · gala', bg: ['#14161f', '#08080d'], text: '#fff8e6', accent: '#f5c945', dots: ['#2a2a3a', '#f5c945', '#fff8e6'] },
celestial: { mood: 'Celestial · brillo', bg: ['#151c3f', '#080b1e'], text: '#f4f6ff', accent: '#9db7ff', dots: ['#263b85', '#9db7ff', '#f4f6ff'] },
fantasia: { mood: 'Fantasía · mágico', bg: ['#241640', '#120a26'], text: '#f3ecff', accent: '#d4a3ff', dots: ['#5a2e9e', '#d4a3ff', '#f3ecff'] },
cultural: { mood: 'Cultural · color', bg: ['#321b16', '#120908'], text: '#fff2e8', accent: '#f2a65a', dots: ['#8a3f2a', '#f2a65a', '#fff2e8'] },
boda: { mood: 'Boda · elegante', bg: ['#2d2a24', '#11100d'], text: '#fffaf0', accent: '#e6c98b', dots: ['#635d50', '#e6c98b', '#fffaf0'] },
deportes: { mood: 'Deporte · energía', bg: ['#142a24', '#071310'], text: '#effff9', accent: '#58d68d', dots: ['#1f6f55', '#58d68d', '#effff9'] },
especial: { mood: 'Especial · personalizado', bg: ['#202033', '#0c0c18'], text: '#f4f1ff', accent: '#f0c46b', dots: ['#4b4b70', '#f0c46b', '#f4f1ff'] },
custom: { mood: 'Personalizada · a medida', bg: ['#221c2b', '#0c0910'], text: '#fff8e6', accent: '#eaa121', dots: ['#4b395c', '#eaa121', '#fff8e6'] }
};
const THEME_PREVIEWS = {
'aladdin': '/orden/lite/assets/theme-hero-previews/aladdin.webp',
'alice-in-wonderland': '/orden/lite/assets/theme-hero-previews/alice-in-wonderland.webp',
'bella-y-la-bestia': '/orden/lite/assets/theme-hero-previews/bella-y-la-bestia.webp',
'bosque-encantado': '/orden/lite/assets/theme-hero-previews/bosque-encantado.webp',
'brave-merida': '/orden/lite/assets/theme-hero-previews/brave-merida.webp',
'cherry-blossom': '/orden/lite/assets/theme-hero-previews/cherry-blossom.webp',
'cinderela': '/orden/lite/assets/theme-hero-previews/cinderela.webp',
'colibri-magico': '/orden/lite/assets/theme-hero-previews/colibri-magico.webp',
'era-victoriana': '/orden/lite/assets/theme-hero-previews/era-victoriana.webp',
'frozen-elsa-anna': '/orden/lite/assets/theme-hero-previews/frozen-elsa-anna.webp',
'jardin-encantado': '/orden/lite/assets/theme-hero-previews/jardin-encantado.webp',
'luna-y-estrellas': '/orden/lite/assets/theme-hero-previews/luna-y-estrellas.webp',
'masquerade-ball': '/orden/lite/assets/theme-hero-previews/masquerade-ball.webp',
'mermaid-ariel': '/orden/lite/assets/theme-hero-previews/mermaid-ariel.webp',
'moana-ocean': '/orden/lite/assets/theme-hero-previews/moana-ocean.webp',
'once-upon-a-time': '/orden/lite/assets/theme-hero-previews/once-upon-a-time.webp',
'pocahontas-native': '/orden/lite/assets/theme-hero-previews/pocahontas-native.webp',
'princesa-y-el-sapo': '/orden/lite/assets/theme-hero-previews/princesa-y-el-sapo.webp',
'princesas-disney-nubes': '/orden/lite/assets/theme-hero-previews/princesas-disney-nubes.webp',
'rapunzel': '/orden/lite/assets/theme-hero-previews/rapunzel.webp',
'rosa-encantada': '/orden/lite/assets/theme-hero-previews/rosa-encantada.webp',
'sleeping-beauty-aurora': '/orden/lite/assets/theme-hero-previews/sleeping-beauty-aurora.webp',
'snow-white': '/orden/lite/assets/theme-hero-previews/snow-white.webp',
'starry-night': '/orden/lite/assets/theme-hero-previews/starry-night.webp',
'talavera': '/orden/lite/assets/theme-hero-previews/talavera.webp',
'under-the-sea': '/orden/lite/assets/theme-hero-previews/under-the-sea.webp',
'wedding-boda': '/orden/lite/assets/theme-hero-previews/wedding-boda.webp',
'winter-wonderland': '/orden/lite/assets/theme-hero-previews/winter-wonderland.webp'
};
const theme = (id, name, category, featured = false) => ({ id, name, category, featured, preview: THEME_PREVIEWS[id] || '', ...THEME_PALETTES[category] || THEME_PALETTES.custom });
// FASE 2 ORDER-VIP: este array YA NO es la fuente en runtime -- StepTheme ahora
// consume /orden/api.php?action=themes_public (mxv_order_themes, gate K3/K5 real).
// Se conserva como fixture de referencia/pruebas hasta demostrar CATALOG READ = PASS +
// BACKFILL = PASS + CLIENT SELECTOR = PASS + LEGACY BRIEFS = PASS (Spec FASE 1B / orden
// FASE 2 seccion 20). No usar para pintar el selector del cliente.
const LEGACY_THEMES_FIXTURE = [
theme('aladdin', 'Aladdin / Jasmin', 'princesa-disney'),
theme('alice-in-wonderland', 'Alice in Wonderland', 'fantasia'),
theme('snow-white', 'Blancanieves / Snow White', 'princesa-disney'),
theme('wedding-boda', 'Boda Elegante', 'boda', true),
theme('bosque-encantado', 'Bosque Encantado', 'natural', true),
theme('brave-merida', 'Brave / Merida', 'princesa-disney'),
theme('cinderela', 'Cenicienta / Cinderella', 'princesa-disney', true),
theme('cherry-blossom', 'Cherry Blossom', 'natural'),
theme('colibri-magico', 'Colibri Mágico', 'natural'),
theme('era-victoriana', 'Era Victoriana', 'elegante'),
theme('frozen-elsa-anna', 'Frozen (Elsa & Anna)', 'princesa-disney', true),
theme('jardin-encantado', 'Jardín Encantado', 'natural', true),
theme('bella-y-la-bestia', 'La Bella y la Bestia', 'princesa-disney', true),
theme('princesa-y-el-sapo', 'La Princesa y el Sapo / Tiana', 'princesa-disney'),
theme('mermaid-ariel', 'La Sirenita / Ariel', 'princesa-disney'),
theme('luna-y-estrellas', 'Luna y Estrellas', 'celestial', true),
theme('masquerade-ball', 'Masquerade Ball', 'elegante', true),
theme('moana-ocean', 'Moana / Ocean', 'princesa-disney'),
theme('once-upon-a-time', 'Once Upon A Time', 'fantasia'),
theme('pocahontas-native', 'Pocahontas / Native', 'princesa-disney'),
theme('princesas-disney-nubes', 'Princesas Disney (nubes)', 'princesa-disney'),
theme('rapunzel', 'Rapunzel / Enredados', 'princesa-disney', true),
theme('rosa-encantada', 'Rosa Encantada', 'natural', true),
theme('sleeping-beauty-aurora', 'Sleeping Beauty / Aurora', 'princesa-disney'),
theme('starry-night', 'Starry Night', 'celestial'),
theme('talavera', 'Talavera', 'cultural'),
theme('under-the-sea', 'Under the Sea', 'natural'),
theme('winter-wonderland', 'Winter Wonderland', 'natural')
];
// FASE 2 ORDER-VIP: cache compartido del catalogo REAL de temáticas (mxv_order_themes
// via action=themes_public, gate K3/K5 servidor). Un solo fetch para todo el wizard --
// StepTheme, Preview, StepSummary y themeOpsEdits() leen de aqui, no de
// LEGACY_THEMES_FIXTURE. FAIL-CLOSED (orden FASE 2 seccion 19): si falla, status='error'
// y data=[] -- nunca se reintroducen los 28 temas legacy como fallback silencioso.
const themeCatalogCache = { status: 'idle', data: [], listeners: new Set() };
function order_theme_adapt(t) {
return {
id: t.slug, name: t.name, category: t.category, featured: !!t.featured,
preview: t.thumbnail_url || '', demoUrl: t.demo_url || '',
...(THEME_PALETTES[t.category] || THEME_PALETTES.custom),
};
}
function fetchThemeCatalog() {
if (themeCatalogCache.status === 'loading' || themeCatalogCache.status === 'ready') return;
themeCatalogCache.status = 'loading';
fetch('/orden/api.php?action=themes_public', { credentials: 'same-origin' })
.then((r) => { if (!r.ok) throw new Error('http_' + r.status); return r.json(); })
.then((d) => {
if (!d || d.success !== true || !Array.isArray(d.themes)) throw new Error('shape');
themeCatalogCache.status = 'ready';
themeCatalogCache.data = d.themes.map(order_theme_adapt);
themeCatalogCache.listeners.forEach((fn) => fn());
})
.catch(() => {
themeCatalogCache.status = 'error';
themeCatalogCache.data = [];
themeCatalogCache.listeners.forEach((fn) => fn());
});
}
function useThemeCatalog() {
const [, forceRender] = useState(0);
useEffect(() => {
const listener = () => forceRender((n) => n + 1);
themeCatalogCache.listeners.add(listener);
fetchThemeCatalog();
return () => themeCatalogCache.listeners.delete(listener);
}, []);
return themeCatalogCache;
}
const STEPS = ['Tus servicios', 'La festejada', 'Secciones', 'Temática', 'Resumen', 'Pago'];
/* secciones de la invitación VIP real (VIPINV_SECTIONS del portal INV) — core = siempre activa; req = servicio del que dependen */
const SECTIONS = [
{ id: 'video_intro', name: 'Video de intro / apertura', desc: 'Pantalla animada de bienvenida', icon: 'play' },
{ id: 'hero', name: 'Portada', desc: 'Nombre y fecha principal', icon: 'crown' },
{ id: 'countdown', name: 'Cuenta regresiva', desc: 'Días, horas y minutos al gran día', icon: 'clock' },
{ id: 'parents', name: 'Padres / Familia', desc: 'Mamá, papá y familia principal', icon: 'heart' },
{ id: 'godparents', name: 'Padrinos', desc: 'Padrinos de honor o bendición', icon: 'users' },
{ id: 'ceremony', name: 'Ceremonia', desc: 'Misa o acto religioso', icon: 'sparkle' },
{ id: 'reception', name: 'Recepción', desc: 'Dirección, hora y mapa del salón', icon: 'mappin' },
{ id: 'food_menu', name: 'Comida / Menú', desc: 'Menú, buffet o cocktail para invitados', icon: 'gift' },
{ id: 'logistics', name: 'Logística / Hoteles', desc: 'Hotel, parking, shuttle y after-party', icon: 'mappin' },
{ id: 'itinerary', name: 'Itinerario', desc: 'Programa del evento', icon: 'calendar' },
{ id: 'dress_code', name: 'Vestimenta', desc: 'Código de vestimenta', icon: 'shirt' },
{ id: 'registry', name: 'Mesa de Regalos', desc: 'Links a tu registro o datos', icon: 'gift' },
{ id: 'corte', name: 'Corte de Honor', desc: 'Chambelanes y damas de honor', icon: 'users' },
{ id: 'gallery', name: 'Galería', desc: 'Hasta 6 fotos en tu invitación', icon: 'image' },
{ id: 'music', name: 'Música de fondo', desc: 'Activa una pista al abrir', icon: 'music' },
{ id: 'qrvip', name: 'Álbum de Fotos QR', desc: 'Muro de fotos colaborativo de invitados', icon: 'qr' },
{ id: 'rsvp', name: 'RSVP', desc: 'Confirmación de asistencia', icon: 'mail' },
{ id: 'guestbook', name: 'Libro de Visitas', desc: 'Mensajes y firmas de invitados', icon: 'message' },
{ id: 'thanks', name: 'Agradecimiento', desc: 'Mensaje de cierre', icon: 'sparkle' },
{ id: 'after_party', name: 'After Party', desc: 'Lugar, mapa y hora de inicio', icon: 'mappin' }];
const REQ_LABEL = { slideshow: 'VIP Slideshow', qrvip: 'QR-VIP', savedate: 'Save the Date' };
/* las secciones son contenido libre — el cliente las enciende/apaga, no dependen de servicios */
function secAvailable() {return true;}
/* paquetes de inicio rápido — precios fijos de negocio (ids no-base) */
const PKGS = [
{ id: 'esencial', name: 'Original', icon: 'sparkle', price: 100, tagline: 'Diseño original de nuestra colección, listo para personalizar.', items: [] },
{ id: 'signature', name: 'Signature', icon: 'palette', price: 200, tagline: 'Invitación a tu medida: tus colores, tu temática y tu estilo.', items: ['signature'], noSavings: true },
{ id: 'lujo', name: 'Premium', icon: 'crown', popular: true, price: 500, tagline: 'Invitación + QR álbum + Organizador de Mesas + Save the Date + video cronológico + artes QR/mesas.', items: ['qrvip', 'seating', 'savedate', 'historia', 'digitaldesign'], comboSlug: 'lujo-vip' }];
function pkgActive(services, pkg) {
if (services.size !== pkg.items.length) return false;
return pkg.items.every((id) => services.has(id));
}
function findPkg(services) {return PKGS.find((p) => pkgActive(services, p)) || null;}
function pkgListValue(pkg) {
if (pkg.noSavings) return pkg.price;
return SERVICES.filter((s) => s.base || pkg.items.includes(s.id)).reduce((a, s) => a + s.price, 0);
}
/* CARRILES SEPARADOS: paquete = precio fijo sin promos | à la carte = lista + promos por monto */
function orderState(services) {
const pkg = findPkg(services);
if (pkg) return { mode: 'pkg', pkg, total: pkg.price, gross: pkg.price, discount: 0, freeIds: new Set(), freeQS: false, freeSlide: false };
return { mode: 'alc', pkg: null, ...calcTotals(services) };
}
function effectiveServicesForTheme(data) {
const effective = new Set(data.services);
if (!chosenTheme(data)) {
effective.add('signature');
}
return effective;
}
function effectivePackageId(data, pkg) {
return !chosenTheme(data) ? 'signature' : pkg ? pkg.id : 'esencial';
}
const USD = (n) => '$' + n.toLocaleString('en-US');
const USD2 = (n) => '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
/* ── COMISIÓN TARJETA (Stripe) ──────────────────────────────────
Ajusta estos dos valores a la tasa real de tu cuenta Stripe.
El recargo SOLO aplica a pagos con tarjeta; el cliente lo absorbe. */
const CARD_FEE_PCT = 0.029; // 2.9 %
const CARD_FEE_FIXED = 0.30; // $0.30 USD por transacción
/* Stripe activo: cuando es false, no se muestra el sello "Recomendado" en tarjeta */
const STRIPE_ACTIVE = false;
/* grossing-up: lo que cobramos para recibir `total` neto tras la comisión */
function cardCharge(total) {
return Math.round((total + CARD_FEE_FIXED) / (1 - CARD_FEE_PCT) * 100) / 100;
}
const MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
const PAY = { zelle: { handle: '(818) 674-3792', titular: 'Manuel Acevedo' }, venmo: { handle: '@missxvvip' } };
/* número de WhatsApp del negocio (formato wa.me, sin + ni espacios). Edítalo aquí. */
const WHATSAPP_NUMBER = '18183106910';
function waLink(msg) {return `https://wa.me/${WHATSAPP_NUMBER}?text=${encodeURIComponent(msg)}`;}
function fmtDate(d) {
if (!d) return null;
const [y, m, day] = d.split('-').map(Number);
if (!y || !m || !day) return null;
return `${String(day).padStart(2, '0')} de ${MESES[m - 1]} de ${y}`;
}
const capMes = (m) => m.charAt(0).toUpperCase() + m.slice(1);
/* etiqueta de fecha flexible: exacta · mes/año · por confirmar */
function eventDateLabel(data) {
const mode = data.dateMode || 'exact';
if (mode === 'tbd') return 'Fecha por confirmar';
if (mode === 'monthyear') {
const dateMY = data.eventMYYear && data.eventMYMonth ? `${data.eventMYYear}-${data.eventMYMonth}` : data.dateMY;
if (!dateMY) return null;
const [y, m] = dateMY.split('-').map(Number);
if (!y || !m) return null;
return `${capMes(MESES[m - 1])} ${y}`;
}
const exactDate = data.eventYear && data.eventMonth && data.eventDay ? `${data.eventYear}-${data.eventMonth}-${data.eventDay}` : data.date;
return fmtDate(exactDate);
}
/* ¿la fecha quedó pendiente de afinar? (tbd o mes/año o vacía) */
function dateIsPending(data) {
const mode = data.dateMode || 'exact';
if (mode === 'tbd') return true;
if (mode === 'monthyear') return !(data.eventMYYear && data.eventMYMonth || data.dateMY);
return !(data.eventYear && data.eventMonth && data.eventDay || data.date);
}
function godparentsText(data) {
const rows = Array.isArray(data.godparentRows) ? data.godparentRows : [];
const text = rows.filter((r) => r.names).map((r) => `${r.role === 'Otro' && r.customRole ? r.customRole : r.role || 'Otro'}: ${r.names}`).join('\n');
return text || data.padrinos || '';
}
function courtText(data) {
const rows = Array.isArray(data.courtRows) ? data.courtRows : [];
const text = rows.filter((r) => r.name).map((r) => `${r.role || 'Otro'}: ${r.name}`).join('\n');
return text || data.corteNames || '';
}
/* folio único de orden para reconciliar pagos (Zelle/Venmo) */
function genFolio() {return 'MXV-' + (1000 + Math.floor(Math.random() * 9000));}
function countdown(d, t) {
if (!d) return null;
const diff = new Date(`${d}T${t || '20:00'}:00`) - new Date();
if (isNaN(diff) || diff <= 0) return null;
return { days: Math.floor(diff / 86400000), hrs: Math.floor(diff % 86400000 / 3600000), min: Math.floor(diff % 3600000 / 60000) };
}
/* promo-aware totals */
function calcTotals(services) {
const usesSignature = services.has('signature');
const chosen = SERVICES.filter((s) => (s.base && !usesSignature) || services.has(s.id));
const gross = chosen.reduce((a, s) => a + s.price, 0);
const freeQS = gross >= 400,freeSlide = gross >= 700;
const freeIds = new Set();
let discount = 0;
chosen.forEach((s) => {
if (freeQS && (s.id === 'qrvip' || s.id === 'seating')) {discount += s.price;freeIds.add(s.id);}
if (freeSlide && s.id === 'slideshow') {discount += s.price;freeIds.add(s.id);}
});
return { gross, discount, total: gross - discount, freeIds, freeQS, freeSlide };
}
/* ============================================================
PANEL DERECHO — invitación en vivo
============================================================ */
function Preview({ data, totals, step }) {
const catalog = useThemeCatalog();
// FASE 2 ORDER-VIP: SIN fallback silencioso a otro tema (era el bug de "se ve
// Aladdin" -- orden FASE 2 seccion 19 / auditoria previa seccion L11). Si el slug
// guardado no esta en el catalogo (brief legacy con free-text, o tema retirado), usa
// una paleta neutra y el nombre crudo guardado -- nunca sustituye por otro tema real.
const th = catalog.data.find((t) => t.id === data.theme)
|| { id: data.theme || '', name: data.theme || '', ...THEME_PALETTES.custom };
const exactDate = data.eventYear && data.eventMonth && data.eventDay ? `${data.eventYear}-${data.eventMonth}-${data.eventDay}` : data.date;
const cd = (data.dateMode || 'exact') === 'exact' ? countdown(exactDate, data.time) : null;
const dateStr = eventDateLabel(data);
const badges = SERVICES.filter((s) => !s.base && data.services.has(s.id)).map((s) => s.badge);
const sec = data.sections;
const godparents = godparentsText(data);
const court = courtText(data);
const pills = ['Eligiendo tus servicios', 'Capturando los datos', 'Eligiendo secciones', 'Aplicando la temática', 'Revisando tu orden', 'Lista para publicar'];
const nameStyle = data.style === 'moderno' ?
{ fontFamily: "'Poppins', sans-serif", fontStyle: 'normal', fontWeight: 800, letterSpacing: '-0.02em', textTransform: 'uppercase', fontSize: 33 } : {};
return (
{sec.hero &&
Mis XV Años
}
{sec.hero &&
{data.honoree || 'Tu Festejada'}
}
{sec.hero &&
}
{data.welcome &&
{data.welcome}
}
{sec.hero &&
{dateStr || 'Fecha por definir'}
}
{sec.countdown && cd &&
{[['días', cd.days], ['hrs', cd.hrs], ['min', cd.min]].map(([l, v]) =>
{String(v).padStart(2, '0')}
{l}
)}
}
{sec.reception && (data.venue || data.city) &&
{data.venue &&
{data.venue}
}
{data.city &&
{data.city}
}
{data.recepTime &&
Recepción · {data.recepTime}
}
}
{sec.parents && (data.mom || data.dad) &&
Con la bendición de
{data.mom &&
{data.mom}
}
{data.mom && data.dad &&
&
}
{data.dad &&
{data.dad}
}
}
{sec.godparents && godparents &&
}
{sec.corte && court &&
}
{sec.dress_code && data.dressCode &&
{data.dressCode}
}
{badges.length > 0 &&
{badges.map((b) => {b} )}
}
{sec.thanks &&
Miss XV VIP
}
{pills[step]}
);
}
/* ============================================================
PANEL DERECHO — video demos (paso 1)
============================================================ */
function VideoShowcase({ data, set, toggleSvc, focus, setFocus, totals, activePkg }) {
const [playing, setPlaying] = useState(false);
const svc = SERVICES.find((s) => s.id === focus) || SERVICES[0];
const promoVideo = true;
const isFree = totals.freeIds.has(svc.id);
const on = svc.base || data.services.has(svc.id);
const inPkg = !!activePkg && on && !svc.base;
const toggle = () => {if (!svc.base) toggleSvc(svc.id);};
React.useEffect(() => {setPlaying(false);}, [focus]);
return (
{promoVideo ?
:
}
{promoVideo ? `Comercial · ${svc.badge}` : svc.demoUrl ? 'En vivo ↑' : `Demo · ${svc.badge}`}
{inPkg ? Incluido : isFree ? GRATIS : <>{svc.was && {USD(svc.was)} }{USD(svc.price)}>}
{!promoVideo &&
{svc.demoUrl ? 'Invitación real · en vivo' : playing ? '0:04 / 0:18' : '0:18'}
}
{svc.name}
{svc.desc}
{inPkg ? Incluido en paquete : isFree ? GRATIS · promo : USD(svc.price)}
{svc.demoUrl &&
Ver invitación real}
{svc.base ? <> Incluida> : on ? <> Agregado> : <> Agregar>}
{SERVICES.map((s) => {
const added = s.base || data.services.has(s.id);
return (
setFocus(s.id)}
style={{ background: `linear-gradient(150deg, ${s.tint}40, #0a0e2a)` }}>
{added && }
);
})}
);
}
/* ============================================================
helpers de form
============================================================ */
function Field({ label, opt, hint, children, full }) {
return (
{label}{opt && · opcional }
{children}
{hint && {hint} }
);
}
function AccountGate({ data, set, onAuthenticated }) {
// RMR-685: cuenta REAL contra el backend (account_register / account_login).
// Antes los botones eran placebo (set auth:true sin crear cuenta) -> con el gate del Paso 1
// eso dejaria pasar gente SIN cuenta y sus datos no irian a BD. Aqui se crea cuenta de verdad.
const emailOk = /.+@.+\..+/.test(data.cEmail);
const phoneOk = (data.cPhone || '').replace(/\D/g, '').length >= 8;
const passOk = data.cPass.length >= 4;
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState('');
const [mode, setMode] = React.useState('register'); // 'register' | 'login'
async function syncCurrentAccount() {
try {
const res = await fetch('/orden/api.php?action=account_me', {
credentials: 'same-origin',
cache: 'no-store',
});
let j = {}; try { j = await res.json(); } catch (e) {}
const u = j.user || j;
if (res.ok && u && (u.id || u.email)) {
set({ auth: true, authMethod: 'email', cEmail: u.email || (data.cEmail || '').trim(), cPhone: u.phone || data.cPhone || '', cName: data.cName || u.name || '' });
setErr('');
return true;
}
} catch (e) {}
return false;
}
async function owAuth(action) {
if (busy) return; // lock anti doble-tap (RMR-669)
setErr(''); setBusy(true);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 12000);
try {
const res = await fetch('/orden/api.php?action=' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
body: JSON.stringify({ email: (data.cEmail || '').trim(), password: data.cPass, phone: (data.cPhone || '').trim(), csrf_token: window.OW_CSRF }),
});
let j = {}; try { j = await res.json(); } catch (e) {}
if (res.ok && (j.success || j.user_id)) {
// cuenta creada/sesion iniciada de verdad -> auth real. auth_register/login ya auto-loguean
// y ligan cualquier draft guest a vipinv_brief.user_id en el servidor.
set({ auth: true, authMethod: 'email', cEmail: j.email || (data.cEmail || '').trim(), cPhone: j.phone || data.cPhone || '', cName: data.cName || j.name || '' });
if (typeof onAuthenticated === 'function') onAuthenticated();
setErr('');
} else if (res.status === 409) {
// correo ya registrado -> cambiar a login, SIN dejar el boton muerto
setMode('login');
setErr('Este correo ya tiene cuenta. Escribe tu contraseña e inicia sesión.');
} else if (res.status === 429) {
setErr('Demasiados intentos. Espera un momento e inténtalo de nuevo.');
} else {
setErr(j.error || 'No se pudo completar. Revisa tus datos e inténtalo de nuevo.');
}
} catch (e) {
const recovered = await syncCurrentAccount();
if (!recovered) setErr('Problema de conexión. Revisa tu internet e inténtalo de nuevo.');
} finally {
clearTimeout(timeoutId);
setBusy(false); // SIEMPRE re-habilita -> nunca dead-end
}
}
if (data.auth) {
const initial = (data.cName || data.cEmail || 'U').trim().charAt(0).toUpperCase();
return (
{initial}
{data.cName || 'Tu cuenta Miss XV VIP'}
{data.cEmail}
Tu progreso se guarda en tu cuenta
set({ auth: false, authMethod: null })}>Cambiar
);
}
return (
Crea tu cuenta para comenzar
Tu invitación y todo tu progreso se guardan en tu cuenta — continúa desde cualquier celular o computadora , y accede a tu RSVP, invitados y álbum QR.
{mode === 'login'
? <>¿No tienes cuenta? { setMode('register'); setErr(''); }}>Crear cuenta >
: <>¿Ya tienes cuenta? { setMode('login'); setErr(''); }}>Inicia sesión >}
);
}
/* ============================================================
PASO 1 — cuenta + servicios à la carte + promos
============================================================ */
function StepServices({ data, set, totals, toggleSvc, focus, setFocus, onAuthenticated }) {
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 = totals.pkg;
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 = activePkg && activePkg.id === p.id;
const t = p.price;
const list = pkgListValue(p);
const save = list - t;
return (
set({ services: new Set(p.items) })}>
{p.popular && Más popular }
{active && }
{p.name}
{p.tagline}
{USD(t)}USD {save > 0 && {USD(list)} }
{save > 0 ? Ahorras {USD(save)}
: Precio base
}
);
})}
{activePkg ?
Paquete {activePkg.name} seleccionado · {activePkg.noSavings ? 'invitación personalizada' : `${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 álbum y Organizador de Mesas GRATIS en tu orden.> :
<>Agrega {USD(toGo400)} más y llévate QR álbum + Organizador de Mesas 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
{SERVICES.map((s) => {
const signatureOn = data.services.has('signature');
const on = s.base ? !signatureOn : data.services.has(s.id);
const free = totals.freeIds.has(s.id);
if (s.id === 'signature' && activePkg && activePkg.id !== 'signature') return null;
return (
setFocus(s.id)} onClick={() => setFocus(s.id)}>
{s.name}{s.popular && Más popular } Demo
{s.short} · {s.base ? 'siempre incluida' : s.desc}
{activePkg && on ? Incluido : <>
{s.was && !free && {USD(s.was)} }
{free ? GRATIS : USD(s.price)}
>}
{e.stopPropagation();toggle(s);}} aria-label="Agregar">
);
})}
);
}
function StepHonoree({ data, set }) {
const mode = data.dateMode || 'exact';
const months = [
['01', 'Enero'], ['02', 'Febrero'], ['03', 'Marzo'], ['04', 'Abril'], ['05', 'Mayo'], ['06', 'Junio'],
['07', 'Julio'], ['08', 'Agosto'], ['09', 'Septiembre'], ['10', 'Octubre'], ['11', 'Noviembre'], ['12', 'Diciembre']];
const days = Array.from({ length: 31 }, (_, i) => String(i + 1).padStart(2, '0'));
const hours = Array.from({ length: 36 }, (_, i) => {
const hour = Math.floor(i / 2) + 6;
const minute = i % 2 === 0 ? '00' : '30';
const value = `${String(hour).padStart(2, '0')}:${minute}`;
const labelHour = hour === 12 ? 12 : hour > 12 ? hour - 12 : hour;
return [value, `${labelHour}:${minute} ${hour >= 12 ? 'PM' : 'AM'}`];
});
return (
Datos del evento
Cuéntanos lo esencial para comenzar a crear tu invitación.
set({ honoree: e.target.value })} />
{[['exact', 'Fecha exacta'], ['monthyear', 'Mes y año'], ['tbd', 'Por confirmar']].map(([m, lbl]) =>
set({ dateMode: m })}>{lbl}
)}
{mode === 'exact' &&
set({ eventMonth: e.target.value })} aria-label="Mes del evento">
Mes
{months.map(([value, label]) => {label} )}
set({ eventDay: e.target.value })} aria-label="Día del evento">
Día
{days.map((day) => {day} )}
set({ eventYear: e.target.value.replace(/\D/g, '').slice(0, 4) })} aria-label="Año del evento" />
set({ time: e.target.value })} aria-label="Hora del evento">
Hora
{hours.map(([value, label]) => {label} )}
set({ endTime: e.target.value })} aria-label="Hora de finalización">
Fin
{hours.map(([value, label]) => {label} )}
}
{mode === 'monthyear' &&
set({ eventMYMonth: e.target.value })} aria-label="Mes del evento">
Mes
{months.map(([value, label]) => {label} )}
set({ eventMYYear: e.target.value.replace(/\D/g, '').slice(0, 4) })} aria-label="Año del evento" />
}
{mode === 'tbd' &&
Tu invitación dirá “Fecha por confirmar” . Podrás fijar el día después desde tu panel.
}
set({ venue: e.target.value })} />
set({ city: e.target.value })} />
Familia principal · opcional
set({ mom: e.target.value })} />
set({ dad: e.target.value })} />
);
}
/* secciones cuyo contenido se arma después en el panel (no se captura en la compra) */
const SEC_PANEL_NOTE = {
gallery: 'Indica cómo enviarás tus fotos y cualquier link de referencia disponible.',
music: 'Escribe la canción o pega un link de Spotify / YouTube si ya lo tienes.',
video_intro: 'La animación de apertura es preparada por nuestro equipo según el estilo de tu evento.'
};
function SecInlineFields({ id, data, set }) {
const hours = Array.from({ length: 36 }, (_, i) => {
const hour = Math.floor(i / 2) + 6;
const minute = i % 2 === 0 ? '00' : '30';
const value = `${String(hour).padStart(2, '0')}:${minute}`;
const labelHour = hour === 12 ? 12 : hour > 12 ? hour - 12 : hour;
return [value, `${labelHour}:${minute} ${hour >= 12 ? 'PM' : 'AM'}`];
});
const updateList = (key, index, patch) => {
const rows = Array.isArray(data[key]) ? data[key] : [];
set({ [key]: rows.map((row, i) => i === index ? { ...row, ...patch } : row) });
};
const addRow = (key, row) => set({ [key]: [...(Array.isArray(data[key]) ? data[key] : []), row] });
const removeRow = (key, index, fallbackRow) => {
const rows = (Array.isArray(data[key]) ? data[key] : []).filter((_, i) => i !== index);
set({ [key]: rows.length ? rows : [fallbackRow] });
};
if (id === 'reception') return (
set({ recepAddr: e.target.value })} />
set({ recepTime: e.target.value })}>
Hora
{hours.map(([value, label]) => {label} )}
set({ recepMaps: e.target.value })} />
);
if (id === 'ceremony') return (
set({ ceremonyVenue: e.target.value })} />
set({ ceremonyTime: e.target.value })}>
Hora
{hours.map(([value, label]) => {label} )}
set({ ceremonyAddress: e.target.value })} />
set({ ceremonyMaps: e.target.value })} />
);
if (id === 'parents') return (
Mamá y papá se toman de Datos del evento. Aquí agrega solo familia adicional o notas para producción.
);
if (id === 'godparents') {
const roles = ['Honor', 'Vals', 'Brindis', 'Pastel', 'Recuerdo', 'Ramo', 'Velación', 'Cojines', 'Libro y Rosario', 'Anillos', 'Otro'];
const rows = Array.isArray(data.godparentRows) && data.godparentRows.length ? data.godparentRows : [{ role: 'Honor', customRole: '', names: '' }];
return (
{rows.map((row, index) =>
updateList('godparentRows', index, { role: e.target.value })}>
{roles.map((role) => {role} )}
updateList('godparentRows', index, { names: e.target.value })} />
{row.role === 'Otro' && updateList('godparentRows', index, { customRole: e.target.value })} /> }
removeRow('godparentRows', index, { role: 'Honor', customRole: '', names: '' })}>Quitar
)}
addRow('godparentRows', { role: 'Honor', customRole: '', names: '' })}>Agregar padrino
);
}
if (id === 'food_menu') return (
set({ foodServiceType: e.target.value })}>
Selecciona
Menú servido
Buffet
Cocktail / bocadillos
Estaciones de comida
Otro
set({ foodServingTime: e.target.value })}>
Hora
{hours.map(([value, label]) => {label} )}
set({ cocktailTime: e.target.value })}>
Hora
{hours.map(([value, label]) => {label} )}
);
if (id === 'logistics') return (
set({ hotelName: e.target.value })} />
set({ hotelAddress: e.target.value })} />
set({ hotelMaps: e.target.value })} />
set({ hotelRateCode: e.target.value })} />
);
if (id === 'itinerary') {
const rows = Array.isArray(data.itineraryRows) && data.itineraryRows.length ? data.itineraryRows : [{ time: '', activity: '' }];
return (
{rows.map((row, index) =>
updateList('itineraryRows', index, { time: e.target.value })}>
Hora
{hours.map(([value, label]) => {label} )}
updateList('itineraryRows', index, { activity: e.target.value })} />
removeRow('itineraryRows', index, { time: '', activity: '' })}>Quitar
)}
addRow('itineraryRows', { time: '', activity: '' })}>Agregar actividad
);
}
if (id === 'registry') return (
);
if (id === 'corte') return (
{(Array.isArray(data.courtRows) && data.courtRows.length ? data.courtRows : [{ role: 'Dama', name: '' }]).map((row, index) =>
updateList('courtRows', index, { role: e.target.value })}>
Dama
Chambelán
Dama de Honor
Chambelán de Honor
Otro
updateList('courtRows', index, { name: e.target.value })} />
removeRow('courtRows', index, { role: 'Dama', name: '' })}>Quitar
)}
addRow('courtRows', { role: 'Dama', name: '' })}>Agregar persona
);
if (id === 'dress_code') return (
set({ dressCode: e.target.value })} /> );
if (id === 'rsvp') return (
set({ rsvpDeadline: e.target.value })} /> );
if (id === 'gallery') return (
set({ galleryPhotoCount: e.target.value })}>
Selecciona
{[1, 2, 3, 4, 5, 6].map((n) => {n} )}
set({ galleryMethod: e.target.value })}>
Selecciona
WhatsApp
Email
Link Google Drive
set({ galleryDriveLink: e.target.value })} />
);
if (id === 'music') return (
set({ musicText: e.target.value })} />
set({ musicUrl: e.target.value })} />
);
if (id === 'qrvip') return (
set({ qrAlbumName: e.target.value })} />
set({ qrAlbumStyle: e.target.value })} />
);
if (id === 'guestbook') return (
);
if (id === 'thanks') return (
);
if (id === 'hero') return (
set({ welcome: e.target.value })} /> );
if (id === 'after_party') return (
set({ includeAfterParty: e.target.value })}>
Selecciona
Sí
No
set({ afterPartyVenue: e.target.value })} />
set({ afterPartyAddress: e.target.value })} />
set({ afterPartyMaps: e.target.value })} />
set({ afterPartyTime: e.target.value })}>
Hora
{hours.map(([value, label]) => {label} )}
);
if (SEC_PANEL_NOTE[id]) return (
{SEC_PANEL_NOTE[id]}
);
return null;
}
const SEC_HAS_FIELDS = (id) => ['ceremony', 'reception', 'parents', 'godparents', 'food_menu', 'logistics', 'itinerary', 'registry', 'corte', 'dress_code', 'rsvp', 'gallery', 'music', 'qrvip', 'guestbook', 'thanks', 'hero', 'after_party'].includes(id) || !!SEC_PANEL_NOTE[id];
function StepSections({ data, set, toggleSec }) {
const lang = data.lang;
const langOpts = [['es', 'Español'], ['en', 'Inglés'], ['bi', 'Bilingüe ES/EN']];
return (
Secciones de tu invitación
Elige qué deseas incluir y completa aquí los detalles principales de tu invitación.
{SECTIONS.map((sec) => {
const on = data.sections[sec.id];
const expand = on && SEC_HAS_FIELDS(sec.id);
return (
toggleSec(sec.id)}>
{expand &&
}
);
})}
{langOpts.map(([v, l]) =>
set({ lang: v })}> {l}
)}
);
}
function StepTheme({ data, set }) {
const isOperator = typeof window !== 'undefined' && window.OW_IS_OPERATOR === true;
const hasOperatorTheme = !!(data.customThemeName || data.customThemeDescription || data.themeCoverUrl || data.themePreviewUrl || (Array.isArray(data.themeColors) && data.themeColors.some(Boolean)));
const [showOperatorTheme, setShowOperatorTheme] = useState(false);
const [editExistingTheme, setEditExistingTheme] = useState('');
const [previewTheme, setPreviewTheme] = useState('');
// FASE 2 ORDER-VIP: catalogo real via cache compartido (una sola llamada de red para
// todo el wizard). catalogState: 'idle'/'loading' = cargando, 'error' = fallo
// (fail-closed, sin fallback legacy silencioso), 'ready' = catalogo cargado.
const catalog = useThemeCatalog();
const loadCatalog = () => { themeCatalogCache.status = 'idle'; fetchThemeCatalog(); };
const THEMES = catalog.status === 'ready' ? catalog.data : [];
const editingTheme = THEMES.find((t) => t.id === editExistingTheme);
const previewingTheme = THEMES.find((t) => t.id === previewTheme);
const usesSignature = !chosenTheme(data);
const previewUrlFor = (theme) => data[`themeEdit_${theme.id}_themePreviewUrl`] || theme.demoUrl || '';
const openPreview = (theme) => {
const url = previewUrlFor(theme);
if (url) window.open(url, '_blank', 'noopener,noreferrer');
else setPreviewTheme(theme.id);
};
return (
Elige el estilo de tu invitación
Explora nuestros diseños originales y selecciona la temática que mejor combine con tu evento. Si no ves tu estilo ideal, continúa con Signature y nuestro equipo crea una invitación desde cero para ti.
{usesSignature ? 'Continúa con tu Signature personalizada' : 'Este diseño queda en Original'}
{usesSignature ? 'Nuestro equipo diseña una temática a tu medida · $200 USD' : 'Si esta temática no es exactamente lo que buscas, podemos crear una desde cero para ti con Signature · $200 USD.'}
{!usesSignature &&
set({ theme: '', themeSelected: false })}>
Quiero una temática personalizada
}
{(catalog.status === 'idle' || catalog.status === 'loading') &&
Cargando temáticas…
}
{catalog.status === 'error' &&
No pudimos cargar el catálogo de temáticas.
Reintentar
}
{catalog.status === 'ready' &&
{THEMES.map((t) =>
set({ theme: t.id, themeSelected: true })} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set({ theme: t.id, themeSelected: true }); } }}>
{isOperator && data.theme === t.id &&
{ e.preventDefault(); e.stopPropagation(); setEditExistingTheme(t.id); setShowOperatorTheme(false); }}>
Personalizar para esta clienta
}
{t.preview ?
:
{(data.honoree || 'Aa').slice(0, 2)} }
{ e.preventDefault(); e.stopPropagation(); openPreview(t); }}>
Vista previa
)}
}
{previewingTheme &&
setPreviewTheme('')}>
{previewingTheme.preview ?
:
{previewingTheme.name.slice(0, 2)} }
{previewingTheme.name}
{previewingTheme.mood}
{previewingTheme.dots.map((c, i) => )}
}
{isOperator &&
{/* FASE 2 ORDER-VIP: separacion explicita de conceptos (orden seccion 10-12).
Esto YA NO pretende crear/editar el catalogo -- solo personaliza el brief
de ESTA clienta (section_2.theme_edits). Para el catalogo global real:
administrar en /orden/formularios/temas/. */}
Administrar catálogo de temáticas →
setShowOperatorTheme((v) => !v)}>
{showOperatorTheme ? 'Ocultar personalización' : hasOperatorTheme ? 'Editar personalización de esta clienta' : 'Personalizar para esta clienta'}
{showOperatorTheme &&
}
{editingTheme &&
setEditExistingTheme('')} />}
}
);
}
function OperatorThemePanel({ data, set, mode = 'new', theme = null, onClose = null }) {
const prefix = mode === 'existing' && theme ? `themeEdit_${theme.id}` : '';
const getValue = (field, fallback = '') => {
if (mode === 'existing' && prefix) return data[`${prefix}_${field}`] || fallback;
return data[field] || fallback;
};
const setValue = (field, value) => {
if (mode === 'existing' && prefix) set({ [`${prefix}_${field}`]: value });
else set({ [field]: value });
};
const updateColor = (index, value) => {
const colorKey = mode === 'existing' && prefix ? `${prefix}_themeColors` : 'themeColors';
const colors = Array.isArray(data[colorKey]) ? data[colorKey].slice(0, 3) : [];
while (colors.length < 3) colors.push('');
colors[index] = value;
set({ [colorKey]: colors });
};
const sourceColors = mode === 'existing' && prefix ? data[`${prefix}_themeColors`] : data.themeColors;
const fallbackColors = theme ? theme.dots || [] : ['', '', ''];
const colors = Array.isArray(sourceColors) ? sourceColors.slice(0, 3) : fallbackColors.slice(0, 3);
while (colors.length < 3) colors.push('');
return (
);
}
function StepSummary({ data, totals, goTo, activePkg, orderTotal }) {
const catalog = useThemeCatalog();
const th = catalog.data.find((t) => t.id === data.theme);
const lines = SERVICES.filter((s) => s.base || data.services.has(s.id));
const recap = [
{ lbl: 'Festejada', ico: 'user', val: data.honoree },
{ lbl: 'Fecha', ico: 'calendar', val: eventDateLabel(data), pending: dateIsPending(data) },
{ lbl: 'Lugar', ico: 'mappin', val: data.venue && data.city ? `${data.venue} · ${data.city}` : data.venue || data.city },
{ lbl: 'Temática', ico: 'palette', val: (th && th.name) || data.theme || '' }];
const langName = { es: 'Español', en: 'Inglés', bi: 'Bilingüe ES/EN' }[data.lang];
const activeSecs = SECTIONS.filter((s) => secAvailable(s, data.services) && data.sections[s.id]);
const pendingData = {
reception: !(data.recepAddr || data.venue),
registry: !data.registry,
corte: !(data.corteNames || (Array.isArray(data.courtRows) && data.courtRows.some((r) => r.name))),
itinerary: !(Array.isArray(data.itineraryRows) && data.itineraryRows.some((r) => r.time || r.activity)),
dress_code: !data.dressCode,
parents: !(data.mom || data.dad || data.grandparents || data.familyNotes),
godparents: !(data.padrinos || (Array.isArray(data.godparentRows) && data.godparentRows.some((r) => r.names))),
food_menu: !(data.foodServiceType && data.foodMenuText),
logistics: !(data.hotelName || data.hotelAddress || data.hotelMaps || data.hotelRateCode || data.parkingNotes || data.shuttleInfo),
after_party: !(data.includeAfterParty || data.afterPartyVenue || data.afterPartyAddress || data.afterPartyMaps || data.afterPartyTime),
// Estas secciones no tienen campos obligatorios en este wizard; las completa produccion/panel.
gallery: false, music: false, video_intro: false
};
const secPending = (id) => !!pendingData[id];
const pendingCount = activeSecs.filter((s) => secPending(s.id)).length;
return (
Resumen de tu orden
Revisa la información antes de pagar. Puedes volver a cualquier paso para hacer ajustes.
{recap.map((r) =>
{r.lbl}
{r.val || 'Por definir'}
{r.pending && r.val &&
Pendiente de afinar — lo actualizas en tu panel
}
)}
Secciones de la invitación · Idioma: {langName}
{activeSecs.map((s) => {
const pend = secPending(s.id);
return {s.name} ;
})}
{pendingCount > 0 &&
{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.
}
{activePkg &&
Paquete {activePkg.name}
{lines.length} {lines.length === 1 ? 'servicio incluido' : 'servicios incluidos'}
{USD(activePkg.price)}
}
{lines.map((s) => {
const free = totals.freeIds.has(s.id);
if (activePkg) {
return (
);
}
return (
{s.name}
{s.base ? 'Invitación base · incluida' : free ? 'Servicio · gratis por promoción' : s.short}
{free ? 'GRATIS' : USD(s.price)}
);
})}
{!activePkg &&
Subtotal {USD(totals.gross)}
}
{!activePkg && totals.discount > 0 &&
Descuento promociones − {USD(totals.discount)}
}
{data.lang === 'bi' &&
Bilingüe EN/ES Incluido
}
Total {USD(orderTotal)} USD
{activePkg && pkgListValue(activePkg) - activePkg.price > 0 &&
Ahorras vs. precio de lista {USD(pkgListValue(activePkg) - activePkg.price)}
}
goTo(0)}> Editar servicios
);
}
function StepPay({ data, set, totals, orderTotal }) {
const detail = data.payMethod === 'zelle' || data.payMethod === 'venmo' ? PAY[data.payMethod] : null;
const isCard = data.payMethod === 'card';
const charge = cardCharge(orderTotal);
const fee = Math.round((charge - orderTotal) * 100) / 100;
const depositDue = orderTotal > 300 ? 50 : orderTotal;
const balanceDue = Math.max(0, orderTotal - depositDue);
// Comprobante como data URL en el borrador (sin backend), con preview.
const onReceipt = (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
fileToDataURL(f, (url) => set({ receiptName: f.name, receiptData: url }));
e.target.value = '';
};
return (
);
}
function Paid({ data, totals, orderTotal }) {
const isCard = data.payMethod === 'card';
const paidAmount = isCard ? cardCharge(orderTotal) : orderTotal;
const depositDue = orderTotal > 300 ? 50 : orderTotal;
const balanceDue = Math.max(0, orderTotal - depositDue);
const payDetail = (data.payMethod === 'zelle' || data.payMethod === 'venmo') ? PAY[data.payMethod] : null;
const payLabel = data.payMethod === 'venmo' ? 'Venmo' : 'Zelle';
const missing = [
{ ok: !dateIsPending(data), label: 'Fecha exacta del evento' },
{ ok: !!(data.venue && data.recepAddr), label: 'Lugar y dirección de recepción' },
{ ok: !!(data.mom || data.dad || data.grandparents || data.padrinos), label: 'Nombres de papás / abuelitos / padrinos' },
{ ok: !data.sections.food_menu || !!(data.foodServiceType && data.foodMenuText), label: 'Comida / menú' },
{ ok: !data.sections.dress_code || !!data.dressCode, label: 'Código de vestimenta' },
{ ok: !data.sections.corte || !!(data.corteNames || (Array.isArray(data.courtRows) && data.courtRows.some((r) => r.name))), label: 'Corte de honor' }];
const pend = missing.filter((m) => !m.ok);
return (
{isCard ? <>¡Pago confirmado !> : <>¡Orden recibida !>}
Orden #{data.orderId}
{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.>}
{!isCard && payDetail &&
{balanceDue > 0 ? 'Anticipo a enviar' : 'Monto a enviar'}
{USD2(depositDue)} USD
{balanceDue > 0 &&
Saldo pendiente
{USD2(balanceDue)} USD
}
Envía a ({payLabel})
{payDetail.handle}
{data.payMethod === 'zelle' && payDetail.titular &&
Titular Zelle
{payDetail.titular}
}
¿Ya enviaste tu comprobante? Si todavía no, envía {USD2(depositDue)} USD por {payLabel}{balanceDue > 0 ? <> como anticipo. El saldo pendiente será {USD2(balanceDue)} USD .> : <>.>} Tu orden avanza cuando verifiquemos el pago.
}
Tu formulario quedó guardado en esta orden
{pend.length > 0 ? `Revisa los datos pendientes aquí antes de producción` : 'Tenemos la información principal para iniciar producción'}
{!isCard &&
Próximos pasos
{[`Envía tu pago por ${payLabel} si todavía no lo hiciste`,
'Nuestro equipo revisa la información guardada en esta orden',
'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_SECTIONS = SECTIONS.reduce((acc, sec) => ({ ...acc, [sec.id]: false }), {});
const DEFAULT_DATA = {
services: new Set(),
honoree: '', date: '', eventMonth: '', eventDay: '', eventYear: '', time: '20:00', dateMode: 'exact', dateMY: '', eventMYMonth: '', eventMYYear: '', venue: '', city: '', mom: '', dad: '', grandparents: '', padrinos: '',
theme: '', style: '', themeSelected: false, styleSelected: false, customThemeName: '', customThemeDescription: '', themeCoverUrl: '', themePreviewUrl: '', themeColors: ['', '', ''], orderId: genFolio(),
sections: DEFAULT_SECTIONS,
endTime: '', recepAddr: '', recepTime: '', recepMaps: '', ceremonyVenue: '', ceremonyTime: '', ceremonyAddress: '', ceremonyMaps: '', foodServiceType: '', foodMenuText: '', foodAccommodationNotes: '', foodServingTime: '', cocktailTime: '', familyNotes: '', hotelName: '', hotelAddress: '', hotelMaps: '', hotelRateCode: '', parkingNotes: '', shuttleInfo: '', includeAfterParty: '', afterPartyVenue: '', afterPartyAddress: '', afterPartyMaps: '', afterPartyTime: '', itineraryRows: [{ time: '', activity: '' }], registry: '', godparentRows: [{ role: 'Honor', customRole: '', names: '' }], courtRows: [{ role: 'Dama', name: '' }], corteNames: '', dressCode: '', welcome: '', rsvpDeadline: '', galleryPhotoCount: '', galleryMethod: '', galleryDriveLink: '', musicText: '', musicUrl: '', qrAlbumName: '', qrAlbumStyle: '', qrAlbumNotes: '', guestbookPrompt: '', thanksMessage: '', 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 normalizeWizardData(raw) {
const source = raw && typeof raw === 'object' ? raw : {};
const merged = { ...DEFAULT_DATA, ...source, sections: { ...DEFAULT_DATA.sections, ...(source.sections || {}) } };
merged.services = new Set(Array.isArray(source.services) ? source.services : []);
if (!source.themeSelected && source.theme === 'esmeralda' && (!source.style || source.style === 'clasico')) merged.theme = '';
if (!source.styleSelected && source.style === 'clasico') merged.style = '';
merged.cPass = '';
merged.receiptData = '';
return merged;
}
function wizardSnapshot(data) {
const copy = { ...data, services: Array.from(data.services), cPass: '', receiptData: '' };
return copy;
}
function chosenTheme(data) {
return data.themeSelected && data.theme ? data.theme : '';
}
function chosenStyle(data) {
return data.styleSelected && data.style ? data.style : '';
}
function themeOpsEdits(data) {
// Funcion imperativa (llamada desde saveStep, no un componente) -- no puede usar el
// hook useThemeCatalog(). Lee el cache compartido directamente; si aun no cargo
// (guardado disparado muy temprano), itera vacio -- no pierde nada, theme_edits
// solo importa para temas YA cargados/visibles cuando el operador los edito.
const out = {};
themeCatalogCache.data.forEach((t) => {
const prefix = `themeEdit_${t.id}`;
const title = data[`${prefix}_customThemeName`] || '';
const description = data[`${prefix}_customThemeDescription`] || '';
const cover = data[`${prefix}_themeCoverUrl`] || '';
const previewUrl = data[`${prefix}_themePreviewUrl`] || '';
const colors = Array.isArray(data[`${prefix}_themeColors`]) ? data[`${prefix}_themeColors`].filter(Boolean) : [];
if (title || description || cover || previewUrl || colors.length) {
out[t.id] = { title, description, cover_url: cover, preview_url: previewUrl, colors };
}
});
return out;
}
function draftFromBrief(brief) {
const data = brief && brief.data || {};
if (data._wizard && data._wizard.data) {
return {
step: typeof data._wizard.step === 'number' ? data._wizard.step : Math.max(0, Math.min((brief.current_section || 1) - 1, STEPS.length - 1)),
data: normalizeWizardData(data._wizard.data)
};
}
const s1 = data.section_1 || {};
const s2 = data.section_2 || {};
const themeEdits = s2.theme_edits && typeof s2.theme_edits === 'object' ? s2.theme_edits : {};
const restoredThemeEdits = {};
Object.keys(themeEdits).forEach((id) => {
const edit = themeEdits[id] || {};
const prefix = `themeEdit_${id}`;
restoredThemeEdits[`${prefix}_customThemeName`] = edit.title || '';
restoredThemeEdits[`${prefix}_customThemeDescription`] = edit.description || '';
restoredThemeEdits[`${prefix}_themeCoverUrl`] = edit.cover_url || '';
restoredThemeEdits[`${prefix}_themePreviewUrl`] = edit.preview_url || '';
restoredThemeEdits[`${prefix}_themeColors`] = Array.isArray(edit.colors) ? edit.colors : [];
});
const s3 = data.section_3 || {};
const s4 = data.section_4 || {};
const fallback = normalizeWizardData({
services: Array.isArray(s4.services) ? s4.services : [],
honoree: s1.honoree_full_name || '',
dateMode: s1.date_mode || 'exact',
date: s1.event_date || '',
time: s1.event_time || '20:00',
endTime: s1.event_end_time || '',
venue: s1.venue || '',
city: s1.city || '',
mom: s1.mom || '',
dad: s1.dad || '',
grandparents: s1.grandparents || '',
padrinos: s1.padrinos || '',
theme: s2.theme || '',
style: s2.style || '',
customThemeName: s2.custom_theme_name || '',
customThemeDescription: s2.theme_description || '',
themeCoverUrl: s2.cover_url || '',
themePreviewUrl: s2.preview_url || '',
themeColors: Array.isArray(s2.theme_colors) ? s2.theme_colors : DEFAULT_DATA.themeColors,
...restoredThemeEdits,
themeSelected: !!s2.theme,
styleSelected: !!s2.style,
sections: s3.sections || DEFAULT_DATA.sections,
lang: s4.lang || data.section_15 && data.section_15.language || DEFAULT_DATA.lang
});
if (s1.event_date && /^\d{4}-\d{2}-\d{2}$/.test(s1.event_date)) {
[fallback.eventYear, fallback.eventMonth, fallback.eventDay] = s1.event_date.split('-');
}
if (s1.event_month_year && /^\d{4}-\d{2}$/.test(s1.event_month_year)) {
fallback.dateMode = 'monthyear';
[fallback.eventMYYear, fallback.eventMYMonth] = s1.event_month_year.split('-');
}
return { step: Math.max(0, Math.min((brief.current_section || 1) - 1, STEPS.length - 1)), data: fallback };
}
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 [savedExit, setSavedExit] = useState(false); // RMR-685: confirmacion "Guardar y continuar despues"
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 };});
const restoreServerBrief = React.useCallback((brief) => {
if (!brief || !brief.data) return false;
const restored = draftFromBrief(brief);
if (brief.token) window.OW_GUEST_TOKEN = brief.token;
setStep(restored.step);
setData((current) => ({ ...restored.data, auth: true, authMethod: 'email', cEmail: current.cEmail || restored.data.cEmail || '', cName: current.cName || restored.data.cName || '' }));
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ step: restored.step, sid: wizSid(), data: wizardSnapshot(restored.data) }));
} catch (e) {}
return true;
}, []);
const loadAccountDraft = React.useCallback(async () => {
try {
const res = await fetch('/orden/api.php?action=account_active_brief', { credentials: 'same-origin', cache: 'no-store' });
let j = {}; try { j = await res.json(); } catch (e) {}
if (res.ok && j.success && j.brief) return restoreServerBrief(j.brief);
} catch (e) {}
return false;
}, [restoreServerBrief]);
React.useEffect(() => {
let alive = true;
(async () => {
try {
const res = await fetch('/orden/api.php?action=account_me', { credentials: 'same-origin', cache: 'no-store' });
let j = {}; try { j = await res.json(); } catch (e) {}
if (!alive || !res.ok || !j.logged_in || !j.user) return;
setData((d) => ({ ...d, auth: true, authMethod: 'email', cEmail: d.cEmail || j.user.email || '', cName: d.cName || j.user.name || '' }));
await loadAccountDraft();
} catch (e) {}
})();
return () => { alive = false; };
}, [loadAccountDraft]);
// 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 effectiveServices = useMemo(() => effectiveServicesForTheme(data), [data.services, data.theme, data.themeSelected]);
const order = useMemo(() => orderState(effectiveServices), [effectiveServices]);
const totals = order;
const activePkg = order.pkg;
const packageId = effectivePackageId(data, activePkg);
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 ? !!data.auth /* RMR-685 (JP, decision de producto) SUPERSEDE RMR-475/WIZARD-017: cuenta OBLIGATORIA en Paso 1. JP la pidio explicito: cliente serio crea su cuenta de entrada -> sus datos viven en BD ligados a su cuenta DESDE el inicio (cross-device, cero perdida, sin mala experiencia). El AccountGate se renderiza EN este mismo paso (StepServices) y crea cuenta REAL via account_register/login, reintentable ante fallo (409/429/red) -> NUNCA queda dead-end. NO quitar este gate sin orden explicita de JP (no es el bug RMR-635; es la decision opuesta y deliberada). */ :
step === 1 ? !!data.honoree.trim() :
step === last ? data.payMethod === 'card' ? true : !!(data.payMethod && data.cName.trim() && phoneOk) :
true;
const owBriefSave = (section, payload, recoverStep = step) => {
// RMR-685: autosave SERVER-SIDE por paso. La cuenta se crea en Paso 1 -> el usuario queda logueado,
// asi brief_save (rama guest por token) sincroniza a vipinv_brief.user_id en BD (cross-device).
// Fire-and-forget + try/catch: NUNCA bloquea el avance (anti dead-end). localStorage queda de red de seguridad.
try {
const tok = window.OW_GUEST_TOKEN;
if (!tok || !window.OW_CSRF) return; // token aun no sembrado por el server -> solo localStorage por ahora
fetch('/orden/api.php?action=brief_save', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
body: JSON.stringify({ token: tok, section, data: payload, csrf_token: window.OW_CSRF, package_type: packageId, total_sections: STEPS.length, wizard: { step: recoverStep, total: STEPS.length, data: wizardSnapshot(data) } }),
}).catch(() => {});
} catch (e) {}
};
const saveStep = (s, recoverStep = s) => {
// mapeo paso->seccion del brief que el backend reconoce (sec1=festejada, sec2=tema, sec3=secciones, sec4=servicios)
if (s === 0) owBriefSave(4, { services: Array.from(data.services), package_id: packageId, lang: data.lang }, recoverStep);
else if (s === 1) {
const exactDate = data.dateMode === 'exact' && data.eventYear && data.eventMonth && data.eventDay ? `${data.eventYear}-${data.eventMonth}-${data.eventDay}` : '';
const monthYear = data.dateMode === 'monthyear' && data.eventMYYear && data.eventMYMonth ? `${data.eventMYYear}-${data.eventMYMonth}` : '';
owBriefSave(1, { honoree_full_name: data.honoree, date_mode: data.dateMode, event_date: exactDate, event_month_year: monthYear, event_time: data.time, event_end_time: data.endTime, venue: data.venue, city: data.city, mom: data.mom, dad: data.dad, grandparents: data.grandparents, padrinos: data.padrinos }, recoverStep);
}
else if (s === 2) {
const fieldValue = (label, fallback = '') => {
const fields = Array.from(document.querySelectorAll('.ow-field'));
const found = fields.find((f) => (f.innerText || '').toLowerCase().includes(label.toLowerCase()));
const el = found && found.querySelector('input, textarea, select');
return el ? el.value : fallback;
};
owBriefSave(3, {
sections: data.sections,
mom: data.mom,
dad: data.dad,
grandparents: data.grandparents,
padrinos: (Array.isArray(data.godparentRows) ? data.godparentRows : []).filter((r) => r.names).map((r) => `${r.role === 'Otro' && r.customRole ? r.customRole : r.role || 'Otro'}: ${r.names}`).join('\n') || data.padrinos,
mother_name: data.mom,
father_name: data.dad,
family_other_notes: fieldValue('Notas de familia', data.familyNotes),
godparents: (Array.isArray(data.godparentRows) ? data.godparentRows : []).filter((r) => r.names).map((r) => ({ role: r.role || 'Otro', custom_role: r.role === 'Otro' ? r.customRole || '' : '', names: r.names })),
recep_addr: fieldValue('Dirección del salón', data.recepAddr),
recep_time: fieldValue('Hora de recepción', data.recepTime),
recep_maps: fieldValue('Enlace de Google Maps', data.recepMaps),
ceremony_venue: fieldValue('Lugar de ceremonia / iglesia', data.ceremonyVenue),
ceremony_time: fieldValue('Hora de ceremonia', data.ceremonyTime),
ceremony_address: fieldValue('Dirección de ceremonia', data.ceremonyAddress),
ceremony_maps: fieldValue('Mapa de ceremonia', data.ceremonyMaps),
food_service_type: fieldValue('Tipo de servicio', data.foodServiceType),
food_menu_text: fieldValue('Menú u opciones principales', data.foodMenuText),
food_accommodation_notes: fieldValue('Notas dietéticas o acomodos', data.foodAccommodationNotes),
food_serving_time: fieldValue('Hora de cena / servicio', data.foodServingTime),
cocktail_time: fieldValue('Hora de cocktail / aperitivos', data.cocktailTime),
hotel_name: fieldValue('Hotel recomendado', data.hotelName),
hotel_address: fieldValue('Dirección del hotel', data.hotelAddress),
hotel_maps: fieldValue('Mapa del hotel', data.hotelMaps),
hotel_rate_code: fieldValue('Código / tarifa de grupo', data.hotelRateCode),
parking_notes: fieldValue('Parking', data.parkingNotes),
shuttle_info: fieldValue('Shuttle / transporte', data.shuttleInfo),
include_after_party: fieldValue('Incluir After Party', data.includeAfterParty),
after_party_venue: fieldValue('Lugar / nombre del After Party', data.afterPartyVenue),
after_party_address: fieldValue('Dirección del After Party', data.afterPartyAddress),
after_party_maps: fieldValue('Mapa del After Party', data.afterPartyMaps),
after_party_time: fieldValue('Hora de inicio del After Party', data.afterPartyTime),
itinerary_items: (Array.isArray(data.itineraryRows) ? data.itineraryRows : []).filter((r) => r.time || r.activity),
itinerary_text: (Array.isArray(data.itineraryRows) ? data.itineraryRows : []).filter((r) => r.time || r.activity).map((r) => `${r.time || 'Sin hora'} - ${r.activity || ''}`.trim()).join('\n'),
registry: fieldValue('Datos o links de tu registro', data.registry),
court_of_honor: (Array.isArray(data.courtRows) ? data.courtRows : []).filter((r) => r.name).map((r) => ({ role: r.role || 'Otro', name: r.name })),
corte_names: (Array.isArray(data.courtRows) ? data.courtRows : []).filter((r) => r.name).map((r) => `${r.role || 'Otro'}: ${r.name}`).join('\n') || data.corteNames,
dress_code: fieldValue('Código de vestimenta', data.dressCode),
rsvp_deadline: fieldValue('Fecha límite de confirmación', data.rsvpDeadline),
gallery_photo_count: fieldValue('Cantidad de fotos', data.galleryPhotoCount),
gallery_method: fieldValue('Cómo enviarás las fotos', data.galleryMethod),
gallery_drive_link: fieldValue('Link Drive de fotos', data.galleryDriveLink),
music_text: fieldValue('Canción y artista', data.musicText),
music_url: fieldValue('Link de Spotify / YouTube', data.musicUrl),
qr_album_name: fieldValue('Nombre del álbum QR', data.qrAlbumName),
qr_album_style: fieldValue('Estilo visual del QR', data.qrAlbumStyle),
qr_album_notes: fieldValue('Notas para los letreros QR', data.qrAlbumNotes),
guestbook_prompt: fieldValue('Mensaje para el libro de visitas', data.guestbookPrompt),
thanks_message: fieldValue('Mensaje de agradecimiento', data.thanksMessage),
welcome: fieldValue('Frase de bienvenida', data.welcome),
lang: data.lang
}, recoverStep);
}
else if (s === 3) owBriefSave(2, { event_type: 'quinceanera', theme: chosenTheme(data), style: chosenStyle(data), custom_theme_name: data.customThemeName || '', theme_description: data.customThemeDescription || '', cover_url: data.themeCoverUrl || '', preview_url: data.themePreviewUrl || '', theme_colors: (Array.isArray(data.themeColors) ? data.themeColors : []).filter(Boolean), theme_edits: themeOpsEdits(data) }, recoverStep);
else if (s === last) owBriefSave(10, { rsvp_whatsapp: data.cPhone }, recoverStep);
};
const saveAndExit = () => {
// RMR-685: "Guardar y continuar despues" -> guarda el paso actual en la cuenta (BD) + confirma cross-device.
saveStep(step);
setSavedExit(true);
setTimeout(() => setSavedExit(false), 6000);
};
const buildOrderBriefData = () => {
const exactDate = data.dateMode === 'exact' && data.eventYear && data.eventMonth && data.eventDay ? `${data.eventYear}-${data.eventMonth}-${data.eventDay}` : data.date || '';
const monthYear = data.dateMode === 'monthyear' && data.eventMYYear && data.eventMYMonth ? `${data.eventMYYear}-${data.eventMYMonth}` : '';
const godparents = (Array.isArray(data.godparentRows) ? data.godparentRows : []).filter((r) => r.names).map((r) => ({
role: r.role || 'Otro',
custom_role: r.role === 'Otro' ? r.customRole || '' : '',
names: r.names
}));
const court = (Array.isArray(data.courtRows) ? data.courtRows : []).filter((r) => r.name).map((r) => ({
role: r.role || 'Otro',
name: r.name
}));
const itinerary = (Array.isArray(data.itineraryRows) ? data.itineraryRows : []).filter((r) => r.time || r.activity);
return {
section_1: {
honoree_full_name: data.honoree,
date_mode: data.dateMode,
event_date: exactDate,
event_month_year: monthYear,
event_time: data.time,
event_end_time: data.endTime,
venue: data.venue,
city: data.city,
mom: data.mom,
dad: data.dad,
grandparents: data.grandparents,
padrinos: data.padrinos
},
section_2: { event_type: 'quinceanera', theme: chosenTheme(data), style: chosenStyle(data), custom_theme_name: data.customThemeName || '', theme_description: data.customThemeDescription || '', cover_url: data.themeCoverUrl || '', preview_url: data.themePreviewUrl || '', theme_colors: (Array.isArray(data.themeColors) ? data.themeColors : []).filter(Boolean), theme_edits: themeOpsEdits(data) },
_contact: { email: data.cEmail, phone: data.cPhone, name: data.cName },
section_3: {
sections: data.sections,
dress_code: data.dressCode,
itinerary_items: itinerary,
itinerary_text: itinerary.map((r) => `${r.time || 'Sin hora'} - ${r.activity || ''}`.trim()).join('\n')
},
section_4: {
include_ceremony: !!data.sections.ceremony,
ceremony_venue: data.ceremonyVenue,
ceremony_time: data.ceremonyTime,
ceremony_address: data.ceremonyAddress,
ceremony_maps: data.ceremonyMaps
},
section_5: {
venue_name: data.venue,
venue_address: data.recepAddr,
venue_maps: data.recepMaps,
venue_time_start: data.recepTime
},
section_6: {
mother_name: data.mom,
father_name: data.dad,
grandparents: data.grandparents,
family_other_notes: data.familyNotes,
court_of_honor: court,
corte_names: court.map((r) => `${r.role || 'Otro'}: ${r.name || ''}`.trim()).join('\n') || data.corteNames
},
section_7: {
godparents,
padrinos: godparents.map((r) => `${r.role === 'Otro' && r.custom_role ? r.custom_role : r.role || 'Otro'}: ${r.names}`).join('\n') || data.padrinos
},
section_10: { include_rsvp: !!data.sections.rsvp, rsvp_deadline: data.rsvpDeadline, rsvp_whatsapp: data.cPhone },
section_8: {
include_gallery: !!data.sections.gallery,
gallery_photo_count: data.galleryPhotoCount,
gallery_method: data.galleryMethod,
gallery_drive_link: data.galleryDriveLink
},
section_9: {
include_music: !!data.sections.music,
music_text: data.musicText,
music_url: data.musicUrl
},
section_11: {
include_itinerary: !!data.sections.itinerary,
itinerary_items: itinerary,
itinerary_text: itinerary.map((r) => `${r.time || 'Sin hora'} - ${r.activity || ''}`.trim()).join('\n'),
include_registry: !!data.sections.registry,
registry: data.registry
},
section_12: {
include_qrvip: !!data.sections.qrvip,
qr_album_name: data.qrAlbumName,
qr_album_style: data.qrAlbumStyle,
qr_album_notes: data.qrAlbumNotes
},
section_13: {
welcome: data.welcome,
guestbook_prompt: data.guestbookPrompt,
thanks_message: data.thanksMessage
},
section_15: { language: data.lang, bilingual: data.lang === 'bi' },
section_17: {
include_food_menu: !!data.sections.food_menu,
food_service_type: data.foodServiceType,
food_menu_text: data.foodMenuText,
food_accommodation_notes: data.foodAccommodationNotes,
food_serving_time: data.foodServingTime,
cocktail_time: data.cocktailTime
},
section_16: {
include_logistics: !!data.sections.logistics,
hotel_name: data.hotelName,
hotel_address: data.hotelAddress,
hotel_maps: data.hotelMaps,
hotel_rate_code: data.hotelRateCode,
parking_notes: data.parkingNotes,
shuttle_info: data.shuttleInfo,
include_after_party: data.includeAfterParty,
after_party_venue: data.afterPartyVenue,
after_party_address: data.afterPartyAddress,
after_party_maps: data.afterPartyMaps,
after_party_time: data.afterPartyTime
},
logistics: {
hotel_name: data.hotelName,
hotel_address: data.hotelAddress,
hotel_maps: data.hotelMaps,
hotel_rate_code: data.hotelRateCode,
parking_notes: data.parkingNotes,
shuttle_info: data.shuttleInfo,
include_after_party: data.includeAfterParty,
after_party_venue: data.afterPartyVenue,
after_party_address: data.afterPartyAddress,
after_party_maps: data.afterPartyMaps,
after_party_time: data.afterPartyTime
}
};
};
const next = () => {
if (step < last) {
saveStep(step, step + 1); // autosave server-side del paso recuperable siguiente
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.eventYear && data.eventMonth && data.eventDay ? `${data.eventYear}-${data.eventMonth}-${data.eventDay}` : data.date : '',
venue: data.venue,
city: data.city,
theme: chosenTheme(data),
lang: data.lang,
payMethod: data.payMethod,
cName: data.cName,
cEmail: data.cEmail,
cPhone: data.cPhone,
orderId: data.orderId,
packageId,
total: orderTotal,
briefData: buildOrderBriefData(),
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 (
{showDemos &&
setShowPrev(true)}> Ver demos }
MissXV VIPOrden de invitación
{data.auth ? <> Progreso guardado> : <> Compra segura>}
{paid ? 'Completado' : STEPS[step]}
{paid ? `${STEPS.length} de ${STEPS.length}` : `Paso ${step + 1} de ${STEPS.length}`}
{paid ?
:
step === 0 ?
:
step === 1 ?
:
step === 2 ?
:
step === 3 ?
:
step === 4 ?
:
}
Borrador guardado automáticamente
{!paid && data.auth && step > 0 && step < last &&
{savedExit
? Guardado en tu cuenta
: Guardar mi avance }
}
{!paid &&
<>
{submitError &&
{submitError}
}
{step !== last &&
Total de la orden
{USD(orderTotal)}USD
}
{step > 0 &&
setStep(step - 1)}> Atrás }
{submitting ? <>Procesando…> :
step === last ?
data.payMethod === 'card' ?
<> Pagar con tarjeta {USD2(payChargeNow)}> :
<> Enviar mi orden> :
<>Guardar y continuar >}
>
}
{showDemos &&
setShowPrev(false)}>
Mira cada servicio en acción
Conoce nuestros servicios premium y mira cada demo antes de agregarla a tu orden.
}
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );