/* ========================================== EFFECTS — Count-up, 3D Tilt, Magnetic buttons Auto-attaches to existing DOM via MutationObserver, so pages don't need to be edited individually. ========================================== */ (function () { const isTouch = window.matchMedia('(hover: none)').matches; const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; /* ----------- Count-up ----------- */ const NUM_RE = /^\s*([+-]?)\s*([\d]+(?:[.,]\d+)?)\s*([%hxXkKmM]?)\s*$/; const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3); const animateCount = (el, target, decimals, prefix, suffix, sep) => { const duration = 1400; const start = performance.now(); const from = 0; const tick = (now) => { const t = Math.min(1, (now - start) / duration); const v = from + (target - from) * easeOutCubic(t); const fixed = decimals > 0 ? v.toFixed(decimals) : Math.round(v).toString(); el.textContent = prefix + (sep === ',' ? fixed.replace('.', ',') : fixed) + suffix; if (t < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }; const countObserver = new IntersectionObserver((entries) => { entries.forEach((e) => { if (!e.isIntersecting) return; const el = e.target; countObserver.unobserve(el); const m = el.dataset.countParsed; if (!m) return; const { target, decimals, prefix, suffix, sep } = JSON.parse(m); if (reducedMotion) return; animateCount(el, target, decimals, prefix, suffix, sep); }); }, { threshold: 0.4 }); const attachCount = (el) => { if (el.dataset.countBound) return; const text = el.textContent.trim(); const m = text.match(NUM_RE); if (!m) return; const sign = m[1] || ''; const numStr = m[2]; const suffix = m[3] || ''; const sep = numStr.includes(',') ? ',' : '.'; const target = parseFloat(numStr.replace(',', '.')); if (isNaN(target)) return; const decimals = numStr.includes('.') || numStr.includes(',') ? numStr.split(/[.,]/)[1].length : 0; el.dataset.countBound = '1'; el.dataset.countParsed = JSON.stringify({ target, decimals, prefix: sign, suffix, sep }); el.textContent = sign + (decimals > 0 ? (sep === ',' ? '0,' + '0'.repeat(decimals) : '0.' + '0'.repeat(decimals)) : '0') + suffix; countObserver.observe(el); }; /* ----------- 3D Tilt ----------- */ const attachTilt = (el) => { if (el.dataset.tiltBound || isTouch || reducedMotion) return; el.dataset.tiltBound = '1'; el.classList.add('tilt-card'); const max = 8; let rect; let raf; const onEnter = () => { rect = el.getBoundingClientRect(); }; const onMove = (ev) => { if (!rect) rect = el.getBoundingClientRect(); const x = (ev.clientX - rect.left) / rect.width - 0.5; const y = (ev.clientY - rect.top) / rect.height - 0.5; if (raf) cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { el.style.transform = `perspective(900px) rotateX(${(-y * max).toFixed(2)}deg) rotateY(${(x * max).toFixed(2)}deg) translateZ(0)`; }); }; const onLeave = () => { if (raf) cancelAnimationFrame(raf); el.style.transform = ''; rect = null; }; el.addEventListener('mouseenter', onEnter); el.addEventListener('mousemove', onMove); el.addEventListener('mouseleave', onLeave); }; /* ----------- Magnetic buttons ----------- */ const attachMagnetic = (el) => { if (el.dataset.magneticBound || isTouch || reducedMotion) return; el.dataset.magneticBound = '1'; el.classList.add('magnetic'); const strength = 0.35; let rect; let raf; const onEnter = () => { rect = el.getBoundingClientRect(); }; const onMove = (ev) => { if (!rect) rect = el.getBoundingClientRect(); const dx = ev.clientX - (rect.left + rect.width / 2); const dy = ev.clientY - (rect.top + rect.height / 2); if (raf) cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { el.style.transform = `translate(${(dx * strength).toFixed(2)}px, ${(dy * strength).toFixed(2)}px)`; }); }; const onLeave = () => { if (raf) cancelAnimationFrame(raf); el.style.transform = ''; rect = null; }; el.addEventListener('mouseenter', onEnter); el.addEventListener('mousemove', onMove); el.addEventListener('mouseleave', onLeave); }; /* ----------- Auto-scan ----------- */ // Selectors that look like stat numbers (big bold numbers). const COUNT_SELECTORS = [ '.stat-num', '.hero-stat-num', '[data-countup]' ]; // Selectors that should tilt. const TILT_SELECTORS = [ '.project-card', '.solution-card', '.feature-card', '.metodo-pillar', '.blog-card', '.case-card', '[data-tilt]' ]; // Selectors that should be magnetic. const MAGNETIC_SELECTORS = [ '.btn-primary', '[data-magnetic]' ]; const scan = (root) => { if (!root || root.nodeType !== 1) return; COUNT_SELECTORS.forEach((s) => { root.querySelectorAll(s).forEach(attachCount); if (root.matches && root.matches(s)) attachCount(root); }); TILT_SELECTORS.forEach((s) => { root.querySelectorAll(s).forEach(attachTilt); if (root.matches && root.matches(s)) attachTilt(root); }); MAGNETIC_SELECTORS.forEach((s) => { root.querySelectorAll(s).forEach(attachMagnetic); if (root.matches && root.matches(s)) attachMagnetic(root); }); }; const start = () => { scan(document.body); const mo = new MutationObserver((mutations) => { for (const m of mutations) { m.addedNodes.forEach((n) => scan(n)); } }); mo.observe(document.body, { childList: true, subtree: true }); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { // Defer one frame so React's first render is done. requestAnimationFrame(start); } /* ----------- React component wrappers (opt-in) ----------- */ const CountUp = ({ value, prefix = '', suffix = '', decimals = 0, className = '', style = {} }) => { return ( {prefix}{decimals > 0 ? Number(value).toFixed(decimals) : value}{suffix} ); }; const TiltCard = ({ children, className = '', style = {}, ...rest }) => (
{children}
); const MagneticButton = ({ children, className = '', as: Tag = 'button', ...rest }) => ( {children} ); window.CountUp = CountUp; window.TiltCard = TiltCard; window.MagneticButton = MagneticButton; })();