// 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
)}
| # |
Calif. |
Habilidad |
Tiempo |
Fecha |
{(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 (
| {i + 1} |
{r.calificacion != null
? {r.calificacion.toFixed(1)}%
: —}
|
{hab
?
{hab.label} · {hab.pct.toFixed(0)}%
: —}
|
{fmtDur(r.duracion_seg)} |
{fmtFecha(r.creado_en)} |
);
})}
{(!modalInfo?.resultados || modalInfo.resultados.length === 0) && (
| 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 */}
| Tipo |
Proceso |
Dificultad |
Calificación |
Duración |
Fecha |
|
{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 (
|
{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).
)}
| Fecha | Concepto | Método | Monto | Estado | |
{compras.map(c => (
| {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 */}
>
)}
);
};