/* ============================================================ 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 ( ); } /* ---------- 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 &&
Padrinos
{godparents}
} {sec.corte && court &&
Corte de Honor
{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 ?
); } /* ============================================================ helpers de form ============================================================ */ function Field({ label, opt, hint, children, full }) { return (
{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
); } 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.
set({ cEmail: e.target.value })} />
{mode === 'register' ? (
set({ cPhone: e.target.value })} />
) : null}
set({ cPass: e.target.value })} />
{err ?
{err}
: null}
{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 ( ); })}
{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)} }
); })}
); } 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]) => )}
{mode === 'exact' &&
set({ eventYear: e.target.value.replace(/\D/g, '').slice(0, 4) })} aria-label="Año del evento" />
} {mode === 'monthyear' &&
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 })} />