(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();
}
})();