// effects.jsx — scroll reveals, parallax, scroll progress, custom cursor, counters // Exports: Cursor, Counter, useSiteFX → window const { useState, useEffect, useRef } = React; const PRM = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; // ── Master site effects: reveal observer + parallax (fully imperative) ─────── // IMPORTANT: reveals are flagged with a `data-in` ATTRIBUTE, not a class. // React owns `className`, so adding a class here would be wiped on the next // re-render; a data-* attribute React doesn't manage survives. This hook also // must NEVER call setState — otherwise it would re-render the app on scroll. function useSiteFX() { // Reveal: observe any .reveal / .line-mask in the DOM. Re-scan a few times // after mount so async-rendered (Babel) sections get picked up. // ROBUSTNESS: never depend on IO alone. We also (a) force-reveal anything in // the viewport on load + on scroll/resize, and (b) re-scan on DOM mutations. // This guarantees above-the-fold content (the hero H1) paints even if the // IntersectionObserver callback never fires in a given environment. useEffect(() => { const SEL = '.reveal:not([data-in]), .line-mask:not([data-in])'; // Flag the element revealed. For .line-mask we ALSO flag the inner span that // actually carries the transform — keying the child's transform off the // PARENT's attribute (`.line-mask[data-in] > span`) doesn't reliably trigger // a style recalc in Chromium, so we use a self-selector (`> span[data-in]`). const flag = (el) => { el.setAttribute('data-in', ''); if (el.classList.contains('line-mask')) { const sp = el.firstElementChild; if (sp) { sp.setAttribute('data-in', ''); void sp.offsetWidth; } } }; const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { flag(e.target); io.unobserve(e.target); } }); }, { threshold: 0.16, rootMargin: '0px 0px -8% 0px' }); const scan = () => document.querySelectorAll(SEL).forEach((el) => io.observe(el)); const revealInView = () => { const vh = window.innerHeight; document.querySelectorAll(SEL).forEach((el) => { const r = el.getBoundingClientRect(); if (r.bottom > 0 && r.top < vh * 0.92) flag(el); }); }; scan(); revealInView(); const timers = [80, 300, 700, 1400, 2200].map((ms) => setTimeout(() => { scan(); revealInView(); }, ms)); let ticking = false; const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(() => { ticking = false; revealInView(); }); } }; window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll); // Re-scan + reveal when the tree changes (e.g. hero variant switched). let raf = 0; const mo = new MutationObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { scan(); revealInView(); }); }); mo.observe(document.body, { childList: true, subtree: true }); return () => { io.disconnect(); mo.disconnect(); cancelAnimationFrame(raf); timers.forEach(clearTimeout); window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); }; }, []); // Parallax — rAF-throttled, imperative transforms only. No React state. useEffect(() => { if (PRM) return; let ticking = false; const run = () => { ticking = false; const vh = window.innerHeight; document.querySelectorAll('[data-speed]').forEach((el) => { const r = el.getBoundingClientRect(); const center = r.top + r.height / 2 - vh / 2; const speed = parseFloat(el.dataset.speed) || 0; el.style.transform = `translate3d(0, ${(-center * speed).toFixed(1)}px, 0)`; }); }; const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(run); } }; window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll); run(); return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); }; }, []); } // ── Nav-local scroll state (isolated so the app doesn't re-render on scroll) ── function useNavScroll() { const [scrolled, setScrolled] = useState(false); const [progress, setProgress] = useState(0); useEffect(() => { let ticking = false; const run = () => { ticking = false; const y = window.scrollY || window.pageYOffset; const h = document.documentElement.scrollHeight - window.innerHeight; setProgress(h > 0 ? (y / h) * 100 : 0); setScrolled(y > 40); }; const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(run); } }; window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll); run(); return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); }; }, []); return { scrolled, progress }; } // ── Custom cursor: survey-crosshair ring + yellow dot, lagged follow ───────── function Cursor() { const ring = useRef(null); const dot = useRef(null); useEffect(() => { if (PRM) return; const fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches; if (!fine) return; document.body.classList.add('gr-cursor-on'); let mx = window.innerWidth / 2, my = window.innerHeight / 2; let rx = mx, ry = my; let raf; const loop = () => { rx = mx; ry = my; if (ring.current) ring.current.style.transform = `translate3d(${rx}px, ${ry}px, 0)`; if (dot.current) dot.current.style.transform = `translate3d(${mx}px, ${my}px, 0)`; raf = requestAnimationFrame(loop); }; const move = (e) => { mx = e.clientX; my = e.clientY; }; const HOT = 'a, button, input, textarea, select, [data-hot]'; const over = (e) => { if (e.target.closest && e.target.closest(HOT)) ring.current && ring.current.classList.add('is-hot'); }; const out = (e) => { if (e.target.closest && e.target.closest(HOT)) ring.current && ring.current.classList.remove('is-hot'); }; window.addEventListener('mousemove', move); document.addEventListener('mouseover', over); document.addEventListener('mouseout', out); raf = requestAnimationFrame(loop); return () => { cancelAnimationFrame(raf); window.removeEventListener('mousemove', move); document.removeEventListener('mouseover', over); document.removeEventListener('mouseout', out); document.body.classList.remove('gr-cursor-on'); }; }, []); return ( <>
); } // ── Counter: animates 0 → value when scrolled into view ────────────────────── function Counter({ value, duration = 1600, prefix = '', suffix = '', decimals = 0 }) { const ref = useRef(null); const [n, setN] = useState(0); useEffect(() => { const el = ref.current; if (!el) return; if (PRM) { setN(value); return; } let raf, started = false; const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting && !started) { started = true; const t0 = performance.now(); const tick = (t) => { const p = Math.min(1, (t - t0) / duration); const eased = 1 - Math.pow(1 - p, 3); setN(value * eased); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); io.disconnect(); } }); }, { threshold: 0.5 }); io.observe(el); return () => { io.disconnect(); cancelAnimationFrame(raf); }; }, [value, duration]); const display = decimals > 0 ? n.toFixed(decimals) : Math.round(n).toLocaleString('es-AR'); return {prefix}{display}{suffix}; } Object.assign(window, { useSiteFX, useNavScroll, Cursor, Counter });