// DASHBOARD CLIENTE window.Dashboard = function Dashboard({ navigate, store }) { const BASE = window.API_BASE || '/simuladordocente/api'; const NIVELES = (store.niveles && store.niveles.length) ? store.niveles : window.SD_SEED.NIVELES; const PRUEBAS = store.pruebas || window.SD_SEED.PRUEBAS; const [tab, setTab] = React.useState('materias'); const [intentosData, setIntentosData] = React.useState(null); // Cargar contadores de intentos cuando hay sesión real React.useEffect(() => { const tok = localStorage.getItem('sd_token'); if (!tok || !store.session) return; fetch(`${BASE}/me/intentos`, { headers: { Authorization: `Bearer ${tok}` } }) .then(r => r.ok ? r.json() : null) .then(d => { if (d) setIntentosData(d); }) .catch(() => {}); }, [store.session?.sub]); if (!store.session) { navigate('/login'); return null; } return (
Mi panel

Hola, {store.session.nombre.split(' ')[0]} 👋

Practica con simuladores muestra (gratis) o desbloquea simuladores completos por materia.

{store.premiumIds.length}simuladores premium
{tab === 'materias' && (
{NIVELES.map((n) => { const pruebaNivel = PRUEBAS.filter(p => (p.nivel || p.nivel_id) === n.id); // Agrupar por materiaKey const groups = {}; pruebaNivel.forEach(p => { const key = p.materiaKey || p.materia_key || n.id; if (!groups[key]) groups[key] = []; groups[key].push(p); }); const groupList = Object.values(groups); if (!groupList.length) return null; return (

{n.nombre}

{groupList.length} {groupList.length === 1 ? 'materia' : 'materias'}
{groupList.map(group => ( ))}
); })}
)} {tab === 'resultados' && ( )} {tab === 'compras' && }
); }; // ── Tarjeta de materia: 1 muestra + 3 completos ─────────────── function MateriaCard({ pruebas, store, navigate, nivel }) { // Datos compartidos de la materia const pRef = pruebas.find(p => p.tipo === 'admision') || pruebas[0]; const matNombre = pRef.materiaNombre || pRef.materia_nombre; // Para Kínder/Primaria (sin materia específica) usar el nombre del nivel const nombre = matNombre || (nivel ? nivel.nombre : pRef.nivel_id); const icono = pRef.icono || '📘'; const precio = pRef.precio || pRef.precio_mxn || 150; // Fallback de muestra: cualquier prueba de esta materia que tenga muestra subida const fallbackMuestra = pruebas.find(p => p.muestra) || null; const tieneMuestra = !!fallbackMuestra; // ¿Al menos un proceso es premium? const algunoPremium = pruebas.some(p => store.hasPremium(p.id)); const TIPOS = [ { id: 'admision', label: 'Admisión' }, { id: 'promocion_vertical', label: 'Horas Adicionales' }, { id: 'promocion_horizontal', label: 'Promoción Horizontal' }, ]; return (
{/* Cabecera */}
{icono}
{algunoPremium ? ★ Acceso parcial : tieneMuestra ? Diagnóstico gratis : Próximamente}

{nombre}

{window.formatPrice(precio)} por proceso · acceso permanente

