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>
}
/>
<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
path="/admin"
element={

View File

@@ -1,248 +1,167 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Card, CardHeader } from '../../ui/Card';
import { Card } from '../../ui/Card';
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 {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
type Agente = 'familias' | 'empresas' | 'estado' | 'exterior';
type TipoFlujo = 'real' | 'monetario';
interface Elemento {
id: string;
texto: string;
tipo: TipoFlujo;
origen: Agente;
destino: Agente;
interface Pregunta {
id: number;
pregunta: string;
opciones: string[];
respuestaCorrecta: number;
explicacion: string;
}
interface Nivel {
nombre: string;
descripcion: string;
agentes: Agente[];
elementos: Elemento[];
}
const NIVELES: Nivel[] = [
const PREGUNTAS: Pregunta[] = [
{
nombre: 'Básico',
descripcion: 'Solo Familias y Empresas',
agentes: ['familias', 'empresas'],
elementos: [
{ id: 'trabajo', texto: '💪 Trabajo', tipo: 'real', origen: 'familias', destino: 'empresas' },
{ id: 'salarios', texto: '💵 Salarios', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
{ id: 'bienes', texto: '📦 Bienes', tipo: 'real', origen: 'empresas', destino: 'familias' },
{ id: 'gasto', texto: '💳 Gasto', tipo: 'monetario', origen: 'familias', destino: 'empresas' },
]
id: 1,
pregunta: "¿Quiénes son los principales agentes económicos en el flujo circular?",
opciones: [
"Solo el gobierno y las empresas",
"Familias y empresas",
"Bancos y familias",
"Empresas y extranjeros"
],
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',
descripcion: 'Incluye al Estado',
agentes: ['familias', 'empresas', 'estado'],
elementos: [
{ id: 'trabajo', texto: '💪 Trabajo', tipo: 'real', origen: 'familias', destino: 'empresas' },
{ id: 'tierra', texto: '🌾 Tierra', tipo: 'real', origen: 'familias', destino: 'empresas' },
{ id: 'capital', texto: '💰 Capital', tipo: 'real', origen: 'familias', destino: 'empresas' },
{ id: 'salarios', texto: '💵 Salarios', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
{ id: 'renta', texto: '🏠 Renta', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
{ id: 'intereses', texto: '📈 Intereses', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
{ 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' },
]
id: 2,
pregunta: "Las familias ofrecen a las empresas:",
opciones: [
"Productos terminados",
"Trabajo, tierra y capital (factores de producción)",
"Dinero para invertir",
"Servicios bancarios"
],
respuestaCorrecta: 1,
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)."
},
{
nombre: 'Avanzado',
descripcion: 'Todos los agentes incluyendo Sector Externo',
agentes: ['familias', 'empresas', 'estado', 'exterior'],
elementos: [
{ id: 'trabajo', texto: '💪 Trabajo', tipo: 'real', origen: 'familias', destino: 'empresas' },
{ id: 'tierra', texto: '🌾 Tierra', tipo: 'real', origen: 'familias', destino: 'empresas' },
{ id: 'capital', texto: '💰 Capital', tipo: 'real', origen: 'familias', destino: 'empresas' },
{ id: 'salarios', texto: '💵 Salarios', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
{ id: 'renta', texto: '🏠 Renta', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
{ id: 'intereses', texto: '📈 Intereses', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
{ 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' },
{ id: 'exportaciones', texto: '📤 Exportaciones', tipo: 'real', origen: 'empresas', destino: 'exterior' },
{ id: 'importaciones', texto: '📥 Importaciones', tipo: 'real', origen: 'exterior', destino: 'empresas' },
{ id: 'divisas-ent', texto: '💱 Divisas (Ent.)', tipo: 'monetario', origen: 'exterior', destino: 'empresas' },
{ id: 'divisas-sal', texto: '💱 Divisas (Sal.)', tipo: 'monetario', origen: 'empresas', destino: 'exterior' },
]
id: 3,
pregunta: "Las empresas le venden a las familias:",
opciones: [
"Acciones de la empresa",
"Bienes y servicios",
"Materias primas",
"Deudas"
],
respuestaCorrecta: 1,
explicacion: "Las empresas producen bienes y servicios que venden a las familias en el mercado de bienes."
},
{
id: 4,
pregunta: "En el flujo MONETARIO (de dinero), el dinero va:",
opciones: [
"De empresas a familias (salarios) y de familias a empresas (gastos)",
"Solo de familias a empresas",
"Solo de empresas a familias",
"En círculo en una sola dirección"
],
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) {
const [nivelActual, setNivelActual] = useState(0);
const [elementosColocados, setElementosColocados] = useState<Record<string, { origen: Agente; destino: Agente } | null>>({});
const [elementoSeleccionado, setElementoSeleccionado] = useState<string | null>(null);
const [preguntaActual, setPreguntaActual] = useState(0);
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<number | null>(null);
const [mostrarResultado, setMostrarResultado] = useState(false);
const [puntuacion, setPuntuacion] = useState(0);
const [completado, setCompletado] = useState(false);
const [aciertos, setAciertos] = useState(0);
const [errores, setErrores] = useState(0);
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
const nivel = NIVELES[nivelActual];
const pregunta = PREGUNTAS[preguntaActual];
useEffect(() => {
const inicial: Record<string, { origen: Agente; destino: Agente } | null> = {};
nivel.elementos.forEach(el => {
inicial[el.id] = null;
});
setElementosColocados(inicial);
}, [nivel]);
const handleElementoClick = (elementoId: string) => {
if (elementosColocados[elementoId]) return;
setElementoSeleccionado(elementoId === elementoSeleccionado ? null : elementoId);
const handleSeleccionar = (index: number) => {
if (mostrarResultado) return;
setRespuestaSeleccionada(index);
};
const handleConexionClick = (origen: Agente, destino: Agente) => {
if (!elementoSeleccionado) return;
const elemento = nivel.elementos.find(el => el.id === elementoSeleccionado);
if (!elemento) return;
const handleVerificar = () => {
if (respuestaSeleccionada === null) return;
const esCorrecto = elemento.origen === origen && elemento.destino === destino;
setElementosColocados(prev => ({
...prev,
[elementoSeleccionado]: { origen, destino }
}));
const esCorrecta = respuestaSeleccionada === pregunta.respuestaCorrecta;
setMostrarResultado(true);
if (esCorrecto) {
setAciertos(prev => prev + 1);
setPuntuacion(prev => prev + 10);
} else {
setErrores(prev => prev + 1);
setPuntuacion(prev => Math.max(0, prev - 2));
if (esCorrecta) {
setPuntuacion(prev => prev + 20);
setRespuestasCorrectas(prev => prev + 1);
}
setElementoSeleccionado(null);
};
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 {
// Si es la última pregunta
if (preguntaActual === PREGUNTAS.length - 1) {
setTimeout(() => {
setCompletado(true);
const puntuacionFinal = puntuacion + (esCorrecta ? 20 : 0);
if (onComplete) {
onComplete(puntuacion + bonus);
onComplete(puntuacionFinal);
}
}
}, 2000);
}
};
useEffect(() => {
const todosColocados = nivel.elementos.every(el => elementosColocados[el.id] !== null);
if (todosColocados && !completado) {
setTimeout(verificarCompletitud, 500);
}
}, [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 handleSiguiente = () => {
setPreguntaActual(prev => prev + 1);
setRespuestaSeleccionada(null);
setMostrarResultado(false);
};
const getConexionesPosibles = (): { origen: Agente; destino: Agente; label: string }[] => {
const conexiones: { origen: Agente; destino: Agente; label: string }[] = [];
if (nivel.agentes.includes('familias') && nivel.agentes.includes('empresas')) {
conexiones.push({ origen: 'familias', destino: 'empresas', label: 'Familias → Empresas' });
conexiones.push({ origen: 'empresas', destino: 'familias', label: 'Empresas → Familias' });
}
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;
const handleReiniciar = () => {
setPreguntaActual(0);
setRespuestaSeleccionada(null);
setMostrarResultado(false);
setPuntuacion(0);
setCompletado(false);
setRespuestasCorrectas(0);
};
if (completado) {
return (
<Card className="w-full max-w-3xl mx-auto">
<div className="text-center py-8">
<Card className="w-full max-w-2xl mx-auto">
<div className="text-center py-8 px-4">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200 }}
className="inline-flex items-center justify-center w-20 h-20 bg-yellow-100 rounded-full mb-4"
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"
>
<Trophy size={40} className="text-yellow-600" />
<Trophy size={40} className="text-white" />
</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">
Has completado todos los niveles del Flujo Circular
Has respondido {respuestasCorrectas} de {PREGUNTAS.length} preguntas correctamente
</p>
<div className="bg-blue-50 rounded-lg p-6 mb-6">
<p className="text-sm text-blue-600 mb-1">Puntuación Final</p>
<p className="text-4xl font-bold text-blue-700">{puntuacion} puntos</p>
<div className="bg-blue-50 rounded-xl p-6 mb-6">
<p className="text-sm text-blue-600 mb-1">Puntuación</p>
<p className="text-4xl font-bold text-blue-700">{puntuacion}</p>
<p className="text-sm text-blue-500">puntos</p>
</div>
<div className="grid grid-cols-2 gap-4 max-w-xs mx-auto mb-6">
<div className="bg-green-50 rounded-lg p-3">
<p className="text-2xl font-bold text-green-600">{aciertos}</p>
<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 onClick={handleReiniciar} variant="outline">
<RotateCcw size={16} className="mr-2" />
Intentar de Nuevo
</Button>
</div>
</Card>
@@ -250,178 +169,128 @@ export function FlujoCircular({ ejercicioId: _ejercicioId, onComplete }: FlujoCi
}
return (
<Card className="w-full max-w-4xl mx-auto">
<CardHeader
title={`Nivel ${nivelActual + 1}: ${nivel.nombre}`}
subtitle={nivel.descripcion}
action={
<Button variant="ghost" size="sm" onClick={handleReiniciarNivel}>
<RefreshCw size={16} />
</Button>
}
/>
<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>
<Card className="w-full max-w-3xl mx-auto">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-xl font-bold text-gray-900">Flujo Circular de la Renta</h3>
<p className="text-sm text-gray-500">Pregunta {preguntaActual + 1} de {PREGUNTAS.length}</p>
</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>
</div>
<div>
<h4 className="font-semibold text-gray-700 mb-3">Elementos ({nivel.elementos.length})</h4>
<p className="text-sm text-gray-500 mb-3">
{elementoSeleccionado
? 'Selecciona una conexión en el diagrama'
: 'Haz clic en un elemento para colocarlo'}
</p>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{nivel.elementos.map((elemento) => {
const colocado = elementosColocados[elemento.id];
const seleccionado = elementoSeleccionado === elemento.id;
const esCorrecto = colocado && colocado.origen === elemento.origen && colocado.destino === elemento.destino;
{/* Barra de progreso */}
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
<motion.div
className="h-2 bg-blue-500 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${((preguntaActual + 1) / PREGUNTAS.length) * 100}%` }}
/>
</div>
{/* Pregunta */}
<div className="mb-6">
<h4 className="text-lg font-medium text-gray-800 mb-4">
{pregunta.pregunta}
</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 (
<motion.button
key={elemento.id}
onClick={() => handleElementoClick(elemento.id)}
disabled={!!colocado}
whileHover={!colocado ? { scale: 1.02 } : {}}
whileTap={!colocado ? { scale: 0.98 } : {}}
className={`w-full p-3 rounded-lg border-2 text-left transition-all ${
colocado
? esCorrecto
? 'border-green-300 bg-green-50'
: 'border-red-300 bg-red-50'
: seleccionado
? 'border-blue-500 bg-blue-50 shadow-md'
key={index}
onClick={() => handleSeleccionar(index)}
disabled={mostrarResultado}
whileHover={!mostrarResultado ? { scale: 1.01 } : {}}
whileTap={!mostrarResultado ? { scale: 0.99 } : {}}
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
mostrarCorrecta
? 'border-green-500 bg-green-50'
: mostrarIncorrecta
? 'border-red-500 bg-red-50'
: estaSeleccionada
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-blue-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{elemento.texto.split(' ')[0]}</span>
<span className="text-sm font-medium">{elemento.texto.split(' ').slice(1).join(' ')}</span>
</div>
{colocado && (
esCorrecto
? <CheckCircle size={16} className="text-green-600" />
: <XCircle size={16} className="text-red-600" />
)}
</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'
<div className="flex items-center gap-3">
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${
mostrarCorrecta
? 'border-green-500 bg-green-500 text-white'
: mostrarIncorrecta
? 'border-red-500 bg-red-500 text-white'
: estaSeleccionada
? 'border-blue-500 bg-blue-500 text-white'
: 'border-gray-300'
}`}>
{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>
{colocado && !esCorrecto && (
<span className="text-xs text-red-600">
{AGENTE_CONFIG[elemento.origen].label} {AGENTE_CONFIG[elemento.destino].label}
</span>
)}
</div>
</motion.button>
);
})}
</div>
</div>
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<p className="text-xs text-gray-600 mb-2">Leyenda:</p>
<div className="flex gap-4 text-xs">
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-blue-100 border border-blue-300 rounded"></span>
Flujo Real
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-green-100 border border-green-300 rounded"></span>
Flujo Monetario
</span>
</div>
</div>
{/* Explicación */}
{mostrarResultado && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-4 rounded-xl mb-6 ${
respuestaSeleccionada === pregunta.respuestaCorrecta
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}
>
<p className={`font-medium mb-2 ${
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>
</Card>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Card } from '../components/ui/Card';
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';
const recursos = [
@@ -39,6 +40,19 @@ const recursos = [
];
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 (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
@@ -98,17 +112,29 @@ export function RecursosPage() {
{recurso.descripcion}
</p>
<a
href={recurso.archivo}
download
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" className="w-full">
<Download size={18} className="mr-2" />
Descargar PDF
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => abrirPdf(recurso.archivo, recurso.titulo)}
>
<Eye size={18} className="mr-2" />
Ver
</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>
</Card>
@@ -127,6 +153,58 @@ export function RecursosPage() {
</Link>
</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>
);
}

View File

@@ -245,7 +245,9 @@ export const useProgressStore = create<ProgressState>()(
(ej) => ej.completado
).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: () => {