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:
@@ -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={
|
||||||
|
|||||||
@@ -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 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>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm text-gray-600">Puntuación</p>
|
<p className="text-xs text-gray-500">Puntos</p>
|
||||||
<p className="text-xl font-bold text-blue-600">{puntuacion} pts</p>
|
<p className="text-xl font-bold text-blue-600">{puntuacion}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
{/* Barra de progreso */}
|
||||||
<div className="lg:col-span-2">
|
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
|
||||||
<div className="relative bg-gray-50 rounded-xl p-8 min-h-[400px]">
|
|
||||||
{nivel.agentes.map((agente) => (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={agente}
|
className="h-2 bg-blue-500 rounded-full"
|
||||||
initial={{ scale: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ width: `${((preguntaActual + 1) / PREGUNTAS.length) * 100}%` }}
|
||||||
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>
|
{/* Pregunta */}
|
||||||
<h4 className="font-semibold text-gray-700 mb-3">Elementos ({nivel.elementos.length})</h4>
|
<div className="mb-6">
|
||||||
<p className="text-sm text-gray-500 mb-3">
|
<h4 className="text-lg font-medium text-gray-800 mb-4">
|
||||||
{elementoSeleccionado
|
{pregunta.pregunta}
|
||||||
? 'Selecciona una conexión en el diagrama'
|
</h4>
|
||||||
: 'Haz clic en un elemento para colocarlo'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
<div className="space-y-3">
|
||||||
{nivel.elementos.map((elemento) => {
|
{pregunta.opciones.map((opcion, index) => {
|
||||||
const colocado = elementosColocados[elemento.id];
|
const estaSeleccionada = respuestaSeleccionada === index;
|
||||||
const seleccionado = elementoSeleccionado === elemento.id;
|
const esCorrecta = index === pregunta.respuestaCorrecta;
|
||||||
const esCorrecto = colocado && colocado.origen === elemento.origen && colocado.destino === elemento.destino;
|
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} />}
|
||||||
</span>
|
{mostrarIncorrecta && <XCircle size={14} />}
|
||||||
{colocado && !esCorrecto && (
|
{!mostrarResultado && estaSeleccionada && (
|
||||||
<span className="text-xs text-red-600">
|
<div className="w-2 h-2 bg-white rounded-full" />
|
||||||
{AGENTE_CONFIG[elemento.origen].label} → {AGENTE_CONFIG[elemento.destino].label}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
mostrarCorrecta ? 'text-green-800' :
|
||||||
|
mostrarIncorrecta ? 'text-red-800' :
|
||||||
|
'text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{opcion}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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,19 +112,31 @@ export function RecursosPage() {
|
|||||||
{recurso.descripcion}
|
{recurso.descripcion}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<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}
|
href={recurso.archivo}
|
||||||
download
|
download
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Button variant="outline" className="w-full">
|
<Button variant="outline" className="w-full">
|
||||||
<Download size={18} className="mr-2" />
|
<Download size={18} className="mr-2" />
|
||||||
Descargar PDF
|
Descargar
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user