481 lines
27 KiB
JavaScript
481 lines
27 KiB
JavaScript
/* =====================================================================
|
|
ONAPB — Kinetic FX
|
|
Capa de interacciones del frontend.
|
|
Vanilla JS, sin dependencias. Cargar al final de <body> con defer.
|
|
===================================================================== */
|
|
(function () {
|
|
'use strict';
|
|
|
|
var prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
var hasFinePointer = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
|
var isTouch = !hasFinePointer || ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
|
|
|
function ready(fn) {
|
|
if (document.readyState !== 'loading') fn();
|
|
else document.addEventListener('DOMContentLoaded', fn);
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
1. Reveal on scroll (.kfx-reveal, [data-stagger], .kfx-section-tag)
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initReveal() {
|
|
if (!('IntersectionObserver' in window)) {
|
|
document.querySelectorAll('.kfx-reveal, [data-stagger], .kfx-section-tag, .hero-title, .kinetic-reveal-text, .kinetic-highlight')
|
|
.forEach(function (el) { el.classList.add('is-in'); });
|
|
return;
|
|
}
|
|
var io = new IntersectionObserver(function (entries) {
|
|
entries.forEach(function (e) {
|
|
if (e.isIntersecting) {
|
|
e.target.classList.add('is-in');
|
|
io.unobserve(e.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.12, rootMargin: '0px 0px -60px 0px' });
|
|
|
|
document.querySelectorAll('.kfx-reveal, [data-stagger], .kfx-section-tag, .hero-title, .kinetic-reveal-text, .kinetic-highlight')
|
|
.forEach(function (el) { io.observe(el); });
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
2. Word splitter para reveal de títulos (hero-title, kinetic-reveal-text)
|
|
───────────────────────────────────────────────────────────────── */
|
|
function splitWords() {
|
|
document.querySelectorAll('.hero-title:not([data-split]), .kinetic-reveal-text:not([data-split])').forEach(function (el) {
|
|
if (el.children.length > 0 && el.querySelector('.word')) return;
|
|
var text = el.textContent.trim();
|
|
if (!text) return;
|
|
var words = text.split(/\s+/);
|
|
el.innerHTML = words.map(function (w) {
|
|
return '<span class="word"><span>' + w + '</span></span>';
|
|
}).join(' ');
|
|
el.setAttribute('data-split', 'true');
|
|
});
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
3. Reading progress bar (top)
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initReadingBar() {
|
|
if (prefersReduced) return;
|
|
var bar = document.getElementById('kinetic-reading-bar');
|
|
if (!bar) {
|
|
bar = document.createElement('div');
|
|
bar.id = 'kinetic-reading-bar';
|
|
document.body.appendChild(bar);
|
|
}
|
|
var ticking = false;
|
|
function update() {
|
|
var scrollTop = window.scrollY || document.documentElement.scrollTop;
|
|
var docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
|
var pct = docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0;
|
|
bar.style.width = pct + '%';
|
|
ticking = false;
|
|
}
|
|
window.addEventListener('scroll', function () {
|
|
if (!ticking) {
|
|
window.requestAnimationFrame(update);
|
|
ticking = true;
|
|
}
|
|
}, { passive: true });
|
|
update();
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
4. Navbar scroll state
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initNavbarScroll() {
|
|
var nav = document.querySelector('.custom-navbar');
|
|
if (!nav) return;
|
|
function update() {
|
|
if (window.scrollY > 30) nav.classList.add('is-scrolled');
|
|
else nav.classList.remove('is-scrolled');
|
|
}
|
|
window.addEventListener('scroll', update, { passive: true });
|
|
update();
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
5. Spotlight para .kinetic-card y .kfx-img-spotlight
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initSpotlight() {
|
|
if (isTouch) return;
|
|
var els = document.querySelectorAll('.kinetic-card, .kfx-img-spotlight');
|
|
els.forEach(function (el) {
|
|
el.addEventListener('mousemove', function (e) {
|
|
var rect = el.getBoundingClientRect();
|
|
var x = ((e.clientX - rect.left) / rect.width) * 100;
|
|
var y = ((e.clientY - rect.top) / rect.height) * 100;
|
|
el.style.setProperty('--mx', x + '%');
|
|
el.style.setProperty('--my', y + '%');
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
6. Tilt 3D (data-tilt)
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initTilt() {
|
|
if (isTouch || prefersReduced) return;
|
|
document.querySelectorAll('[data-tilt]').forEach(function (el) {
|
|
var max = parseFloat(el.getAttribute('data-tilt-max') || '8');
|
|
el.addEventListener('mousemove', function (e) {
|
|
var rect = el.getBoundingClientRect();
|
|
var x = (e.clientX - rect.left) / rect.width - 0.5;
|
|
var y = (e.clientY - rect.top) / rect.height - 0.5;
|
|
el.classList.add('is-tilting');
|
|
el.style.setProperty('--ry', (x * max) + 'deg');
|
|
el.style.setProperty('--rx', (-y * max) + 'deg');
|
|
});
|
|
el.addEventListener('mouseleave', function () {
|
|
el.classList.remove('is-tilting');
|
|
el.style.setProperty('--rx', '0deg');
|
|
el.style.setProperty('--ry', '0deg');
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
7. Magnetic buttons (data-magnetic)
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initMagnetic() {
|
|
if (isTouch || prefersReduced) return;
|
|
document.querySelectorAll('[data-magnetic]').forEach(function (el) {
|
|
var strength = parseFloat(el.getAttribute('data-magnetic-strength') || '0.25');
|
|
el.addEventListener('mousemove', function (e) {
|
|
var rect = el.getBoundingClientRect();
|
|
var x = e.clientX - rect.left - rect.width / 2;
|
|
var y = e.clientY - rect.top - rect.height / 2;
|
|
el.style.transform = 'translate(' + (x * strength) + 'px, ' + (y * strength) + 'px)';
|
|
});
|
|
el.addEventListener('mouseleave', function () {
|
|
el.style.transform = '';
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
8. Ripple en clicks (cualquier .btn, .btn-kinetic-primary)
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initRipple() {
|
|
document.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.btn, .btn-kinetic-primary, .btn-admin-primary, .sidebar-link');
|
|
if (!btn || btn.classList.contains('no-ripple')) return;
|
|
var rect = btn.getBoundingClientRect();
|
|
var size = Math.max(rect.width, rect.height);
|
|
var wave = document.createElement('span');
|
|
wave.className = 'kinetic-ripple-wave';
|
|
wave.style.width = wave.style.height = size + 'px';
|
|
wave.style.left = (e.clientX - rect.left) + 'px';
|
|
wave.style.top = (e.clientY - rect.top) + 'px';
|
|
// Posición relativa para contener el ripple
|
|
var pos = window.getComputedStyle(btn).position;
|
|
if (pos === 'static') btn.style.position = 'relative';
|
|
btn.style.overflow = 'hidden';
|
|
btn.appendChild(wave);
|
|
setTimeout(function () { wave.remove(); }, 650);
|
|
});
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
9. Counter animado (.kfx-counter con data-target)
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initCounters() {
|
|
if (!('IntersectionObserver' in window)) return;
|
|
var counters = document.querySelectorAll('.kfx-counter[data-target]');
|
|
if (!counters.length) return;
|
|
var io = new IntersectionObserver(function (entries) {
|
|
entries.forEach(function (entry) {
|
|
if (!entry.isIntersecting) return;
|
|
var el = entry.target;
|
|
var target = parseFloat(el.getAttribute('data-target')) || 0;
|
|
var duration = parseInt(el.getAttribute('data-duration') || '1400', 10);
|
|
var decimals = parseInt(el.getAttribute('data-decimals') || '0', 10);
|
|
var prefix = el.getAttribute('data-prefix') || '';
|
|
var suffix = el.getAttribute('data-suffix') || '';
|
|
var start = 0;
|
|
var startTime = null;
|
|
if (prefersReduced) {
|
|
el.textContent = prefix + target.toFixed(decimals) + suffix;
|
|
io.unobserve(el);
|
|
return;
|
|
}
|
|
function tick(ts) {
|
|
if (!startTime) startTime = ts;
|
|
var progress = Math.min(1, (ts - startTime) / duration);
|
|
var eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
|
|
var current = start + (target - start) * eased;
|
|
el.textContent = prefix + current.toFixed(decimals) + suffix;
|
|
if (progress < 1) requestAnimationFrame(tick);
|
|
}
|
|
requestAnimationFrame(tick);
|
|
io.unobserve(el);
|
|
});
|
|
}, { threshold: 0.4 });
|
|
counters.forEach(function (c) { io.observe(c); });
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
10. Parallax suave (data-parallax="speed")
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initParallax() {
|
|
if (prefersReduced) return;
|
|
var els = Array.prototype.slice.call(document.querySelectorAll('[data-parallax]'));
|
|
if (!els.length) return;
|
|
var ticking = false;
|
|
function update() {
|
|
var vh = window.innerHeight;
|
|
els.forEach(function (el) {
|
|
var rect = el.getBoundingClientRect();
|
|
if (rect.bottom < 0 || rect.top > vh) return;
|
|
var speed = parseFloat(el.getAttribute('data-parallax')) || 0.2;
|
|
var offset = (rect.top - vh / 2) * speed * -1;
|
|
el.style.transform = 'translate3d(0, ' + offset.toFixed(1) + 'px, 0)';
|
|
});
|
|
ticking = false;
|
|
}
|
|
window.addEventListener('scroll', function () {
|
|
if (!ticking) {
|
|
requestAnimationFrame(update);
|
|
ticking = true;
|
|
}
|
|
}, { passive: true });
|
|
update();
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
11. Marquee duplicador (.kfx-marquee con un solo track)
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initMarquee() {
|
|
document.querySelectorAll('.kfx-marquee').forEach(function (el) {
|
|
var track = el.querySelector('.kfx-marquee__track');
|
|
if (!track || track.dataset.cloned === '1') return;
|
|
track.dataset.cloned = '1';
|
|
track.innerHTML += track.innerHTML; // duplicar para loop seamless
|
|
});
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
12. Cursor personalizado (sólo desktop hover-fino)
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initCustomCursor() {
|
|
if (!hasFinePointer || isTouch || prefersReduced) return;
|
|
if (document.querySelector('.kinetic-cursor-dot')) return;
|
|
|
|
var dot = document.createElement('div');
|
|
var ring = document.createElement('div');
|
|
dot.className = 'kinetic-cursor-dot';
|
|
ring.className = 'kinetic-cursor-ring';
|
|
document.body.appendChild(dot);
|
|
document.body.appendChild(ring);
|
|
document.body.classList.add('kinetic-cursor-on');
|
|
|
|
var mx = window.innerWidth / 2, my = window.innerHeight / 2;
|
|
var rx = mx, ry = my;
|
|
|
|
document.addEventListener('mousemove', function (e) {
|
|
mx = e.clientX; my = e.clientY;
|
|
dot.style.transform = 'translate(' + mx + 'px, ' + my + 'px) translate(-50%, -50%)';
|
|
}, { passive: true });
|
|
|
|
function loop() {
|
|
rx += (mx - rx) * 0.18;
|
|
ry += (my - ry) * 0.18;
|
|
ring.style.transform = 'translate(' + rx + 'px, ' + ry + 'px) translate(-50%, -50%)';
|
|
requestAnimationFrame(loop);
|
|
}
|
|
loop();
|
|
|
|
// Hover en interactivos
|
|
var hoverSel = 'a, button, .btn, .kinetic-card, [role="button"], [data-tilt], [data-magnetic], .nav-link, input[type="submit"]';
|
|
document.addEventListener('mouseover', function (e) {
|
|
if (e.target.closest(hoverSel)) {
|
|
ring.classList.add('is-hover');
|
|
dot.classList.add('is-hover');
|
|
}
|
|
});
|
|
document.addEventListener('mouseout', function (e) {
|
|
if (e.target.closest(hoverSel)) {
|
|
ring.classList.remove('is-hover');
|
|
dot.classList.remove('is-hover');
|
|
}
|
|
});
|
|
document.addEventListener('mouseleave', function () {
|
|
ring.style.opacity = '0';
|
|
dot.style.opacity = '0';
|
|
});
|
|
document.addEventListener('mouseenter', function () {
|
|
ring.style.opacity = '1';
|
|
dot.style.opacity = '1';
|
|
});
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
13. Auto-aplicar reveal a tarjetas / encabezados (best-effort)
|
|
───────────────────────────────────────────────────────────────── */
|
|
function autoTagReveal() {
|
|
// Tarjetas comunes en home/eventos
|
|
document.querySelectorAll('.kinetic-card:not(.kfx-reveal)').forEach(function (el) {
|
|
el.classList.add('kfx-reveal');
|
|
if (!el.hasAttribute('data-fx')) el.setAttribute('data-fx', 'up');
|
|
});
|
|
|
|
// Display headings (h1.display-* / h2.display-*)
|
|
document.querySelectorAll('h1.display-1, h1.display-2, h1.display-3, h1.display-4, h2.display-3, h2.display-4, h2.display-5')
|
|
.forEach(function (el) {
|
|
if (!el.classList.contains('kfx-reveal') && !el.classList.contains('hero-title')) {
|
|
el.classList.add('kfx-reveal');
|
|
el.setAttribute('data-fx', 'up');
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
14. Smooth anchor scroll
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initAnchorSmooth() {
|
|
document.addEventListener('click', function (e) {
|
|
var a = e.target.closest('a[href^="#"]');
|
|
if (!a) return;
|
|
var href = a.getAttribute('href');
|
|
if (!href || href === '#' || href.length < 2) return;
|
|
var target = document.querySelector(href);
|
|
if (!target) return;
|
|
e.preventDefault();
|
|
target.scrollIntoView({ behavior: prefersReduced ? 'auto' : 'smooth', block: 'start' });
|
|
});
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
15. SweetAlert2 patcher (a prueba de cache)
|
|
Detecta cuando aparece un .swal2-popup y fuerza inline styles en
|
|
los botones para garantizar fondo rojo en confirm y gris en cancel,
|
|
sin importar el orden/cache del CSS o el confirmButtonColor del JS.
|
|
───────────────────────────────────────────────────────────────── */
|
|
function initSwalPatcher() {
|
|
if (!('MutationObserver' in window)) return;
|
|
|
|
var BRAND_RED = '#c20000';
|
|
var BRAND_RED_DEEP = '#8a0000';
|
|
var BRAND_GRAY = '#6c757d';
|
|
|
|
// Inyectar reglas críticas como <style> en runtime para evitar
|
|
// problemas de caché del archivo .css (LiteSpeed/Cloudflare).
|
|
if (!document.getElementById('kfx-swal-runtime-style')) {
|
|
var s = document.createElement('style');
|
|
s.id = 'kfx-swal-runtime-style';
|
|
s.textContent = [
|
|
/* Selección visible dentro del modal SweetAlert (rojo + texto blanco). */
|
|
'.swal2-popup ::selection,.swal2-popup *::selection{background-color:' + BRAND_RED + ' !important;color:#fff !important;}',
|
|
'.swal2-popup ::-moz-selection,.swal2-popup *::-moz-selection{background-color:' + BRAND_RED + ' !important;color:#fff !important;}',
|
|
/* Selección global (failsafe por si el CSS principal cachea viejo). */
|
|
'::selection{background-color:' + BRAND_RED + ' !important;color:#fff !important;}',
|
|
'::-moz-selection{background-color:' + BRAND_RED + ' !important;color:#fff !important;}',
|
|
/* Botones de SweetAlert (failsafe). */
|
|
'body .swal2-popup .swal2-actions .swal2-styled.swal2-confirm{' +
|
|
'background-color:' + BRAND_RED + ' !important;' +
|
|
'background-image:linear-gradient(135deg,' + BRAND_RED + ',' + BRAND_RED_DEEP + ') !important;' +
|
|
'color:#fff !important;border:none !important;' +
|
|
'}',
|
|
'body .swal2-popup .swal2-actions .swal2-styled.swal2-cancel{' +
|
|
'background-color:' + BRAND_GRAY + ' !important;' +
|
|
'background-image:none !important;' +
|
|
'color:#fff !important;border:none !important;' +
|
|
'}'
|
|
].join('\n');
|
|
document.head.appendChild(s);
|
|
}
|
|
|
|
function styleConfirm(btn) {
|
|
if (!btn) return;
|
|
btn.style.setProperty('background-color', BRAND_RED, 'important');
|
|
btn.style.setProperty('background-image',
|
|
'linear-gradient(135deg, ' + BRAND_RED + ', ' + BRAND_RED_DEEP + ')', 'important');
|
|
btn.style.setProperty('color', '#ffffff', 'important');
|
|
btn.style.setProperty('border', 'none', 'important');
|
|
btn.style.setProperty('text-shadow', 'none', 'important');
|
|
btn.style.setProperty('font-weight', '700', 'important');
|
|
btn.style.setProperty('text-transform', 'uppercase', 'important');
|
|
btn.style.setProperty('letter-spacing', '0.08em', 'important');
|
|
}
|
|
function styleCancel(btn) {
|
|
if (!btn) return;
|
|
btn.style.setProperty('background-color', BRAND_GRAY, 'important');
|
|
btn.style.setProperty('background-image', 'none', 'important');
|
|
btn.style.setProperty('color', '#ffffff', 'important');
|
|
btn.style.setProperty('border', 'none', 'important');
|
|
btn.style.setProperty('font-weight', '700', 'important');
|
|
btn.style.setProperty('text-transform', 'uppercase', 'important');
|
|
btn.style.setProperty('letter-spacing', '0.08em', 'important');
|
|
}
|
|
function styleDeny(btn) {
|
|
if (!btn) return;
|
|
btn.style.setProperty('background-color', '#b91c1c', 'important');
|
|
btn.style.setProperty('color', '#ffffff', 'important');
|
|
}
|
|
|
|
function patch(popup) {
|
|
if (!popup || popup.dataset.kfxPatched === '1') return;
|
|
popup.dataset.kfxPatched = '1';
|
|
styleConfirm(popup.querySelector('.swal2-confirm'));
|
|
styleCancel(popup.querySelector('.swal2-cancel'));
|
|
styleDeny(popup.querySelector('.swal2-deny'));
|
|
}
|
|
|
|
// Patch popups que ya estén en el DOM al cargar
|
|
document.querySelectorAll('.swal2-popup').forEach(patch);
|
|
|
|
// Observa nuevos popups (SWAL los agrega/quita dinámicamente)
|
|
var obs = new MutationObserver(function (mutations) {
|
|
mutations.forEach(function (m) {
|
|
m.addedNodes && m.addedNodes.forEach(function (n) {
|
|
if (!(n instanceof HTMLElement)) return;
|
|
if (n.classList && n.classList.contains('swal2-popup')) {
|
|
patch(n);
|
|
} else {
|
|
// Por si el contenedor agrega un wrapper antes del popup
|
|
var inner = n.querySelector && n.querySelector('.swal2-popup');
|
|
if (inner) patch(inner);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
obs.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────
|
|
Init
|
|
───────────────────────────────────────────────────────────────── */
|
|
ready(function () {
|
|
try { splitWords(); } catch (e) { console.warn('kinetic-fx splitWords:', e); }
|
|
try { autoTagReveal(); } catch (e) { console.warn('kinetic-fx autoTagReveal:', e); }
|
|
try { initReveal(); } catch (e) { console.warn('kinetic-fx reveal:', e); }
|
|
try { initReadingBar(); } catch (e) { console.warn('kinetic-fx readingBar:', e); }
|
|
try { initNavbarScroll(); } catch (e) { console.warn('kinetic-fx navbarScroll:', e); }
|
|
try { initSpotlight(); } catch (e) { console.warn('kinetic-fx spotlight:', e); }
|
|
try { initTilt(); } catch (e) { console.warn('kinetic-fx tilt:', e); }
|
|
try { initMagnetic(); } catch (e) { console.warn('kinetic-fx magnetic:', e); }
|
|
try { initRipple(); } catch (e) { console.warn('kinetic-fx ripple:', e); }
|
|
try { initCounters(); } catch (e) { console.warn('kinetic-fx counters:', e); }
|
|
try { initParallax(); } catch (e) { console.warn('kinetic-fx parallax:', e); }
|
|
try { initMarquee(); } catch (e) { console.warn('kinetic-fx marquee:', e); }
|
|
try { initCustomCursor(); } catch (e) { console.warn('kinetic-fx cursor:', e); }
|
|
try { initAnchorSmooth(); } catch (e) { console.warn('kinetic-fx anchor:', e); }
|
|
try { initSwalPatcher(); } catch (e) { console.warn('kinetic-fx swalPatcher:', e); }
|
|
});
|
|
|
|
// API pública mínima (por si una vista necesita re-inicializar tras AJAX)
|
|
window.KineticFX = {
|
|
rescan: function () {
|
|
splitWords();
|
|
autoTagReveal();
|
|
initReveal();
|
|
initSpotlight();
|
|
initTilt();
|
|
initMagnetic();
|
|
initCounters();
|
|
initMarquee();
|
|
}
|
|
};
|
|
})();
|