// Shared UI: nav, footer, logo mark, utils const { useState, useEffect, useRef, useCallback } = React; // El tema lo define data-tema en ("claro" | "oscuro") y lo aplica // page-transition.js antes del primer pintado. Acá sólo se lee, para que // React no pise la decisión ni provoque un cambio de color al montar. function useTheme() { const raiz = document.documentElement; const leer = () => (raiz.getAttribute('data-tema') === 'oscuro' ? 'dark' : 'light'); const [theme, setTheme] = useState(leer); const aplicar = useCallback((next) => { const oscuro = next === 'dark'; raiz.setAttribute('data-tema', oscuro ? 'oscuro' : 'claro'); if (oscuro) raiz.setAttribute('data-theme', 'dark'); else raiz.removeAttribute('data-theme'); setTheme(oscuro ? 'dark' : 'light'); }, []); useEffect(() => { setTheme(leer()); }, []); return [theme, aplicar]; } // Intersection observer hook for reveal-on-scroll function useReveal() { const ref = useRef(null); const [visible, setVisible] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; // Immediate check: if already in viewport at mount, reveal const rect = el.getBoundingClientRect(); if (rect.top < window.innerHeight && rect.bottom > 0) { setVisible(true); return; } const obs = new IntersectionObserver((entries) => { entries.forEach((e) => {if (e.isIntersecting) {setVisible(true);obs.disconnect();}}); }, { threshold: 0.1, rootMargin: "0px 0px -10% 0px" }); obs.observe(el); return () => obs.disconnect(); }, []); return [ref, visible]; } // Animated number that counts up when visible function CountUp({ to, duration = 1600, suffix = "" }) { const [ref, visible] = useReveal(); const [val, setVal] = useState(0); useEffect(() => { if (!visible) return; const start = performance.now(); let raf; const tick = (t) => { const p = Math.min(1, (t - start) / duration); const eased = 1 - Math.pow(1 - p, 3); setVal(Math.floor(to * eased)); if (p < 1) raf = requestAnimationFrame(tick);else setVal(to); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [visible, to, duration]); return {val}{suffix}; } // Reveal wrapper function Reveal({ children, delay = 0, style }) { const [ref, visible] = useReveal(); return (
;
}
// Navigation
function Nav({ activePage = "home" }) {
const [theme, setTheme] = useTheme();
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const on = () => setScrolled(window.scrollY > 20);
on();
window.addEventListener('scroll', on, { passive: true });
return () => window.removeEventListener('scroll', on);
}, []);
const links = [
{ id: "home", href: "index.html", label: "Inicio", ico: "fa-house" },
{ id: "categorias", href: "categorias.html", label: "Categorías", ico: "fa-layer-group" },
{ id: "partidos", href: "index.html#partidos", label: "Partidos", ico: "fa-calendar-days" },
{ id: "galeria", href: "index.html#galeria", label: "Galería", ico: "fa-images" },
{ id: "contacto", href: "contacto.html", label: "Contacto", ico: "fa-envelope" },
// Acceso al panel: socios, delegados, tesorería y administración.
{ id: "socios", href: "panel/", label: "Socios", ico: "fa-lock", externo: true }];
return (
);
}
// Marquesina (ticker de novedades). Texto, colores y velocidad se configuran
// desde el panel. Va en el shell porque se muestra en todas las páginas.
const TICKER_DEFAULT = {
activo: true,
fondo: "#4FB8E6",
texto: "#0B2545",
velocidad: 30,
mensajes: ["INSCRIPCIONES 2026 ABIERTAS", "PRIMERA CLASE DE PRUEBA GRATIS"]
};
function Marquesina() {
const [cfg, setCfg] = useState(TICKER_DEFAULT);
useEffect(() => {
fetch("api/contenido.php", { headers: { Accept: "application/json" } }).
then((r) => r.ok ? r.json() : Promise.reject()).
then((j) => {
if (j && j.ok && j.ticker && Array.isArray(j.ticker.mensajes) && j.ticker.mensajes.length) {
setCfg({ ...TICKER_DEFAULT, ...j.ticker });
} else if (j && j.ok && j.ticker && !j.ticker.activo) {
setCfg({ ...TICKER_DEFAULT, activo: false });
}
}).
catch(() => {});
}, []);
if (!cfg.activo || !cfg.mensajes.length) return null;
const linea = cfg.mensajes.map((m) => "★ " + m).join(" ");
return (