/* CreadoraVirtual — bundle para GoHighLevel (script clásico, sin módulos). Página: creator · generado 2026-10-09 por tools/build-ghl.mjs. No editar a mano: vuelve a ejecutar `node tools/build-ghl.mjs` tras cambiar js/ o css/. Three.js se carga desde el CDN con import() dinámico (MIME correcto). */ (function () { 'use strict'; let THREE; const CV_THREE_URL = (window.CV_CONFIG && window.CV_CONFIG.threeUrl) || 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js'; /* ======== assets.js ======== */ /* ========================================================================= asset(path) — resolves every image URL. Locally the project path is used as-is. On GoHighLevel (or any CDN with id-based URLs) define window.CV_CONFIG before the script loads: assets: { 'assets/img/hero-neon.jpg': 'https://…/media/abc.jpg', … } assetBase: 'https://…/' // fallback: base + file name (real folders) ========================================================================= */ function asset(path) { const CV = (typeof window !== 'undefined' && window.CV_CONFIG) || {}; const map = CV.assets || {}; if (map[path]) return map[path]; if (CV.assetBase) return CV.assetBase.replace(/\/?$/, '/') + path.split('/').pop(); return path; } /* ======== content-ai-model.js ======== */ /* ========================================================================= Landing "Agencia de Modelos IA" (ai-model.html) ------------------------------------------------------------------------- Mismo sitio, misma animación, otro ángulo: el público aquí no es la creadora que se clona a sí misma, sino quien quiere montar un negocio con modelos IA anónimas (cuentas de Instagram, contenido, crecimiento). Este archivo SOLO contiene lo que cambia respecto a js/content.js. Todo lo que no esté aquí se hereda (tema, ritmo del scroll, imágenes…). ========================================================================= */ /* ---- Titulares alternativos (cambia `CHAPTERS[0].title` por el que quieras) 1. Cómo crear una agencia de modelos IA anónimas para generar ingresos pasivos. ← actual 2. Crea modelos que no existen. Cobra por el contenido que sí. 3. El negocio invisible: cuentas de Instagram con modelos IA que nadie sabe que son IA. 4. Una modelo IA. Diez cuentas. Un sistema que no depende de ti. 5. De cero a agencia: crea, haz crecer y monetiza modelos IA sin dar la cara. 6. Cómo construir perfiles de Instagram con modelos IA 100% realistas (y monetizarlos). 7. El activo digital de 2026: modelos que no existen, audiencias que sí. ---------------------------------------------------------------------------- */ const AI_MODEL = { /* Qué se oculta en ESTA landing (ver HIDE_BASE en js/content.js). */ HIDE: { finalChapter: true, pains: false, method: true }, /* La historia acaba en el anillo: las tarjetas se desvanecen y no se forma la cuadrícula 3×3 (el capítulo final se eliminó en esta landing). */ ENDING: 'fade', TRAVEL_VH: 5.6, BEATS: [ { id: 'hero', start: 0.0, end: 0.16, copy: 'left', hold: true }, { id: 'scan', start: 0.16, end: 0.3, copy: 'none' }, { id: 'twin', start: 0.3, end: 0.56, copy: 'right' }, { id: 'orbit', start: 0.56, end: 0.68, copy: 'none' }, { id: 'multiply', start: 0.68, end: 0.9, copy: 'left' }, { id: 'gallery', start: 0.9, end: 1.0, copy: 'none' }, ], SITE: { name: 'CreadoraVirtual', url: 'https://creadoravirtual.com/ai-model', loginUrl: 'https://app.creadoravirtual.com/b/creadoravirtual', lang: 'es', title: 'Agencia de Modelos IA — Crea, haz crecer y monetiza modelos que no existen', description: 'Workshop gratuito: el sistema para crear modelos IA 100% realistas, construir cuentas de Instagram con ellas y monetizarlas sin dar la cara.', copyright: '©2025 – CreadoraVirtual | All Right Reserved', }, /* Paleta del 3D (espejo de css/ai-model.css) */ THEME: { ink: '#0b0d1a', ink2: '#131a2c', violet: '#7b5cff', violetSoft: '#a9b6ff', pink: '#4f7cff', pinkGlow: '#42b8ff', cyan: '#5bc8ff', paper: '#fafafa', cardBody: '#121a30', cardEdge: '#7b5cff', }, EVENT: { label: 'WORKSHOP GRATUITO', when: ' (ACCESO INMEDIATO)', whenShort: ' (ACCESO INMEDIATO)', seats: 'Acceso limitado', }, CTA: { primary: '👉 Ver el Workshop Ahora Mismo', secondary: 'Ver el sistema', skip: 'Saltar la animación', submit: 'Ver el Workshop', }, /* Rótulos bajo las tarjetas 3D */ LABELS: { original: 'Rostro base', twin: 'Modelo IA · no existe', }, NAV: [ { label: 'Workshop', href: '#workshop' }, { label: 'Acceso', href: '#registro' }, ], CHAPTERS: [ { beat: 'hero', eyebrow: 'WORKSHOP GRATUITO · ACCESO INMEDIATO', title: 'Cómo crear una agencia de modelos IA anónimas para generar ingresos pasivos.', text: 'El sistema para crear modelos que no existen, hacerlas crecer en Instagram y monetizar su contenido — sin cámara, sin estudio y sin dar la cara.', cta: { label: '👉 Ver el Workshop Ahora Mismo', href: '#registro', sub: 'Acceso inmediato' }, ctaSecondary: { label: 'Ver el temario', href: '#workshop' }, }, { beat: 'twin', eyebrow: 'Tu modelo IA', title: 'Un rostro que no existe. Y nadie lo nota.', text: 'La diferencia entre una cuenta que crece y una que delatan es el realismo y la consistencia.', bullets: [ 'Poros, luz natural y cabello realista — no la “IA de plástico”.', 'La misma cara en cada foto, ángulo y escenario.', 'Cero cámara, cero estudio, cero exposición de tu identidad.', ], }, { beat: 'multiply', eyebrow: 'Una modelo', title: 'Muchas cuentas.', text: 'Cuando el rostro es consistente, el contenido deja de ser una foto suelta y pasa a ser un activo.', bullets: [ 'Feeds completos de lifestyle, viajes y día a día en minutos.', 'Varias modelos y nichos operando con el mismo proceso.', 'Contenido listo para publicar, colaborar o vender.', ], }, ], PAINS: { title: 'Por qué casi nadie lo logra', subtitle: '(y qué hace distinto quien sí)', items: [ { title: 'Modelos que se delatan', text: 'Piel de plástico, manos raras, miradas sin vida y una cara distinta en cada publicación. El perfil pierde credibilidad, la gente lo huele y la cuenta deja de crecer.', }, { title: 'Fotos sueltas, no un negocio', text: 'Generar imágenes bonitas no es un sistema. Sin consistencia de rostro, sin nicho definido y sin un proceso repetible, cada cuenta te consume el día y nunca escala.', }, { title: 'Censura y herramientas limitadas', text: 'Las IA tradicionales bloquean buena parte del contenido que de verdad monetiza, y las plataformas penalizan lo que parece falso. Sin las herramientas correctas te quedas a medias.', }, ], }, /* Esta landing no muestra ni la sección "El método" ni la galería "Antes / después" (ambas quitadas de ai-model.html); METHOD queda aquí solo por si quieres volver a activarlas. */ METHOD: { title: 'Cómo se construye una agencia de modelos IA', text: 'En el workshop verás el proceso completo: crear un rostro que no existe, mantenerlo idéntico en cada foto, construir la cuenta alrededor de ella y convertir ese contenido en un activo que puedes operar o delegar.', gallery: [ { src: asset('assets/img/compare-01.jpg'), caption: 'De una referencia cualquiera a una modelo en alta resolución y con textura humana.' }, { src: asset('assets/img/compare-02.jpg'), caption: 'Perfiles y ángulos generados automáticamente: el mismo rostro desde cualquier lado.' }, { src: asset('assets/img/compare-03.jpg'), caption: 'Natural, HD y consistente — la base de una cuenta creíble.' }, { src: asset('assets/img/compare-04.jpg'), caption: 'Varias modelos distintas: un nicho y una cuenta para cada una.' }, { src: asset('assets/img/compare-05.jpg'), caption: 'Contenido lifestyle listo para el feed.' }, { src: asset('assets/img/compare-06.jpg'), caption: 'Cambia escenario, ropa y ciudad con un clic.' }, { src: asset('assets/img/compare-07.jpg'), caption: '¿Funcionó una pose en otro perfil? Replícala con tu modelo.' }, { src: asset('assets/img/compare-08.jpg'), caption: '…o hazla totalmente diferente para otra cuenta.' }, ], }, MODULES: { title: 'Lo que verás en el Workshop', image: { src: asset('assets/img/workshop-vertical.jpg'), alt: 'De una imagen de referencia a una modelo IA generada automáticamente', }, items: [ { emoji: '🧬', title: 'Tu modelo IA desde cero', text: 'Cómo crear un rostro que no existe y fijarlo: misma identidad en cada foto, cada ángulo y cada escenario. Y por qué los métodos comunes delatan a tu modelo (piel extra lisa, filtros exagerados), con el truco privado para lograr texturas 100% humanas: poros, luz natural y cabello realista.', }, { emoji: '💼', title: 'Vías de monetización', text: 'Las formas habituales de rentabilizar una cuenta con modelo IA (contenido premium, colaboraciones, tráfico a otras plataformas) y qué hace falta en cada una, manteniendo tu identidad fuera.', }, { emoji: '📸', title: 'El robo de contenido “legal”', text: '¿Viste una foto increíble en el perfil de tu influencer favorita de viaje o en un hotel de lujo? Te mostraré cómo “robar” esa foto y reemplazarla perfectamente con tu modelo IA en segundos. Tendrás su mismo nivel de producción, a costo cero.', }, { emoji: '🔥', title: 'El secreto del contenido “Spicy”', text: 'Las inteligencias artificiales tradicionales como ChatGPT o Gemini bloquean y censuran la creación de fotos sensuales, lencería o trajes de baño. Te revelaré mi herramienta secreta (sin censura) para generar contenido altamente atractivo, “spicy” y monetizable, luciendo espectacular y 100% natural.', }, ], }, REGISTER: { title: '¡Desbloquea el Workshop ahora mismo!', text: 'Completa el formulario para liberar tu enlace privado. Podrás ver el workshop al instante, pero recuerda: tu acceso a esta clase gratuita expirará en 24 horas. ¡Entra ahora antes de que la retiremos!', fields: { name: 'Nombre completo*', email: 'Email*', phone: 'Teléfono*', phoneHint: '(Para mensaje de texto SMS solamente)', consent: 'Acepto recibir mensajes sobre el workshop y promociones especiales. Reply STOP to opt out.', }, success: '¡Listo! Revisa tu correo: ahí tienes tu enlace de acceso privado.', error: 'No pudimos enviar tu registro. Inténtalo de nuevo en unos minutos.', demoNotice: 'Modo demo: este formulario todavía no está conectado. Configura FORM.endpoint en js/content.js.', }, /* Mismo formulario de GHL que la otra landing. Cambia embedUrl/embedId si quieres un formulario (y un contacto/tag) distinto para esta campaña. */ FORM: { embedUrl: 'https://app.monetizate.net/widget/form/angNGkfcsm9fv1p1nyXY?notrack=true', embedScript: 'https://app.monetizate.net/js/form_embed.js', embedId: 'angNGkfcsm9fv1p1nyXY', embedHeight: 483, embedTitle: 'Acceso al Workshop', endpoint: '', method: 'POST', }, }; /* ======== content.js ======== */ /* ========================================================================= CreadoraVirtual — editable content ------------------------------------------------------------------------- Everything a non-developer may want to change lives here: copy, calls to action, images, beat pacing, brand colours, form endpoint. The scene code (js/scene.js, js/timeline.js) reads from this file. ========================================================================= */ /* ---- Variante de página ---------------------------------------------------- La landing de creadoras (index.html) usa los valores base de este archivo. La landing de agencia (ai-model.html) los sobrescribe con los de js/content-ai-model.js. La variante se lee de window.CV_CONFIG.variant o window.CV_VARIANT, definidos antes de que se ejecute este script. ---------------------------------------------------------------------------- */ const VARIANT = (typeof window !== 'undefined' && ((window.CV_CONFIG && window.CV_CONFIG.variant) || window.CV_VARIANT)) || 'creator'; const OV = VARIANT === 'ai-model' ? AI_MODEL : {}; const pick = (name, base) => !(name in OV) ? base : Array.isArray(OV[name]) || typeof OV[name] !== 'object' ? OV[name] : { ...base, ...OV[name] }; /* ---- Secciones ocultas en esta landing ------------------------------------ Nada se borra: pon el valor en false y la sección vuelve tal cual estaba. finalChapter → capítulo "Tu feed, listo desde el pijama" + cuadrícula 3×3 final (con true la historia acaba en el anillo y las tarjetas se desvanecen; también acorta el scroll fijado) pains → "La cruda realidad de ser creadora" method → "Cómo clonarte sin parecer un robot" ---------------------------------------------------------------------------- */ const HIDE_BASE = { finalChapter: true, pains: true, method: true, }; const SITE_BASE = { name: 'CreadoraVirtual', url: 'https://creadoravirtual.com/', loginUrl: 'https://app.creadoravirtual.com/b/creadoravirtual', lang: 'es', title: 'CreadoraVirtual - Crea tu Gemela Digital y Crece Tus Redes', // igual que los Page settings de GHL description: 'Crea tu Gemela Digital y Crece Tus Redes', // igual que los Page settings de GHL copyright: '©2025 – CreadoraVirtual | All Right Reserved', }; const EVENT_BASE = { label: 'WORKSHOP GRATUITO', when: ' (ACCESO INMEDIATO)', whenShort: ' (ACCESO INMEDIATO)', seats: 'Cupos limitados', }; const CTA_BASE = { primary: '👉 Ver el Workshop Ahora Mismo', secondary: 'Ver el método', skip: 'Saltar la animación', submit: 'Ver el Workshop', }; /* ---- Brand tokens (mirrored in css/styles.css) --------------------------- */ const THEME_BASE = { ink: '#0e0b1a', ink2: '#1c1726', violet: '#8151fc', violetSoft: '#b79cff', pink: '#e8308a', pinkGlow: '#ff5fb0', cyan: '#5bc8ff', paper: '#fafafa', // 3D finish of the photo cards cardBody: '#17122a', cardEdge: '#8151fc', }; /* ---- Scroll story ---------------------------------------------------------- One pinned stage. TRAVEL_VH is the pinned scroll distance in viewport heights (the stage itself adds one more). Beats are intervals of the clamped progress p ∈ [0, 1]. `copy` says where the readable HTML sits during that beat (left | right | none). Timeline keyframes in js/timeline.js reference these ids, so re-timing here re-times the motion. ---------------------------------------------------------------------------- */ const TRAVEL_VH_BASE = HIDE_BASE.finalChapter ? 5.6 : 6.5; const BEATS_FULL = [ { id: 'hero', start: 0.00, end: 0.14, copy: 'left', hold: true }, { id: 'scan', start: 0.14, end: 0.26, copy: 'none' }, { id: 'twin', start: 0.26, end: 0.44, copy: 'right' }, { id: 'orbit', start: 0.44, end: 0.54, copy: 'none' }, { id: 'multiply', start: 0.54, end: 0.74, copy: 'left' }, { id: 'gallery', start: 0.74, end: 0.86, copy: 'none' }, { id: 'resolution', start: 0.86, end: 1.00, copy: 'left', hold: true }, ]; /* Sin capítulo final: la historia acaba en el anillo (beat "gallery" = fundido). */ const BEATS_SHORT = [ { id: 'hero', start: 0.0, end: 0.16, copy: 'left', hold: true }, { id: 'scan', start: 0.16, end: 0.3, copy: 'none' }, { id: 'twin', start: 0.3, end: 0.56, copy: 'right' }, { id: 'orbit', start: 0.56, end: 0.68, copy: 'none' }, { id: 'multiply', start: 0.68, end: 0.9, copy: 'left' }, { id: 'gallery', start: 0.9, end: 1.0, copy: 'none' }, ]; const BEATS_BASE = HIDE_BASE.finalChapter ? BEATS_SHORT : BEATS_FULL; /* ---- Card textures (all cropped from the graphics on creadoravirtual.com) */ const IMG = 'assets/img/cards/'; const card = (name) => asset(IMG + name); const CARDS_BASE = { original: { src: card('selfie-original.jpg'), alt: 'Selfie original en baja resolución' }, twin: { src: card('twin-sharp-b.jpg'), low: card('twin-pixel.jpg'), alt: 'Gemela Digital, versión natural en alta resolución', }, // 8 satellites, in the order they burst out of the twin and their grid slot // (grid is 3×3, read left→right, top→bottom; the twin takes the centre). satellites: [ { id: 'alter-curly', src: card('alter-curly.jpg'), alt: 'Alter ego: rizos y lentes' }, { id: 'life-cafe', src: card('life-cafe.jpg'), alt: 'Foto lifestyle en un café' }, { id: 'alter-blonde', src: card('alter-blonde.jpg'), alt: 'Alter ego: rubia con sombrero' }, { id: 'life-window', src: card('life-window.jpg'), alt: 'Foto lifestyle junto a la ventana' }, { id: 'life-mall', src: card('life-mall.jpg'), alt: 'Foto lifestyle en el centro comercial' }, { id: 'life-garden', src: card('life-garden.jpg'), alt: 'Foto lifestyle en un jardín' }, { id: 'alter-brunette',src: card('alter-brunette.jpg'),alt: 'Alter ego: morena' }, { id: 'life-armchair', src: card('life-armchair.jpg'), alt: 'Foto lifestyle en un sillón' }, ], }; /* ---- Small captions pinned under the 3D cards ---------------------------- */ const LABELS_BASE = { original: 'Selfie original', twin: 'Tu Gemela Digital', }; /* ---- Chapters shown over the 3D stage ------------------------------------ */ const CHAPTERS_BASE = [ { beat: 'hero', eyebrow: `${EVENT_BASE.label} · ${EVENT_BASE.when}`, title: 'El método secreto para crear tu “Gemela Digital” 100% realista y natural.', text: 'Descubre el sistema que usan las creadoras top más exitosas para multiplicar su presencia en redes.', cta: { label: CTA_BASE.primary, href: '#registro', sub: EVENT_BASE.whenShort }, ctaSecondary: { label: CTA_BASE.secondary, href: '#metodo' }, }, { beat: 'twin', eyebrow: 'Tu Gemela Digital', title: 'Piel real, luz real, cero plástico.', text: 'Un método para que tu modelo se vea tan humana que nadie podrá adivinar tu secreto.', bullets: [ 'Poros, luz natural y cabello realista — no la “IA de plástico”.', 'Perfiles y ángulos generados automáticamente desde una selfie.', 'Nunca más pagar (ni estresarte por) una sesión de fotos profesional.', ], }, { beat: 'multiply', eyebrow: 'Una selfie', title: 'Infinitas versiones.', text: 'Tu clon exacto o un alter ego con rasgos distintos para proteger tu privacidad.', bullets: [ 'Fotos lifestyle espectaculares en 5 minutos desde tu computadora.', '¿Viste una foto increíble de tu influencer favorita? Copia la pose con tu gemela.', 'Genera versiones diferentes con un clic.', ], }, { beat: 'resolution', hidden: HIDE_BASE.finalChapter, eyebrow: 'El método “Influencer en Pijama”', title: 'Tu feed, listo desde el pijama.', text: 'Sin buscar la luz perfecta. Sin exponer tu casa. Sin miradas sin vida.', cta: { label: CTA_BASE.primary, href: '#registro', sub: `${EVENT_BASE.whenShort} · ${EVENT_BASE.seats}` }, }, ]; /* ---- Flowing sections after the stage ------------------------------------ */ const PAINS_BASE = { title: 'La cruda realidad de ser creadora', subtitle: '(y por qué necesitas este sistema)', items: [ { title: 'El agotamiento', text: 'Arreglarte durante horas, buscar la luz perfecta y tomar 100 fotos para que solo una quede bien es frustrante. Si tienes un “mal día” o te sientes insegura, simplemente no publicas, y tu cuenta se estanca.', }, { title: 'La falta de privacidad', text: 'Te aterra mostrar el interior de tu casa o perder tu paz mental. Quieres los ingresos y la libertad de una influencer, pero odias la idea de exponer tu vida privada en internet o no tener buenos lugares para tomarte fotos.', }, { title: 'El problema de la IA falsa', text: 'Sabes que usar herramientas digitales es el futuro. Pero con la IA tradicional (ChatGPT, etc.) el resultado es desastroso: piel de plástico, miradas sin vida o caras que parecen dibujos animados. Todos se dan cuenta de que es falso, y eso arruina tu imagen.', }, ], }; const METHOD_BASE = { title: 'Cómo clonarte sin parecer un robot', text: 'En nuestro próximo workshop te enseñaremos a usar nuestra tecnología para clonarte de la forma correcta. Un método oculto para que tu modelo se vea tan humana, real y natural, que nadie podrá adivinar tu secreto.', gallery: [ { src: asset('assets/img/compare-01.jpg'), caption: 'De una selfie en baja resolución a una Gemela Digital natural y en alta resolución.' }, { src: asset('assets/img/compare-02.jpg'), caption: 'Perfiles generados automáticamente.' }, { src: asset('assets/img/compare-03.jpg'), caption: 'Natural, HD y con perfiles.' }, { src: asset('assets/img/compare-04.jpg'), caption: 'Versiones alter ego: crea tu clon o crea tu alter ego.' }, { src: asset('assets/img/compare-05.jpg'), caption: 'Fotos de lifestyle.' }, { src: asset('assets/img/compare-06.jpg'), caption: 'Genera diferentes versiones con un clic.' }, { src: asset('assets/img/compare-07.jpg'), caption: '¡Copéala! Te gustó la pose de tu influencer favorita: copéala.' }, { src: asset('assets/img/compare-08.jpg'), caption: '…o hazla totalmente diferente con un clic.' }, ], }; const GALLERY_BASE = { eyebrow: 'Antes / después', title: 'De una selfie a todo tu feed', note: 'Ejemplos reales del método. Desliza para ver más →', }; const MODULES_BASE = { title: 'Lo que descubrirás en el Workshop', image: { src: asset('assets/img/workshop-vertical.jpg'), alt: 'Imagen original borrosa → Gemela Virtual generada automáticamente' }, items: [ { emoji: '🤖', title: 'El fin de la “IA de plástico”', text: 'Por qué los métodos comunes te hacen lucir falsa (con piel extra lisa y filtros exagerados), y cuál es nuestro truco privado para lograr texturas 100% humanas (poros, luz natural, cabello realista). Nunca más tendrás que pagar o estresarte por una sesión de fotos profesional.', }, { emoji: '☕', title: 'El método “Influencer en Pijama”', text: 'Olvídate del estrés visual y la inseguridad. Te enseñaré a generar fotos magnéticas, espectaculares y perfectas en 5 minutos desde tu computadora, ahorrándote horas de trabajo mientras tú sigues en pijama tomando café.', }, { emoji: '🎭', title: 'Tu clon exacto o tu “Alter Ego”', text: 'Te enseñaré cómo crear tu Gemela Digital idéntica a ti, o bien, cómo diseñar un “Alter Ego” (una modelo con rasgos ligeramente diferentes a los tuyos) para que puedas monetizar tu cuenta manteniendo tu privacidad 100% protegida.', }, { emoji: '📸', title: 'El robo de contenido “legal”', text: '¿Viste una foto increíble en el perfil de tu influencer favorita de viaje o en un hotel de lujo? Te mostraré cómo “robar” esa foto y reemplazarla perfectamente con tu Gemela Digital en segundos. Tendrás su mismo nivel de producción, a costo cero.', }, { emoji: '🔥', title: 'El secreto del contenido “Spicy”', text: 'Las inteligencias artificiales tradicionales como ChatGPT o Gemini bloquean y censuran la creación de fotos sensuales, lencería o trajes de baño. Te revelaré mi herramienta secreta (sin censura) para generar contenido altamente atractivo, “spicy” y monetizable, luciendo espectacular y 100% natural.', }, ], }; const REGISTER_BASE = { title: '¡Desbloquea el Workshop ahora mismo!', text: 'Completa el formulario para liberar tu enlace privado. Podrás ver el workshop al instante, pero recuerda: tu acceso a esta clase gratuita expirará en 24 horas. ¡Entra ahora antes de que la retiremos!', fields: { name: 'Nombre completo*', email: 'Email*', phone: 'Teléfono*', phoneHint: '(Para mensaje de texto SMS solamente)', consent: 'Acepto recibir mensajes sobre el workshop y promociones especiales. Reply STOP to opt out.', }, success: '¡Listo! Revisa tu correo: te enviaremos la fecha exacta y tu enlace de acceso privado.', error: 'No pudimos enviar tu registro. Inténtalo de nuevo en unos minutos.', demoNotice: 'Modo demo: este formulario todavía no está conectado. Configura FORM.endpoint en js/content.js.', }; /* ---- Registration form ------------------------------------------------------ `embedUrl` embeds the real GoHighLevel form (app.monetizate.net) inside the registration panel; the GHL script auto-sizes the iframe. Leave `embedUrl` empty to use the built-in HTML form instead, which POSTs JSON { full_name, email, phone, terms_and_conditions, source } to `endpoint` (and runs in clearly-labelled DEMO mode while `endpoint` is empty). ---------------------------------------------------------------------------- */ const FORM_BASE = { embedUrl: 'https://app.monetizate.net/widget/form/angNGkfcsm9fv1p1nyXY?notrack=true', embedScript: 'https://app.monetizate.net/js/form_embed.js', embedId: 'angNGkfcsm9fv1p1nyXY', embedHeight: 483, embedTitle: 'Registro al Mini-Workshop', endpoint: '', method: 'POST', }; const NAV_BASE = [ { label: 'El método', href: '#metodo' }, { label: 'Antes / después', href: '#antes-despues' }, { label: 'Workshop', href: '#workshop' }, { label: 'Registro', href: '#registro' }, ]; /* ---- Exports (base + variant overrides) --------------------------------- */ /* Final de la historia: 'grid' = las tarjetas se ordenan en cuadrícula 3×3 y se quedan; 'fade' = se desvanecen al terminar el anillo. */ const ENDING_BASE = HIDE_BASE.finalChapter ? 'fade' : 'grid'; const HIDE = pick('HIDE', HIDE_BASE); const VARIANT_ID = VARIANT; const TRAVEL_VH = pick('TRAVEL_VH', TRAVEL_VH_BASE); const BEATS = pick('BEATS', BEATS_BASE); const ENDING = pick('ENDING', ENDING_BASE); const SITE = pick('SITE', SITE_BASE); const EVENT = pick('EVENT', EVENT_BASE); const CTA = pick('CTA', CTA_BASE); const THEME = pick('THEME', THEME_BASE); const CARDS = pick('CARDS', CARDS_BASE); const LABELS = pick('LABELS', LABELS_BASE); const CHAPTERS = pick('CHAPTERS', CHAPTERS_BASE); const PAINS = pick('PAINS', PAINS_BASE); const METHOD = pick('METHOD', METHOD_BASE); const GALLERY = pick('GALLERY', GALLERY_BASE); const MODULES = pick('MODULES', MODULES_BASE); const REGISTER = pick('REGISTER', REGISTER_BASE); const FORM = pick('FORM', FORM_BASE); const NAV = pick('NAV', NAV_BASE); /* ======== timeline.js ======== */ /* ========================================================================= Timeline — the beat table as data. ------------------------------------------------------------------------- Every keyframe time is expressed relative to a beat from content.js (`at('twin', 0.6)` = 60 % of the way through the "twin" beat), so re-timing BEATS re-times the whole animation. Values are sampled with `sample(track, p)`: a pure function of progress p. Consecutive keys with identical values are holds. Units used by the scene: x, y fractions of the visible half-width / half-height at z = 0 z world units rx/ry/rz radians s scale o opacity 0..1 rv reveal 0..1 (pixelated → sharp) scan -2 = off, else local y ========================================================================= */ /* 'fade' (ver js/content-ai-model.js): la historia acaba en el anillo y las tarjetas se desvanecen, sin formar la cuadrícula 3×3. */ const FADE_END = ENDING === 'fade'; const B = Object.fromEntries(BEATS.map((b) => [b.id, b])); const at = (id, f = 0) => B[id].start + (B[id].end - B[id].start) * f; const EASE = { lin: (t) => t, in: (t) => t * t * t, out: (t) => 1 - Math.pow(1 - t, 3), io: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2), soft: (t) => t * t * (3 - 2 * t), step: (t) => (t >= 1 ? 1 : 0), // hold, then jump at the next key }; /* Forward-fill sparse keys so every key carries every value. */ function build(keys) { const out = []; let prev = {}; for (const k of keys) { const v = { ...prev, ...k.v }; out.push({ t: k.t, v, e: EASE[k.e || 'io'] }); prev = v; } return out.sort((a, b) => a.t - b.t); } function sample(track, p) { if (p <= track[0].t) return { ...track[0].v }; const last = track[track.length - 1]; if (p >= last.t) return { ...last.v }; let i = 0; while (i < track.length - 1 && track[i + 1].t <= p) i++; const a = track[i]; const b = track[i + 1]; const span = b.t - a.t; const u = span > 0 ? a.e((p - a.t) / span) : 1; const r = {}; for (const k in a.v) { const av = a.v[k]; const bv = b.v[k]; r[k] = typeof av === 'number' && typeof bv === 'number' ? av + (bv - av) * u : bv; } return r; } /* ---- Camera --------------------------------------------------------------- */ const CAMERA = build([ { t: 0, v: { z: 6.5, y: 0 } }, { t: at('scan'), v: {} , e: 'io' }, { t: at('scan', 1), v: { z: 5.3 } }, { t: at('twin', 0.6), v: { z: 6.0 } }, { t: at('orbit', 1), v: { z: 6.0 } }, { t: at('multiply', 0.45), v: { z: 9.6 } }, { t: at('gallery'), v: {} }, { t: at('gallery', 1), v: { z: 9.2 } }, ]); /* ---- Whole rig (orbit swing) --------------------------------------------- */ const RIG = build([ { t: 0, v: { ry: 0 } }, { t: at('orbit'), v: { ry: 0 }, e: 'io' }, { t: at('orbit', 0.5), v: { ry: 0.55 }, e: 'io' }, { t: at('orbit', 1), v: { ry: 0 } }, ]); /* ---- Original selfie card ------------------------------------------------ */ const ORIGINAL = build([ { t: 0, v: { x: 0.52, y: 0, z: 0, rx: 0.05, ry: -0.32, rz: 0.02, s: 0.94, o: 1, scan: -2 } }, { t: at('scan'), v: {}, e: 'io' }, { t: at('scan', 0.3), v: { x: 0, rx: 0, ry: 0, rz: 0, s: 1 }, e: 'step' }, { t: at('scan', 0.32), v: { scan: 1.08 }, e: 'lin' }, { t: at('scan', 0.95), v: { scan: -1.08 }, e: 'step' }, { t: at('twin'), v: { scan: -2 }, e: 'io' }, { t: at('twin', 0.45), v: { x: -0.66, z: -0.5, ry: 0.35, o: 0.55, s: 0.9 } }, { t: at('orbit'), v: {}, e: 'in' }, { t: at('orbit', 0.6), v: { x: -1.4, z: -1.4, o: 0 } }, ]); /* ---- Digital twin card --------------------------------------------------- */ const TWIN = build([ { t: 0, v: { x: 0, y: 0, z: -0.06, rx: 0, ry: 0, rz: 0, s: 0.98, o: 0, rv: 0, scan: -2 } }, { t: at('twin'), v: {}, e: 'out' }, { t: at('twin', 0.12), v: { o: 1 }, e: 'io' }, { t: at('twin', 0.42), v: { x: -0.3, z: 0.25, ry: -0.08, s: 1 }, e: 'step' }, { t: at('twin', 0.44), v: { scan: 1.08, rv: 0 }, e: 'lin' }, { t: at('twin', 0.9), v: { scan: -1.08, rv: 1 }, e: 'step' }, { t: at('twin', 0.92), v: { scan: -2 }, e: 'io' }, { t: at('orbit'), v: {}, e: 'io' }, { t: at('orbit', 0.5), v: { x: 0, z: 0, ry: 0 }, e: 'io' }, { t: at('multiply'), v: {}, e: 'io' }, { t: at('multiply', 0.35), v: { x: 0.5 }, e: 'io' }, ...(FADE_END ? [ { t: at('gallery', 0.25), v: {}, e: 'io' }, { t: at('gallery', 0.82), v: { o: 0, s: 0.72 } }, ] : [ { t: at('gallery'), v: {}, e: 'io' }, { t: at('gallery', 0.9), v: { x: 0.48, s: 0.78 } }, ]), ]); /* ---- Satellites: offsets from the twin --------------------------------- */ const RING_RADIUS = 1.62; const GRID_STEP = 1.3; const GRID_CELLS = [0, 1, 2, 3, 5, 6, 7, 8]; // 3×3, centre (4) is the twin function satelliteTrack(i, count) { const a = -Math.PI / 2 + (i / count) * Math.PI * 2; // start at the top, clockwise const ring = { dx: Math.cos(a) * RING_RADIUS, dy: Math.sin(a) * RING_RADIUS * 0.92, dz: 0.15 + 0.12 * Math.sin(2 * a), }; const cell = GRID_CELLS[i]; const grid = { dx: ((cell % 3) - 1) * GRID_STEP, dy: (1 - Math.floor(cell / 3)) * GRID_STEP, dz: 0 }; const stagger = i * 0.035; return build([ { t: 0, v: { dx: 0, dy: 0, dz: -0.12, s: 0.3, o: 0, rx: 0, ry: 0, rz: 0 } }, { t: at('multiply', 0.02 + stagger), v: {}, e: 'out' }, { t: at('multiply', 0.38 + stagger), v: { ...ring, s: 0.6, o: 1, ry: -ring.dx * 0.12, rx: ring.dy * 0.08, rz: -ring.dx * 0.02 }, e: 'io', }, ...(FADE_END ? [ { t: at('gallery', 0.12 + i * 0.03), v: {}, e: 'io' }, { t: at('gallery', 0.62 + i * 0.03), v: { o: 0, s: 0.5 } }, ] : [ { t: at('gallery', i * 0.02), v: {}, e: 'io' }, { t: at('gallery', 0.7 + i * 0.03), v: { ...grid, s: 0.78, rx: 0, ry: 0, rz: 0 } }, ]), ]); } /* ---- Captions under the cards ------------------------------------------- "Selfie original" sits under the first card from the start; "Tu Gemela Digital" appears once the twin has separated; both leave at the orbit. */ const LABELS_T = build([ { t: 0, v: { original: 1, twin: 0 } }, { t: at('twin', 0.45), v: {}, e: 'io' }, { t: at('twin', 0.6), v: { twin: 1 } }, { t: at('orbit'), v: {}, e: 'io' }, { t: at('orbit', 0.3), v: { original: 0, twin: 0 } }, ]); /* ---- Atmosphere: backdrop photo opacity + particle energy --------------- */ const FX = build([ { t: 0, v: { bg: 1, energy: 0.2 } }, { t: at('scan'), v: { bg: 1 }, e: 'io' }, { t: at('scan', 0.75), v: { bg: 0, energy: 1 } }, { t: at('twin', 0.5), v: { energy: 0.4 } }, { t: at('orbit', 1), v: { energy: 0.4 } }, { t: at('multiply', 0.5), v: { energy: 1 } }, { t: at('gallery', 1), v: { energy: 0.35 } }, ]); /* ---- Narrow viewports only --------------------------------------------- On phones the hero copy owns the opening frame: the card stays hidden until that copy has faded, then the before/after pair renders larger. */ const NARROW = build([ { t: 0, v: { cardOpacity: 0, boost: 1, dxOrig: 0 } }, { t: at('hero', 0.8), v: {}, e: 'io' }, { t: at('scan', 0.3), v: { cardOpacity: 1 }, e: 'io' }, { t: at('twin', 0.1), v: { boost: 1, dxOrig: 0 }, e: 'io' }, { t: at('twin', 0.45), v: { boost: 1.28, dxOrig: -0.3 } }, { t: at('twin', 1), v: {}, e: 'io' }, { t: at('orbit', 0.8), v: { boost: 1, dxOrig: 0 } }, ]); /* ---- Copy visibility for a beat (DOM chapters) -------------------------- */ function chapterOpacity(beatId, p, fade = 0.035) { const b = B[beatId]; const first = b.start <= 0; const last = b.end >= 1; const inStart = first ? 1 : smooth((p - (b.start + 0.07)) / fade); const outEnd = last ? 1 : 1 - smooth((p - (b.end - fade - 0.01)) / fade); return Math.max(0, Math.min(1, Math.min(inStart, outEnd))); } function smooth(t) { t = Math.max(0, Math.min(1, t)); return t * t * (3 - 2 * t); } /* ======== scene.js ======== */ /* ========================================================================= Scene — Three.js photo-card rig for the CreadoraVirtual scroll story. ------------------------------------------------------------------------- Everything on screen is a pure function of the clamped progress p: setProgress(p) samples the tracks in timeline.js, poses every object, and asks for one render. Nothing accumulates, nothing tweens on its own, so scrolling backwards reproduces the exact earlier frame. ========================================================================= */ /* ---- Dimensions (one place) --------------------------------------------- */ const DIM = { card: 1.5, // card width & height (world units) radius: 0.14, // corner radius depth: 0.06, // body thickness bevel: 0.012, inset: 0.045, // photo inset from the card edge halo: 2.9, // halo plane size }; /* ---- Helpers ------------------------------------------------------------- */ function roundedRectShape(w, h, r) { const s = new THREE.Shape(); const x = -w / 2, y = -h / 2; s.moveTo(x + r, y); s.lineTo(x + w - r, y); s.quadraticCurveTo(x + w, y, x + w, y + r); s.lineTo(x + w, y + h - r); s.quadraticCurveTo(x + w, y + h, x + w - r, y + h); s.lineTo(x + r, y + h); s.quadraticCurveTo(x, y + h, x, y + h - r); s.lineTo(x, y + r); s.quadraticCurveTo(x, y, x + r, y); return s; } function roundedMaskTexture(size, radiusFrac) { const c = document.createElement('canvas'); c.width = c.height = size; const g = c.getContext('2d'); g.fillStyle = '#000'; g.fillRect(0, 0, size, size); g.fillStyle = '#fff'; const r = size * radiusFrac; g.beginPath(); g.roundRect(0, 0, size, size, r); g.fill(); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.NoColorSpace; return t; } function haloTexture(inner, outer) { const size = 256; const c = document.createElement('canvas'); c.width = c.height = size; const g = c.getContext('2d'); const grad = g.createRadialGradient(size / 2, size / 2, size * 0.18, size / 2, size / 2, size / 2); grad.addColorStop(0, inner); grad.addColorStop(0.45, outer); grad.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = grad; g.fillRect(0, 0, size, size); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; } /* A small studio drawn on a canvas: softbox key (top-left), pink rim strip (right), violet floor bounce (bottom), faint cyan kicker (top-right). */ function studioEnvironment(renderer) { const w = 1024, h = 512; const c = document.createElement('canvas'); c.width = w; c.height = h; const g = c.getContext('2d'); const base = g.createLinearGradient(0, 0, 0, h); base.addColorStop(0, '#0b0916'); base.addColorStop(0.55, '#07060d'); base.addColorStop(1, '#130c24'); g.fillStyle = base; g.fillRect(0, 0, w, h); const soft = (x, y, rw, rh, color, feather) => { const grad = g.createRadialGradient(x + rw / 2, y + rh / 2, 0, x + rw / 2, y + rh / 2, Math.max(rw, rh) / 2 + feather); grad.addColorStop(0, color); grad.addColorStop(0.6, color); grad.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = grad; g.fillRect(x - feather, y - feather, rw + feather * 2, rh + feather * 2); }; soft(160, 70, 260, 150, 'rgba(255,250,245,0.95)', 90); // key softbox soft(720, 110, 60, 280, 'rgba(255,95,176,0.9)', 70); // pink rim strip soft(300, 400, 500, 60, 'rgba(129,81,252,0.55)', 90); // violet floor bounce soft(880, 60, 90, 70, 'rgba(91,200,255,0.55)', 60); // cyan kicker const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.mapping = THREE.EquirectangularReflectionMapping; const pmrem = new THREE.PMREMGenerator(renderer); const env = pmrem.fromEquirectangular(tex).texture; tex.dispose(); pmrem.dispose(); return env; } /* ---- Photo card ---------------------------------------------------------- */ class Card { constructor(shared, { map, low = null, alt = '' }) { this.group = new THREE.Group(); this.group.name = alt; const { bodyGeo, photoGeo, mask, haloTex, scanGeo, ringGeo } = shared; this.bodyMat = new THREE.MeshPhysicalMaterial({ color: new THREE.Color(THEME.cardBody), roughness: 0.32, metalness: 0.12, clearcoat: 0.85, clearcoatRoughness: 0.18, envMapIntensity: 1.1, transparent: true, }); this.body = new THREE.Mesh(bodyGeo, this.bodyMat); this.body.renderOrder = 1; this.group.add(this.body); const zFront = DIM.depth / 2 + DIM.bevel + 0.003; this.photoMat = Card.photoMaterial(map, mask, true); this.photo = new THREE.Mesh(photoGeo, this.photoMat); this.photo.position.z = zFront; this.photo.renderOrder = 3; this.group.add(this.photo); if (low) { this.lowMat = Card.photoMaterial(low, mask, false); this.low = new THREE.Mesh(photoGeo, this.lowMat); this.low.position.z = zFront - 0.0015; this.low.renderOrder = 2; this.group.add(this.low); } this.haloMat = new THREE.MeshBasicMaterial({ map: haloTex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0.32, toneMapped: false, }); this.halo = new THREE.Mesh(new THREE.PlaneGeometry(DIM.halo, DIM.halo), this.haloMat); this.halo.position.z = -DIM.depth; this.halo.renderOrder = 0; this.group.add(this.halo); // Scan rig: a horizontal line across the face + a ring around the card. this.scan = new THREE.Group(); this.scanLine = new THREE.Mesh( scanGeo, new THREE.MeshBasicMaterial({ color: THEME.pinkGlow, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false }) ); this.scanLine.position.z = zFront + 0.03; this.scanRing = new THREE.Mesh( ringGeo, new THREE.MeshBasicMaterial({ color: THEME.violetSoft, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false }) ); this.scanRing.rotation.x = Math.PI / 2; this.scan.add(this.scanLine, this.scanRing); this.scan.renderOrder = 4; this.scan.visible = false; this.group.add(this.scan); } /* Photos are unlit on purpose: no key-light glare, no rim tint, no tone mapping — the print shows its true colours. Only the card body is lit. */ static photoMaterial(map, mask, withReveal) { const m = new THREE.MeshBasicMaterial({ map, alphaMap: mask, transparent: true, toneMapped: false, }); if (withReveal) { m.userData.uReveal = { value: 1 }; m.onBeforeCompile = (shader) => { shader.uniforms.uReveal = m.userData.uReveal; shader.vertexShader = shader.vertexShader .replace('#include ', '#include \nvarying vec2 vRevealUv;') .replace('#include ', '#include \nvRevealUv = uv;'); shader.fragmentShader = shader.fragmentShader .replace('#include ', '#include \nuniform float uReveal; varying vec2 vRevealUv;') .replace( '#include ', '#include \n' + 'float edge = 1.0 - uReveal;\n' + 'gl_FragColor.a *= smoothstep(edge - 0.02, edge + 0.02, vRevealUv.y);' ); }; } return m; } /* pose: { x, y, z (world), rx, ry, rz, s, o, rv, scan } */ pose(v) { const g = this.group; g.position.set(v.x, v.y, v.z); g.rotation.set(v.rx || 0, v.ry || 0, v.rz || 0); g.scale.setScalar(v.s ?? 1); const o = Math.max(0, Math.min(1, v.o ?? 1)); g.visible = o > 0.002; this.bodyMat.opacity = o; this.photoMat.opacity = o; if (this.lowMat) this.lowMat.opacity = o; this.haloMat.opacity = 0.32 * o; if (this.photoMat.userData.uReveal) this.photoMat.userData.uReveal.value = v.rv ?? 1; const scan = v.scan ?? -2; const on = scan > -1.5 && o > 0.05; this.scan.visible = on; if (on) { this.scan.position.y = scan * (DIM.card / 2); // brighten in the middle of the sweep, soften at the ends const k = 1 - Math.min(1, Math.abs(scan) / 1.08); this.scanLine.material.opacity = 0.35 + 0.65 * k; this.scanRing.material.opacity = 0.25 + 0.6 * k; } } } /* ---- Particle field ------------------------------------------------------ */ function makeParticles(count) { const geo = new THREE.BufferGeometry(); const pos = new Float32Array(count * 3); const col = new Float32Array(count * 3); const base = new Float32Array(count * 3); const speed = new Float32Array(count); const palette = [THEME.pinkGlow, THEME.violetSoft, THEME.cyan, '#ffffff', THEME.violet].map((c) => new THREE.Color(c)); for (let i = 0; i < count; i++) { base[i * 3] = (Math.random() * 2 - 1) * 9; base[i * 3 + 1] = (Math.random() * 2 - 1) * 5; base[i * 3 + 2] = -4 + Math.random() * 6; speed[i] = 0.4 + Math.random() * 1.6; const c = palette[Math.floor(Math.random() * palette.length)]; col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b; } geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); const mat = new THREE.PointsMaterial({ size: 0.055, vertexColors: true, transparent: true, opacity: 0.7, blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true, toneMapped: false, }); const points = new THREE.Points(geo, mat); points.frustumCulled = false; return { points, /* Drift is a function of p only: particles rise as the visitor scrolls. */ update(p, energy) { const a = geo.attributes.position.array; for (let i = 0; i < count; i++) { const y = base[i * 3 + 1] + p * 6 * speed[i]; a[i * 3] = base[i * 3] + Math.sin(p * 4 + i) * 0.15; a[i * 3 + 1] = ((y + 5) % 10 + 10) % 10 - 5; a[i * 3 + 2] = base[i * 3 + 2]; } geo.attributes.position.needsUpdate = true; mat.opacity = 0.45 + 0.5 * energy; mat.size = 0.045 + 0.04 * energy; }, }; } /* ---- Scene factory ------------------------------------------------------- */ function createScene(canvas, { onReady, onFrame } = {}) { const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance', }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05; renderer.setClearColor(0x000000, 0); const scene = new THREE.Scene(); scene.environment = studioEnvironment(renderer); const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100); camera.position.set(0, 0, 6.5); // Lights like a product shoot: key, pink rim, violet fill. const key = new THREE.DirectionalLight(0xfff4ec, 1.6); key.position.set(3, 4, 5); const rim = new THREE.DirectionalLight(new THREE.Color(THEME.pinkGlow), 1.4); rim.position.set(-4, 2, -3); const fill = new THREE.DirectionalLight(new THREE.Color(THEME.violet), 0.7); fill.position.set(-3, -1, 4); scene.add(key, rim, fill, new THREE.AmbientLight(0x2a2140, 0.6)); const rig = new THREE.Group(); scene.add(rig); // Shared geometry const bodyGeo = new THREE.ExtrudeGeometry(roundedRectShape(DIM.card, DIM.card, DIM.radius), { depth: DIM.depth, bevelEnabled: true, bevelThickness: DIM.bevel, bevelSize: DIM.bevel, bevelSegments: 3, curveSegments: 12, }); bodyGeo.translate(0, 0, -DIM.depth / 2); const photoSize = DIM.card - DIM.inset * 2; const shared = { bodyGeo, photoGeo: new THREE.PlaneGeometry(photoSize, photoSize), mask: roundedMaskTexture(512, (DIM.radius - DIM.inset * 0.6) / photoSize), haloTex: haloTexture('rgba(255,95,176,0.55)', 'rgba(129,81,252,0.35)'), scanGeo: new THREE.PlaneGeometry(DIM.card * 1.12, 0.028), ringGeo: new THREE.TorusGeometry(DIM.card * 0.86, 0.012, 8, 72), }; // Textures const loader = new THREE.TextureLoader(); const maxAniso = renderer.capabilities.getMaxAnisotropy(); const tex = (src) => { const t = loader.load(src, () => request()); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = Math.min(8, maxAniso); t.generateMipmaps = true; t.minFilter = THREE.LinearMipmapLinearFilter; return t; }; const original = new Card(shared, { map: tex(CARDS.original.src), alt: CARDS.original.alt }); const twin = new Card(shared, { map: tex(CARDS.twin.src), low: tex(CARDS.twin.low), alt: CARDS.twin.alt }); const satellites = CARDS.satellites.map((c) => new Card(shared, { map: tex(c.src), alt: c.alt })); const satTracks = satellites.map((_, i) => satelliteTrack(i, satellites.length)); rig.add(original.group, twin.group, ...satellites.map((s) => s.group)); const particles = makeParticles(520); scene.add(particles.points); /* ---- Layout ---------------------------------------------------------- */ const layout = { aspect: 1, narrow: false, xScale: 1, yLift: 0, scale: 1, camPush: 0, fov: 34 }; function resize() { const w = canvas.clientWidth; const h = canvas.clientHeight; if (!w || !h) return; // not laid out yet (CSS still loading); the observer will call again renderer.setSize(w, h, false); layout.aspect = w / h; layout.narrow = layout.aspect < 0.9 || w < 720; // Narrow viewports: subject centred in the upper part of the stage, // copy sits at the bottom (see CSS). Wide viewports: subject on the half // opposite the copy. layout.fov = layout.narrow ? 40 : 34; layout.xScale = layout.narrow ? 0.22 : 1; layout.yLift = layout.narrow ? 0.44 : 0; layout.scale = layout.narrow ? 0.62 : 1; layout.camPush = layout.narrow ? 1.4 : 0; camera.fov = layout.fov; camera.aspect = layout.aspect; camera.updateProjectionMatrix(); dirty = true; request(); } /* Convert (x,y) fractions + scale into world units for the current camera. */ function toWorld(v, camZ) { const halfH = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * camZ; const halfW = halfH * layout.aspect; return { x: (v.x || 0) * halfW * layout.xScale, y: (v.y || 0) * halfH + layout.yLift * halfH, halfW, halfH, }; } /* ---- Pose = f(p) ------------------------------------------------------ */ let progress = 0; let lastPosed = -1; let dirty = true; let scheduled = false; let disposed = false; function poseAll(p) { const cam = sample(CAMERA, p); const camZ = cam.z + layout.camPush; camera.position.set(0, cam.y || 0, camZ); camera.lookAt(0, 0, 0); rig.rotation.y = sample(RIG, p).ry; // Phone-only adjustments: hide the card behind the hero copy, enlarge // the before/after pair. const n = layout.narrow ? sample(NARROW, p) : { cardOpacity: 1, boost: 1, dxOrig: 0 }; const o = sample(ORIGINAL, p); const ow = toWorld(o, camZ); original.pose({ ...o, x: ow.x + n.dxOrig * ow.halfW, y: ow.y, s: o.s * layout.scale * n.boost, o: o.o * n.cardOpacity }); const t = sample(TWIN, p); const tw = toWorld(t, camZ); const twinPose = { ...t, x: tw.x, y: tw.y, s: t.s * layout.scale * n.boost }; twin.pose(twinPose); // Satellites orbit / grid around the twin's current position. const spread = layout.narrow ? 0.62 : 1; for (let i = 0; i < satellites.length; i++) { const s = sample(satTracks[i], p); satellites[i].pose({ x: twinPose.x + s.dx * spread, y: twinPose.y + s.dy * spread, z: t.z + s.dz, rx: s.rx, ry: s.ry, rz: s.rz, s: s.s * layout.scale, o: s.o, }); } const fx = sample(FX, p); particles.update(p, fx.energy); // Screen anchors (CSS px) just below each card, for the DOM captions. const lt = sample(LABELS_T, p); rig.updateMatrixWorld(true); // On phones the two cards overlap, so the first caption yields to the second. const origLabel = layout.narrow ? lt.original * (1 - lt.twin) : lt.original; fx.labels = { original: anchor(original, o.o * n.cardOpacity * origLabel), twin: anchor(twin, t.o * lt.twin), }; return fx; } const _v = new THREE.Vector3(); function anchor(card, opacity) { const g = card.group; _v.set(0, -(DIM.card / 2) * g.scale.y - 0.12, 0); g.localToWorld(_v); _v.project(camera); const w = canvas.clientWidth, h = canvas.clientHeight; return { x: ((_v.x + 1) / 2) * w, y: ((1 - _v.y) / 2) * h, opacity: Math.max(0, Math.min(1, opacity)), visible: _v.z < 1 && opacity > 0.01, }; } let fxState = { bg: 1, energy: 0.2 }; function render() { if (disposed) return; if (dirty || progress !== lastPosed) { fxState = poseAll(progress); lastPosed = progress; dirty = false; renderer.render(scene, camera); if (onFrame) onFrame(fxState); } } function frame() { scheduled = false; render(); } function request() { if (scheduled || disposed) return; scheduled = true; // rAF is paused in hidden tabs; fall back to a timer so the first frame // after the tab returns is already correct. if (document.visibilityState === 'hidden') setTimeout(frame, 32); else requestAnimationFrame(frame); } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { dirty = true; request(); } }); function setProgress(p) { p = Math.max(0, Math.min(1, p)); if (p === progress && !dirty) return fxState; progress = p; request(); return sample(FX, p); } /* Poster: render the resting pose and read the pixels back immediately. */ function capture(p = 0, type = 'image/jpeg', quality = 0.9) { progress = p; dirty = true; render(); return canvas.toDataURL(type, quality); } function dispose() { disposed = true; if (observer) observer.disconnect(); scene.traverse((obj) => { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; mats.forEach((m) => { for (const k in m) if (m[k] && m[k].isTexture) m[k].dispose(); m.dispose(); }); } }); renderer.dispose(); } resize(); // Re-fit whenever the canvas box changes: late CSS, fonts, host layout // shifts (GoHighLevel), device rotation. Without this the scene could keep // a stale size/aspect until the window was resized. const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => resize()) : null; if (observer) observer.observe(canvas); // Signal readiness once every texture has arrived (or failed). const all = [CARDS.original.src, CARDS.twin.src, CARDS.twin.low, ...CARDS.satellites.map((c) => c.src)]; let pending = all.length; all.forEach((src) => { const img = new Image(); img.onload = img.onerror = () => { if (--pending === 0) { dirty = true; request(); onReady && onReady(); } }; img.src = src; }); return { setProgress, resize, capture, dispose, renderer, scene, camera, get layout() { return layout; } }; } /* ======== main.js ======== */ /* ========================================================================= main.js — renders the page from content.js, drives the pinned stage, times the chapter copy, handles fallbacks and the waitlist form. ========================================================================= */ const $ = (sel, root = document) => root.querySelector(sel); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); /* ---- Render static structure --------------------------------------------- */ function renderChrome() { document.title = SITE.title; document.querySelectorAll('[data-logo]').forEach((img) => { img.src = asset('brand/logo-creadoravirtual.png'); }); const poster = $('[data-poster]'); if (poster) poster.src = asset('assets/img/poster.jpg'); const bg = $('[data-stage-bg]'); if (bg) bg.style.backgroundImage = `url("${asset('assets/img/hero-neon.jpg')}")`; const navHtml = NAV.map((n) => `${esc(n.label)}`).join(''); $('[data-nav]').innerHTML = navHtml; $('[data-mobile-nav]').innerHTML = navHtml + `${esc(CTA.primary)}`; $('[data-skip]').textContent = CTA.skip + ' ↓'; $('.skip-link').textContent = CTA.skip; const toggle = $('.nav-toggle'); const mobile = $('[data-mobile-nav]'); toggle.addEventListener('click', () => { const open = toggle.getAttribute('aria-expanded') === 'true'; toggle.setAttribute('aria-expanded', String(!open)); mobile.hidden = open; document.body.classList.toggle('nav-open', !open); }); mobile.addEventListener('click', (e) => { if (e.target.closest('a')) toggle.click(); }); } function renderChapters() { const wrap = $('[data-chapters]'); const chapters = CHAPTERS.filter((c) => !c.hidden); wrap.innerHTML = chapters.map((c) => { const beat = BEATS.find((b) => b.id === c.beat); const bullets = c.bullets ? `
    ${c.bullets.map((b) => `
  • ${esc(b)}
  • `).join('')}
` : ''; const cta = c.cta ? `` : ''; return ``; }).join(''); // Hero copy is the page's H1. const first = wrap.querySelector('.chapter__title'); if (first) first.outerHTML = first.outerHTML.replace(/^

$/, 'h1>'); $('[data-labels]').innerHTML = Object.entries(LABELS) .map(([k, txt]) => ``).join(''); // Un punto por capítulo existente (una variante puede no traerlos todos). $('[data-dots]').innerHTML = chapters.map((c) => `
  • `).join(''); } /* Deja fuera del menú los enlaces a secciones que no existen y manda los enlaces "saltar la animación" a la primera sección visible. */ function fixInternalLinks() { const exists = (href) => !href.startsWith('#') || !!document.querySelector(href); document.querySelectorAll('[data-nav] a, [data-mobile-nav] a, .chapter a').forEach((a) => { const href = a.getAttribute('href') || ''; if (href.startsWith('#') && !exists(href)) a.remove(); }); const first = document.querySelector('main section:not(#historia)'); if (first) { document.querySelectorAll('.skip-link, [data-skip]').forEach((a) => { a.setAttribute('href', '#' + first.id); }); } } function renderSections() { const painsEl = $('[data-pains]'); if (HIDE.pains) painsEl.remove(); else painsEl.innerHTML = `

    ${esc(PAINS.title)} ${esc(PAINS.subtitle)}

    ${PAINS.items.map((p, i) => `
    0${i + 1}

    ${esc(p.title)}

    ${esc(p.text)}

    `).join('')}
    `; // METHOD.hidden quita la sección "El método" sin tocar la galería (que usa METHOD.gallery). const methodEl = $('[data-method]'); if (!methodEl) { /* la página no trae la sección (ai-model.html) */ } else if (HIDE.method) methodEl.remove(); else methodEl.innerHTML = `

    El método

    ${esc(METHOD.title)}

    ${esc(METHOD.text)}

    ${esc(CTA.primary)}${esc(EVENT.whenShort)}
    `; const galleryEl = $('[data-gallery]'); if (galleryEl) galleryEl.innerHTML = `

    ${esc(GALLERY.eyebrow)}

    ${esc(GALLERY.title)}

    ${esc(GALLERY.note)}

    ${METHOD.gallery.map((g, i) => `
    Comparación ${i + 1}: ${esc(g.caption)}
    ${esc(g.caption)}
    `).join('')}
    `; $('[data-modules]').innerHTML = `

    ${esc(EVENT.label)} · ${esc(EVENT.when)}

    ${esc(MODULES.title)}

      ${MODULES.items.map((m) => `
    1. ${esc(m.title)}

      ${esc(m.text)}

    2. `).join('')}
    `; const f = REGISTER.fields; const embed = FORM.embedUrl ? `` : ''; $('[data-register]').innerHTML = `

    ${esc(EVENT.whenShort)} · ${esc(EVENT.seats)}

    ${esc(REGISTER.title)}

    ${esc(REGISTER.text)}

    ${embed}

    `; $('[data-footer]').innerHTML = ` `; } /* ---- Waitlist form --------------------------------------------------------- */ function setupForm() { if (FORM.embedUrl) { // Real GoHighLevel form: its script listens for the iframe and resizes it. const sc = document.createElement('script'); sc.src = FORM.embedScript; sc.async = true; document.body.appendChild(sc); return; } const form = $('[data-form]'); const status = $('[data-form-status]'); const demo = $('[data-form-demo]'); const isDemo = !FORM.endpoint; if (isDemo) { demo.hidden = false; demo.textContent = REGISTER.demoNotice; } form.addEventListener('submit', async (e) => { e.preventDefault(); status.textContent = ''; status.className = 'form-status'; if (!form.checkValidity()) { form.reportValidity(); return; } const data = Object.fromEntries(new FormData(form).entries()); data.terms_and_conditions = !!data.terms_and_conditions; data.source = location.href; if (isDemo) { status.textContent = 'Modo demo — no se envió nada. ' + REGISTER.demoNotice; status.classList.add('is-demo'); return; } const btn = form.querySelector('button[type=submit]'); btn.disabled = true; try { const res = await fetch(FORM.endpoint, { method: FORM.method || 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!res.ok) throw new Error('HTTP ' + res.status); status.textContent = REGISTER.success; status.classList.add('is-ok'); form.reset(); } catch (err) { status.textContent = REGISTER.error; status.classList.add('is-error'); } finally { btn.disabled = false; } }); } /* ---- Scroll story driver --------------------------------------------------- */ const params = new URLSearchParams(location.search); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const forceStatic = params.has('static') || reduced; function setStatic(reason) { document.body.classList.add('is-static'); document.body.dataset.staticReason = reason; document.querySelectorAll('[data-chapter]').forEach((el) => { el.setAttribute('aria-hidden', 'false'); el.style.opacity = ''; el.style.transform = ''; }); } async function setupStory() { const story = $('#historia'); const stage = $('[data-stage]'); const canvas = $('[data-canvas]'); const bg = $('[data-stage-bg]'); const poster = $('[data-poster]'); const chapters = [...document.querySelectorAll('[data-chapter]')]; const dots = [...document.querySelectorAll('[data-dot]')]; const hint = $('[data-hint]'); story.style.setProperty('--travel', TRAVEL_VH); if (forceStatic) { setStatic(reduced ? 'reduced-motion' : 'query'); return; } let scene = null; try { // Probe WebGL on a throwaway canvas so Three.js owns the real context. const probe = document.createElement('canvas'); if (!(probe.getContext('webgl2') || probe.getContext('webgl'))) throw new Error('WebGL unavailable'); THREE = await import(CV_THREE_URL); const labels = Object.fromEntries([...document.querySelectorAll('[data-label]')].map((el) => [el.dataset.label, el])); scene = createScene(canvas, { onFrame: (fx) => { for (const k in fx.labels) { const a = fx.labels[k]; const el = labels[k]; if (!el) continue; el.style.opacity = a.opacity.toFixed(3); el.style.transform = `translate3d(${a.x.toFixed(1)}px, ${a.y.toFixed(1)}px, 0) translate(-50%, 0)`; el.style.visibility = a.visible ? 'visible' : 'hidden'; } }, onReady: () => { stage.classList.add('is-ready'); if (params.has('poster')) offerPoster(scene); }, }); if (params.has('debug')) window.__cvScene = scene; } catch (err) { console.warn('[story] falling back to static poster:', err); setStatic('webgl'); return; } /* progress = clamp(sceneScroll / sceneScrollDistance, 0, 1) */ let progress = -1; let ticking = false; function measure() { const rect = story.getBoundingClientRect(); const travel = story.offsetHeight - window.innerHeight; const p = travel > 0 ? Math.max(0, Math.min(1, -rect.top / travel)) : 0; return p; } function applyCopy(p) { for (const el of chapters) { const o = chapterOpacity(el.dataset.chapter, p); const visible = o > 0.02; el.style.opacity = o.toFixed(3); el.style.transform = `translate3d(0, ${((1 - o) * 18).toFixed(1)}px, 0)`; if (el.classList.contains('is-visible') !== visible) { el.classList.toggle('is-visible', visible); el.setAttribute('aria-hidden', visible ? 'false' : 'true'); el.inert = !visible; } } const current = BEATS.find((b) => p >= b.start && p < b.end) || BEATS[BEATS.length - 1]; dots.forEach((d) => d.classList.toggle('is-on', d.dataset.dot === current.id)); hint.classList.toggle('is-hidden', p > 0.03); } function update() { ticking = false; const p = measure(); if (p === progress) return; progress = p; const fx = scene.setProgress(p); bg.style.opacity = (fx.bg * 0.55).toFixed(3); applyCopy(p); } function onScroll() { if (!ticking) { ticking = true; requestAnimationFrame(update); } } window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', () => { scene.resize(); progress = -1; onScroll(); }); window.addEventListener('pagehide', (e) => { if (!e.persisted) scene.dispose(); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { progress = -1; onScroll(); } }); update(); poster.classList.add('is-behind'); } /* Dev helper: ?poster renders the resting pose and offers it as a download. */ function offerPoster(scene) { const url = scene.capture(0); const a = document.createElement('a'); a.href = url; a.download = 'poster.jpg'; a.textContent = 'Descargar poster.jpg (pose de reposo)'; a.className = 'poster-download'; document.body.appendChild(a); } /* ---- Boot ------------------------------------------------------------------- */ function cvBoot() { renderChrome(); renderChapters(); renderSections(); fixInternalLinks(); setupForm(); setupStory(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', cvBoot); else cvBoot(); // GHL build hides .cv-root until styles.css applies; never leave it hidden. setTimeout(() => document.querySelectorAll('.cv-hide-until-css').forEach((el) => el.classList.remove('cv-hide-until-css')), 4000); })();