// src/pages-miembros.jsx — Área privada para Colegios Miembros // // AUTENTICACIÓN — MAGIC LINK (sin contraseñas) // ──────────────────────────────────────────── // Backend PHP en lpdebate.org/plataforma/miembros-auth/ (mismo dominio → // cookie httpOnly de sesión, 30 días): // login.php → POST {correo}: si el colegio está activo, envía enlace // acceso.php → el enlace del correo; setea cookie y vuelve aquí // session.php → ¿quién soy? {colegio:{id, nombre}} // sesiones.php→ contenido del Sheet de formación (requiere sesión) // logout.php → borra la cookie // El alta/baja de colegios se administra en el Sheet "Accesos Miembros" // (estado: activo/suspendido). El hash demo client-side fue eliminado. // // VIDEOS // ────── // Importante: ningún SPA puede impedir 100% que un usuario autenticado // comparta la URL del embed. Defensas razonables (no infalibles): // - Reproductor con rel=0, modestbranding, sin sugerencias // - Watermark superpuesto con el nombre del colegio logueado // - Click derecho bloqueado sobre el contenedor del reproductor // - Disclaimer visible al usuario const MIEMBROS_API = 'https://lpdebate.org/plataforma/miembros-auth'; /* ── Filas del Sheet (objetos del backend) → sesiones del dashboard ── */ function rowsToSessions(rows) { const out = []; for (const raw of rows || []) { // Normalizar claves a minúsculas por si los headers cambian de caja const row = {}; for (const k of Object.keys(raw || {})) row[k.trim().toLowerCase()] = raw[k]; const get = (name) => ((row[name] === undefined || row[name] === null) ? '' : String(row[name]).trim()); // Saltar filas sin id o marcadas como no publicadas const id = get('id'); if (!id) continue; const pub = get('published').toLowerCase(); if (pub === 'false' || pub === 'no' || pub === '0') continue; const materials = []; for (let n = 1; n <= 5; n++) { const url = get(`material_${n}_url`); if (url) { materials.push({ kind: get(`material_${n}_kind`) || 'Material', label: get(`material_${n}_label`) || get(`material_${n}_kind`) || 'Descargar', url, }); } } out.push({ id, kind: get('kind') || 'Sesión', title: get('title'), speaker: get('speaker'), date: get('date'), duration: get('duration'), area: get('area'), level: get('level'), path: get('path'), summary: get('summary'), video: get('video'), materials, }); } return out; } /* ── Hook: sesiones desde sesiones.php (requiere cookie), fallback a data.js ── */ function useMiembrosSessions() { const D = window.LPDE_DATA; const fallback = (D && D.miembros && D.miembros.sessions) || []; const [sessions, setSessions] = React.useState(fallback); const [status, setStatus] = React.useState('loading'); // loading | live | fallback React.useEffect(() => { let cancelled = false; fetch(`${MIEMBROS_API}/sesiones.php`, { cache: 'no-store', credentials: 'include' }) .then((r) => r.ok ? r.json() : Promise.reject('http ' + r.status)) .then((data) => { if (cancelled) return; const parsed = rowsToSessions(data && data.rows); if (parsed.length) { setSessions(parsed); setStatus('live'); } else { setStatus('fallback'); } }) .catch((err) => { console.warn('[miembros] sesiones.php failed, using fallback:', err); if (!cancelled) setStatus('fallback'); }); return () => { cancelled = true; }; }, []); return { sessions, status }; } /* ── Convertir URL de YouTube en URL embebible ── */ function ytEmbedUrl(url) { if (!url) return null; const m = url.match(/(?:youtu\.be\/|v=|\/embed\/)([A-Za-z0-9_-]{6,})/); if (!m) return null; const id = m[1]; // rel=0 (sin sugerencias de otros canales), modestbranding (logo discreto), // playsinline (no fullscreen forzado), iv_load_policy=3 (sin anotaciones). return `https://www.youtube-nocookie.com/embed/${id}?rel=0&modestbranding=1&playsinline=1&iv_load_policy=3`; } /* ═══════════════════════════════════════════════════════════ LOGIN ═══════════════════════════════════════════════════════════ */ const MiembrosLogin = ({ setPage }) => { const { t, lang } = useLang(); const [correo, setCorreo] = React.useState(''); const [err, setErr] = React.useState(''); const [sent, setSent] = React.useState(false); const [busy, setBusy] = React.useState(false); const submit = async (e) => { if (e) e.preventDefault(); setErr(''); const c = correo.trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c)) { setErr(t('Escribe el correo registrado de tu colegio.', 'Enter your school’s registered email.')); return; } setBusy(true); try { const r = await fetch(`${MIEMBROS_API}/login.php`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ correo: c }), }); const data = await r.json().catch(() => ({ ok: false })); if (data.ok && data.found) { setSent(true); } else if (data.ok && data.found === false) { setErr(t('Este correo no está registrado como colegio miembro. Verifica que sea el correo de contacto de tu colegio, o escríbenos a contacto@lpdebate.org para registrarlo.', 'This email isn’t registered to a member school. Make sure it’s your school’s contact email, or write to contacto@lpdebate.org to register it.')); } else if (data.error === 'rate_limited') { setErr(t('Demasiados intentos seguidos. Espera unos minutos y vuelve a intentar.', 'Too many attempts in a row. Please wait a few minutes and try again.')); } else if (data.error === 'datos_no_disponibles') { setErr(t('No pudimos consultar el registro de colegios en este momento. Intenta de nuevo en unos minutos.', 'We couldn’t check the school registry right now. Please try again in a few minutes.')); } else if (data.error === 'mail_failed') { setErr(t('No pudimos enviar el correo. Intenta de nuevo o escríbenos a contacto@lpdebate.org.', 'We couldn’t send the email. Try again or write to contacto@lpdebate.org.')); } else { setErr(t('No pudimos procesar la solicitud. Intenta de nuevo o escríbenos a contacto@lpdebate.org.', 'We couldn’t process your request. Try again or write to contacto@lpdebate.org.')); } } catch (e2) { setErr(t('No pudimos conectar con el servidor. Revisa tu conexión o escríbenos a contacto@lpdebate.org.', 'We couldn’t reach the server. Check your connection or write to contacto@lpdebate.org.')); } finally { setBusy(false); } }; return (
{/* Panel izquierdo — branding */}
{t('Área privada', 'Members area')}