{/* 3 procesos: cada uno independiente */}
{TIPOS.map(tipo => { const p = pruebas.find(pr => pr.tipo === tipo.id); if (!p) return null; const esPremium = store.hasPremium(p.id); const enCarrito = store.cart.includes(p.id); const hasCompleto = !!p.completo; const pMuestra = p.muestra ? p : fallbackMuestra; const hasMuestra = !!pMuestra; const precioProceso = p.precio || p.precio_mxn || 150; return (
{/* Encabezado del proceso */}
{esPremium ? '★ ' : ''}{tipo.label} {/* Acción principal */} {esPremium ? ( hasCompleto ? ( ) : ( Completo próximamente ) ) : enCarrito ? ( ) : hasCompleto ? ( ) : ( Próximamente )}
{/* Botón diagnóstico */} {hasMuestra && !esPremium && ( )} {/* PDFs del proceso (solo si premium) */} {esPremium && ( )}
); })}
); } // ── Lista de PDFs por proceso (solo para usuarios premium de ese proceso) ── function MaterialesList({ pruebaId, isPremium }) { const BASE = window.API_BASE || '/simuladordocente/api'; const [mats, setMats] = React.useState(null); React.useEffect(() => { const tok = localStorage.getItem('sd_token'); if (!tok || !pruebaId) return; fetch(`${BASE}/materiales/${encodeURIComponent(pruebaId)}`, { headers: { Authorization: `Bearer ${tok}` }, }).then(r => r.ok ? r.json() : { materiales: [] }) .then(d => setMats(d.materiales || [])) .catch(() => setMats([])); }, [pruebaId]); if (!mats || mats.length === 0) return null; const resolveUrl = url => url && (url.startsWith('/') || url.startsWith('http')) ? url : url ? `/simuladordocente/${url}` : null; return (
📄 Material de estudio
{mats.map(m => { const url = resolveUrl(m.archivo_url); return url ? ( 📄{m.titulo} ) : null; })}
); } function PruebaCard({ prueba, store, navigate, intentosInfo, limiteMuestra = 5 }) { const isPremium = store.hasPremium(prueba.id); const inCart = store.cart.includes(prueba.id); const tieneMuestra = !!prueba.muestra; const tieneCompleto = !!prueba.completo; const precio = prueba.precio || prueba.precio_mxn || 150; // Datos de muestra (limitado para no-premium) const muestraInfo = intentosInfo?.muestra; const intentosM = muestraInfo?.intentos ?? 0; const bonusM = muestraInfo?.bonus ?? 0; const limEfM = limiteMuestra + bonusM; const agotados = !isPremium && tieneMuestra && intentosM >= limEfM; // Datos de completo (sin límite) const completoInfo = intentosInfo?.completo; const intentosC = completoInfo?.intentos ?? 0; const tieneHistorial = (muestraInfo?.intentos ?? 0) > 0 || (completoInfo?.intentos ?? 0) > 0; const [modal, setModal] = React.useState(false); const [modalTab, setModalTab] = React.useState('muestra'); // Pill de estado let pill; if (isPremium) pill = ★ Premium; else if (tieneMuestra) pill = Muestra gratis; else pill = Próximamente; const fmtDur = (s) => !s ? '—' : s < 60 ? `${s}s` : `${Math.floor(s/60)}m ${s%60}s`; const fmtFecha = (d) => new Date(d).toLocaleDateString('es-MX', { day:'numeric', month:'short' }); const scoreColor = (v) => v >= 70 ? 'var(--green)' : v >= 50 ? 'var(--amber)' : 'var(--red)'; // Qué info/límite mostrar en el modal según el tab activo const modalInfo = modalTab === 'completo' ? completoInfo : muestraInfo; const modalLimit = modalTab === 'completo' ? limEfC : limEfM; const modalUsed = modalTab === 'completo' ? intentosC : intentosM; return ( <>
{prueba.icono || '📘'}
{pill}

{prueba.materiaNombre || prueba.nombre.replace(/^[^·]+·\s*/, '')}

{prueba.preguntas} preguntas
{/* ── Contador de intentos ── */} {tieneHistorial && ( )}
{tieneMuestra && !agotados ? ( ) : tieneMuestra && agotados ? ( ) : ( )} {isPremium ? ( ) : !tieneCompleto ? ( ) : inCart ? ( ) : ( )}
{/* ── Mini modal de resultados ── */} {modal && (
setModal(false)}>
e.stopPropagation()} style={{ maxWidth: 440 }}>

{prueba.icono} {prueba.materiaNombre || prueba.nombre.replace(/^[^·]+·\s*/, '')}

Historial de intentos
{/* Tabs muestra / completo */} {(muestraInfo || completoInfo) && (
{muestraInfo && ( )} {completoInfo && ( )}
)} {/* Resumen */}
{modalUsed}/{modalLimit}
intentos usados
{modalInfo?.calificacion_max != null && (
{modalInfo.calificacion_max.toFixed(1)}%
mejor calificación
)}
{(modalInfo?.resultados || []).map((r, i) => { const hab = r.analisis_pct != null ? { label:'Análisis', pct: r.analisis_pct, bg:'#FEF3C7', color:'#92400E' } : r.comprension_pct != null ? { label:'Comprensión', pct: r.comprension_pct, bg:'var(--teal-soft)', color:'var(--teal-2)' } : null; return ( ); })} {(!modalInfo?.resultados || modalInfo.resultados.length === 0) && ( )}
# Calif. Habilidad Tiempo Fecha
{i + 1} {r.calificacion != null ? {r.calificacion.toFixed(1)}% : } {hab ? {hab.label} · {hab.pct.toFixed(0)}% : } {fmtDur(r.duracion_seg)} {fmtFecha(r.creado_en)}
Sin resultados registrados
{!isPremium && tieneCompleto && (
¿Listo para el simulador completo?
Pago único {window.formatPrice(precio)}.
)}
)} ); } // ── Mis Resultados ───────────────────────────────────────────── function ResultadosTab({ store, navigate }) { const BASE = window.API_BASE || '/simuladordocente/api'; const PRUEBAS = store.pruebas || window.SD_SEED.PRUEBAS; const [resultados, setResultados] = React.useState([]); const [loading, setLoading] = React.useState(true); React.useEffect(() => { const tok = localStorage.getItem('sd_token'); if (!tok) { setLoading(false); return; } fetch(`${BASE}/me/resultados`, { headers: { Authorization: `Bearer ${tok}` } }) .then(r => r.ok ? r.json() : null) .then(d => { if (d?.resultados) setResultados(d.resultados); setLoading(false); }) .catch(() => setLoading(false)); }, []); const fmtDur = s => !s ? '—' : s < 60 ? `${s}s` : `${Math.floor(s/60)}m ${s%60}s`; const fmtFecha = d => new Date(d).toLocaleDateString('es-MX', { day:'numeric', month:'short', year:'numeric' }); const scoreColor = v => v == null ? 'var(--muted)' : v >= 70 ? 'var(--green)' : v >= 50 ? 'var(--amber)' : 'var(--red)'; const TIPO_LABEL = { admision: 'Admisión', promocion_vertical: 'Horas Adicionales', promocion_horizontal: 'Promoción Horizontal' }; if (loading) return
Cargando resultados…
; if (resultados.length === 0) { return (
📊

Sin resultados todavía

Completa un diagnóstico o simulador completo para ver tu historial aquí.

); } // Agrupar por materia_key (para mostrar juntos los 3 tipos) const porMateria = {}; resultados.forEach(r => { const mKey = r.materia_key || r.prueba_id; const prueba = PRUEBAS.find(p => p.id === r.prueba_id); if (!porMateria[mKey]) { porMateria[mKey] = { icono: r.icono || prueba?.icono || '📘', materiaNombre: r.materia_nombre || prueba?.materiaNombre || r.prueba_nombre, intentos: [], }; } porMateria[mKey].intentos.push(r); }); return (
{Object.entries(porMateria).map(([mKey, { icono, materiaNombre, intentos }]) => { const muestras = intentos.filter(i => i.modo === 'muestra'); const completos = intentos.filter(i => i.modo === 'completo'); const califs = intentos.map(i => i.calificacion).filter(v => v != null); const mejor = califs.length ? Math.max(...califs) : null; const promedio = califs.length ? califs.reduce((a, b) => a + b, 0) / califs.length : null; return (
{/* Cabecera materia */}
{icono}
{materiaNombre}
{muestras.length} diagnóstico{muestras.length !== 1 ? 's' : ''}{' '} {completos.length > 0 && `· ${completos.length} completo${completos.length !== 1 ? 's' : ''}`} {' · '}{intentos.length} total
{califs.length > 0 && (
{mejor != null && (
{mejor.toFixed(0)}%
Mejor
)} {promedio != null && (
{promedio.toFixed(0)}%
Promedio
)}
)}
{/* Tabla de intentos */} {intentos.map(i => { const tipoLabel = TIPO_LABEL[i.tipo] || i.tipo || '—'; const pct = i.calificacion; // Normalizar dificultad: puede venir como 'comprension', 'Comprensión', 'analisis', 'Análisis' const difRaw = (i.dificultad || '').toLowerCase() .normalize('NFD').replace(/[̀-ͯ]/g, ''); const esComp = difRaw.includes('comprens'); const esAnal = difRaw.includes('analis'); return ( ); })}
Tipo Proceso Dificultad Calificación Duración Fecha
{i.modo === 'completo' ? ★ Completo : Diagnóstico} {tipoLabel} {esComp ? 🟢 Comprensión : esAnal ? 🟠 Análisis : } {pct != null ? {pct.toFixed(1)}% : Sin calificar} {fmtDur(i.duracion_seg)} {fmtFecha(i.creado_en)}
); })}
); } function ComprasTable({ navigate, store }) { const BASE = window.API_BASE || '/simuladordocente/api'; const PRUEBAS = store.pruebas || window.SD_SEED.PRUEBAS; const [compras, setCompras] = React.useState([]); const [loading, setLoading] = React.useState(true); const [recibo, setRecibo] = React.useState(null); React.useEffect(() => { const tok = localStorage.getItem('sd_token'); if (!tok) { setLoading(false); return; } fetch(`${BASE}/me/compras`, { headers: { Authorization: `Bearer ${tok}` } }) .then(r => r.ok ? r.json() : null) .then(d => { if (d?.compras) setCompras(d.compras); setLoading(false); }) .catch(() => setLoading(false)); }, []); const fmtFecha = iso => new Date(iso).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' }); const resolverConcepto = ids => ids.map(id => { const p = PRUEBAS.find(p => p.id === id); return p ? (p.materiaNombre || p.nombre.replace(/^[^·]+·\s*/, '')) : id; }).join(' + '); const estadoPill = estado => { if (estado === 'acreditado') return Pagado; if (estado === 'pendiente') return Pendiente; return Expirado; }; if (loading) return (
Cargando compras…
); return (

Historial de compras

{compras.length === 0 ? (
🧾

Aún no tienes compras registradas.

) : ( <> {compras.some(c => c.estado === 'pendiente') && (
Tienes pagos OXXO pendientes. Tu acceso se activará automáticamente al acreditarse (1–48 h).
)} {compras.map(c => ( ))}
FechaConceptoMétodoMontoEstado
{fmtFecha(c.creado_en)} {resolverConcepto(c.prueba_ids)} (Simulador completo) {c.metodo === 'mercadopago' ? 'Mercado Pago' : 'Oxxo Pay'} ${c.monto} MXN {estadoPill(c.estado)} {c.estado === 'acreditado' && ( )} {c.estado === 'pendiente' && c.metodo === 'oxxo' && c.external_id && ( Ver ficha ↗ )}
)} {recibo && ( setRecibo(null)} /> )}
); } function ReciboModal({ pago, pruebas, onClose }) { const fmtFechaLarga = iso => new Date(iso).toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }); const items = (pago.prueba_ids || []).map(id => pruebas.find(p => p.id === id)).filter(Boolean); const ref = pago.id.slice(-10).toUpperCase(); return (
e.stopPropagation()} style={{ maxWidth: 460 }}> {/* Encabezado */}
Recibo de compra

