// src/components.jsx — shared layout pieces (Header, Footer, Reveal, icons, modal)
const { useState, useEffect, useRef } = React;
/* ─── Icons ─────────────────────────────────────────────── */
const Icon = ({ name, size = 16 }) => {
const paths = {
arrow: ,
'arrow-up-right': ,
close: ,
menu: ,
mail: ,
instagram: ,
linkedin: ,
facebook: ,
download: ,
play: ,
chevron: ,
plus: ,
map: ,
phone: ,
quote: ,
search: ,
check: ,
trophy: ,
};
return (
{paths[name]}
);
};
/* ─── Idioma (ES/EN) ────────────────────────────────────── */
// t('texto español', 'english text') — si falta el inglés, cae al español.
const LangContext = React.createContext({ lang: 'es', setLang: () => {}, t: (es) => es });
const useLang = () => React.useContext(LangContext);
const LangProvider = ({ children }) => {
const initial = (() => {
try {
const p = new URLSearchParams(window.location.search).get('lang');
if (p === 'en' || p === 'es') return p;
const s = localStorage.getItem('lpde_lang');
if (s === 'en' || s === 'es') return s;
} catch (e) {}
return 'es';
})();
const [lang, setLangState] = useState(initial);
const setLang = (l) => {
setLangState(l);
try { localStorage.setItem('lpde_lang', l); } catch (e) {}
};
useEffect(() => { document.documentElement.lang = lang; }, [lang]);
const t = React.useCallback((es, en) => (lang === 'en' && en != null ? en : es), [lang]);
return {children} ;
};
const LangToggle = () => {
const { lang, setLang } = useLang();
return (
setLang(lang === 'es' ? 'en' : 'es')}
aria-label={lang === 'es' ? 'Switch to English' : 'Cambiar a español'}
title={lang === 'es' ? 'Switch to English' : 'Cambiar a español'}
>
ES
/
EN
);
};
/* ─── useReveal hook + Reveal wrapper ───────────────────── */
const useReveal = () => {
useEffect(() => {
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in');
io.unobserve(e.target);
}
});
}, { threshold: 0.12, rootMargin: '0px 0px -50px 0px' });
document.querySelectorAll('.reveal:not(.in)').forEach(el => io.observe(el));
return () => io.disconnect();
});
};
/* ─── Animated count-up ─────────────────────────────────── */
const CountUp = ({ to, duration = 1400, suffix = '' }) => {
const [val, setVal] = useState(0);
const ref = useRef(null);
const started = useRef(false);
useEffect(() => {
if (!ref.current) return;
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting && !started.current) {
started.current = true;
const start = performance.now();
const tick = (now) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
setVal(Math.round(eased * to));
if (t < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
});
}, { threshold: 0.3 });
io.observe(ref.current);
return () => io.disconnect();
}, [to, duration]);
return {val}{suffix} ;
};
/* ─── Header / Nav ──────────────────────────────────────── */
const NAV_ITEMS = [
{ id: 'presentacion', label: 'Presentación', en: 'About' },
{ id: 'equipo', label: 'Equipo', en: 'Team' },
{ id: 'noticias', label: 'Noticias y recursos', en: 'News & Resources' },
{ id: 'formacion', label: 'Formación', en: 'Training' },
{ id: 'calendario', label: 'Calendario', en: 'Calendar' },
{ id: 'snde', label: 'Selección Nacional', en: 'National Team' },
{ id: 'admision', label: 'Admisión', en: 'Membership' },
{ id: 'tnde', label: 'TNDE', en: 'TNDE' },
];
const Header = ({ page, setPage }) => {
const { t } = useLang();
const [menuOpen, setMenuOpen] = useState(false);
// Menú móvil: bloquear scroll del fondo + cerrar con Esc
useEffect(() => {
if (!menuOpen) return;
const onKey = (e) => { if (e.key === 'Escape') setMenuOpen(false); };
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [menuOpen]);
const go = (id) => { setMenuOpen(false); setPage(id); };
return (
go('presentacion')} aria-label="Inicio LPDE">
{NAV_ITEMS.map(item => (
setPage(item.id)}
className={`nav-link ${page === item.id ? 'active' : ''}`}
data-comment-anchor={`nav-${item.id}`}
>
{t(item.label, item.en)}
))}
setPage('colegios-miembros')}
title={t('Área de formación para colegios miembros', 'Training area for member schools')}
>
{t('Iniciar sesión', 'Log in')}
setMenuOpen(true)} aria-label={t('Abrir menú', 'Open menu')} aria-expanded={menuOpen}>
{/* Portal a body: el backdrop-filter del .nav crea un containing block
que atraparía (y recortaría) este overlay position:fixed */}
{menuOpen && ReactDOM.createPortal(
setMenuOpen(false)} aria-label={t('Cerrar menú', 'Close menu')}>
{NAV_ITEMS.map((item, i) => (
go(item.id)}
className={`nav-menu-link ${page === item.id ? 'active' : ''}`}
style={{ animationDelay: `${60 + i * 35}ms` }}>
{String(i + 1).padStart(2, '0')}
{t(item.label, item.en)}
))}
go('colegios-miembros')}>
{t('Iniciar sesión · Colegios miembros', 'Log in · Member schools')}
,
document.body
)}
);
};
/* ─── Footer ────────────────────────────────────────────── */
const Footer = ({ setPage }) => {
const { t } = useLang();
return (
Liga Peruana de Debate Escolar
{t('La red más grande de debate escolar del Perú. 103 colegios, 11 regiones, 3 mundiales consecutivos.',
'Peru’s largest school debate network. 103 schools, 11 regions, 3 consecutive world titles.')}
{t('Programas', 'Programs')}
{t('Contacto', 'Contact')}
© 2026 LPDE · METADIDACTAS S.A.C. · {t('Todos los derechos reservados', 'All rights reserved')}
{t('Argumentar · Pensar · Convencer', 'Argue · Think · Persuade')}
);
};
/* ─── Marquee ───────────────────────────────────────────── */
const Marquee = ({ items }) => {
const doubled = [...items, ...items];
return (
{doubled.map((t, i) => (
{t}
))}
);
};
/* ─── Modal ─────────────────────────────────────────────── */
const Modal = ({ open, onClose, children }) => {
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [open, onClose]);
if (!open) return null;
return (
e.stopPropagation()}>
{children}
);
};
/* ─── Page wrapper (handles reveal init + key) ──────────── */
const Page = ({ children, screen }) => {
useReveal();
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' });
}, []);
return (
{children}
);
};
Object.assign(window, { Icon, useReveal, CountUp, Header, Footer, Marquee, Modal, Page, NAV_ITEMS, LangProvider, useLang, LangToggle });