{t(<>Formación para
colegios miembros, <>Training for
member schools)}

{t('Plataforma de capacitación continua incluida en la membresía: seminarios, talleres y rutas de aprendizaje con material descargable.', 'An ongoing training platform included in your membership: seminars, workshops and learning tracks with downloadable materials.')}
{/* Panel derecho — form de correo (magic link) */} {sent ?
{t('Revisa tu correo', 'Check your inbox')}

{t('Enlace en camino ✓', 'Link on its way ✓')}

{t(<>Enviamos un enlace de acceso a {correo.trim().toLowerCase()}. Ábrelo en este dispositivo y entrarás directo al área de miembros., <>We sent an access link to {correo.trim().toLowerCase()}. Open it on this device and you’ll go straight to the members area.)}

{t('¿No llega? Revisa la carpeta de spam, o escríbenos a contacto@lpdebate.org.', 'Didn’t get it? Check your spam folder, or write to contacto@lpdebate.org.')}

:
{t('Ingreso', 'Log in')}

{t('Entra con tu correo', 'Log in with your email')}

{t('Sin contraseñas: escribe el correo registrado de tu colegio y te enviamos un enlace de acceso. La sesión dura 30 días en este navegador.', 'No passwords: enter your school’s registered email and we’ll send you an access link. Your session stays active for 30 days in this browser.')}

{ setCorreo(e.target.value); setErr(''); }} autoFocus autoComplete="email" placeholder={t('direccion@tucolegio.edu.pe', 'office@yourschool.edu.pe')} style={{ width: '100%', marginBottom: err ? 8 : 20 }} /> {err &&
{err}
}
}
); }; /* ═══════════════════════════════════════════════════════════ DASHBOARD — grilla de sesiones con filtros ═══════════════════════════════════════════════════════════ */ const FILTERS = [ { key: 'area', label: 'Tema', en: 'Topic' }, { key: 'level', label: 'Nivel', en: 'Level' }, { key: 'path', label: 'Ruta', en: 'Track' }, ]; const MiembrosDashboard = ({ sess, onOpen, onLogout }) => { const { t, lang } = useLang(); const D = window.LPDE_DATA; const M = (D && D.miembros) || { areas: [], levels: [], paths: [] }; const { sessions, status } = useMiembrosSessions(); const [filterKey, setFilterKey] = React.useState('area'); const [filterValue, setFilterValue] = React.useState('Todos'); const [query, setQuery] = React.useState(''); // Valores disponibles para el filtro: catálogo base + cualquier valor extra del Sheet const collectValues = (key, base) => { const fromSessions = sessions.map((s) => s[key]).filter(Boolean); return ['Todos', ...Array.from(new Set([...(base || []), ...fromSessions]))]; }; const valuesByKey = { area: collectValues('area', M.areas), level: collectValues('level', M.levels), path: collectValues('path', M.paths), }; const filtered = sessions.filter((s) => { const okFilter = filterValue === 'Todos' || s[filterKey] === filterValue; const q = query.trim().toLowerCase(); const okQuery = q === '' || (s.title + ' ' + (s.summary || '') + ' ' + (s.speaker || '')).toLowerCase().includes(q); return okFilter && okQuery; }); return ( <> {/* Barra de sesión */}
{t('Área privada · Colegios miembros', 'Members area · Member schools')}
{t('Hola,', 'Hello,')} {sess.user}
{/* Header */}
{t('Capacitación', 'Training')}

{t(<>Sesiones disponibles, <>Available sessions)}

{t('Material exclusivo para el cuerpo docente del colegio miembro. Cada sesión incluye video y descargables.', 'Exclusive material for your member school’s teaching staff. Each session includes a video and downloads.')}

setQuery(e.target.value)} style={{ border: 'none', background: 'transparent', padding: '6px 0', width: 220 }} />
{/* Barras de filtro */}
{t('Agrupar por', 'Group by')} {FILTERS.map((f) => ( ))}
{(valuesByKey[filterKey] || []).map((v) => ( ))}
{/* Grid de sesiones */}
{filtered.length === 0 ?

{t('No hay sesiones para tu búsqueda.', 'No sessions match your search.')}

:
{filtered.map((s, i) => (
onOpen(s)} style={{ background: 'var(--bg)', border: '1px solid var(--line)', cursor: 'pointer', display: 'flex', flexDirection: 'column', transition: 'all 280ms ease', overflow: 'hidden', }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.boxShadow = '0 16px 40px rgba(0,0,0,0.08)'; e.currentTarget.style.borderColor = 'var(--red)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.borderColor = 'var(--line)'; }} > {/* Tag bar */}
{s.kind ? s.kind.toUpperCase() : t('SESIÓN', 'SESSION')} {s.date} {s.duration}
{/* Body */}

{s.title}

{s.speaker && (
{t('con', 'with')} {s.speaker}
)}

{(s.summary || '').slice(0, 130)}{(s.summary || '').length > 130 ? '…' : ''}

{[s.area, s.level, s.path].filter(Boolean).map((t, j) => ( {t} ))}
{t('Abrir sesión', 'Open session')} → {s.video && ▶ Video} {Array.isArray(s.materials) && s.materials.length > 0 && ⬇ {s.materials.length} material{s.materials.length > 1 ? t('es', 's') : ''}}
))}
}

{filtered.length} {t('de', 'of')} {sessions.length} {t('sesiones', 'sessions')} · {status === 'live' ? t('sincronizado con el Sheet', 'live data') : status === 'loading' ? t('cargando…', 'loading…') : t('modo offline (snapshot local)', 'offline mode (local snapshot)')}

); }; /* ═══════════════════════════════════════════════════════════ VISTA DE SESIÓN — video + descargables ═══════════════════════════════════════════════════════════ */ const MiembrosSessionView = ({ session, sess, onBack }) => { const { t, lang } = useLang(); const embed = ytEmbedUrl(session.video); // Bloquear menú contextual sobre el reproductor (deterrent) const blockContext = (e) => { e.preventDefault(); return false; }; return ( <> {/* Barra de sesión */}
{t('Sesión iniciada como', 'Logged in as')} {sess.user}
{/* Encabezado de sesión */}
{(session.kind || t('SESIÓN', 'SESSION')).toUpperCase()} {session.date} · {session.duration}

{session.title}

{session.speaker && (

{t('con', 'with')} {session.speaker}

)}
{/* Layout principal: video + sidebar de materiales */}
{/* Video con watermark */}
{embed ?