Simulador Docente

{/* Meta */}
{[ ['Referencia', `#${ref}`], ['Fecha', fmtFechaLarga(pago.creado_en)], ['Método', pago.metodo === 'mercadopago' ? 'Mercado Pago' : 'Oxxo Pay'], ].map(([k, v]) => (
{k} {v}
))}
{/* Artículos */}
Artículos
{items.map(p => (
{p.icono} {p.materiaNombre || p.nombre.replace(/^[^·]+·\s*/, '')} Simulador completo ${p.precio ?? pago.monto}
))} {items.length === 0 && (
{(pago.prueba_ids || []).join(', ')}
)} {/* Total */}
Total pagado ${pago.monto} MXN
); } window.DashHeader = function DashHeader({ navigate, store }) { const [menuOpen, setMenuOpen] = React.useState(false); const initials = store.session.nombre.split(' ').map(s => s[0]).slice(0, 2).join(''); const premium = store.premiumIds.length; const logout = () => { store.logout(); navigate('/'); }; return (
{/* Logo */} { e.preventDefault(); navigate('/'); }}> {/* Nav */} {/* Derecha */}
{store.cart.length > 0 && ( )} {/* User chip con dropdown */}
setMenuOpen(o => !o)} style={{ cursor:'pointer', userSelect:'none' }}>
{initials}
{store.session.nombre}
{store.session.email}
{menuOpen && ( <> {/* Overlay para cerrar */}
setMenuOpen(false)} />
{/* Cabecera dropdown */}
{store.session.nombre}
{store.session.email}
{premium > 0 && (
★ {premium} simulador{premium !== 1 ? 'es' : ''} premium
)}
{/* Acciones */}
)}
); };