• File: main.js
  • Full Path: /home2/jasoftec/educativovasconcelos.edu.mx/assets/images/brand/main.js
  • Date Modified: 04/03/2026 1:11 AM
  • File size: 75.47 KB
  • MIME-type: text/plain
  • Charset: utf-8
(function () {
  "use strict";

  /* =========================================================
     CONSTANTS & GLOBALS
  ========================================================= */
  const DATA = window.SUVJ_DATA;
  const REDUCED_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const IS_TOUCH = ("ontouchstart" in window) || navigator.maxTouchPoints > 0;
  const METRICS = new Set();

  if (!DATA) return;

  const PAGE_LINKS = [
    { href: "nosotros.html",      label: "Nosotros" },
    { href: "modalidades.html",   label: "Modalidades" },
    { href: "admisiones.html",    label: "Admisiones" },
    { href: "campus.html",        label: "Campus" },
    { href: "vida-estudiantil.html", label: "Vida estudiantil" },
    { href: "contacto.html",      label: "Contacto" }
  ];

  const CAMPUS_LOOKUP = new Map(DATA.campus.map((item) => [item.id, item]));

  /* =========================================================
     UTILITIES
  ========================================================= */
  function getCurrentPage() {
    const raw = window.location.pathname.split("/").pop();
    return raw && raw.length ? raw : "index.html";
  }

  function detailPage(modality) {
    return `modalidad-${modality.id}.html`;
  }

  function getCampusNames(campusIds) {
    return campusIds.map((id) => CAMPUS_LOOKUP.get(id)?.name).filter(Boolean);
  }

  function formatCampusCoverage(campusIds) {
    if (campusIds.length === DATA.campus.length) return "Todos los planteles";
    return getCampusNames(campusIds).join(" / ");
  }

  function getModalitiesForCampus(campusId) {
    return DATA.modalities.filter((item) => item.campusIds.includes(campusId));
  }

  function trackEvent(eventName, payload) {
    const body = {
      event: eventName,
      ts: new Date().toISOString(),
      page: getCurrentPage(),
      ...(payload || {})
    };
    window.__suvjMetrics = window.__suvjMetrics || [];
    window.__suvjMetrics.push(body);
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push(body);
  }

  function showToast(message) {
    const toast = document.getElementById("toast");
    if (!toast) return;
    toast.textContent = message;
    toast.classList.add("show");
    window.setTimeout(() => toast.classList.remove("show"), 2300);
  }

  function resetSharedVideoPlayer() {
    const video = document.getElementById("institutionalVideo");
    if (!video) return;

    video.pause();
    video.currentTime = 0;
    video.removeAttribute("src");
    video.removeAttribute("poster");
    video.onerror = null;
    video.oncanplay = null;

    while (video.firstChild) video.removeChild(video.firstChild);

    video.load();
  }

  function toAbsoluteSiteUrl(pathOrUrl) {
    if (!pathOrUrl) return "";
    if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
    const cleanBase = (DATA.site.canonicalBase || window.location.origin || "").replace(/\/$/, "");
    const cleanPath = String(pathOrUrl).replace(/^\//, "");
    return `${cleanBase}/${cleanPath}`;
  }

  function getTelephoneSchemaValue() {
    const display = DATA.site.whatsappDisplay || DATA.site.phone || "";
    return display ? `+52 ${display}` : "";
  }

  function getPhoneDialValue() {
    const source = DATA.site.phone || DATA.site.whatsappDisplay || "";
    const digits = String(source).replace(/\D+/g, "");
    return digits ? `+52${digits}` : "";
  }

  function upsertRuntimeSchema(id, payload) {
    if (!payload) return;

    const prior = document.getElementById(id);
    if (prior) prior.remove();

    const script = document.createElement("script");
    script.type = "application/ld+json";
    script.id = id;
    script.textContent = JSON.stringify(payload);
    document.head.appendChild(script);
  }

  function mountPosterMedia(poster, src, alt) {
    if (!poster || !src) return;

    poster.style.backgroundImage = "none";
    let media = poster.querySelector(".video-poster-media");
    if (!media) {
      media = document.createElement("img");
      media.className = "video-poster-media";
      media.loading = "lazy";
      media.decoding = "async";
      poster.prepend(media);
    }

    media.src = src;
    media.alt = alt || "";
  }

  function closeModal(modalId) {
    const modal = document.getElementById(modalId);
    if (!modal) return;
    // Enhanced modal close: reverse animation via class swap
    modal.classList.add("closing");
    /* Match the CSS exit animation: 0.18s = 180ms */
    window.setTimeout(() => {
      modal.classList.remove("open", "closing");
      if (modalId === "videoModal") {
        resetSharedVideoPlayer();
      }
    }, REDUCED_MOTION ? 0 : 200);
  }

  // Debounce helper for resize events
  function debounce(fn, delay) {
    let timer;
    return function (...args) {
      window.clearTimeout(timer);
      timer = window.setTimeout(() => fn.apply(this, args), delay);
    };
  }

  // Linear interpolation for smooth follower animation
  function lerp(a, b, t) {
    return a + (b - a) * t;
  }

  // Format numbers with commas (e.g. 1000 -> 1,000)
  function formatNumber(n) {
    return n.toLocaleString("es-MX");
  }

  /* =========================================================
     SPLIT WORDS — staggered heading animation helper
  ========================================================= */
  function splitWords(el) {
    if (!el || el.dataset.wordSplit) return;
    el.dataset.wordSplit = "1";
    const html = el.innerHTML.trim();
    // If heading contains HTML child tags (like <em>), skip word-splitting
    // to avoid breaking the markup — the heading still gets color-morph animation
    if (/<[a-z]/i.test(html)) return;
    el.innerHTML = html.split(/\s+/).map(w =>
      `<span class="word-wrap"><span class="word-inner">${w}</span></span>`
    ).join(' ');
  }

  /* =========================================================
     1. PRELOADER
  ========================================================= */
  function initPreloader() {
    if (REDUCED_MOTION) return;

    /* Use the HTML preloader if it exists, otherwise skip */
    const preloader = document.getElementById("pagePreloader");
    if (!preloader) return;

    /* Emil: preloader exits with opacity fade — simple, not distracting */
    window.setTimeout(() => {
      preloader.classList.add("loaded");
    }, 900);
    window.setTimeout(() => {
      preloader.remove();
    }, 1600);
  }

  /* =========================================================
     2. SCROLL PROGRESS BAR
  ========================================================= */
  function initScrollProgressBar() {
    if (REDUCED_MOTION) return;

    /* Use HTML element if exists (index.html has it), else create */
    let bar = document.getElementById("scrollProgress");
    if (!bar) {
      bar = document.createElement("div");
      bar.id = "scrollProgress";
      bar.setAttribute("aria-hidden", "true");
      bar.className = "scroll-progress";
      document.body.prepend(bar);
    }

    let ticking = false;

    function updateProgress() {
      const scrollTop  = window.scrollY;
      const docHeight  = document.documentElement.scrollHeight - window.innerHeight;
      const progress   = docHeight > 0 ? Math.min(scrollTop / docHeight, 1) : 0;
      /* GPU-accelerated: transform only — Emil principle */
      bar.style.transform = `scaleX(${progress})`;
      ticking = false;
    }

    window.addEventListener("scroll", () => {
      if (!ticking) {
        requestAnimationFrame(updateProgress);
        ticking = true;
      }
    }, { passive: true });

    /* Initial call in case page loaded mid-scroll */
    updateProgress();
  }

  /* =========================================================
     3. CUSTOM CURSOR FOLLOWER (desktop only)
  ========================================================= */
  function initCursorFollower() {
    return;
  }

  /* =========================================================
     4. MAGNETIC BUTTONS
  ========================================================= */
  function initMagneticButtons() {
    if (REDUCED_MOTION || IS_TOUCH) return;

    const STRENGTH = 0.28; // how strongly the button pulls toward cursor

    function attachMagnet(el) {
      el.addEventListener("mousemove", (e) => {
        const rect   = el.getBoundingClientRect();
        const cx     = rect.left + rect.width  / 2;
        const cy     = rect.top  + rect.height / 2;
        const dx     = (e.clientX - cx) * STRENGTH;
        const dy     = (e.clientY - cy) * STRENGTH;
        /* Emil: ease-out for entering state, instant response feels quality */
        el.style.transform    = `translate(${dx}px, ${dy}px)`;
        el.style.transition   = "transform 0.1s cubic-bezier(0.23, 1, 0.32, 1)";
      });

      el.addEventListener("mouseleave", () => {
        /* Spring back: slower ease-out, feels natural */
        el.style.transform  = "translate(0, 0)";
        el.style.transition = "transform 0.5s cubic-bezier(0.23, 1, 0.32, 1)";
      });
    }

    // Attach to existing buttons immediately and watch for new DOM nodes
    function attachAll() {
      document.querySelectorAll(".btn, .btn-outline").forEach((el) => {
        if (!el.dataset.magnetAttached) {
          el.dataset.magnetAttached = "1";
          attachMagnet(el);
        }
      });
    }

    attachAll();

    // Re-run after dynamic renders settle
    window.setTimeout(attachAll, 800);
  }

  /* =========================================================
     5. 3D CARD TILT
  ========================================================= */
  function initCardTilt() {
    if (REDUCED_MOTION || IS_TOUCH) return;

    const MAX_TILT   = 8; // degrees
    const PERSPECTIVE = 1000;

    function attachTilt(card) {
      card.addEventListener("mousemove", (e) => {
        const rect  = card.getBoundingClientRect();
        const relX  = (e.clientX - rect.left) / rect.width  - 0.5; // -0.5 to 0.5
        const relY  = (e.clientY - rect.top)  / rect.height - 0.5;
        const rotX  = -relY * MAX_TILT;
        const rotY  =  relX * MAX_TILT;

        /* Emil: ease-out, immediate response — scale subtle (1.015 not 1.02) */
        card.style.willChange = "transform";
        card.style.transform  = `perspective(${PERSPECTIVE}px) rotateX(${rotX}deg) rotateY(${rotY}deg) scale3d(1.015,1.015,1.015)`;
        card.style.transition = "transform 0.08s cubic-bezier(0.23, 1, 0.32, 1)";
        card.style.setProperty("--mouse-x", `${(relX + 0.5) * 100}%`);
        card.style.setProperty("--mouse-y", `${(relY + 0.5) * 100}%`);
      });

      card.addEventListener("mouseleave", () => {
        /* Emil: spring-back with strong ease-out — feels alive */
        card.style.transform  = "perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1,1,1)";
        card.style.transition = "transform 0.6s cubic-bezier(0.16, 1, 0.3, 1)";
        /* Remove will-change after animation settles */
        window.setTimeout(() => { card.style.willChange = "auto"; }, 600);
      });
    }

    function attachAll() {
      const selectors = ".info-card, .modality-card, .proof-card, .admission-step, .kpi-item, .form-card, .timeline-item";
      document.querySelectorAll(selectors).forEach((card) => {
        if (!card.dataset.tiltAttached) {
          card.dataset.tiltAttached = "1";
          card.style.willChange = "transform";
          attachTilt(card);
        }
      });
    }

    attachAll();
    window.setTimeout(attachAll, 800);
  }

  /* =========================================================
     6. ENHANCED REVEAL SYSTEM
     Supports: .reveal, .reveal-left, .reveal-right, .reveal-scale, .reveal-rotate
  ========================================================= */
  function initReveal() {
    const BASE_CLASSES = ["reveal", "reveal-left", "reveal-right", "reveal-scale", "reveal-rotate"];
    const selector = BASE_CLASSES.map((c) => `.${c}`).join(", ");
    const nodes = Array.from(document.querySelectorAll(selector));
    if (!nodes.length) return;

    if (REDUCED_MOTION) {
      nodes.forEach((node) => node.classList.add("is-visible"));
      return;
    }

    // Auto-stagger sibling groups: children of same parent get incremental delays
    const parentSeen = new Map();
    nodes.forEach((node) => {
      const parent = node.parentElement;
      if (!parent) return;
      const siblings = parent.querySelectorAll(selector);
      if (siblings.length > 1) {
        if (!parentSeen.has(parent)) parentSeen.set(parent, 0);
        const idx = parentSeen.get(parent);
        // Only set if no explicit delay class already present
        if (!node.className.match(/delay-\d/)) {
          node.style.transitionDelay = `${idx * 80}ms`;
        }
        parentSeen.set(parent, idx + 1);
      }
    });

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            entry.target.classList.add("is-visible");
            observer.unobserve(entry.target);
          }
        });
      },
      { threshold: 0.1, rootMargin: "0px 0px -40px 0px" }
    );

    nodes.forEach((node) => observer.observe(node));
  }

  /* =========================================================
     7. SMOOTH WORD-BY-WORD TEXT ANIMATION
  ========================================================= */
  function initTextAnimations() {
    if (REDUCED_MOTION) return;

    // Only animate headings that are direct children of .reveal containers
    const headings = document.querySelectorAll(".reveal h1, .reveal h2, .reveal-left h1, .reveal-left h2");

    headings.forEach((heading) => {
      if (heading.dataset.textSplit) return;
      heading.dataset.textSplit = "1";

      const words = heading.textContent.split(" ");
      heading.innerHTML = words
        .map((word, i) =>
          `<span class="word-span" style="display:inline-block; overflow:hidden; vertical-align:bottom;">
            <span style="display:inline-block; transform:translateY(110%); opacity:0; transition: transform 0.55s cubic-bezier(0.22,1,0.36,1) ${i * 65}ms, opacity 0.45s ease ${i * 65}ms;">
              ${word}${i < words.length - 1 ? "&nbsp;" : ""}
            </span>
          </span>`
        )
        .join("");

      const parent = heading.closest(".reveal, .reveal-left, .reveal-right");
      if (!parent) return;

      const wordObserver = new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            if (entry.isIntersecting) {
              heading.querySelectorAll(".word-span > span").forEach((span) => {
                span.style.transform = "translateY(0)";
                span.style.opacity   = "1";
              });
              wordObserver.unobserve(heading);
            }
          });
        },
        { threshold: 0.3 }
      );

      wordObserver.observe(heading);
    });
  }

  /* =========================================================
     8. PARALLAX EFFECTS
  ========================================================= */
  function initParallax() {
    if (REDUCED_MOTION) return;

    const hero     = document.querySelector(".page-hero, .hero");
    const mascots  = document.querySelectorAll(".mascot-scene img");
    const kpiBand  = document.querySelector(".kpi-band, .kpi-section");

    let lastScrollY = window.scrollY;
    let rafPending  = false;

    function applyParallax() {
      const scrollY = window.scrollY;

      if (hero) {
        // Hero bg moves at 40% of scroll speed (slower = depth)
        hero.style.backgroundPositionY = `${scrollY * 0.4}px`;
      }

      mascots.forEach((img) => {
        const rect   = img.closest(".mascot-scene")?.getBoundingClientRect();
        if (!rect) return;
        const offset = (window.innerHeight / 2 - rect.top - rect.height / 2) * 0.06;
        img.style.transform = `translateY(${offset}px)`;
      });

      if (kpiBand) {
        const rect   = kpiBand.getBoundingClientRect();
        const offset = (window.innerHeight / 2 - rect.top - rect.height / 2) * 0.04;
        kpiBand.style.backgroundPositionY = `${offset}px`;
      }

      lastScrollY  = scrollY;
      rafPending   = false;
    }

    window.addEventListener("scroll", () => {
      if (!rafPending) {
        requestAnimationFrame(applyParallax);
        rafPending = true;
      }
    }, { passive: true });
  }

  /* =========================================================
     9. ENHANCED COUNTERS (easeOutExpo + commas + pulse)
  ========================================================= */
  function initCounters() {
    const counters = document.querySelectorAll(".js-counter[data-count]");
    if (!counters.length) return;

    function easeOutExpo(t) {
      return t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
    }

    function animate(node) {
      const target   = Number(node.getAttribute("data-count"));
      const suffix   = node.getAttribute("data-suffix") || "";
      // Longer durations for larger numbers
      const duration = Math.min(1200 + target * 0.4, 2400);
      const startAt  = performance.now();

      function tick(now) {
        const elapsed  = now - startAt;
        const progress = Math.min(elapsed / duration, 1);
        const eased    = easeOutExpo(progress);
        const current  = Math.floor(eased * target);
        node.textContent = `${formatNumber(current)}${suffix}`;

        if (progress < 1) {
          requestAnimationFrame(tick);
        } else {
          node.textContent = `${formatNumber(target)}${suffix}`;
          // Subtle scale pulse on finish
          if (!REDUCED_MOTION) {
            node.style.transition = "transform 0.2s ease";
            node.style.transform  = "scale(1.12)";
            window.setTimeout(() => {
              node.style.transform = "scale(1)";
            }, 200);
          }
        }
      }

      requestAnimationFrame(tick);
    }

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            animate(entry.target);
            observer.unobserve(entry.target);
          }
        });
      },
      { threshold: 0.6 }
    );

    counters.forEach((node) => observer.observe(node));
  }

  /* =========================================================
     9b. DATA-COUNTER ANIMATION (for [data-counter] elements)
  ========================================================= */
  function initDataCounters() {
    const counters = document.querySelectorAll('[data-counter]');
    if (!counters.length) return;
    const obs = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (!entry.isIntersecting) return;
        const el = entry.target;
        const target = parseFloat(el.dataset.counter);
        const suffix = el.dataset.counterSuffix || '';
        const duration = 1200;
        const start = performance.now();
        function update(now) {
          const p = Math.min((now - start) / duration, 1);
          const ease = 1 - Math.pow(1 - p, 3);
          el.textContent = Math.round(target * ease) + suffix;
          if (p < 1) requestAnimationFrame(update);
        }
        requestAnimationFrame(update);
        obs.unobserve(el);
      });
    }, { threshold: 0.5 });
    counters.forEach(el => obs.observe(el));
  }

  /* =========================================================
     10. SCROLL-TRIGGERED SECTION TRANSITIONS
  ========================================================= */
  function initSectionTransitions() {
    if (REDUCED_MOTION) return;

    const contrastSections = document.querySelectorAll(".surface-contrast");
    if (!contrastSections.length) return;

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          const ratio  = entry.intersectionRatio;
          const scale  = 1 + (ratio - 0.5) * 0.04; // subtle zoom between 0.98–1.02
          entry.target.style.backgroundSize = `${Math.max(100, scale * 100)}%`;
        });
      },
      { threshold: Array.from({ length: 21 }, (_, i) => i * 0.05) }
    );

    contrastSections.forEach((section) => observer.observe(section));
  }

  /* =========================================================
     11. IMAGE REVEAL WITH CLIP-PATH
  ========================================================= */
  function initImageReveal() {
    const gallery = document.getElementById("galleryGrid");
    if (!gallery) return;

    const figures = Array.from(gallery.querySelectorAll("figure.reveal"));
    if (!figures.length) return;

    if (REDUCED_MOTION || !("IntersectionObserver" in window)) {
      figures.forEach((fig) => fig.classList.add("is-visible"));
      return;
    }

    // Reuse the existing `.reveal.is-visible` CSS state instead of forcing
    // a second inline clip-path animation that can leave Chromium browsers stuck hidden.
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (!entry.isIntersecting) return;
          entry.target.classList.add("is-visible");
          observer.unobserve(entry.target);
        });
      },
      { threshold: 0.05, rootMargin: "0px 0px 120px 0px" }
    );

    figures.forEach((fig) => observer.observe(fig));

    requestAnimationFrame(() => {
      figures.forEach((fig) => {
        const rect = fig.getBoundingClientRect();
        if (rect.top < window.innerHeight && rect.bottom > 0) {
          fig.classList.add("is-visible");
          observer.unobserve(fig);
        }
      });
    });
  }

  /* =========================================================
     12. ENHANCED FAQ ACCORDION
     (keeps existing toggle logic, adds icon rotation + bg change)
  ========================================================= */
  function enhanceFaqAccordion(root) {
    if (!root) return;

    root.querySelectorAll(".faq-trigger").forEach((trigger) => {
      trigger.addEventListener("click", () => {
        const item = trigger.closest(".faq-item");
        if (!item) return;
        const willOpen = !item.classList.contains("open");

        root.querySelectorAll(".faq-item.open").forEach((openItem) => {
          openItem.classList.remove("open");
          openItem.querySelector(".faq-trigger")?.setAttribute("aria-expanded", "false");
          const openIcon = openItem.querySelector(".faq-icon");
          if (openIcon) openIcon.style.transform = "rotate(0deg)";
        });

        if (willOpen) {
          item.classList.add("open");
          trigger.setAttribute("aria-expanded", "true");
        } else {
          item.classList.remove("open");
          trigger.setAttribute("aria-expanded", "false");
        }

        // Rotate icon span (last span inside trigger)
        const icon = trigger.querySelector(".faq-icon");
        if (icon) {
          icon.style.transition   = "transform 0.3s ease, content 0.1s";
          icon.style.display      = "inline-block";
          icon.style.transform    = willOpen ? "rotate(45deg)" : "rotate(0deg)";
        }

        // Animate answer lines
        if (willOpen && !REDUCED_MOTION) {
          const panel = item.querySelector(".faq-panel p");
          if (panel) {
            panel.style.opacity   = "0";
            panel.style.transform = "translateY(8px)";
            panel.style.transition = "opacity 0.35s ease 0.1s, transform 0.35s ease 0.1s";
            requestAnimationFrame(() => {
              panel.style.opacity   = "1";
              panel.style.transform = "translateY(0)";
            });
          }
        }
      });
    });
  }

  /* =========================================================
     13. SCROLL-TRIGGERED CLASS CHANGES
     (header .scrolled, floating CTA .visible, back-to-top)
  ========================================================= */
  function initScrollClassTriggers() {
    const header      = document.querySelector(".site-header");
    const floatingCta = document.querySelector(".floating-cta");
    const backToTop   = document.querySelector(".back-to-top, #backToTop");

    let ticking = false;

    function onScroll() {
      const scrollY = window.scrollY;

      if (header) {
        header.classList.toggle("scrolled", scrollY > 50);
      }
      if (floatingCta) {
        floatingCta.classList.toggle("visible", scrollY > 400);
      }
      if (backToTop) {
        backToTop.classList.toggle("visible", scrollY > 600);
      }

      ticking = false;
    }

    window.addEventListener("scroll", () => {
      if (!ticking) {
        requestAnimationFrame(onScroll);
        ticking = true;
      }
    }, { passive: true });

    // Trigger once on init in case page is already scrolled
    onScroll();
  }

  /* =========================================================
     14. SMOOTH INTERNAL NAVIGATION
  ========================================================= */
  function initSmoothNav() {
    if (REDUCED_MOTION) return;

    function smoothScrollTo(target, duration) {
      const start    = window.scrollY;
      const distance = target - start;
      const startAt  = performance.now();

      function step(now) {
        const elapsed  = now - startAt;
        const progress = Math.min(elapsed / duration, 1);
        // easeInOutCubic
        const ease     = progress < 0.5
          ? 4 * progress * progress * progress
          : 1 - Math.pow(-2 * progress + 2, 3) / 2;
        window.scrollTo(0, start + distance * ease);
        if (progress < 1) requestAnimationFrame(step);
      }

      requestAnimationFrame(step);
    }

    document.addEventListener("click", (e) => {
      const link = e.target.closest("a[href^='#']");
      if (!link) return;
      const id      = link.getAttribute("href").slice(1);
      const section = document.getElementById(id);
      if (!section) return;
      e.preventDefault();
      const offsetTop = section.getBoundingClientRect().top + window.scrollY - 80;
      smoothScrollTo(offsetTop, 700);
    });
  }

  /* =========================================================
     15. FLOATING PARTICLES IN HERO
  ========================================================= */
  function initHeroParticles() {
    if (REDUCED_MOTION) return;

    const hero = document.querySelector(".page-hero, .hero");
    if (!hero) return;

    // Avoid adding particles more than once
    if (hero.querySelector(".hero-particles")) return;

    const container = document.createElement("div");
    container.className = "hero-particles";
    container.setAttribute("aria-hidden", "true");
    container.style.cssText = `
      position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 0;
    `;

    const particleStyle = document.createElement("style");
    particleStyle.textContent = `
      .hero-particle {
        position: absolute; border-radius: 50%;
        background: var(--color-primary, #2563eb);
        animation: floatParticle var(--dur, 6s) ease-in-out infinite alternate;
        will-change: transform;
      }
      @keyframes floatParticle {
        from { transform: translateY(0) translateX(0); }
        to   { transform: translateY(var(--fy, -30px)) translateX(var(--fx, 15px)); }
      }
    `;
    document.head.appendChild(particleStyle);

    const PARTICLE_COUNT = 18;
    for (let i = 0; i < PARTICLE_COUNT; i++) {
      const p     = document.createElement("div");
      p.className = "hero-particle";
      const size  = (Math.random() * 4 + 2).toFixed(1); // 2–6px
      const x     = (Math.random() * 95).toFixed(1);
      const y     = (Math.random() * 90).toFixed(1);
      const op    = (Math.random() * 0.3 + 0.08).toFixed(2); // 0.08–0.38
      const dur   = (Math.random() * 5 + 4).toFixed(1); // 4–9s
      const fx    = ((Math.random() - 0.5) * 40).toFixed(0);
      const fy    = (-(Math.random() * 30 + 10)).toFixed(0);
      const delay = (Math.random() * 4).toFixed(1);

      p.style.cssText = `
        width:${size}px; height:${size}px;
        left:${x}%; top:${y}%;
        opacity:${op};
        --dur:${dur}s; --fx:${fx}px; --fy:${fy}px;
        animation-delay:${delay}s;
      `;
      container.appendChild(p);
    }

    // Insert as first child so it's behind content
    hero.style.position = hero.style.position || "relative";
    hero.insertBefore(container, hero.firstChild);
  }

  /* =========================================================
     16. TYPEWRITER EFFECT FOR HERO SUBTITLE
  ========================================================= */
  function initTypewriter() {
    if (REDUCED_MOTION) return;

    const subtitle = document.querySelector(".page-hero .hero-subtitle, .hero .hero-subtitle");
    if (!subtitle || subtitle.dataset.typed) return;
    subtitle.dataset.typed = "1";

    const original = subtitle.textContent.trim();
    subtitle.textContent = "";

    const cursor = document.createElement("span");
    cursor.textContent = "|";
    cursor.style.cssText = `
      display:inline-block; margin-left:2px;
      animation: blink 0.7s step-end infinite;
    `;
    document.head.insertAdjacentHTML("beforeend", `<style>@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }</style>`);
    subtitle.appendChild(cursor);

    let i = 0;
    const interval = window.setInterval(() => {
      if (i < original.length) {
        cursor.insertAdjacentText("beforebegin", original[i]);
        i++;
      } else {
        window.clearInterval(interval);
        window.setTimeout(() => { cursor.remove(); }, 1200);
      }
    }, 38);
  }

  /* =========================================================
     17. SCROLL SNAP HINT (bounce if user hasn't scrolled)
  ========================================================= */
  function initScrollHint() {
    if (REDUCED_MOTION) return;

    const indicator = document.querySelector(".scroll-indicator, .scroll-hint, [data-scroll-hint]");
    if (!indicator) return;

    let hasScrolled = false;

    window.addEventListener("scroll", () => { hasScrolled = true; }, { passive: true, once: true });

    window.setTimeout(() => {
      if (!hasScrolled) {
        indicator.style.animation = "scrollBounce 0.6s ease-in-out 3";
        document.head.insertAdjacentHTML("beforeend", `
          <style>
            @keyframes scrollBounce {
              0%,100% { transform: translateY(0); }
              50%      { transform: translateY(8px); }
            }
          </style>
        `);
      }
    }, 3000);
  }

  /* =========================================================
     19. ENHANCED MODAL ANIMATIONS
  ========================================================= */
  function injectModalAnimationStyles() {
    const style = document.createElement("style");
    /* Emil Kowalski: never scale(0), start from 0.95; exit ~70% of enter duration */
    style.textContent = `
      .modal { transition: opacity 0.22s ease; opacity: 0; pointer-events: none; }
      .modal.open {
        opacity: 1; pointer-events: auto;
        display: flex; align-items: center; justify-content: center;
      }
      .modal.open .modal-dialog,
      .modal.open .modal-content,
      .modal.open [class*="modal-box"],
      .modal.open [class*="modal-inner"] {
        animation: modalEnter 0.32s cubic-bezier(0.16, 1, 0.3, 1) both;
      }
      .modal.closing { opacity: 0; }
      .modal.closing .modal-dialog,
      .modal.closing .modal-content,
      .modal.closing [class*="modal-box"],
      .modal.closing [class*="modal-inner"] {
        animation: modalLeave 0.18s cubic-bezier(0.4, 0, 1, 1) both;
      }
      @keyframes modalEnter {
        from { opacity:0; transform: scale(0.95); filter: blur(6px); }
        to   { opacity:1; transform: scale(1);    filter: blur(0); }
      }
      @keyframes modalLeave {
        from { opacity:1; transform: scale(1);    filter: blur(0); }
        to   { opacity:0; transform: scale(0.96); filter: blur(3px); }
      }
    `;
    document.head.appendChild(style);
  }

  /* =========================================================
     20. EASTER EGG — KONAMI CODE CONFETTI
  ========================================================= */
  function initKonamiEasterEgg() {
    const KONAMI = [38,38,40,40,37,39,37,39,66,65]; // ↑↑↓↓←→←→BA
    let progress = 0;

    document.addEventListener("keydown", (e) => {
      if (e.keyCode === KONAMI[progress]) {
        progress++;
        if (progress === KONAMI.length) {
          launchConfetti();
          progress = 0;
        }
      } else {
        progress = 0;
      }
    });

    function launchConfetti() {
      const colors = ["#2563eb","#10b981","#f59e0b","#ef4444","#8b5cf6","#ec4899","#14b8a6"];
      const container = document.createElement("div");
      container.setAttribute("aria-hidden", "true");
      container.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:99999;overflow:hidden;";
      document.body.appendChild(container);

      const styleTag = document.createElement("style");
      styleTag.textContent = `
        .konfetti {
          position:absolute; width:8px; height:8px; border-radius:2px;
          animation: konfettiFall var(--kd,2.5s) ease-in var(--kdelay,0s) forwards;
          will-change: transform, opacity;
        }
        @keyframes konfettiFall {
          0%   { transform: translateY(-20px) rotate(0deg);   opacity:1; }
          100% { transform: translateY(110vh) rotate(var(--kr,720deg)); opacity:0; }
        }
      `;
      document.head.appendChild(styleTag);

      for (let i = 0; i < 140; i++) {
        const k = document.createElement("div");
        k.className = "konfetti";
        const x     = Math.random() * 100;
        const color = colors[Math.floor(Math.random() * colors.length)];
        const dur   = (Math.random() * 1.5 + 2).toFixed(2);
        const delay = (Math.random() * 0.8).toFixed(2);
        const rot   = Math.round((Math.random() - 0.5) * 900);

        k.style.cssText = `left:${x}%;top:0;background:${color};--kd:${dur}s;--kdelay:${delay}s;--kr:${rot}deg;
          width:${(Math.random()*8+4).toFixed(0)}px;
          height:${(Math.random()*8+4).toFixed(0)}px;`;
        container.appendChild(k);
      }

      showToast("KONAMI! Gracias por explorar SUVJ con curiosidad 🎉");
      window.setTimeout(() => {
        container.remove();
        styleTag.remove();
      }, 4500);
    }
  }

  /* =========================================================
     ORIGINAL RENDER FUNCTIONS (unchanged)
  ========================================================= */

  function renderHeader() {
    const root = document.getElementById("site-header");
    if (!root) return;

    const current     = getCurrentPage();
    const logoAsset   = DATA.site.logoAsset || "assets/images/brand/logo-sistema-jose-vasconcelos.png";
    const modalityCards = DATA.modalities
      .map(
        (item) => `
          <a class="mega-link" href="${detailPage(item)}">
            <strong>${item.name}</strong>
            <span>${item.summary}</span>
          </a>
        `
      )
      .join("");

    const links = PAGE_LINKS.map((item) => {
      const active = current === item.href ? "active" : "";
      return `<li class="nav-item"><a class="nav-link ${active}" href="${item.href}">${item.label}</a></li>`;
    }).join("");

    root.innerHTML = `
      <header class="site-header">
        <div class="header-inner">
          <a class="brand" href="index.html" aria-label="Inicio ${DATA.site.brand}">
            <img src="${logoAsset}" alt="Logo ${DATA.site.brand}">
          </a>

          <button class="menu-toggle" aria-label="Abrir menu" aria-expanded="false" id="menuToggle">
            <span></span>
            <span></span>
            <span></span>
          </button>

          <nav class="primary-nav" id="primaryNav" aria-label="Navegacion principal">
            <ul class="nav-list">
              ${links}
              <li class="nav-item mega" id="megaItem">
                <button class="nav-link" id="megaTrigger" aria-expanded="false" type="button">Detalle modalidades ▾</button>
                <div class="mega-panel" aria-label="Menu modalidades">
                  <div class="mega-grid">${modalityCards}</div>
                  <div class="mega-foot">
                    <span>Consulta la disponibilidad real por nivel, perfil y campus antes de salir al sitio oficial.</span>
                    <a href="modalidades.html" class="btn-outline">Ver comparativa</a>
                  </div>
                </div>
              </li>
              <li class="nav-item nav-cta"><a class="btn btn-accent" href="contacto.html#crm-form">Solicitar orientación</a></li>
            </ul>
          </nav>
        </div>
      </header>
    `;

    const menuToggle = document.getElementById("menuToggle");
    const primaryNav = document.getElementById("primaryNav");
    const megaItem   = document.getElementById("megaItem");
    const megaTrigger = document.getElementById("megaTrigger");

    const closeMenu = () => {
      primaryNav?.classList.remove("open");
      menuToggle?.classList.remove("is-open");
      menuToggle?.setAttribute("aria-expanded", "false");
      megaItem?.classList.remove("open");
      megaTrigger?.setAttribute("aria-expanded", "false");
      document.body.classList.remove("nav-open");
    };

    const toggleMenu = () => {
      if (!primaryNav || !menuToggle) return;
      const open = primaryNav.classList.toggle("open");
      menuToggle.classList.toggle("is-open", open);
      menuToggle.setAttribute("aria-expanded", open ? "true" : "false");
      document.body.classList.toggle("nav-open", open);
    };

    if (menuToggle && primaryNav) {
      menuToggle.addEventListener("click", toggleMenu);
    }

    if (megaTrigger && megaItem) {
      megaTrigger.addEventListener("click", () => {
        if (window.innerWidth <= 860) {
          window.location.href = "modalidades.html";
          return;
        }
        const open = megaItem.classList.toggle("open");
        megaTrigger.setAttribute("aria-expanded", open ? "true" : "false");
      });
    }

    document.querySelectorAll(".primary-nav a").forEach((link) => {
      link.addEventListener("click", () => {
        closeMenu();
      });
    });

    document.addEventListener("click", (event) => {
      if (window.innerWidth > 860 || !primaryNav?.classList.contains("open")) return;
      if (event.target.closest("#primaryNav") || event.target.closest("#menuToggle")) return;
      closeMenu();
    });

    document.addEventListener("keydown", (event) => {
      if (event.key === "Escape" && primaryNav?.classList.contains("open")) {
        closeMenu();
      }
    });

    window.addEventListener("resize", debounce(() => {
      if (window.innerWidth > 860) {
        closeMenu();
      }
    }, 120));
  }

  function renderFooter() {
    const footer = document.getElementById("site-footer");
    if (!footer) return;

    const logoAsset = DATA.site.logoAsset || "assets/images/brand/logo-sistema-jose-vasconcelos.png";
    const modalityLinks = DATA.modalities.map((item) => `<a href="${detailPage(item)}">${item.name}</a>`).join("");
    const campusLinks   = DATA.campus.map((item) => `<p>${item.name}</p>`).join("");

    footer.innerHTML = `
      <footer class="footer">
        <div class="container footer-inner">
          <div class="footer-grid">
            <div>
              <a class="brand footer-brand" href="index.html" aria-label="Inicio ${DATA.site.brand}">
                <img src="${logoAsset}" alt="Logo ${DATA.site.brand}">
                <span class="footer-brand-copy">
                  <strong>${DATA.site.brand}</strong>
                  <small>${DATA.site.city}</small>
                </span>
              </a>
              <p>Educación con visión tecnológica, acompañamiento cercano y valores sólidos para cada etapa académica.</p>
              ${DATA.site.email ? `<a href="mailto:${DATA.site.email}">${DATA.site.email}</a>` : ''}
            </div>
            <div>
              <h4>Niveles</h4>
              ${modalityLinks}
            </div>
            <div>
              <h4>Campus</h4>
              ${campusLinks}
            </div>
            <div>
              <h4>Contacto</h4>
              <p>${DATA.site.city}</p>
              <a href="contacto.html#crm-form">Formulario de orientación</a>
              <a href="https://wa.me/${DATA.site.whatsapp}" target="_blank" rel="noopener noreferrer">WhatsApp</a>
            </div>
          </div>
          <div class="footer-bottom">
            <span>© <span class="js-year">2026</span> ${DATA.site.brand}. Todos los derechos reservados.</span>
            <a href="${DATA.site.poweredByUrl || "#"}" target="_blank" rel="noopener noreferrer">Powered by ${DATA.site.poweredBy}</a>
          </div>
        </div>
      </footer>
    `;
  }

  function renderFloatingCtas() {
    const root = document.getElementById("floating-cta-root");
    if (!root) return;
    root.innerHTML = `
      <div class="floating-cta" aria-label="Accesos rapidos de contacto">
        <a class="wa" href="https://wa.me/${DATA.site.whatsapp}" target="_blank" rel="noopener noreferrer">WhatsApp</a>
        <a class="form" href="contacto.html#crm-form">Formulario</a>
      </div>
      <div class="mobile-cta-bar">
        <a href="https://wa.me/${DATA.site.whatsapp}" target="_blank" rel="noopener noreferrer">WhatsApp</a>
        <a href="contacto.html#crm-form">Formulario</a>
      </div>
    `;
  }

  function renderMascotNotes() {
    const nodes = document.querySelectorAll("[data-mascot-section]");
    nodes.forEach((node) => {
      const section = node.getAttribute("data-mascot-section");
      const scene   = DATA.mascotScenes.find((item) => item.section === section);
      if (!scene) return;

      const animClass = `anim-${scene.animationType || "float"}`;
      node.innerHTML  = `
        <figure class="mascot-scene mascot-scene--${section} reveal ${animClass}" aria-label="${scene.message}">
          <img src="${scene.asset}" alt="Pepe Teco. ${scene.artDirection || scene.message}" loading="lazy">
        </figure>
      `;
    });
  }

  function buildModalityCard(item) {
    const audienceValue  = item.audience.toLowerCase();
    const campusCoverage = formatCampusCoverage(item.campusIds);
    const benefitsPreview = item.benefits.slice(0, 2).map((benefit) => `<li>${benefit}</li>`).join("");

    return `
      <article class="modality-card reveal" data-audience="${audienceValue}" data-modality-id="${item.id}">
        <h3 class="modality-name">${item.name}</h3>
        <p>${item.summary}</p>
        <div class="modality-meta">
          <span class="meta-pill">Perfil: ${item.audience}</span>
          <span class="meta-pill">Sedes: ${campusCoverage}</span>
        </div>
        <ul>${benefitsPreview}</ul>
        <div class="modality-actions">
          <a class="btn-outline" href="${detailPage(item)}">Ver detalle interno</a>
          <button class="btn js-external-link" data-url="${item.externalUrl}" data-label="${item.name}" data-status="${item.status}">Ir al sitio oficial</button>
        </div>
      </article>
    `;
  }

  function buildModalityMascotCard() {
    const graduateMascotAsset = DATA.site.graduateMascotAsset || "assets/images/mascots/pepe-teco-graduado.png";
    return `
      <article class="modality-mascot-card reveal" data-audience="profesionales" data-role="mascot-posgrados">
        <div class="modality-mascot-copy">
          <span class="modality-badge">Pepe Teco</span>
          <h3>Listo para acompañarte hasta <span class="accent-word">Posgrados</span></h3>
          <p>Una presencia visual para reforzar la continuidad académica y cerrar el bloque profesional con más personalidad.</p>
        </div>
        <img src="${graduateMascotAsset}" alt="Pepe Teco graduado" loading="lazy" decoding="async">
      </article>
    `;
  }

  function renderCrmEmbeds() {
    const shells = document.querySelectorAll("[data-crm-embed]");
    if (!shells.length || !DATA.site.crmEmbedUrl) return;

    shells.forEach((shell) => {
      const isCompact = shell.getAttribute("data-crm-embed") === "compact";
      shell.classList.toggle("crm-embed-shell--compact", isCompact);
      shell.classList.toggle("crm-embed-shell--full", !isCompact);
      shell.innerHTML = `
        <iframe
          src="${DATA.site.crmEmbedUrl}"
          title="Formulario CRM ${DATA.site.brand}"
          loading="lazy"
          referrerpolicy="strict-origin-when-cross-origin"
          allow="clipboard-write"
        ></iframe>
      `;
    });
  }

  function renderValueProposition() {
    const root = document.getElementById("valuePropositionGrid");
    if (!root || !Array.isArray(DATA.valueProposition)) return;

    root.innerHTML = DATA.valueProposition
      .map(
        (item, idx) => `
          <article class="value-prop-card reveal delay-${Math.min(idx, 3)}">
            <div class="value-prop-icon">
              <img src="${item.icon}" alt="${item.title}" loading="lazy">
            </div>
            <h3>${item.title}</h3>
            <p>${item.description}</p>
          </article>
        `
      )
      .join("");
  }

  function renderModalitiesGrid() {
    const grid = document.getElementById("modalitiesGrid");
    if (!grid) return;
    grid.innerHTML = DATA.modalities.map(buildModalityCard).join("");

    const posgradosCard = grid.querySelector('[data-modality-id="posgrados"]');
    if (posgradosCard && !grid.querySelector('[data-role="mascot-posgrados"]')) {
      posgradosCard.insertAdjacentHTML("afterend", buildModalityMascotCard());
    }

    const filterRoot = document.getElementById("modalityFilter");
    if (filterRoot) {
      const chips = Array.from(filterRoot.querySelectorAll(".filter-chip"));
      chips.forEach((chip) => {
        chip.addEventListener("click", () => {
          const target = chip.getAttribute("data-filter");
          chips.forEach((node) => node.classList.remove("active"));
          chip.classList.add("active");

          Array.from(grid.children).forEach((card) => {
            const raw  = (card.getAttribute("data-audience") || "").toLowerCase();
            const show =
              target === "all" ||
              (target === "familias"      && raw.includes("famil")) ||
              (target === "jovenes"       && (raw.includes("joven") || raw.includes("aspir"))) ||
              (target === "profesionales" && raw.includes("profes"));

            card.style.display = show ? "grid" : "none";
            card.setAttribute("aria-hidden", show ? "false" : "true");
          });
        });
      });
    }
  }

  function getVideoSources(videoMeta) {
    if (!videoMeta) return [];

    const sources = [];
    if (videoMeta.srcMp4) sources.push({ src: videoMeta.srcMp4, type: "video/mp4" });
    if (videoMeta.srcWebm) sources.push({ src: videoMeta.srcWebm, type: "video/webm" });
    if (videoMeta.src && !videoMeta.srcWebm && !videoMeta.srcMp4) {
      const type = /\.webm($|\?)/i.test(videoMeta.src) ? "video/webm" : "video/mp4";
      sources.push({ src: videoMeta.src, type });
    }
    return sources;
  }

  function getDirectVideoUrl(videoMeta) {
    const sources = getVideoSources(videoMeta);
    return sources[0]?.src || videoMeta?.src || "";
  }

  function openSharedVideoModal(videoMeta) {
    if (!videoMeta) return false;

    const sources = getVideoSources(videoMeta);
    if (!sources.length) return false;

    const modal      = document.getElementById("videoModal");
    const video      = document.getElementById("institutionalVideo");
    const modalTitle = document.getElementById("videoModalTitle");
    const statusNote = document.getElementById("videoModalStatus");
    const directLink = document.getElementById("videoModalDirect");
    const directUrl  = getDirectVideoUrl(videoMeta);

    if (!modal || !video) {
      window.open(directUrl, "_blank", "noopener,noreferrer");
      return true;
    }

    resetSharedVideoPlayer();

    sources.forEach((item) => {
      const source = document.createElement("source");
      source.src = item.src;
      source.type = item.type;
      video.appendChild(source);
    });

    if (videoMeta.poster) {
      video.setAttribute("poster", videoMeta.poster);
    } else {
      video.removeAttribute("poster");
    }

    video.dataset.videoId = videoMeta.id || videoMeta.title || "institutional-video";
    if (modalTitle) {
      modalTitle.textContent = videoMeta.modalTitle || videoMeta.teaserTitle || videoMeta.title || "Video institucional";
    }

    if (statusNote) {
      statusNote.textContent = "Si el reproductor no inicia dentro del sitio, puedes abrir el video directo en otra pestaña.";
      statusNote.dataset.state = "ready";
    }

    if (directLink) {
      directLink.href = directUrl;
    }

    video.preload = "metadata";
    video.onerror = () => {
      if (statusNote) {
        statusNote.textContent = "Tu navegador no pudo reproducir el video aquí. Usa el enlace directo para verlo completo.";
        statusNote.dataset.state = "error";
      }
    };
    video.oncanplay = () => {
      if (statusNote) {
        statusNote.textContent = "Video listo para reproducirse. Si lo prefieres, también puedes abrirlo directo.";
        statusNote.dataset.state = "playing";
      }
    };

    video.load();
    modal.classList.add("open");
    video.play().catch(() => {
      if (statusNote) {
        statusNote.textContent = "Presiona reproducir o abre el video directo si tu navegador bloqueó el inicio automático.";
        statusNote.dataset.state = "manual";
      }
    });
    return true;
  }

  function initSharedVideoTracking() {
    const video = document.getElementById("institutionalVideo");
    if (!video || video.dataset.trackingBound) return;

    video.dataset.trackingBound = "1";
    const marks = [0.25, 0.5, 0.75, 1];

    video.addEventListener("timeupdate", () => {
      if (!video.duration) return;

      const videoId = video.dataset.videoId || "institutional-video";
      marks.forEach((mark) => {
        const key = `${videoId}_${mark}`;
        if (video.currentTime / video.duration >= mark && !METRICS.has(key)) {
          METRICS.add(key);
          trackEvent(`video_${Math.round(mark * 100)}`, { video_id: videoId });
        }
      });
    });
  }

  function renderComparisonTable() {
    const body = document.getElementById("modalityCompareBody");
    if (!body) return;
    body.innerHTML = DATA.modalities
      .map(
        (item) => `
          <tr>
            <td>${item.name}</td>
            <td>${item.audience}</td>
            <td>${item.planSummary}</td>
            <td>${formatCampusCoverage(item.campusIds)}</td>
            <td><a class="btn-outline" href="${detailPage(item)}">Detalle</a></td>
          </tr>
        `
      )
      .join("");
  }

  function renderTimeline() {
    const root = document.getElementById("timelineList");
    if (!root) return;
    root.innerHTML = DATA.timeline
      .map(
        (item) => `
          <article class="timeline-item reveal">
            <div class="timeline-year">${item.year}</div>
            <div class="timeline-content"><p>${item.text}</p></div>
          </article>
        `
      )
      .join("");
  }

  function renderValues() {
    const container = document.querySelector("[data-values]") || document.getElementById("valuesList");
    if (!container) return;
    const values = Array.isArray(DATA.values) ? DATA.values : [];
    if (!values.length) return;

    container.innerHTML = values.map((v, i) => {
      const name = typeof v === "string" ? v : v.name;
      const desc = typeof v === "object" && v.description ? v.description : "";
      return `
        <article class="value-item reveal delay-${Math.min(i, 3)}">
          <button class="value-question" type="button" aria-expanded="false" aria-controls="value-answer-${i}">
            <span>${name}</span>
            <span class="value-icon">+</span>
          </button>
          <div class="value-answer" id="value-answer-${i}">
            <p>${desc}</p>
          </div>
        </article>
      `;
    }).join("");

    enhanceValueAccordion(container);
  }

  function enhanceValueAccordion(root) {
    if (!root) return;

    root.querySelectorAll(".value-question").forEach((trigger) => {
      trigger.addEventListener("click", () => {
        const item = trigger.closest(".value-item");
        if (!item) return;

        const willOpen = !item.classList.contains("open");
        root.querySelectorAll(".value-item.open").forEach((openItem) => {
          openItem.classList.remove("open");
          const openTrigger = openItem.querySelector(".value-question");
          openTrigger?.setAttribute("aria-expanded", "false");
          const openIcon = openItem.querySelector(".value-icon");
          if (openIcon) openIcon.style.transform = "rotate(0deg)";
        });

        if (willOpen) {
          item.classList.add("open");
          trigger.setAttribute("aria-expanded", "true");
          const icon = item.querySelector(".value-icon");
          if (icon) icon.style.transform = "rotate(45deg)";
        } else {
          item.classList.remove("open");
          trigger.setAttribute("aria-expanded", "false");
          const icon = item.querySelector(".value-icon");
          if (icon) icon.style.transform = "rotate(0deg)";
        }
      });
    });
  }

  function renderAdmissionSteps() {
    const root = document.getElementById("admissionSteps");
    if (!root) return;
    root.innerHTML = DATA.admissionSteps
      .map(
        (item, idx) => `
          <article class="admission-step reveal delay-${Math.min(idx, 3)}">
            <span class="step-number">${item.number}</span>
            <h3>${item.title}</h3>
            <p>${item.description}</p>
          </article>
        `
      )
      .join("");
  }

  function buildFaqSource() {
    return [
      {
        q: "¿Cómo elijo la modalidad adecuada para mi caso?",
        a: "Te acompañamos desde Secundaria hasta Posgrados para identificar la etapa y la modalidad que mejor responden a tu meta actual. Si lo necesitas, un asesor te orienta sobre campus, ingreso y siguiente paso para que decidas con más certeza."
      },
      {
        q: "¿En qué campus está disponible cada nivel educativo?",
        a: "Secundaria está disponible en Campus Río y Campus Patria Nueva; Universidad se ofrece en Campus Río, Campus Murua y Campus Patria Nueva; Posgrados se concentran en Campus Río; y las dos preparatorias están disponibles en todos los planteles. Así eliges sede con claridad desde el inicio."
      },
      {
        q: "¿Los certificados y títulos tienen validez oficial?",
        a: "Sí. Nuestra oferta educativa cuenta con validez oficial y continuidad académica formal para que avances con respaldo real en cada etapa."
      },
      {
        q: "¿Puedo iniciar el proceso de admisión por WhatsApp?",
        a: "Sí. WhatsApp es la forma más rápida de comenzar: ahí resolvemos dudas, revisamos modalidad, campus y siguientes pasos para que avances sin perder impulso."
      },
      {
        q: "¿Cuánto tiempo toma completar el proceso de admisión?",
        a: "Depende de la modalidad y de qué tan rápido avances con tus documentos, pero nuestro objetivo es que recibas orientación clara y puedas asegurar tu lugar en pocos días."
      }
    ];
  }

  function renderFaq() {
    const root = document.getElementById("faqList");
    if (!root) return;

    root.innerHTML = buildFaqSource()
      .map(
        (item) => `
          <article class="faq-item reveal">
            <button class="faq-trigger" type="button" aria-expanded="false">
              <span>${item.q}</span>
              <span class="faq-icon">+</span>
            </button>
            <div class="faq-panel"><div class="faq-panel-inner"><p>${item.a}</p></div></div>
          </article>
        `
      )
      .join("");

    // Enhanced accordion (replaces original bare toggle)
    enhanceFaqAccordion(root);
  }

  function renderGallery() {
    const root = document.getElementById("galleryGrid");
    if (!root) return;
    root.innerHTML = DATA.studentLife
      .map(
        (item, index) => {
          const asset = typeof item === "string"
            ? { src: item, alt: `Vida estudiantil ${index + 1}`, caption: `Vida estudiantil ${index + 1}` }
            : item;

          return `
          <figure class="gallery-item">
            <img src="${asset.src}" alt="${asset.alt}" loading="lazy" decoding="async">
            <figcaption><span>${asset.caption || asset.alt}</span></figcaption>
          </figure>
        `
        }
      )
      .join("");

    root.querySelectorAll("img").forEach((img) => {
      img.addEventListener(
        "error",
        () => {
          const figure = img.closest("figure");
          if (!figure) return;

          figure.classList.add("is-broken");
          const copy = figure.querySelector("figcaption span");
          if (copy) copy.textContent = "Momentos de vida estudiantil disponibles muy pronto";
          img.remove();
        },
        { once: true }
      );
    });
  }

  function renderCampusSelector() {
    const tabs  = document.getElementById("campusTabs");
    const info  = document.getElementById("campusInfo");
    const frame = document.getElementById("campusMap");
    if (!tabs || !info || !frame) return;

    tabs.innerHTML = DATA.campus
      .map(
        (item, idx) =>
          `<button class="campus-tab ${idx === 0 ? "active" : ""}" data-campus-id="${item.id}" title="${item.name}" aria-label="${item.name}">
  <span>${item.tabLabel || item.name}</span>
</button>`
      )
      .join("");

    function paint(campusId) {
      const campus             = DATA.campus.find((item) => item.id === campusId) || DATA.campus[0];
      const availableModalities = getModalitiesForCampus(campus.id).map((item) => item.name).join(" / ");

      info.classList.remove("is-ready");
      frame.classList.remove("is-ready");

      window.setTimeout(() => {
        info.innerHTML = `
          <div class="campus-info-header">
            ${campus.logo ? `<img class="campus-logo" src="${campus.logo}" alt="Logo ${campus.name}" loading="lazy">` : ""}
            <div>
              <h3>${campus.name}</h3>
              <p class="campus-address">${campus.address}</p>
            </div>
          </div>
          <div class="campus-meta">
            <span><strong>Horario:</strong> ${campus.schedule}</span>
            <span><strong>Teléfono:</strong> ${campus.phones.join(" / ")}</span>
            <span><strong>Servicios:</strong> ${campus.services.join(", ")}</span>
            <span><strong>Oferta académica:</strong> ${availableModalities}</span>
            <a href="${campus.mapsUrl}" target="_blank" rel="noopener noreferrer">Abrir en Google Maps</a>
          </div>
        `;
        frame.src = `https://www.google.com/maps?q=${encodeURIComponent(campus.address)}&output=embed`;

        requestAnimationFrame(() => {
          info.classList.add("is-ready");
          frame.classList.add("is-ready");
        });
      }, REDUCED_MOTION ? 0 : 110);
    }

    tabs.querySelectorAll(".campus-tab").forEach((button) => {
      button.addEventListener("click", () => {
        tabs.querySelectorAll(".campus-tab").forEach((tab) => tab.classList.remove("active"));
        button.classList.add("active");
        paint(button.getAttribute("data-campus-id"));
      });
    });

    paint(DATA.campus[0].id);
  }

  function renderDetailPage() {
    const root = document.getElementById("modalityDetailRoot");
    if (!root) return;

    const key      = document.body.getAttribute("data-modalidad-id");
    const modality = DATA.modalities.find((item) => item.id === key);
    if (!modality) return;

    const matchedCampus  = DATA.campus.filter((camp) => modality.campusIds.includes(camp.id));
    const listBenefits   = modality.benefits.map((item) => `<li>${item}</li>`).join("");
    const listFaq        = modality.faq
      .map(
        (item) => `
          <article class="faq-item reveal">
            <button class="faq-trigger" type="button" aria-expanded="false">
              <span>${item.question}</span>
              <span class="faq-icon">+</span>
            </button>
            <div class="faq-panel"><div class="faq-panel-inner"><p>${item.answer}</p></div></div>
          </article>
        `
      )
      .join("");

    const campusList = matchedCampus
      .map((item) => `<li><strong>${item.name}:</strong> ${item.address}</li>`)
      .join("");

    root.innerHTML = `
      <section class="page-hero">
        <div class="container">
          <span class="eyebrow">${modality.badge}</span>
          <h1>${modality.name}</h1>
          <p>${modality.summary}</p>
          <div class="breadcrumbs"><a href="index.html">Inicio</a> / <a href="modalidades.html">Modalidades</a> / <span>${modality.name}</span></div>
        </div>
      </section>

      <section class="section surface-white">
        <div class="container split">
          <article class="form-card reveal">
            <h2>Ideal para quienes <em class="accent-word">buscan</em></h2>
            <p class="section-copy">${modality.audience}</p>
            <h3 style="margin-top:16px;">Lo que hace valiosa esta modalidad</h3>
            <ul>${listBenefits}</ul>
          </article>
          <article class="form-card reveal delay-1">
            <h2>Experiencia <em class="accent-word">académica</em></h2>
            <p class="section-copy">${modality.planSummary}</p>
            <h3 style="margin-top:16px;">Campus donde puedes estudiarla</h3>
            <ul>${campusList}</ul>
            <div class="modality-actions">
              <a class="btn btn-accent" href="contacto.html#crm-form">Solicitar orientación</a>
              <button class="btn-outline js-external-link" data-url="${modality.externalUrl}" data-label="${modality.name}" data-status="${modality.status}">Ir al sitio oficial</button>
            </div>
          </article>
        </div>
      </section>

      <section class="section">
        <div class="container">
          <div class="section-head">
            <span class="eyebrow">Preguntas clave</span>
            <h2>Lo que necesitas saber antes de <em class="accent-word">elegir</em> ${modality.name}</h2>
          </div>
          <div class="faq-list" id="detailFaqList">${listFaq}</div>
        </div>
      </section>
    `;

    // Attach enhanced accordion to detail FAQ
    enhanceFaqAccordion(root.querySelector("#detailFaqList"));
  }

  function setFormHiddenSource() {
    const forms = document.querySelectorAll(".js-contact-form");
    forms.forEach((form) => {
      const sourceInput = form.querySelector("input[name='source_page']");
      if (sourceInput) sourceInput.value = getCurrentPage();
      form.addEventListener("submit", () => {
        trackEvent("form_submit", { form_id: form.getAttribute("id") || "contact" });
      });
    });
  }

  function syncContactLinks() {
    document.querySelectorAll('a[href*="wa.me/"]').forEach((anchor) => {
      anchor.setAttribute("href", `https://wa.me/${DATA.site.whatsapp}`);
    });

    document.querySelectorAll("[data-site-phone]").forEach((node) => {
      node.textContent = DATA.site.whatsappDisplay || DATA.site.phone || "";
    });

    document.querySelectorAll("[data-site-phone-link]").forEach((node) => {
      node.setAttribute("href", `tel:${getPhoneDialValue()}`);
    });

    document.querySelectorAll('a[href="contacto.html"], a[href="contacto.html#crm-form"]').forEach((anchor) => {
      if (anchor.closest("#site-header") || anchor.classList.contains("brand")) return;
      anchor.setAttribute("href", "contacto.html#crm-form");
    });

    document.querySelectorAll(".faq-mascot img").forEach((img) => {
      img.src = DATA.site.questionMascotAsset;
      img.loading = "lazy";
      img.alt = "";
    });
  }

  function injectFaceMascotCta() {
    const currentPage = getCurrentPage();
    const eligiblePages = new Set([
      "modalidades.html",
      "campus.html",
      "vida-estudiantil.html",
      "admisiones.html"
    ]);

    if (!eligiblePages.has(currentPage)) return;

    const ctaSection = Array.from(document.querySelectorAll("main > section.surface-contrast")).at(-1);
    const split = ctaSection?.querySelector(".container.split");
    if (!split || split.querySelector(".cta-face-mascot")) return;

    split.classList.add("cta-face-layout");
    split.insertAdjacentHTML(
      "beforeend",
      `
        <figure class="cta-face-mascot reveal delay-1" aria-hidden="true">
          <img src="${DATA.site.faceMascotAsset}" alt="" loading="lazy">
        </figure>
      `
    );
  }

  function openFallbackModal(label, url) {
    const modal  = document.getElementById("fallbackModal");
    const title  = document.getElementById("fallbackTitle");
    const detail = document.getElementById("fallbackDetail");
    if (!modal || !title || !detail) return;

    title.textContent  = `No logramos abrir ${label} en este momento`;
    detail.textContent = `Puedes continuar por WhatsApp o formulario mientras restablecemos el acceso externo. URL detectada: ${url}`;
    modal.classList.add("open");
    trackEvent("external_fallback_opened", { target: label, url });
  }

  function closeModalsOnBackdrop() {
    document.querySelectorAll(".modal").forEach((modal) => {
      modal.addEventListener("click", (event) => {
        if (event.target === modal) closeModal(modal.id);
      });
    });

    document.querySelectorAll("[data-modal-close]").forEach((button) => {
      button.addEventListener("click", () => {
        const id = button.getAttribute("data-modal-close");
        if (id) closeModal(id);
      });
    });

    document.addEventListener("keydown", (event) => {
      if (event.key === "Escape") {
        document.querySelectorAll(".modal.open").forEach((modal) => closeModal(modal.id));
      }
    });
  }

  async function probeExternal(url) {
    const controller = new AbortController();
    const timeout    = window.setTimeout(() => controller.abort(), 2800);

    try {
      await fetch(url, {
        method: "GET",
        mode: "no-cors",
        cache: "no-store",
        signal: controller.signal
      });
      return true;
    } catch {
      return false;
    } finally {
      window.clearTimeout(timeout);
    }
  }

  async function handleExternalClick(event) {
    const button = event.target.closest(".js-external-link");
    if (!button) return;

    const url    = button.getAttribute("data-url");
    const label  = button.getAttribute("data-label") || "sitio";
    const status = (button.getAttribute("data-status") || "active").toLowerCase();

    trackEvent("external_click_intent", { target: label, url });

    if (!url) {
      showToast("No hay URL configurada para este enlace.");
      return;
    }

    if (status !== "active") {
      openFallbackModal(label, url);
      return;
    }

    const original       = button.textContent;
    button.textContent   = "Verificando acceso...";
    button.disabled      = true;

    const ok         = await probeExternal(url);
    button.textContent = original;
    button.disabled    = false;

    if (ok) {
      window.open(url, "_blank", "noopener,noreferrer");
      trackEvent("external_open_success", { target: label, url });
      return;
    }

    openFallbackModal(label, url);
    showToast("No se pudo validar el sitio. Te mostramos alternativa de contacto.");
  }

  function initExternalLinks() {
    document.addEventListener("click", (event) => {
      if (event.target.closest(".js-external-link")) {
        event.preventDefault();
        handleExternalClick(event);
      }
    });
  }

  function initVideoSpotlight() {
    const block = document.getElementById("videoSpotlight");
    if (!block) return;

    const videoMeta  = DATA.institutionalVideos[0];
    if (!videoMeta) return;

    const poster      = block.querySelector("#videoPoster");
    const title       = block.querySelector("#videoTitle");
    const description = block.querySelector("#videoDescription");
    const cta         = block.querySelector("#videoCta");

    if (poster) {
      mountPosterMedia(poster, videoMeta.poster, videoMeta.title);
    }
    if (title)    title.textContent = videoMeta.teaserTitle || videoMeta.title;
    if (description) description.textContent = videoMeta.teaserDescription || "";
    if (cta)      cta.setAttribute("href", videoMeta.ctaUrl);

    initSharedVideoTracking();

    const openButton  = block.querySelector("#videoPlayButton");
    if (openButton) {
      openButton.addEventListener("click", () => {
        const hasSources = openSharedVideoModal(videoMeta);
        if (!hasSources) {
          showToast("Falta cargar archivo de video institucional en assets/media.");
          return;
        }
        trackEvent("video_play_click", { video_id: videoMeta.id });
      });
    }

    if (cta) {
      cta.addEventListener("click", () => {
        trackEvent("cta_universidad_click", { target: videoMeta.ctaUrl });
      });
    }

    const teaserObserver = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting && !METRICS.has("video_teaser_view")) {
            METRICS.add("video_teaser_view");
            trackEvent("video_teaser_view", { video_id: videoMeta.id });
          }
        });
      },
      { threshold: 0.4 }
    );

    teaserObserver.observe(block);
  }

  function initVeladaVideo() {
    const wrap = document.getElementById("veladaVideoWrap");
    if (!wrap || !DATA.systemVideo) return;

    const v = DATA.systemVideo;
    const logoAsset = DATA.site.logoAsset || "assets/images/brand/logo-sistema-jose-vasconcelos.png";
    wrap.innerHTML = `
      <article class="video-spotlight video-spotlight--community">
        <div class="video-poster video-poster--community video-poster--brand">
          <div class="video-poster-brandstage" aria-hidden="true">
            <img class="video-brand-logo" src="${logoAsset}" alt="Logo ${DATA.site.brand}" loading="lazy" decoding="async">
          </div>
          <button class="video-play velada-video-play" type="button" aria-label="Reproducir video de comunidad">▶</button>
        </div>
        <div class="video-copy">
          <h3>${v.teaserTitle || v.title}</h3>
          <p>${v.teaserDescription || v.description || ""}</p>
          <div class="modality-actions">
            <button class="btn velada-video-open" type="button">Ver video</button>
            ${v.ctaUrl ? `<a class="btn-outline" href="${v.ctaUrl}">Explorar comunidad</a>` : ""}
          </div>
        </div>
      </article>
    `;

    initSharedVideoTracking();

    const openVideo = () => {
      const hasSources = openSharedVideoModal(v);
      if (!hasSources) {
        showToast("No pudimos cargar este video en este momento.");
        return;
      }
      trackEvent("video_play_click", { video_id: v.id || "comunidad" });
    };

    wrap.querySelector(".velada-video-play")?.addEventListener("click", openVideo);
    wrap.querySelector(".velada-video-open")?.addEventListener("click", openVideo);
  }

  function injectRuntimeSchemas() {
    const currentPage = getCurrentPage();

    upsertRuntimeSchema("suvj-schema-org", {
      "@context": "https://schema.org",
      "@type": "EducationalOrganization",
      "@id": `${DATA.site.canonicalBase}/#organization-runtime`,
      name: DATA.site.brand,
      url: DATA.site.canonicalBase,
      logo: DATA.site.logoAsset || DATA.site.defaultOgImage,
      image: DATA.site.defaultOgImage,
      telephone: getTelephoneSchemaValue(),
      address: {
        "@type": "PostalAddress",
        addressLocality: "Tijuana",
        addressRegion: "Baja California",
        addressCountry: "MX"
      }
    });

    if (currentPage === "index.html") {
      upsertRuntimeSchema("suvj-schema-home-faq", {
        "@context": "https://schema.org",
        "@type": "FAQPage",
        mainEntity: buildFaqSource().map((item) => ({
          "@type": "Question",
          name: item.q,
          acceptedAnswer: {
            "@type": "Answer",
            text: item.a
          }
        }))
      });

      upsertRuntimeSchema("suvj-schema-home-video", {
        "@context": "https://schema.org",
        "@type": "VideoObject",
        name: DATA.systemVideo.teaserTitle || DATA.systemVideo.title,
        description: DATA.systemVideo.description || DATA.systemVideo.teaserDescription,
        thumbnailUrl: toAbsoluteSiteUrl(DATA.site.logoAsset || DATA.site.defaultOgImage),
        contentUrl: toAbsoluteSiteUrl(getDirectVideoUrl(DATA.systemVideo)),
        embedUrl: toAbsoluteSiteUrl("index.html"),
        publisher: {
          "@id": `${DATA.site.canonicalBase}/#organization-runtime`
        }
      });
    }

    if (currentPage === "nosotros.html") {
      upsertRuntimeSchema("suvj-schema-about", {
        "@context": "https://schema.org",
        "@type": "AboutPage",
        name: "Nosotros | La historia y el modelo educativo de José Vasconcelos",
        url: toAbsoluteSiteUrl("nosotros.html"),
        about: {
          "@id": `${DATA.site.canonicalBase}/#organization-runtime`
        }
      });

      upsertRuntimeSchema("suvj-schema-about-video", {
        "@context": "https://schema.org",
        "@type": "VideoObject",
        name: DATA.systemVideo.teaserTitle || DATA.systemVideo.title,
        description: DATA.systemVideo.description || DATA.systemVideo.teaserDescription,
        thumbnailUrl: toAbsoluteSiteUrl(DATA.site.logoAsset || DATA.site.defaultOgImage),
        contentUrl: toAbsoluteSiteUrl(getDirectVideoUrl(DATA.systemVideo)),
        embedUrl: toAbsoluteSiteUrl("nosotros.html"),
        publisher: {
          "@id": `${DATA.site.canonicalBase}/#organization-runtime`
        }
      });
    }

    if (currentPage === "contacto.html") {
      upsertRuntimeSchema("suvj-schema-contact", {
        "@context": "https://schema.org",
        "@graph": [
          {
            "@type": "ContactPage",
            name: "Contacto | Habla con un asesor de José Vasconcelos",
            url: toAbsoluteSiteUrl("contacto.html"),
            description: "Habla con un asesor de José Vasconcelos para elegir modalidad, campus y proceso de ingreso con orientación clara y seguimiento cercano."
          },
          {
            "@type": "ContactPoint",
            contactType: "admisiones",
            telephone: getTelephoneSchemaValue(),
            areaServed: "Tijuana, Baja California",
            availableLanguage: ["es-MX"]
          }
        ]
      });
    }

    if (document.body.hasAttribute("data-modalidad-id")) {
      const key = document.body.getAttribute("data-modalidad-id");
      const modality = DATA.modalities.find((item) => item.id === key);
      if (modality?.faq?.length) {
        upsertRuntimeSchema("suvj-schema-modality-faq", {
          "@context": "https://schema.org",
          "@type": "FAQPage",
          mainEntity: modality.faq.map((item) => ({
            "@type": "Question",
            name: item.question,
            acceptedAnswer: {
              "@type": "Answer",
              text: item.answer
            }
          }))
        });
      }
    }
  }

  function syncYear() {
    const yearNodes = document.querySelectorAll(".js-year");
    yearNodes.forEach((node) => {
      node.textContent = String(new Date().getFullYear());
    });
  }

  /* =========================================================
     INIT — orchestrates all features
  ========================================================= */
  function init() {
    // --- Phase 0: pre-content animations
    initPreloader();
    injectModalAnimationStyles();

    // --- Phase 1: render all content
    renderHeader();
    renderFooter();
    renderFloatingCtas();

    renderMascotNotes();
    renderModalitiesGrid();
    renderValueProposition();
    renderComparisonTable();
    renderTimeline();
    renderValues();
    renderAdmissionSteps();
    renderFaq();
    renderGallery();
    renderCampusSelector();
    renderDetailPage();
    renderCrmEmbeds();

    setFormHiddenSource();
    initExternalLinks();
    initVideoSpotlight();
    initVeladaVideo();
    closeModalsOnBackdrop();
    syncYear();
    syncContactLinks();
    injectRuntimeSchemas();
    injectFaceMascotCta();

    // --- Phase 2: scroll + visibility
    initScrollProgressBar();
    initScrollClassTriggers();
    initSmoothNav();
    initReveal();
    initCounters();
    initDataCounters();
    initSectionTransitions();

    // --- Phase 3: rich interactions (deferred slightly for performance)
    window.setTimeout(() => {
      initCursorFollower();

      // Image reveal needs gallery to exist first
      initImageReveal();
    }, 0);

    // --- Resize handler: debounced to avoid thrashing
    window.addEventListener(
      "resize",
      debounce(() => {
        syncYear();
      }, 350)
    );
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();