Fix PDF permissions, routing, and FlujoCircular exercise

- Fix PDF file permissions (403 Forbidden error)
- Fix routing bug preventing access to modules 2-4
- Replace FlujoCircular with intuitive quiz version
- Cap progress percentage at 100%
- Add PDF viewer with modal in Recursos page
This commit is contained in:
Renato
2026-02-12 04:30:15 +01:00
parent a2ed69fdb8
commit ed62af159d
4 changed files with 312 additions and 395 deletions

View File

@@ -61,38 +61,6 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/modulo/1"
element={
<ProtectedRoute>
<Modulo />
</ProtectedRoute>
}
/>
<Route
path="/modulo/2"
element={
<ProtectedRoute>
<Modulo />
</ProtectedRoute>
}
/>
<Route
path="/modulo/3"
element={
<ProtectedRoute>
<Modulo />
</ProtectedRoute>
}
/>
<Route
path="/modulo/4"
element={
<ProtectedRoute>
<Modulo />
</ProtectedRoute>
}
/>
<Route <Route
path="/admin" path="/admin"
element={ element={

View File

@@ -1,248 +1,167 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Card, CardHeader } from '../../ui/Card'; import { Card } from '../../ui/Card';
import { Button } from '../../ui/Button'; import { Button } from '../../ui/Button';
import { CheckCircle, XCircle, Trophy, Users, Building2, Landmark, Globe, RefreshCw } from 'lucide-react'; import { CheckCircle, XCircle, Trophy, RotateCcw, ArrowRight } from 'lucide-react';
interface FlujoCircularProps { interface FlujoCircularProps {
ejercicioId: string; ejercicioId: string;
onComplete?: (puntuacion: number) => void; onComplete?: (puntuacion: number) => void;
} }
type Agente = 'familias' | 'empresas' | 'estado' | 'exterior'; interface Pregunta {
type TipoFlujo = 'real' | 'monetario'; id: number;
pregunta: string;
interface Elemento { opciones: string[];
id: string; respuestaCorrecta: number;
texto: string; explicacion: string;
tipo: TipoFlujo;
origen: Agente;
destino: Agente;
} }
interface Nivel { const PREGUNTAS: Pregunta[] = [
nombre: string;
descripcion: string;
agentes: Agente[];
elementos: Elemento[];
}
const NIVELES: Nivel[] = [
{ {
nombre: 'Básico', id: 1,
descripcion: 'Solo Familias y Empresas', pregunta: "¿Quiénes son los principales agentes económicos en el flujo circular?",
agentes: ['familias', 'empresas'], opciones: [
elementos: [ "Solo el gobierno y las empresas",
{ id: 'trabajo', texto: '💪 Trabajo', tipo: 'real', origen: 'familias', destino: 'empresas' }, "Familias y empresas",
{ id: 'salarios', texto: '💵 Salarios', tipo: 'monetario', origen: 'empresas', destino: 'familias' }, "Bancos y familias",
{ id: 'bienes', texto: '📦 Bienes', tipo: 'real', origen: 'empresas', destino: 'familias' }, "Empresas y extranjeros"
{ id: 'gasto', texto: '💳 Gasto', tipo: 'monetario', origen: 'familias', destino: 'empresas' }, ],
] respuestaCorrecta: 1,
explicacion: "Las familias (consumidores) y las empresas (productores) son los dos agentes principales en el modelo básico del flujo circular."
}, },
{ {
nombre: 'Intermedio', id: 2,
descripcion: 'Incluye al Estado', pregunta: "Las familias ofrecen a las empresas:",
agentes: ['familias', 'empresas', 'estado'], opciones: [
elementos: [ "Productos terminados",
{ id: 'trabajo', texto: '💪 Trabajo', tipo: 'real', origen: 'familias', destino: 'empresas' }, "Trabajo, tierra y capital (factores de producción)",
{ id: 'tierra', texto: '🌾 Tierra', tipo: 'real', origen: 'familias', destino: 'empresas' }, "Dinero para invertir",
{ id: 'capital', texto: '💰 Capital', tipo: 'real', origen: 'familias', destino: 'empresas' }, "Servicios bancarios"
{ id: 'salarios', texto: '💵 Salarios', tipo: 'monetario', origen: 'empresas', destino: 'familias' }, ],
{ id: 'renta', texto: '🏠 Renta', tipo: 'monetario', origen: 'empresas', destino: 'familias' }, respuestaCorrecta: 1,
{ id: 'intereses', texto: '📈 Intereses', tipo: 'monetario', origen: 'empresas', destino: 'familias' }, explicacion: "Las familias son propietarias de los factores de producción (trabajo, tierra, capital) y los ofrecen a las empresas a cambio de ingresos (salarios, rentas, intereses)."
{ id: 'bienes', texto: '📦 Bienes', tipo: 'real', origen: 'empresas', destino: 'familias' },
{ id: 'servicios', texto: '🔧 Servicios', tipo: 'real', origen: 'empresas', destino: 'familias' },
{ id: 'gasto', texto: '💳 Gasto', tipo: 'monetario', origen: 'familias', destino: 'empresas' },
{ id: 'impuestos', texto: '📝 Impuestos', tipo: 'monetario', origen: 'familias', destino: 'estado' },
{ id: 'transferencias', texto: '🎁 Transferencias', tipo: 'monetario', origen: 'estado', destino: 'familias' },
{ id: 'gasto-publico', texto: '🏗️ Gasto Público', tipo: 'monetario', origen: 'estado', destino: 'empresas' },
]
}, },
{ {
nombre: 'Avanzado', id: 3,
descripcion: 'Todos los agentes incluyendo Sector Externo', pregunta: "Las empresas le venden a las familias:",
agentes: ['familias', 'empresas', 'estado', 'exterior'], opciones: [
elementos: [ "Acciones de la empresa",
{ id: 'trabajo', texto: '💪 Trabajo', tipo: 'real', origen: 'familias', destino: 'empresas' }, "Bienes y servicios",
{ id: 'tierra', texto: '🌾 Tierra', tipo: 'real', origen: 'familias', destino: 'empresas' }, "Materias primas",
{ id: 'capital', texto: '💰 Capital', tipo: 'real', origen: 'familias', destino: 'empresas' }, "Deudas"
{ id: 'salarios', texto: '💵 Salarios', tipo: 'monetario', origen: 'empresas', destino: 'familias' }, ],
{ id: 'renta', texto: '🏠 Renta', tipo: 'monetario', origen: 'empresas', destino: 'familias' }, respuestaCorrecta: 1,
{ id: 'intereses', texto: '📈 Intereses', tipo: 'monetario', origen: 'empresas', destino: 'familias' }, explicacion: "Las empresas producen bienes y servicios que venden a las familias en el mercado de bienes."
{ id: 'bienes', texto: '📦 Bienes', tipo: 'real', origen: 'empresas', destino: 'familias' }, },
{ id: 'servicios', texto: '🔧 Servicios', tipo: 'real', origen: 'empresas', destino: 'familias' }, {
{ id: 'gasto', texto: '💳 Gasto', tipo: 'monetario', origen: 'familias', destino: 'empresas' }, id: 4,
{ id: 'impuestos', texto: '📝 Impuestos', tipo: 'monetario', origen: 'familias', destino: 'estado' }, pregunta: "En el flujo MONETARIO (de dinero), el dinero va:",
{ id: 'transferencias', texto: '🎁 Transferencias', tipo: 'monetario', origen: 'estado', destino: 'familias' }, opciones: [
{ id: 'gasto-publico', texto: '🏗️ Gasto Público', tipo: 'monetario', origen: 'estado', destino: 'empresas' }, "De empresas a familias (salarios) y de familias a empresas (gastos)",
{ id: 'exportaciones', texto: '📤 Exportaciones', tipo: 'real', origen: 'empresas', destino: 'exterior' }, "Solo de familias a empresas",
{ id: 'importaciones', texto: '📥 Importaciones', tipo: 'real', origen: 'exterior', destino: 'empresas' }, "Solo de empresas a familias",
{ id: 'divisas-ent', texto: '💱 Divisas (Ent.)', tipo: 'monetario', origen: 'exterior', destino: 'empresas' }, "En círculo en una sola dirección"
{ id: 'divisas-sal', texto: '💱 Divisas (Sal.)', tipo: 'monetario', origen: 'empresas', destino: 'exterior' }, ],
] respuestaCorrecta: 0,
explicacion: "El flujo monetario es bidireccional: empresas pagan salarios/rentas a familias, y familias gastan dinero comprando bienes a las empresas."
},
{
id: 5,
pregunta: "En el flujo REAL (de bienes/factores), ¿qué ofrecen las familias?",
opciones: [
"Productos terminados",
"Factores de producción (trabajo, tierra, capital)",
"Dinero",
"Servicios financieros"
],
respuestaCorrecta: 1,
explicacion: "En el flujo real, las familias ofrecen sus factores de producción (trabajo, tierra, capital) a las empresas."
} }
]; ];
const AGENTE_CONFIG: Record<Agente, { icon: React.ReactNode; label: string; color: string; position: string }> = {
familias: { icon: <Users size={24} />, label: 'Familias', color: 'bg-green-100 text-green-700 border-green-300', position: 'left-4 top-1/2 -translate-y-1/2' },
empresas: { icon: <Building2 size={24} />, label: 'Empresas', color: 'bg-blue-100 text-blue-700 border-blue-300', position: 'right-4 top-1/2 -translate-y-1/2' },
estado: { icon: <Landmark size={24} />, label: 'Estado', color: 'bg-orange-100 text-orange-700 border-orange-300', position: 'left-1/2 -translate-x-1/2 top-4' },
exterior: { icon: <Globe size={24} />, label: 'Sector Externo', color: 'bg-purple-100 text-purple-700 border-purple-300', position: 'left-1/2 -translate-x-1/2 bottom-4' },
};
export function FlujoCircular({ ejercicioId: _ejercicioId, onComplete }: FlujoCircularProps) { export function FlujoCircular({ ejercicioId: _ejercicioId, onComplete }: FlujoCircularProps) {
const [nivelActual, setNivelActual] = useState(0); const [preguntaActual, setPreguntaActual] = useState(0);
const [elementosColocados, setElementosColocados] = useState<Record<string, { origen: Agente; destino: Agente } | null>>({}); const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<number | null>(null);
const [elementoSeleccionado, setElementoSeleccionado] = useState<string | null>(null); const [mostrarResultado, setMostrarResultado] = useState(false);
const [puntuacion, setPuntuacion] = useState(0); const [puntuacion, setPuntuacion] = useState(0);
const [completado, setCompletado] = useState(false); const [completado, setCompletado] = useState(false);
const [aciertos, setAciertos] = useState(0); const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
const [errores, setErrores] = useState(0);
const nivel = NIVELES[nivelActual]; const pregunta = PREGUNTAS[preguntaActual];
useEffect(() => { const handleSeleccionar = (index: number) => {
const inicial: Record<string, { origen: Agente; destino: Agente } | null> = {}; if (mostrarResultado) return;
nivel.elementos.forEach(el => { setRespuestaSeleccionada(index);
inicial[el.id] = null;
});
setElementosColocados(inicial);
}, [nivel]);
const handleElementoClick = (elementoId: string) => {
if (elementosColocados[elementoId]) return;
setElementoSeleccionado(elementoId === elementoSeleccionado ? null : elementoId);
}; };
const handleConexionClick = (origen: Agente, destino: Agente) => { const handleVerificar = () => {
if (!elementoSeleccionado) return; if (respuestaSeleccionada === null) return;
const elemento = nivel.elementos.find(el => el.id === elementoSeleccionado); const esCorrecta = respuestaSeleccionada === pregunta.respuestaCorrecta;
if (!elemento) return; setMostrarResultado(true);
const esCorrecto = elemento.origen === origen && elemento.destino === destino; if (esCorrecta) {
setPuntuacion(prev => prev + 20);
setElementosColocados(prev => ({ setRespuestasCorrectas(prev => prev + 1);
...prev,
[elementoSeleccionado]: { origen, destino }
}));
if (esCorrecto) {
setAciertos(prev => prev + 1);
setPuntuacion(prev => prev + 10);
} else {
setErrores(prev => prev + 1);
setPuntuacion(prev => Math.max(0, prev - 2));
} }
setElementoSeleccionado(null); // Si es la última pregunta
}; if (preguntaActual === PREGUNTAS.length - 1) {
setTimeout(() => {
const verificarCompletitud = () => {
const todosColocados = nivel.elementos.every(el => elementosColocados[el.id] !== null);
if (todosColocados) {
const bonus = 50;
setPuntuacion(prev => prev + bonus);
if (nivelActual < NIVELES.length - 1) {
setNivelActual(prev => prev + 1);
} else {
setCompletado(true); setCompletado(true);
const puntuacionFinal = puntuacion + (esCorrecta ? 20 : 0);
if (onComplete) { if (onComplete) {
onComplete(puntuacion + bonus); onComplete(puntuacionFinal);
} }
} }, 2000);
} }
}; };
useEffect(() => { const handleSiguiente = () => {
const todosColocados = nivel.elementos.every(el => elementosColocados[el.id] !== null); setPreguntaActual(prev => prev + 1);
if (todosColocados && !completado) { setRespuestaSeleccionada(null);
setTimeout(verificarCompletitud, 500); setMostrarResultado(false);
}
}, [elementosColocados]);
const handleReiniciarNivel = () => {
const inicial: Record<string, { origen: Agente; destino: Agente } | null> = {};
nivel.elementos.forEach(el => {
inicial[el.id] = null;
});
setElementosColocados(inicial);
setElementoSeleccionado(null);
setAciertos(0);
setErrores(0);
}; };
const getConexionesPosibles = (): { origen: Agente; destino: Agente; label: string }[] => { const handleReiniciar = () => {
const conexiones: { origen: Agente; destino: Agente; label: string }[] = []; setPreguntaActual(0);
setRespuestaSeleccionada(null);
if (nivel.agentes.includes('familias') && nivel.agentes.includes('empresas')) { setMostrarResultado(false);
conexiones.push({ origen: 'familias', destino: 'empresas', label: 'Familias → Empresas' }); setPuntuacion(0);
conexiones.push({ origen: 'empresas', destino: 'familias', label: 'Empresas → Familias' }); setCompletado(false);
} setRespuestasCorrectas(0);
if (nivel.agentes.includes('estado')) {
if (nivel.agentes.includes('familias')) {
conexiones.push({ origen: 'familias', destino: 'estado', label: 'Familias → Estado' });
conexiones.push({ origen: 'estado', destino: 'familias', label: 'Estado → Familias' });
}
if (nivel.agentes.includes('empresas')) {
conexiones.push({ origen: 'estado', destino: 'empresas', label: 'Estado → Empresas' });
}
}
if (nivel.agentes.includes('exterior') && nivel.agentes.includes('empresas')) {
conexiones.push({ origen: 'empresas', destino: 'exterior', label: 'Empresas → Exterior' });
conexiones.push({ origen: 'exterior', destino: 'empresas', label: 'Exterior → Empresas' });
}
return conexiones;
}; };
if (completado) { if (completado) {
return ( return (
<Card className="w-full max-w-3xl mx-auto"> <Card className="w-full max-w-2xl mx-auto">
<div className="text-center py-8"> <div className="text-center py-8 px-4">
<motion.div <motion.div
initial={{ scale: 0 }} initial={{ scale: 0 }}
animate={{ scale: 1 }} animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200 }} className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-yellow-400 to-yellow-600 rounded-full mb-6"
className="inline-flex items-center justify-center w-20 h-20 bg-yellow-100 rounded-full mb-4"
> >
<Trophy size={40} className="text-yellow-600" /> <Trophy size={40} className="text-white" />
</motion.div> </motion.div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Juego Completado!</h3> <h3 className="text-2xl font-bold text-gray-900 mb-2">
¡Ejercicio Completado!
</h3>
<p className="text-gray-600 mb-6"> <p className="text-gray-600 mb-6">
Has completado todos los niveles del Flujo Circular Has respondido {respuestasCorrectas} de {PREGUNTAS.length} preguntas correctamente
</p> </p>
<div className="bg-blue-50 rounded-lg p-6 mb-6"> <div className="bg-blue-50 rounded-xl p-6 mb-6">
<p className="text-sm text-blue-600 mb-1">Puntuación Final</p> <p className="text-sm text-blue-600 mb-1">Puntuación</p>
<p className="text-4xl font-bold text-blue-700">{puntuacion} puntos</p> <p className="text-4xl font-bold text-blue-700">{puntuacion}</p>
<p className="text-sm text-blue-500">puntos</p>
</div> </div>
<div className="grid grid-cols-2 gap-4 max-w-xs mx-auto mb-6"> <Button onClick={handleReiniciar} variant="outline">
<div className="bg-green-50 rounded-lg p-3"> <RotateCcw size={16} className="mr-2" />
<p className="text-2xl font-bold text-green-600">{aciertos}</p> Intentar de Nuevo
<p className="text-sm text-green-700">Aciertos</p>
</div>
<div className="bg-red-50 rounded-lg p-3">
<p className="text-2xl font-bold text-red-600">{errores}</p>
<p className="text-sm text-red-700">Errores</p>
</div>
</div>
<Button onClick={() => {
setNivelActual(0);
setPuntuacion(0);
setCompletado(false);
setAciertos(0);
setErrores(0);
handleReiniciarNivel();
}} variant="outline">
<RefreshCw size={16} className="mr-2" />
Jugar de Nuevo
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -250,178 +169,128 @@ export function FlujoCircular({ ejercicioId: _ejercicioId, onComplete }: FlujoCi
} }
return ( return (
<Card className="w-full max-w-4xl mx-auto"> <Card className="w-full max-w-3xl mx-auto">
<CardHeader <div className="p-6">
title={`Nivel ${nivelActual + 1}: ${nivel.nombre}`} {/* Header */}
subtitle={nivel.descripcion} <div className="flex items-center justify-between mb-6">
action={ <div>
<Button variant="ghost" size="sm" onClick={handleReiniciarNivel}> <h3 className="text-xl font-bold text-gray-900">Flujo Circular de la Renta</h3>
<RefreshCw size={16} /> <p className="text-sm text-gray-500">Pregunta {preguntaActual + 1} de {PREGUNTAS.length}</p>
</Button> </div>
} <div className="text-right">
/> <p className="text-xs text-gray-500">Puntos</p>
<p className="text-xl font-bold text-blue-600">{puntuacion}</p>
<div className="mb-4 flex items-center justify-between">
<div className="flex gap-2">
{NIVELES.map((_, idx) => (
<div
key={idx}
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
idx < nivelActual
? 'bg-green-500 text-white'
: idx === nivelActual
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-500'
}`}
>
{idx < nivelActual ? <CheckCircle size={16} /> : idx + 1}
</div>
))}
</div>
<div className="text-right">
<p className="text-sm text-gray-600">Puntuación</p>
<p className="text-xl font-bold text-blue-600">{puntuacion} pts</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="relative bg-gray-50 rounded-xl p-8 min-h-[400px]">
{nivel.agentes.map((agente) => (
<motion.div
key={agente}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className={`absolute ${AGENTE_CONFIG[agente].position} w-24 h-24 rounded-xl border-2 ${AGENTE_CONFIG[agente].color} flex flex-col items-center justify-center gap-1 cursor-pointer hover:shadow-lg transition-shadow`}
>
{AGENTE_CONFIG[agente].icon}
<span className="text-xs font-bold text-center">{AGENTE_CONFIG[agente].label}</span>
</motion.div>
))}
<svg className="absolute inset-0 w-full h-full pointer-events-none" style={{ zIndex: 0 }}>
{getConexionesPosibles().map((conexion, idx) => (
<g key={idx}>
<line
x1={conexion.origen === 'familias' ? '15%' : conexion.origen === 'empresas' ? '85%' : conexion.origen === 'estado' ? '50%' : '50%'}
y1={conexion.origen === 'familias' ? '50%' : conexion.origen === 'empresas' ? '50%' : conexion.origen === 'estado' ? '15%' : '85%'}
x2={conexion.destino === 'familias' ? '15%' : conexion.destino === 'empresas' ? '85%' : conexion.destino === 'estado' ? '50%' : '50%'}
y2={conexion.destino === 'familias' ? '50%' : conexion.destino === 'empresas' ? '50%' : conexion.destino === 'estado' ? '15%' : '85%'}
stroke="#e5e7eb"
strokeWidth="2"
strokeDasharray="5,5"
/>
</g>
))}
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<div className="grid grid-cols-2 gap-2">
{getConexionesPosibles().map((conexion, idx) => {
const elementosEnConexion = nivel.elementos.filter(el =>
elementosColocados[el.id]?.origen === conexion.origen &&
elementosColocados[el.id]?.destino === conexion.destino
);
return (
<button
key={idx}
onClick={() => handleConexionClick(conexion.origen, conexion.destino)}
disabled={!elementoSeleccionado}
className={`p-2 rounded-lg border-2 text-xs font-medium transition-all min-w-[120px] ${
elementoSeleccionado
? 'border-blue-300 bg-blue-50 hover:bg-blue-100 cursor-pointer'
: 'border-gray-200 bg-white'
}`}
>
<div className="text-gray-500 mb-1">{conexion.label}</div>
<div className="flex flex-wrap gap-1 justify-center">
{elementosEnConexion.map((el, i) => (
<span key={i} className="text-lg" title={el.texto}>
{el.texto.split(' ')[0]}
</span>
))}
</div>
</button>
);
})}
</div>
</div>
</div> </div>
</div> </div>
<div> {/* Barra de progreso */}
<h4 className="font-semibold text-gray-700 mb-3">Elementos ({nivel.elementos.length})</h4> <div className="w-full bg-gray-200 rounded-full h-2 mb-6">
<p className="text-sm text-gray-500 mb-3"> <motion.div
{elementoSeleccionado className="h-2 bg-blue-500 rounded-full"
? 'Selecciona una conexión en el diagrama' initial={{ width: 0 }}
: 'Haz clic en un elemento para colocarlo'} animate={{ width: `${((preguntaActual + 1) / PREGUNTAS.length) * 100}%` }}
</p> />
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto"> {/* Pregunta */}
{nivel.elementos.map((elemento) => { <div className="mb-6">
const colocado = elementosColocados[elemento.id]; <h4 className="text-lg font-medium text-gray-800 mb-4">
const seleccionado = elementoSeleccionado === elemento.id; {pregunta.pregunta}
const esCorrecto = colocado && colocado.origen === elemento.origen && colocado.destino === elemento.destino; </h4>
<div className="space-y-3">
{pregunta.opciones.map((opcion, index) => {
const estaSeleccionada = respuestaSeleccionada === index;
const esCorrecta = index === pregunta.respuestaCorrecta;
const mostrarCorrecta = mostrarResultado && esCorrecta;
const mostrarIncorrecta = mostrarResultado && estaSeleccionada && !esCorrecta;
return ( return (
<motion.button <motion.button
key={elemento.id} key={index}
onClick={() => handleElementoClick(elemento.id)} onClick={() => handleSeleccionar(index)}
disabled={!!colocado} disabled={mostrarResultado}
whileHover={!colocado ? { scale: 1.02 } : {}} whileHover={!mostrarResultado ? { scale: 1.01 } : {}}
whileTap={!colocado ? { scale: 0.98 } : {}} whileTap={!mostrarResultado ? { scale: 0.99 } : {}}
className={`w-full p-3 rounded-lg border-2 text-left transition-all ${ className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
colocado mostrarCorrecta
? esCorrecto ? 'border-green-500 bg-green-50'
? 'border-green-300 bg-green-50' : mostrarIncorrecta
: 'border-red-300 bg-red-50' ? 'border-red-500 bg-red-50'
: seleccionado : estaSeleccionada
? 'border-blue-500 bg-blue-50 shadow-md' ? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-blue-300' : 'border-gray-200 bg-white hover:border-blue-300'
}`} }`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${
<span className="text-xl">{elemento.texto.split(' ')[0]}</span> mostrarCorrecta
<span className="text-sm font-medium">{elemento.texto.split(' ').slice(1).join(' ')}</span> ? 'border-green-500 bg-green-500 text-white'
</div> : mostrarIncorrecta
{colocado && ( ? 'border-red-500 bg-red-500 text-white'
esCorrecto : estaSeleccionada
? <CheckCircle size={16} className="text-green-600" /> ? 'border-blue-500 bg-blue-500 text-white'
: <XCircle size={16} className="text-red-600" /> : 'border-gray-300'
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-2 py-0.5 rounded ${
elemento.tipo === 'real' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
}`}> }`}>
{elemento.tipo === 'real' ? 'Real' : 'Monetario'} {mostrarCorrecta && <CheckCircle size={14} />}
{mostrarIncorrecta && <XCircle size={14} />}
{!mostrarResultado && estaSeleccionada && (
<div className="w-2 h-2 bg-white rounded-full" />
)}
</div>
<span className={`font-medium ${
mostrarCorrecta ? 'text-green-800' :
mostrarIncorrecta ? 'text-red-800' :
'text-gray-700'
}`}>
{opcion}
</span> </span>
{colocado && !esCorrecto && (
<span className="text-xs text-red-600">
{AGENTE_CONFIG[elemento.origen].label} {AGENTE_CONFIG[elemento.destino].label}
</span>
)}
</div> </div>
</motion.button> </motion.button>
); );
})} })}
</div> </div>
</div>
<div className="mt-4 p-3 bg-gray-50 rounded-lg"> {/* Explicación */}
<p className="text-xs text-gray-600 mb-2">Leyenda:</p> {mostrarResultado && (
<div className="flex gap-4 text-xs"> <motion.div
<span className="flex items-center gap-1"> initial={{ opacity: 0, y: 10 }}
<span className="w-3 h-3 bg-blue-100 border border-blue-300 rounded"></span> animate={{ opacity: 1, y: 0 }}
Flujo Real className={`p-4 rounded-xl mb-6 ${
</span> respuestaSeleccionada === pregunta.respuestaCorrecta
<span className="flex items-center gap-1"> ? 'bg-green-50 border border-green-200'
<span className="w-3 h-3 bg-green-100 border border-green-300 rounded"></span> : 'bg-red-50 border border-red-200'
Flujo Monetario }`}
</span> >
</div> <p className={`font-medium mb-2 ${
</div> respuestaSeleccionada === pregunta.respuestaCorrecta
? 'text-green-800'
: 'text-red-800'
}`}>
{respuestaSeleccionada === pregunta.respuestaCorrecta
? '¡Correcto!'
: 'Incorrecto'}
</p>
<p className="text-sm text-gray-700">{pregunta.explicacion}</p>
</motion.div>
)}
{/* Botones */}
<div className="flex justify-end">
{!mostrarResultado ? (
<Button
onClick={handleVerificar}
disabled={respuestaSeleccionada === null}
>
Verificar Respuesta
</Button>
) : preguntaActual < PREGUNTAS.length - 1 ? (
<Button onClick={handleSiguiente}>
Siguiente
<ArrowRight size={16} className="ml-2" />
</Button>
) : null}
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Card } from '../components/ui/Card'; import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button'; import { Button } from '../components/ui/Button';
import { FileText, Download, BookOpen, ArrowLeft } from 'lucide-react'; import { FileText, Download, BookOpen, ArrowLeft, X, Eye } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
const recursos = [ const recursos = [
@@ -39,6 +40,19 @@ const recursos = [
]; ];
export function RecursosPage() { export function RecursosPage() {
const [pdfSeleccionado, setPdfSeleccionado] = useState<string | null>(null);
const [pdfTitulo, setPdfTitulo] = useState<string>('');
const abrirPdf = (archivo: string, titulo: string) => {
setPdfSeleccionado(archivo);
setPdfTitulo(titulo);
};
const cerrarPdf = () => {
setPdfSeleccionado(null);
setPdfTitulo('');
};
return ( return (
<div className="min-h-screen bg-gray-50 py-8"> <div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4"> <div className="max-w-6xl mx-auto px-4">
@@ -98,17 +112,29 @@ export function RecursosPage() {
{recurso.descripcion} {recurso.descripcion}
</p> </p>
<a <div className="flex gap-2">
href={recurso.archivo} <Button
download variant="outline"
target="_blank" className="flex-1"
rel="noopener noreferrer" onClick={() => abrirPdf(recurso.archivo, recurso.titulo)}
> >
<Button variant="outline" className="w-full"> <Eye size={18} className="mr-2" />
<Download size={18} className="mr-2" /> Ver
Descargar PDF
</Button> </Button>
</a>
<a
href={recurso.archivo}
download
target="_blank"
rel="noopener noreferrer"
className="flex-1"
>
<Button variant="outline" className="w-full">
<Download size={18} className="mr-2" />
Descargar
</Button>
</a>
</div>
</div> </div>
</div> </div>
</Card> </Card>
@@ -127,6 +153,58 @@ export function RecursosPage() {
</Link> </Link>
</div> </div>
</div> </div>
{/* Modal para visualizar PDF */}
{pdfSeleccionado && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75 p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-6xl h-[90vh] flex flex-col">
{/* Header del modal */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 truncate pr-4">
{pdfTitulo}
</h2>
<button
onClick={cerrarPdf}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={24} />
</button>
</div>
{/* Contenido del PDF */}
<div className="flex-1 p-4 bg-gray-100">
<iframe
src={pdfSeleccionado}
className="w-full h-full rounded-lg bg-white"
title={pdfTitulo}
/>
</div>
{/* Footer del modal */}
<div className="flex items-center justify-between p-4 border-t border-gray-200 bg-gray-50">
<p className="text-sm text-gray-600">
Usa los controles del visor de PDF para navegar, hacer zoom y descargar.
</p>
<div className="flex gap-2">
<a
href={pdfSeleccionado}
download
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
<Download size={16} className="mr-2" />
Descargar
</Button>
</a>
<Button onClick={cerrarPdf} size="sm">
Cerrar
</Button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -245,7 +245,9 @@ export const useProgressStore = create<ProgressState>()(
(ej) => ej.completado (ej) => ej.completado
).length; ).length;
return Math.round((ejerciciosCompletados / totalEjercicios) * 100); // Limitar a máximo 100%
const porcentaje = Math.round((ejerciciosCompletados / totalEjercicios) * 100);
return Math.min(porcentaje, 100);
}, },
getBadgesDesbloqueados: () => { getBadgesDesbloqueados: () => {