Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
assets
/
media
:
main.js
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
(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 ? " " : ""} </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(); } })();