Fix login blank screen and progress persistence

- Fix authStore to persist user data, not just isAuthenticated
- Fix progressStore handling of undefined API responses
- Remove minimax.md documentation file
- All progress now properly saves to PostgreSQL
- Login flow working correctly
This commit is contained in:
Renato
2026-02-12 03:38:33 +01:00
parent d31575a143
commit a2ed69fdb8
68 changed files with 14321 additions and 397 deletions

View File

@@ -0,0 +1,184 @@
import React, { useState, isValidElement } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Trophy, Star, RotateCcw, Home, ArrowRight, CheckCircle } from 'lucide-react';
import { useEjercicioProgreso } from '../../hooks/useEjercicioProgreso';
interface EjercicioWrapperProps {
moduloId: string;
ejercicioId: string;
titulo: string;
descripcion: string;
puntosMaximos: number;
onComplete?: (puntuacion?: number) => void;
onRetry?: () => void;
onExit?: () => void;
children: React.ReactNode;
}
export function EjercicioWrapper({
moduloId,
ejercicioId,
titulo,
descripcion,
puntosMaximos,
onComplete,
onRetry,
onExit,
children,
}: EjercicioWrapperProps) {
const { puntuacionAnterior, intentos, guardarProgreso } = useEjercicioProgreso({
moduloId,
ejercicioId,
onComplete,
});
const [mostrarCompletado, setMostrarCompletado] = useState(false);
const [puntuacionActual, setPuntuacionActual] = useState(0);
const handleCompletar = (puntuacion: number) => {
guardarProgreso(puntuacion);
setPuntuacionActual(puntuacion);
setMostrarCompletado(true);
};
const esMejorPuntuacion = puntuacionAnterior !== undefined && puntuacionActual > puntuacionAnterior;
// Pasar handleCompletar a los hijos
const childrenWithProps = isValidElement(children)
? React.cloneElement(children as React.ReactElement<any>, { onCompletar: handleCompletar })
: children;
return (
<div className="space-y-6">
<AnimatePresence mode="wait">
{!mostrarCompletado ? (
<motion.div
key="ejercicio"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="mb-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">{titulo}</h2>
<p className="text-gray-600 mt-1">{descripcion}</p>
</div>
<div className="text-right">
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-1 rounded-full">
<Trophy size={16} />
<span className="font-semibold">{puntosMaximos} pts máx.</span>
</div>
{puntuacionAnterior !== undefined && (
<div className="mt-2 text-sm text-gray-500">
Mejor puntuación: {puntuacionAnterior} pts
<span className="text-gray-400"> ({intentos} {intentos === 1 ? 'intento' : 'intentos'})</span>
</div>
)}
</div>
</div>
</div>
{childrenWithProps}
</motion.div>
) : (
<motion.div
key="completado"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 200 }}
>
<Card className="text-center py-12">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 200,
delay: 0.2
}}
className="inline-flex items-center justify-center w-24 h-24 bg-gradient-to-br from-yellow-400 to-orange-500 rounded-full mb-6 shadow-lg"
>
<Trophy size={48} className="text-white" />
</motion.div>
<h3 className="text-3xl font-bold text-gray-900 mb-2">
¡Ejercicio Completado!
</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
Has completado el ejercicio. Revisa tu puntuación y decide si quieres intentarlo de nuevo para mejorar tu marca.
</p>
<div className="grid grid-cols-3 gap-4 max-w-lg mx-auto mb-8">
<div className="bg-blue-50 rounded-xl p-4">
<Star className="w-6 h-6 text-blue-500 mx-auto mb-2" />
<p className="text-2xl font-bold text-blue-700">{puntuacionActual}</p>
<p className="text-sm text-blue-600">Puntuación</p>
</div>
<div className="bg-purple-50 rounded-xl p-4">
<Trophy className="w-6 h-6 text-purple-500 mx-auto mb-2" />
<p className="text-2xl font-bold text-purple-700">{puntosMaximos}</p>
<p className="text-sm text-purple-600">Máximo</p>
</div>
<div className={`rounded-xl p-4 ${esMejorPuntuacion ? 'bg-green-50' : 'bg-gray-50'}`}>
<CheckCircle className={`w-6 h-6 mx-auto mb-2 ${esMejorPuntuacion ? 'text-green-500' : 'text-gray-400'}`} />
<p className={`text-2xl font-bold ${esMejorPuntuacion ? 'text-green-700' : 'text-gray-700'}`}>
{Math.round((puntuacionActual / puntosMaximos) * 100)}%
</p>
<p className={`text-sm ${esMejorPuntuacion ? 'text-green-600' : 'text-gray-500'}`}>
{esMejorPuntuacion ? '¡Récord!' : 'Precisión'}
</p>
</div>
</div>
{esMejorPuntuacion && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6"
>
<div className="inline-flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-full font-medium">
<Star size={18} />
¡Nueva mejor puntuación! +{puntuacionActual - (puntuacionAnterior || 0)} pts
</div>
</motion.div>
)}
<div className="flex flex-wrap justify-center gap-4">
<Button variant="outline" onClick={onExit}>
<Home size={18} className="mr-2" />
Volver al módulo
</Button>
<Button
variant="outline"
onClick={() => {
setMostrarCompletado(false);
if (onRetry) onRetry();
}}
>
<RotateCcw size={18} className="mr-2" />
Intentar de nuevo
</Button>
{!esMejorPuntuacion && puntuacionActual < puntosMaximos && (
<Button onClick={onExit}>
Siguiente ejercicio
<ArrowRight size={18} className="ml-2" />
</Button>
)}
</div>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default EjercicioWrapper;

View File

@@ -0,0 +1 @@
export { EjercicioWrapper } from './EjercicioWrapper';

View File

@@ -0,0 +1,431 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Card, CardHeader } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { CheckCircle, XCircle, Trophy, Users, Building2, Landmark, Globe, RefreshCw } 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 Nivel {
nombre: string;
descripcion: string;
agentes: Agente[];
elementos: Elemento[];
}
const NIVELES: Nivel[] = [
{
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' },
]
},
{
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' },
]
},
{
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' },
]
}
];
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 [puntuacion, setPuntuacion] = useState(0);
const [completado, setCompletado] = useState(false);
const [aciertos, setAciertos] = useState(0);
const [errores, setErrores] = useState(0);
const nivel = NIVELES[nivelActual];
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 handleConexionClick = (origen: Agente, destino: Agente) => {
if (!elementoSeleccionado) return;
const elemento = nivel.elementos.find(el => el.id === elementoSeleccionado);
if (!elemento) return;
const esCorrecto = elemento.origen === origen && elemento.destino === destino;
setElementosColocados(prev => ({
...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);
};
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);
if (onComplete) {
onComplete(puntuacion + bonus);
}
}
}
};
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 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;
};
if (completado) {
return (
<Card className="w-full max-w-3xl mx-auto">
<div className="text-center py-8">
<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"
>
<Trophy size={40} className="text-yellow-600" />
</motion.div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Juego Completado!</h3>
<p className="text-gray-600 mb-6">
Has completado todos los niveles del Flujo Circular
</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>
<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>
</div>
</Card>
);
}
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>
</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;
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'
: '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'
}`}>
{elemento.tipo === 'real' ? 'Real' : 'Monetario'}
</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 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>
</div>
</div>
</Card>
);
}
export default FlujoCircular;

View File

@@ -0,0 +1,310 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card, CardHeader } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { CheckCircle, XCircle, ArrowRight, Trophy, BookOpen } from 'lucide-react';
interface QuizBienesProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
interface Pregunta {
id: string;
bien: string;
descripcion: string;
opciones: string[];
respuestaCorrecta: string;
explicacionDetallada: string;
}
const PREGUNTAS: Pregunta[] = [
{
id: 'p1',
bien: 'Carne de primera calidad',
descripcion: 'Carne de res premium vendida en supermercados de alta gama',
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
respuestaCorrecta: 'Bien de lujo',
explicacionDetallada: 'La carne premium es considerada un bien de lujo porque cuando el ingreso aumenta significativamente, las familias aumentan su consumo de este tipo de carne sustituyendo carnes de menor calidad.'
},
{
id: 'p2',
bien: 'Pan',
descripcion: 'Pan básico de consumo diario',
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
respuestaCorrecta: 'Bien normal',
explicacionDetallada: 'El pan es un bien normal porque su consumo aumenta moderadamente con el ingreso, aunque llega un punto donde se estabiliza (saturación).'
},
{
id: 'p3',
bien: 'Transporte público (autobús)',
descripcion: 'Servicio de autobuses urbanos',
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
respuestaCorrecta: 'Bien inferior',
explicacionDetallada: 'El transporte público es un bien inferior porque cuando los ingresos aumentan, las personas tienden a comprar automóviles o usar taxis/Uber, reduciendo el uso del autobús.'
},
{
id: 'p4',
bien: 'Fideos instantáneos',
descripcion: 'Comida rápida económica',
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
respuestaCorrecta: 'Bien inferior',
explicacionDetallada: 'Los fideos instantáneos son claramente un bien inferior. A medida que aumentan los ingresos, las personas prefieren alimentos más nutritivos y de mejor calidad.'
},
{
id: 'p5',
bien: 'Vacaciones en el extranjero',
descripcion: 'Viajes turísticos internacionales',
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
respuestaCorrecta: 'Bien de lujo',
explicacionDetallada: 'Las vacaciones internacionales son un bien de lujo porque su consumo aumenta significativamente cuando el ingreso crece, incluso más que proporcionalmente.'
},
{
id: 'p6',
bien: 'Ropa de marca',
descripcion: 'Vestimenta de diseñador',
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
respuestaCorrecta: 'Bien de lujo',
explicacionDetallada: 'La ropa de marca es un bien de lujo porque su demanda crece más rápido que el ingreso, especialmente en rangos de ingreso altos.'
},
{
id: 'p7',
bien: 'Cine',
descripcion: 'Entradas a salas de cine',
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
respuestaCorrecta: 'Bien normal',
explicacionDetallada: 'El cine es un bien normal. Aunque con el auge del streaming podría debatirse, generalmente el consumo de entretenimiento aumenta con el ingreso de forma moderada.'
},
{
id: 'p8',
bien: 'Productos de marca blanca',
descripcion: 'Productos genéricos de supermercado',
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
respuestaCorrecta: 'Bien inferior',
explicacionDetallada: 'Los productos de marca blanca son bienes inferiores porque son sustituidos por marcas reconocidas cuando el consumidor tiene mayores ingresos.'
}
];
export function QuizBienes({ ejercicioId: _ejercicioId, onComplete }: QuizBienesProps) {
const [preguntaActual, setPreguntaActual] = useState(0);
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<string | null>(null);
const [mostrarRetroalimentacion, setMostrarRetroalimentacion] = useState(false);
const [puntuacion, setPuntuacion] = useState(0);
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
const [completado, setCompletado] = useState(false);
const [progreso, setProgreso] = useState(0);
useEffect(() => {
setProgreso(((preguntaActual + (completado ? 1 : 0)) / PREGUNTAS.length) * 100);
}, [preguntaActual, completado]);
const handleSeleccionarRespuesta = (opcion: string) => {
if (mostrarRetroalimentacion) return;
setRespuestaSeleccionada(opcion);
};
const handleValidar = () => {
if (!respuestaSeleccionada) return;
const esCorrecta = respuestaSeleccionada === PREGUNTAS[preguntaActual].respuestaCorrecta;
setMostrarRetroalimentacion(true);
if (esCorrecta) {
setRespuestasCorrectas(prev => prev + 1);
setPuntuacion(prev => prev + 100);
}
};
const handleSiguiente = () => {
if (preguntaActual < PREGUNTAS.length - 1) {
setPreguntaActual(prev => prev + 1);
setRespuestaSeleccionada(null);
setMostrarRetroalimentacion(false);
} else {
setCompletado(true);
const puntuacionFinal = puntuacion + (respuestaSeleccionada === PREGUNTAS[preguntaActual].respuestaCorrecta ? 100 : 0);
if (onComplete) {
onComplete(puntuacionFinal);
}
}
};
const handleReiniciar = () => {
setPreguntaActual(0);
setRespuestaSeleccionada(null);
setMostrarRetroalimentacion(false);
setPuntuacion(0);
setRespuestasCorrectas(0);
setCompletado(false);
};
const pregunta = PREGUNTAS[preguntaActual];
const esCorrecta = respuestaSeleccionada === pregunta.respuestaCorrecta;
if (completado) {
return (
<Card className="w-full max-w-2xl mx-auto">
<div className="text-center py-8">
<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"
>
<Trophy size={40} className="text-yellow-600" />
</motion.div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Quiz Completado!</h3>
<p className="text-gray-600 mb-6">
Respondiste correctamente {respuestasCorrectas} de {PREGUNTAS.length} preguntas
</p>
<div className="bg-blue-50 rounded-lg p-6 mb-6">
<p className="text-sm text-blue-600 mb-1">Puntuación Total</p>
<p className="text-4xl font-bold text-blue-700">{puntuacion} puntos</p>
</div>
<Button onClick={handleReiniciar} variant="outline">
Intentar de Nuevo
</Button>
</div>
</Card>
);
}
return (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader
title="Quiz: Clasificación de Bienes"
subtitle={`Pregunta ${preguntaActual + 1} de ${PREGUNTAS.length}`}
/>
<div className="mb-6">
<div className="flex justify-between text-sm text-gray-600 mb-2">
<span>Progreso</span>
<span>{Math.round(progreso)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<motion.div
className="bg-blue-600 h-2.5 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progreso}%` }}
transition={{ duration: 0.3 }}
/>
</div>
</div>
<div className="mb-6">
<div className="flex items-center gap-2 text-blue-600 mb-3">
<BookOpen size={20} />
<span className="font-medium">Clasifica el siguiente bien:</span>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">{pregunta.bien}</h3>
<p className="text-gray-600">{pregunta.descripcion}</p>
</div>
<div className="space-y-3 mb-6">
{pregunta.opciones.map((opcion, index) => {
const isSelected = respuestaSeleccionada === opcion;
const isCorrect = opcion === pregunta.respuestaCorrecta;
const showCorrect = mostrarRetroalimentacion && isCorrect;
const showIncorrect = mostrarRetroalimentacion && isSelected && !isCorrect;
return (
<motion.button
key={index}
onClick={() => handleSeleccionarRespuesta(opcion)}
disabled={mostrarRetroalimentacion}
whileHover={!mostrarRetroalimentacion ? { scale: 1.02 } : {}}
whileTap={!mostrarRetroalimentacion ? { scale: 0.98 } : {}}
className={`w-full p-4 rounded-lg border-2 text-left transition-all ${
showCorrect
? 'border-green-500 bg-green-50'
: showIncorrect
? 'border-red-500 bg-red-50'
: isSelected
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-blue-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 text-gray-700 font-semibold text-sm">
{String.fromCharCode(65 + index)}
</span>
<span className="font-medium">{opcion}</span>
</div>
{showCorrect && <CheckCircle size={20} className="text-green-600" />}
{showIncorrect && <XCircle size={20} className="text-red-600" />}
</div>
</motion.button>
);
})}
</div>
<AnimatePresence>
{mostrarRetroalimentacion && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden mb-6"
>
<div className={`p-4 rounded-lg border ${
esCorrecta
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}`}>
<div className="flex items-center gap-2 mb-2">
{esCorrecta ? (
<CheckCircle size={20} className="text-green-600" />
) : (
<XCircle size={20} className="text-red-600" />
)}
<span className={`font-semibold ${
esCorrecta ? 'text-green-800' : 'text-red-800'
}`}>
{esCorrecta ? '¡Correcto!' : 'Incorrecto'}
</span>
</div>
<p className={`text-sm ${
esCorrecta ? 'text-green-700' : 'text-red-700'
}`}>
{pregunta.explicacionDetallada}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600">
Puntuación: <span className="font-bold text-blue-600">{puntuacion}</span> pts
</div>
{!mostrarRetroalimentacion ? (
<Button
onClick={handleValidar}
disabled={!respuestaSeleccionada}
>
Validar Respuesta
</Button>
) : (
<Button onClick={handleSiguiente}>
{preguntaActual < PREGUNTAS.length - 1 ? (
<>
Siguiente
<ArrowRight size={16} className="ml-2" />
</>
) : (
'Finalizar'
)}
</Button>
)}
</div>
</Card>
);
}
export default QuizBienes;

View File

@@ -0,0 +1,248 @@
import { useState, useCallback } from 'react';
interface SimuladorDisyuntivasProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
export function SimuladorDisyuntivas({ ejercicioId: _ejercicioId, onComplete }: SimuladorDisyuntivasProps) {
const [bienX, setBienX] = useState(50);
const [bienY, setBienY] = useState(50);
const [validacion, setValidacion] = useState<'eficiente' | 'ineficiente' | 'inalcanzable' | null>(null);
const [completado, setCompletado] = useState(false);
const MAX_X = 100;
const MAX_Y = 80;
const calcularFPP = useCallback((x: number): number => {
const ratio = x / MAX_X;
const y = MAX_Y * Math.pow(1 - ratio, 0.7);
return Math.max(0, Math.min(MAX_Y, y));
}, []);
const validarPosicion = useCallback(() => {
const yFPP = calcularFPP(bienX);
const diferencia = Math.abs(bienY - yFPP);
const tolerancia = 3;
if (bienY > yFPP + tolerancia) {
return 'inalcanzable';
} else if (diferencia <= tolerancia) {
return 'eficiente';
} else {
return 'ineficiente';
}
}, [bienX, bienY, calcularFPP]);
const handleValidar = () => {
const resultado = validarPosicion();
setValidacion(resultado);
if (resultado === 'eficiente' && !completado) {
setCompletado(true);
if (onComplete) {
onComplete(100);
}
}
};
const handleReset = () => {
setBienX(50);
setBienY(50);
setValidacion(null);
setCompletado(false);
};
// Generar puntos para la curva FPP
const puntosFPP: string[] = [];
for (let x = 0; x <= MAX_X; x += 2) {
const y = calcularFPP(x);
const svgX = 40 + (x / MAX_X) * 260;
const svgY = 200 - (y / MAX_Y) * 180;
puntosFPP.push(`${svgX},${svgY}`);
}
const pathData = puntosFPP.length > 0
? `M ${puntosFPP.join(' L ')}`
: '';
const colorValidacion = validacion === 'eficiente'
? 'text-green-600 bg-green-50 border-green-200'
: validacion === 'ineficiente'
? 'text-yellow-600 bg-yellow-50 border-yellow-200'
: validacion === 'inalcanzable'
? 'text-red-600 bg-red-50 border-red-200'
: 'text-gray-600 bg-gray-50 border-gray-200';
const mensajeValidacion = validacion === 'eficiente'
? '¡Excelente! Estás sobre la FPP (Asignación eficiente)'
: validacion === 'ineficiente'
? 'Punto ineficiente: Estás dentro de la FPP, hay recursos sin usar'
: validacion === 'inalcanzable'
? 'Punto inalcanzable: No tienes suficientes recursos'
: 'Ajusta los sliders para explorar la FPP';
const puntoColor = validacion === 'eficiente'
? '#10b981'
: validacion === 'ineficiente'
? '#f59e0b'
: validacion === 'inalcanzable'
? '#ef4444'
: '#6b7280';
return (
<div className="w-full max-w-4xl mx-auto bg-white rounded-lg shadow-lg p-6">
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900">Simulador de Disyuntivas Económicas</h3>
<p className="text-sm text-gray-500">Explora la Frontera de Posibilidades de Producción (FPP)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-6">
{/* Slider X */}
<div>
<label className="flex justify-between text-sm font-medium text-gray-700 mb-2">
<span>Alimentos (X)</span>
<span className="text-blue-600 font-bold">{bienX} millones de toneladas</span>
</label>
<input
type="range"
min="0"
max={MAX_X}
value={bienX}
onChange={(e) => setBienX(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg cursor-pointer"
style={{ accentColor: '#2563eb' }}
/>
</div>
{/* Slider Y */}
<div>
<label className="flex justify-between text-sm font-medium text-gray-700 mb-2">
<span>Tecnología (Y)</span>
<span className="text-green-600 font-bold">{bienY} millones de unidades</span>
</label>
<input
type="range"
min="0"
max={MAX_Y}
value={bienY}
onChange={(e) => setBienY(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg cursor-pointer"
style={{ accentColor: '#16a34a' }}
/>
</div>
{/* Mensaje de validación */}
<div className={`p-4 rounded-lg border-2 ${colorValidacion}`}>
<span className="font-semibold capitalize">{validacion || 'Selecciona'}:</span>
<p className="text-sm mt-1">{mensajeValidacion}</p>
</div>
{/* Botones */}
<div className="flex gap-3">
<button
onClick={handleValidar}
disabled={completado}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
>
Validar Posición
</button>
<button
onClick={handleReset}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Reiniciar
</button>
</div>
{/* Mensaje de éxito */}
{completado && (
<div className="bg-green-100 border border-green-300 rounded-lg p-4 text-center">
<p className="text-green-800 font-semibold">¡Ejercicio Completado!</p>
<p className="text-green-700 text-2xl font-bold mt-1">100 puntos</p>
</div>
)}
</div>
{/* Gráfico SVG */}
<div className="bg-gray-50 rounded-lg p-4 border-2 border-gray-200">
<svg viewBox="0 0 340 240" className="w-full" style={{ minHeight: '300px' }}>
{/* Grid */}
<defs>
<pattern id="grid" width="30" height="27" patternUnits="userSpaceOnUse">
<path d="M 30 0 L 0 0 0 27" fill="none" stroke="#e5e7eb" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="340" height="240" fill="url(#grid)" />
{/* Ejes */}
<line x1="40" y1="210" x2="320" y2="210" stroke="#374151" strokeWidth="2" />
<line x1="40" y1="210" x2="40" y2="30" stroke="#374151" strokeWidth="2" />
{/* Flechas */}
<polygon points="320,210 315,207 315,213" fill="#374151" />
<polygon points="40,30 37,35 43,35" fill="#374151" />
{/* Etiquetas */}
<text x="180" y="235" textAnchor="middle" fill="#6b7280" fontSize="12">
Alimentos (millones de toneladas)
</text>
<text x="15" y="120" textAnchor="middle" fill="#6b7280" fontSize="12" transform="rotate(-90, 15, 120)">
Tecnología (millones de unidades)
</text>
{/* Marcas X */}
{[0, 25, 50, 75, 100].map((val, i) => (
<g key={`x-${val}`}>
<line x1={40 + i * 70} y1="210" x2={40 + i * 70} y2="215" stroke="#374151" />
<text x={40 + i * 70} y="228" textAnchor="middle" fill="#6b7280" fontSize="11">{val}</text>
</g>
))}
{/* Marcas Y */}
{[0, 20, 40, 60, 80].map((val, i) => (
<g key={`y-${val}`}>
<line x1="35" y1={210 - i * 45} x2="40" y2={210 - i * 45} stroke="#374151" />
<text x="30" y={210 - i * 45 + 4} textAnchor="end" fill="#6b7280" fontSize="11">{val}</text>
</g>
))}
{/* Curva FPP */}
{pathData && (
<path
d={pathData}
fill="none"
stroke="#2563eb"
strokeWidth="3"
/>
)}
{/* Punto actual */}
<circle
cx={40 + (bienX / MAX_X) * 280}
cy={210 - (bienY / MAX_Y) * 180}
r="8"
fill={puntoColor}
stroke="white"
strokeWidth="3"
/>
{/* Coordenadas */}
<text
x={40 + (bienX / MAX_X) * 280}
y={210 - (bienY / MAX_Y) * 180 - 15}
textAnchor="middle"
fill="#1f2937"
fontSize="12"
fontWeight="bold"
>
({bienX}, {bienY})
</text>
</svg>
</div>
</div>
</div>
);
}
export default SimuladorDisyuntivas;

View File

@@ -0,0 +1,3 @@
export { SimuladorDisyuntivas } from './SimuladorDisyuntivas';
export { QuizBienes } from './QuizBienes';
export { FlujoCircular } from './FlujoCircular';

View File

@@ -0,0 +1,505 @@
import React, { useState, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { LineChart, Check, X, RotateCcw, Trophy, ArrowRight, HelpCircle } from 'lucide-react';
interface Punto {
x: number;
y: number;
id: string;
}
interface ConstructorCurvasProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
type Nivel = 'demanda' | 'oferta' | 'equilibrio';
type TipoCurva = 'demanda' | 'oferta';
interface NivelConfig {
tipo: Nivel;
titulo: string;
descripcion: string;
tipoCurvaEsperada: TipoCurva | 'ambas';
mensajeExito: string;
}
const niveles: NivelConfig[] = [
{
tipo: 'demanda',
titulo: 'Nivel 1: Curva de Demanda',
descripcion: 'La demanda tiene pendiente negativa (cuando el precio sube, la cantidad demandada baja). Coloca al menos 2 puntos y traza la línea.',
tipoCurvaEsperada: 'demanda',
mensajeExito: '¡Correcto! La curva de demanda tiene pendiente negativa.'
},
{
tipo: 'oferta',
titulo: 'Nivel 2: Curva de Oferta',
descripcion: 'La oferta tiene pendiente positiva (cuando el precio sube, los productores quieren vender más). Coloca al menos 2 puntos.',
tipoCurvaEsperada: 'oferta',
mensajeExito: '¡Correcto! La curva de oferta tiene pendiente positiva.'
},
{
tipo: 'equilibrio',
titulo: 'Nivel 3: Equilibrio de Mercado',
descripcion: 'Dibuja ambas curvas para encontrar el punto de equilibrio donde se cruzan.',
tipoCurvaEsperada: 'ambas',
mensajeExito: '¡Excelente! Has encontrado el equilibrio de mercado.'
}
];
const GRID_SIZE = 300;
const PADDING = 40;
const MAX_PRECIO = 100;
const MAX_CANTIDAD = 100;
export const ConstructorCurvas: React.FC<ConstructorCurvasProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
const [nivelActual, setNivelActual] = useState(0);
const [puntosDemanda, setPuntosDemanda] = useState<Punto[]>([]);
const [puntosOferta, setPuntosOferta] = useState<Punto[]>([]);
const [modoActivo, setModoActivo] = useState<TipoCurva>('demanda');
const [mensaje, setMensaje] = useState<string>('');
const [showSuccess, setShowSuccess] = useState(false);
const [score, setScore] = useState(0);
const [_startTime] = useState(Date.now());
const [, setPuntosDibujados] = useState<{ demanda: boolean; oferta: boolean }>({ demanda: false, oferta: false });
const svgRef = useRef<SVGSVGElement>(null);
const [draggedPoint, setDraggedPoint] = useState<string | null>(null);
const nivel = niveles[nivelActual];
const cartesianToSvg = useCallback((x: number, y: number) => {
const svgX = PADDING + (x / MAX_CANTIDAD) * GRID_SIZE;
const svgY = PADDING + GRID_SIZE - (y / MAX_PRECIO) * GRID_SIZE;
return { x: svgX, y: svgY };
}, []);
const svgToCartesian = useCallback((svgX: number, svgY: number) => {
const x = ((svgX - PADDING) / GRID_SIZE) * MAX_CANTIDAD;
const y = ((PADDING + GRID_SIZE - svgY) / GRID_SIZE) * MAX_PRECIO;
return {
x: Math.max(0, Math.min(MAX_CANTIDAD, Math.round(x))),
y: Math.max(0, Math.min(MAX_PRECIO, Math.round(y)))
};
}, []);
const handleSvgClick = (e: React.MouseEvent<SVGSVGElement>) => {
if (draggedPoint || nivelActual === 2) return;
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
const svgX = e.clientX - rect.left;
const svgY = e.clientY - rect.top;
const cartesian = svgToCartesian(svgX, svgY);
if (nivelActual === 0 && puntosDemanda.length >= 4) {
setMensaje('Máximo 4 puntos para la demanda');
return;
}
if (nivelActual === 1 && puntosOferta.length >= 4) {
setMensaje('Máximo 4 puntos para la oferta');
return;
}
const newPoint: Punto = {
x: cartesian.x,
y: cartesian.y,
id: `point-${Date.now()}-${Math.random()}`
};
if (modoActivo === 'demanda') {
setPuntosDemanda(prev => [...prev, newPoint]);
} else {
setPuntosOferta(prev => [...prev, newPoint]);
}
setMensaje('');
};
const handlePointDrag = (pointId: string, _tipo: TipoCurva) => {
setDraggedPoint(pointId);
};
const handlePointMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (!draggedPoint) return;
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
const svgX = e.clientX - rect.left;
const svgY = e.clientY - rect.top;
const cartesian = svgToCartesian(svgX, svgY);
const updatePoint = (puntos: Punto[]) =>
puntos.map(p => p.id === draggedPoint ? { ...p, x: cartesian.x, y: cartesian.y } : p);
if (puntosDemanda.some(p => p.id === draggedPoint)) {
setPuntosDemanda(updatePoint);
} else if (puntosOferta.some(p => p.id === draggedPoint)) {
setPuntosOferta(updatePoint);
}
};
const handlePointUp = () => {
setDraggedPoint(null);
};
const calcularPendiente = (puntos: Punto[]): number | null => {
if (puntos.length < 2) return null;
const sorted = [...puntos].sort((a, b) => a.x - b.x);
const first = sorted[0];
const last = sorted[sorted.length - 1];
if (last.x === first.x) return 0;
return (last.y - first.y) / (last.x - first.x);
};
const validarCurva = () => {
const puntos = modoActivo === 'demanda' ? puntosDemanda : puntosOferta;
if (puntos.length < 2) {
setMensaje('Necesitas al menos 2 puntos para trazar una curva');
return;
}
const pendiente = calcularPendiente(puntos);
if (pendiente === null) return;
if (modoActivo === 'demanda') {
if (pendiente >= 0) {
setMensaje('La demanda debe tener pendiente negativa (bajar de izquierda a derecha)');
return;
}
setPuntosDibujados(prev => ({ ...prev, demanda: true }));
} else {
if (pendiente <= 0) {
setMensaje('La oferta debe tener pendiente positiva (subir de izquierda a derecha)');
return;
}
setPuntosDibujados(prev => ({ ...prev, oferta: true }));
}
setMensaje('');
setShowSuccess(true);
setScore(prev => prev + 33);
setTimeout(() => {
if (nivelActual < 2) {
setNivelActual(prev => prev + 1);
setShowSuccess(false);
setMensaje('');
if (nivelActual === 0) setModoActivo('oferta');
}
}, 2000);
};
const validarEquilibrio = () => {
if (puntosDemanda.length < 2 || puntosOferta.length < 2) {
setMensaje('Necesitas trazar ambas curvas con al menos 2 puntos cada una');
return;
}
setShowSuccess(true);
setScore(100);
setTimeout(() => {
if (onComplete) {
onComplete(100);
}
}, 2000);
};
const reiniciar = () => {
setPuntosDemanda([]);
setPuntosOferta([]);
setNivelActual(0);
setModoActivo('demanda');
setMensaje('');
setShowSuccess(false);
setScore(0);
setPuntosDibujados({ demanda: false, oferta: false });
};
const eliminarPunto = (id: string, tipo: TipoCurva) => {
if (tipo === 'demanda') {
setPuntosDemanda(prev => prev.filter(p => p.id !== id));
} else {
setPuntosOferta(prev => prev.filter(p => p.id !== id));
}
};
const renderLineaCurva = (puntos: Punto[], color: string) => {
if (puntos.length < 2) return null;
const sorted = [...puntos].sort((a, b) => a.x - b.x);
const points = sorted.map(p => {
const svg = cartesianToSvg(p.x, p.y);
return `${svg.x},${svg.y}`;
}).join(' ');
return (
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
);
};
return (
<div className="w-full max-w-4xl mx-auto p-6 bg-white rounded-xl shadow-lg">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<LineChart className="w-8 h-8 text-blue-600" />
<h2 className="text-2xl font-bold text-gray-800">{nivel.titulo}</h2>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-500">Nivel {nivelActual + 1} de 3</span>
<div className="w-32 h-2 bg-gray-200 rounded-full overflow-hidden">
<motion.div
className="h-full bg-blue-600"
initial={{ width: 0 }}
animate={{ width: `${score}%` }}
/>
</div>
<button
onClick={reiniciar}
className="p-2 text-gray-500 hover:text-blue-600 transition-colors"
>
<RotateCcw className="w-5 h-5" />
</button>
</div>
</div>
<p className="text-gray-600">{nivel.descripcion}</p>
</div>
{nivelActual === 2 && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg flex items-center gap-2">
<HelpCircle className="w-5 h-5 text-blue-600" />
<span className="text-sm text-blue-700">
Nivel Avanzado: Dibuja ambas curvas. La demanda (azul) con pendiente negativa,
y la oferta (verde) con pendiente positiva.
</span>
</div>
)}
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1">
<svg
ref={svgRef}
width={GRID_SIZE + 2 * PADDING}
height={GRID_SIZE + 2 * PADDING}
className="border-2 border-gray-300 rounded-lg bg-white cursor-crosshair"
onClick={handleSvgClick}
onMouseMove={handlePointMove}
onMouseUp={handlePointUp}
onMouseLeave={handlePointUp}
>
{/* Grid */}
{Array.from({ length: 11 }).map((_, i) => (
<g key={i}>
<line
x1={PADDING + (i * GRID_SIZE) / 10}
y1={PADDING}
x2={PADDING + (i * GRID_SIZE) / 10}
y2={PADDING + GRID_SIZE}
stroke="#e5e7eb"
strokeWidth="1"
/>
<line
x1={PADDING}
y1={PADDING + (i * GRID_SIZE) / 10}
x2={PADDING + GRID_SIZE}
y2={PADDING + (i * GRID_SIZE) / 10}
stroke="#e5e7eb"
strokeWidth="1"
/>
</g>
))}
{/* Ejes */}
<line
x1={PADDING}
y1={PADDING + GRID_SIZE}
x2={PADDING + GRID_SIZE}
y2={PADDING + GRID_SIZE}
stroke="#374151"
strokeWidth="2"
/>
<line
x1={PADDING}
y1={PADDING}
x2={PADDING}
y2={PADDING + GRID_SIZE}
stroke="#374151"
strokeWidth="2"
/>
{/* Labels ejes */}
<text x={PADDING + GRID_SIZE / 2} y={PADDING + GRID_SIZE + 25} textAnchor="middle" className="text-sm fill-gray-600">
Cantidad
</text>
<text x={15} y={PADDING + GRID_SIZE / 2} textAnchor="middle" transform={`rotate(-90, 15, ${PADDING + GRID_SIZE / 2})`} className="text-sm fill-gray-600">
Precio
</text>
{/* Curvas */}
{(nivelActual === 0 || nivelActual === 2) && renderLineaCurva(puntosDemanda, '#3b82f6')}
{(nivelActual === 1 || nivelActual === 2) && renderLineaCurva(puntosOferta, '#22c55e')}
{/* Puntos Demanda */}
{(nivelActual === 0 || nivelActual === 2) && puntosDemanda.map(punto => {
const svg = cartesianToSvg(punto.x, punto.y);
return (
<motion.g key={punto.id}>
<circle
cx={svg.x}
cy={svg.y}
r="8"
fill="#3b82f6"
stroke="white"
strokeWidth="2"
className="cursor-move hover:r-10"
onMouseDown={() => handlePointDrag(punto.id, 'demanda')}
onClick={(e) => {
e.stopPropagation();
eliminarPunto(punto.id, 'demanda');
}}
/>
<text x={svg.x} y={svg.y - 12} textAnchor="middle" className="text-xs fill-gray-500">
({punto.x}, {punto.y})
</text>
</motion.g>
);
})}
{/* Puntos Oferta */}
{(nivelActual === 1 || nivelActual === 2) && puntosOferta.map(punto => {
const svg = cartesianToSvg(punto.x, punto.y);
return (
<motion.g key={punto.id}>
<circle
cx={svg.x}
cy={svg.y}
r="8"
fill="#22c55e"
stroke="white"
strokeWidth="2"
className="cursor-move"
onMouseDown={() => handlePointDrag(punto.id, 'oferta')}
onClick={(e) => {
e.stopPropagation();
eliminarPunto(punto.id, 'oferta');
}}
/>
<text x={svg.x} y={svg.y - 12} textAnchor="middle" className="text-xs fill-gray-500">
({punto.x}, {punto.y})
</text>
</motion.g>
);
})}
</svg>
</div>
<div className="w-full md:w-64 space-y-4">
{nivelActual === 2 && (
<div className="flex gap-2 p-3 bg-gray-100 rounded-lg">
<button
onClick={() => setModoActivo('demanda')}
className={`flex-1 py-2 px-3 rounded text-sm font-medium transition-colors ${
modoActivo === 'demanda'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
Demanda
</button>
<button
onClick={() => setModoActivo('oferta')}
className={`flex-1 py-2 px-3 rounded text-sm font-medium transition-colors ${
modoActivo === 'oferta'
? 'bg-green-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
Oferta
</button>
</div>
)}
<div className="p-4 bg-gray-50 rounded-lg">
<h3 className="font-semibold text-gray-700 mb-2">Puntos colocados:</h3>
{modoActivo === 'demanda' || nivelActual === 2 ? (
<div className="mb-2">
<span className="text-sm text-blue-600 font-medium">Demanda: </span>
<span className="text-sm text-gray-600">{puntosDemanda.length} puntos</span>
</div>
) : null}
{(modoActivo === 'oferta' || nivelActual === 2) && (
<div>
<span className="text-sm text-green-600 font-medium">Oferta: </span>
<span className="text-sm text-gray-600">{puntosOferta.length} puntos</span>
</div>
)}
</div>
{mensaje && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2"
>
<X className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700">{mensaje}</p>
</motion.div>
)}
{nivelActual < 2 ? (
<button
onClick={validarCurva}
disabled={showSuccess}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
<Check className="w-5 h-5" />
Validar Curva
</button>
) : (
<button
onClick={validarEquilibrio}
disabled={showSuccess}
className="w-full py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
<Check className="w-5 h-5" />
Validar Equilibrio
</button>
)}
<AnimatePresence>
{showSuccess && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="p-4 bg-green-50 border border-green-200 rounded-lg text-center"
>
<Trophy className="w-8 h-8 text-green-500 mx-auto mb-2" />
<p className="font-semibold text-green-700">{nivel.mensajeExito}</p>
{nivelActual === 2 && (
<div className="mt-3 flex items-center justify-center gap-2 text-green-600">
<span>Completado</span>
<ArrowRight className="w-4 h-4" />
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
};
export default ConstructorCurvas;

View File

@@ -0,0 +1,467 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Brain, ArrowRight, ArrowLeft, TrendingUp, TrendingDown, CheckCircle2, XCircle, Trophy, RotateCcw, BookOpen } from 'lucide-react';
interface IdentificarShocksProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
type DireccionShock = 'oferta-up' | 'oferta-down' | 'demanda-up' | 'demanda-down';
type CurvaTipo = 'oferta' | 'demanda';
type Direccion = 'arriba' | 'abajo';
interface Escenario {
id: number;
descripcion: string;
respuesta: DireccionShock;
curva: CurvaTipo;
direccion: Direccion;
explicacion: string;
dificultad: 'facil' | 'medio' | 'dificil';
}
const escenarios: Escenario[] = [
{
id: 1,
descripcion: 'Una nueva tecnología permite producir smartphones más rápido y barato.',
respuesta: 'oferta-up',
curva: 'oferta',
direccion: 'arriba',
explicacion: 'La tecnología mejora la productividad, reduciendo costos. Esto aumenta la oferta (la curva se desplaza a la derecha).',
dificultad: 'facil'
},
{
id: 2,
descripcion: 'Se anuncia que el café causa cáncer y la demanda disminuye drásticamente.',
respuesta: 'demanda-down',
curva: 'demanda',
direccion: 'abajo',
explicacion: 'Las preferencias de los consumidores cambian negativamente. La demanda disminuye (la curva se desplaza a la izquierda).',
dificultad: 'facil'
},
{
id: 3,
descripcion: 'Una sequía severa destruye el 40% de la cosecha de trigo.',
respuesta: 'oferta-down',
curva: 'oferta',
direccion: 'abajo',
explicacion: 'La sequía reduce la cantidad disponible de trigo. La oferta disminuye (la curva se desplaza a la izquierda).',
dificultad: 'facil'
},
{
id: 4,
descripcion: 'El ingreso promedio de la población aumenta un 15% (bien normal).',
respuesta: 'demanda-up',
curva: 'demanda',
direccion: 'arriba',
explicacion: 'Para bienes normales, al aumentar el ingreso, aumenta la demanda (la curva se desplaza a la derecha).',
dificultad: 'medio'
},
{
id: 5,
descripcion: 'El precio del petróleo (insumo) sube un 30%.',
respuesta: 'oferta-down',
curva: 'oferta',
direccion: 'abajo',
explicacion: 'Al subir el costo de los insumos, producir es más caro. La oferta disminuye (la curva se desplaza a la izquierda).',
dificultad: 'medio'
},
{
id: 6,
descripcion: 'El gobierno subsidia la compra de autos eléctricos con $5,000.',
respuesta: 'demanda-up',
curva: 'demanda',
direccion: 'arriba',
explicacion: 'El subsidio reduce el precio efectivo para consumidores. La demanda aumenta (la curva se desplaza a la derecha).',
dificultad: 'dificil'
}
];
interface Opcion {
value: DireccionShock;
label: string;
icon: React.ReactNode;
color: string;
}
const opciones: Opcion[] = [
{ value: 'oferta-up', label: 'Oferta ↑', icon: <TrendingUp className="w-5 h-5" />, color: 'green' },
{ value: 'oferta-down', label: 'Oferta ↓', icon: <TrendingDown className="w-5 h-5" />, color: 'red' },
{ value: 'demanda-up', label: 'Demanda ↑', icon: <TrendingUp className="w-5 h-5" />, color: 'blue' },
{ value: 'demanda-down', label: 'Demanda ↓', icon: <TrendingDown className="w-5 h-5" />, color: 'orange' },
];
export const IdentificarShocks: React.FC<IdentificarShocksProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
const [escenarioActual, setEscenarioActual] = useState(0);
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<DireccionShock | null>(null);
const [mostrarResultado, setMostrarResultado] = useState(false);
const [score, setScore] = useState(0);
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
const [_startTime] = useState(Date.now());
const [completado, setCompletado] = useState(false);
const escenario = escenarios[escenarioActual];
const handleSeleccionar = (respuesta: DireccionShock) => {
if (mostrarResultado) return;
setRespuestaSeleccionada(respuesta);
};
const handleVerificar = () => {
if (!respuestaSeleccionada) return;
const esCorrecta = respuestaSeleccionada === escenario.respuesta;
setMostrarResultado(true);
if (esCorrecta) {
setScore(prev => prev + Math.round(100 / escenarios.length));
setRespuestasCorrectas(prev => prev + 1);
}
};
const handleSiguiente = () => {
if (escenarioActual < escenarios.length - 1) {
setEscenarioActual(prev => prev + 1);
setRespuestaSeleccionada(null);
setMostrarResultado(false);
} else {
setCompletado(true);
if (onComplete) {
onComplete(score);
}
}
};
const handleReiniciar = () => {
setEscenarioActual(0);
setRespuestaSeleccionada(null);
setMostrarResultado(false);
setScore(0);
setRespuestasCorrectas(0);
setCompletado(false);
};
const getDificultadColor = (dificultad: string) => {
switch (dificultad) {
case 'facil': return 'bg-green-100 text-green-700';
case 'medio': return 'bg-yellow-100 text-yellow-700';
case 'dificil': return 'bg-red-100 text-red-700';
default: return 'bg-gray-100 text-gray-700';
}
};
const renderGraficoShock = () => {
const { curva, direccion } = escenario;
const isOferta = curva === 'oferta';
const isUp = direccion === 'arriba';
return (
<svg width="300" height="250" className="mx-auto">
{/* Grid */}
{Array.from({ length: 6 }).map((_, i) => (
<g key={i}>
<line x1={50 + i * 40} y1="30" x2={50 + i * 40} y2="210" stroke="#e5e7eb" strokeWidth="1" />
<line x1="50" y1={30 + i * 36} x2="250" y2={30 + i * 36} stroke="#e5e7eb" strokeWidth="1" />
</g>
))}
{/* Ejes */}
<line x1="50" y1="210" x2="250" y2="210" stroke="#374151" strokeWidth="2" />
<line x1="50" y1="30" x2="50" y2="210" stroke="#374151" strokeWidth="2" />
{/* Curva original */}
{isOferta ? (
<line x1="80" y1="180" x2="220" y2="80" stroke="#22c55e" strokeWidth="3" />
) : (
<line x1="80" y1="80" x2="220" y2="180" stroke="#3b82f6" strokeWidth="3" />
)}
<text x={isOferta ? 230 : 230} y={isOferta ? 75 : 190} className={`text-sm ${isOferta ? 'fill-green-600' : 'fill-blue-600'}`}>
{isOferta ? 'S₁' : 'D₁'}
</text>
{/* Curva desplazada */}
<motion.g
initial={{ opacity: 0, x: isUp ? 30 : -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
>
{isOferta ? (
<line x1={isUp ? 110 : 50} y1="180" x2={isUp ? 250 : 190} y2="80" stroke="#22c55e" strokeWidth="3" strokeDasharray="5,5" />
) : (
<line x1={isUp ? 110 : 50} y1="80" x2={isUp ? 250 : 190} y2="180" stroke="#3b82f6" strokeWidth="3" strokeDasharray="5,5" />
)}
<text x={isUp ? 260 : 200} y={isOferta ? 75 : 190} className={`text-sm ${isOferta ? 'fill-green-600' : 'fill-blue-600'}`}>
{isOferta ? 'S₂' : 'D₂'}
</text>
</motion.g>
{/* Flecha de dirección */}
<motion.path
d={isUp ? 'M 280 130 L 300 130' : 'M 300 130 L 280 130'}
stroke={isOferta ? '#22c55e' : '#3b82f6'}
strokeWidth="3"
markerEnd={`url(#arrowhead-${isOferta ? 'green' : 'blue'})`}
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
/>
{/* Defs para flechas */}
<defs>
<marker id="arrowhead-green" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#22c55e" />
</marker>
<marker id="arrowhead-blue" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6" />
</marker>
</defs>
</svg>
);
};
if (completado) {
const porcentaje = Math.round((respuestasCorrectas / escenarios.length) * 100);
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-2xl mx-auto p-8 bg-white rounded-xl shadow-lg text-center"
>
<Trophy className="w-16 h-16 text-yellow-500 mx-auto mb-4" />
<h2 className="text-3xl font-bold text-gray-800 mb-2">¡Ejercicio Completado!</h2>
<p className="text-gray-600 mb-6">Has identificado shocks del mercado</p>
<div className="bg-gray-50 rounded-lg p-6 mb-6">
<div className="text-5xl font-bold text-purple-600 mb-2">{porcentaje}%</div>
<p className="text-gray-600">
{respuestasCorrectas} de {escenarios.length} respuestas correctas
</p>
</div>
<button
onClick={handleReiniciar}
className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors"
>
<RotateCcw className="w-5 h-5" />
Intentar de nuevo
</button>
</motion.div>
);
}
return (
<div className="w-full max-w-4xl mx-auto p-6 bg-white rounded-xl shadow-lg">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Brain className="w-8 h-8 text-purple-600" />
<h2 className="text-2xl font-bold text-gray-800">Identificar Shocks del Mercado</h2>
</div>
<div className="flex items-center gap-4">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getDificultadColor(escenario.dificultad)}`}>
{escenario.dificultad.toUpperCase()}
</span>
<span className="text-sm text-gray-500">
{escenarioActual + 1} de {escenarios.length}
</span>
<div className="w-32 h-2 bg-gray-200 rounded-full overflow-hidden">
<motion.div
className="h-full bg-purple-600"
initial={{ width: 0 }}
animate={{ width: `${((escenarioActual + 1) / escenarios.length) * 100}%` }}
/>
</div>
</div>
</div>
<p className="text-gray-600">
Lee cada escenario e identifica qué curva se desplaza y en qué dirección.
</p>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div>
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-lg p-6 mb-4">
<div className="flex items-start gap-3">
<BookOpen className="w-6 h-6 text-purple-600 flex-shrink-0 mt-1" />
<div>
<h3 className="font-semibold text-gray-800 mb-2">Escenario {escenario.id}</h3>
<p className="text-gray-700 text-lg">{escenario.descripcion}</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{opciones.map((opcion) => {
const isSelected = respuestaSeleccionada === opcion.value;
const isCorrect = mostrarResultado && opcion.value === escenario.respuesta;
const isWrong = mostrarResultado && isSelected && opcion.value !== escenario.respuesta;
let buttonClass = 'p-4 rounded-lg border-2 transition-all flex flex-col items-center gap-2 ';
if (isCorrect) {
buttonClass += 'border-green-500 bg-green-50';
} else if (isWrong) {
buttonClass += 'border-red-500 bg-red-50';
} else if (isSelected) {
buttonClass += `border-${opcion.color}-500 bg-${opcion.color}-50`;
} else {
buttonClass += 'border-gray-200 hover:border-gray-300 hover:bg-gray-50';
}
return (
<motion.button
key={opcion.value}
onClick={() => handleSeleccionar(opcion.value)}
disabled={mostrarResultado}
whileHover={!mostrarResultado ? { scale: 1.02 } : {}}
whileTap={!mostrarResultado ? { scale: 0.98 } : {}}
className={buttonClass}
>
{opcion.icon}
<span className={`font-semibold ${
isCorrect ? 'text-green-700' :
isWrong ? 'text-red-700' :
isSelected ? `text-${opcion.color}-700` : 'text-gray-700'
}`}>
{opcion.label}
</span>
{isCorrect && <CheckCircle2 className="w-5 h-5 text-green-600" />}
{isWrong && <XCircle className="w-5 h-5 text-red-600" />}
</motion.button>
);
})}
</div>
<AnimatePresence>
{mostrarResultado && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className={`mt-4 p-4 rounded-lg ${
respuestaSeleccionada === escenario.respuesta
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}
>
<div className="flex items-start gap-2">
{respuestaSeleccionada === escenario.respuesta ? (
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
) : (
<XCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
)}
<div>
<p className={`font-semibold ${
respuestaSeleccionada === escenario.respuesta ? 'text-green-800' : 'text-red-800'
}`}>
{respuestaSeleccionada === escenario.respuesta ? '¡Correcto!' : 'Incorrecto'}
</p>
<p className="text-sm mt-1 text-gray-700">{escenario.explicacion}</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="mt-4 flex gap-3">
{!mostrarResultado ? (
<button
onClick={handleVerificar}
disabled={!respuestaSeleccionada}
className="flex-1 py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Verificar respuesta
</button>
) : (
<button
onClick={handleSiguiente}
className="flex-1 py-3 px-4 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
>
{escenarioActual < escenarios.length - 1 ? (
<>
Siguiente
<ArrowRight className="w-5 h-5" />
</>
) : (
'Finalizar'
)}
</button>
)}
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-700 mb-4 text-center">Visualización del Shock</h3>
{renderGraficoShock()}
<div className="mt-4 p-3 bg-white rounded-lg">
<h4 className="font-medium text-gray-700 mb-2">Leyenda:</h4>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<div className="w-8 h-0.5 bg-blue-600"></div>
<span className="text-gray-600">Curva de Demanda (D)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-0.5 bg-green-600"></div>
<span className="text-gray-600">Curva de Oferta (S)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-0.5 border-t-2 border-dashed border-gray-400"></div>
<span className="text-gray-600">Curva después del shock</span>
</div>
</div>
</div>
<div className="mt-4 p-3 bg-yellow-50 rounded-lg border border-yellow-200">
<p className="text-sm text-yellow-800">
<strong>Tip:</strong> Recuerda que:
</p>
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
<li> Factores de oferta: tecnología, insumos, número de vendedores</li>
<li> Factores de demanda: ingreso, preferencias, precios relacionados</li>
</ul>
</div>
</div>
</div>
<div className="mt-6 flex justify-between items-center">
<button
onClick={() => setEscenarioActual(Math.max(0, escenarioActual - 1))}
disabled={escenarioActual === 0}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Anterior
</button>
<div className="flex items-center gap-1">
{escenarios.map((_, index) => (
<div
key={index}
className={`w-2 h-2 rounded-full ${
index === escenarioActual
? 'bg-purple-600'
: index < escenarioActual
? 'bg-green-500'
: 'bg-gray-300'
}`}
/>
))}
</div>
<button
onClick={() => setEscenarioActual(Math.min(escenarios.length - 1, escenarioActual + 1))}
disabled={escenarioActual === escenarios.length - 1}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Siguiente
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
);
};
export default IdentificarShocks;

View File

@@ -0,0 +1,454 @@
import React, { useState, useMemo, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { TrendingUp, TrendingDown, AlertTriangle, Calculator, RotateCcw, Info } from 'lucide-react';
interface SimuladorPreciosProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
interface CurvaParams {
pendienteDemanda: number;
interceptoDemanda: number;
pendienteOferta: number;
interceptoOferta: number;
}
const DEFAULT_PARAMS: CurvaParams = {
pendienteDemanda: -1.5,
interceptoDemanda: 90,
pendienteOferta: 1.2,
interceptoOferta: 10
};
const calcularEquilibrio = (params: CurvaParams) => {
const { pendienteDemanda, interceptoDemanda, pendienteOferta, interceptoOferta } = params;
// Pd = Po => a + b*Q = c + d*Q
const Q = (interceptoOferta - interceptoDemanda) / (pendienteDemanda - pendienteOferta);
const P = interceptoDemanda + pendienteDemanda * Q;
return { Q: Math.max(0, Q), P: Math.max(0, P) };
};
const calcularCantidadEnPrecio = (precio: number, params: CurvaParams) => {
// P = a + b*Q => Q = (P - a) / b
const Qd = (precio - params.interceptoDemanda) / params.pendienteDemanda;
const Qo = (precio - params.interceptoOferta) / params.pendienteOferta;
return { Qd: Math.max(0, Qd), Qo: Math.max(0, Qo) };
};
export const SimuladorPrecios: React.FC<SimuladorPreciosProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
const [params, _setParams] = useState<CurvaParams>(DEFAULT_PARAMS);
const [precioMaximo, setPrecioMaximo] = useState<number | null>(null);
const [precioMinimo, setPrecioMinimo] = useState<number | null>(null);
const [showInfo, setShowInfo] = useState(true);
const [startTime] = useState(Date.now());
const [hasInteracted, setHasInteracted] = useState(false);
const equilibrio = useMemo(() => calcularEquilibrio(params), [params]);
const analisis = useMemo(() => {
if (precioMaximo !== null && precioMaximo < equilibrio.P) {
const { Qd, Qo } = calcularCantidadEnPrecio(precioMaximo, params);
const excesoDemanda = Qd - Qo;
const cantidadTransada = Qo;
// Pérdida de peso muerto: área del triángulo
const base = equilibrio.Q - cantidadTransada;
const altura = precioMaximo - (params.interceptoOferta + params.pendienteOferta * cantidadTransada);
const deadweightLoss = 0.5 * base * altura;
return {
tipo: 'precio-maximo' as const,
excesoDemanda: Math.max(0, excesoDemanda),
excesoOferta: 0,
cantidadTransada,
deadweightLoss: Math.max(0, deadweightLoss),
mensaje: 'Precio máximo crea escasez (exceso de demanda)'
};
}
if (precioMinimo !== null && precioMinimo > equilibrio.P) {
const { Qd, Qo } = calcularCantidadEnPrecio(precioMinimo, params);
const excesoOferta = Qo - Qd;
const cantidadTransada = Qd;
const base = equilibrio.Q - cantidadTransada;
const altura = (params.interceptoDemanda + params.pendienteDemanda * cantidadTransada) - precioMinimo;
const deadweightLoss = 0.5 * base * altura;
return {
tipo: 'precio-minimo' as const,
excesoDemanda: 0,
excesoOferta: Math.max(0, excesoOferta),
cantidadTransada,
deadweightLoss: Math.max(0, deadweightLoss),
mensaje: 'Precio mínimo crea superávit (exceso de oferta)'
};
}
return {
tipo: 'equilibrio' as const,
excesoDemanda: 0,
excesoOferta: 0,
cantidadTransada: equilibrio.Q,
deadweightLoss: 0,
mensaje: 'Mercado en equilibrio'
};
}, [precioMaximo, precioMinimo, equilibrio, params]);
useEffect(() => {
if (hasInteracted && (precioMaximo !== null || precioMinimo !== null)) {
const timer = setTimeout(() => {
if (onComplete) {
onComplete(100);
}
}, 5000);
return () => clearTimeout(timer);
}
}, [hasInteracted, precioMaximo, precioMinimo, startTime, onComplete]);
const reset = () => {
setPrecioMaximo(null);
setPrecioMinimo(null);
setHasInteracted(false);
};
// Generar puntos para las curvas
const generateCurvePoints = () => {
const points = [];
for (let Q = 0; Q <= 60; Q += 2) {
const Pd = params.interceptoDemanda + params.pendienteDemanda * Q;
const Po = params.interceptoOferta + params.pendienteOferta * Q;
points.push({ Q, Pd: Math.max(0, Pd), Po: Math.max(0, Po) });
}
return points;
};
const curvePoints = generateCurvePoints();
// Escalar para SVG
const scaleX = (Q: number) => 50 + (Q / 60) * 400;
const scaleY = (P: number) => 350 - (P / 100) * 300;
const demandaPath = curvePoints.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.Pd)}`
).join(' ');
const ofertaPath = curvePoints.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.Po)}`
).join(' ');
return (
<div className="w-full max-w-5xl mx-auto p-6 bg-white rounded-xl shadow-lg">
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
<Calculator className="w-8 h-8 text-purple-600" />
Simulador de Precios Intervenidos
</h2>
<p className="text-gray-600 mt-2">
Experimenta con precios máximos y mínimos para ver cómo afectan el equilibrio de mercado.
</p>
</div>
{showInfo && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-3"
>
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="font-semibold text-blue-800 mb-1">Cómo usar:</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li> Ajusta los sliders para establecer un precio máximo o mínimo</li>
<li> Observa cómo cambian las cantidades demandadas y ofrecidas</li>
<li> Identifica escasez (exceso de demanda) o superávit (exceso de oferta)</li>
<li> La pérdida de peso muerto representa la ineficiencia creada</li>
</ul>
</div>
<button
onClick={() => setShowInfo(false)}
className="text-blue-600 hover:text-blue-800"
>
×
</button>
</motion.div>
)}
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-gray-700">Gráfico de Mercado</h3>
<button
onClick={reset}
className="p-2 text-gray-500 hover:text-purple-600 transition-colors"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
<svg width="500" height="400" className="w-full">
{/* Grid */}
{Array.from({ length: 11 }).map((_, i) => (
<g key={i}>
<line
x1={50 + (i * 400) / 10}
y1={50}
x2={50 + (i * 400) / 10}
y2={350}
stroke="#e5e7eb"
strokeWidth="1"
/>
<line
x1={50}
y1={50 + (i * 300) / 10}
x2={450}
y2={50 + (i * 300) / 10}
stroke="#e5e7eb"
strokeWidth="1"
/>
</g>
))}
{/* Ejes */}
<line x1="50" y1="350" x2="450" y2="350" stroke="#374151" strokeWidth="2" />
<line x1="50" y1="50" x2="50" y2="350" stroke="#374151" strokeWidth="2" />
{/* Labels */}
<text x="250" y="385" textAnchor="middle" className="text-sm fill-gray-600">Cantidad</text>
<text x="15" y="200" textAnchor="middle" transform="rotate(-90, 15, 200)" className="text-sm fill-gray-600">Precio</text>
{/* Curva de Demanda */}
<path d={demandaPath} fill="none" stroke="#3b82f6" strokeWidth="3" />
<text x="420" y={scaleY(15)} className="text-sm fill-blue-600 font-medium">D</text>
{/* Curva de Oferta */}
<path d={ofertaPath} fill="none" stroke="#22c55e" strokeWidth="3" />
<text x="420" y={scaleY(80)} className="text-sm fill-green-600 font-medium">S</text>
{/* Punto de equilibrio */}
<circle
cx={scaleX(equilibrio.Q)}
cy={scaleY(equilibrio.P)}
r="6"
fill="#8b5cf6"
stroke="white"
strokeWidth="2"
/>
<text x={scaleX(equilibrio.Q) + 10} y={scaleY(equilibrio.P)} className="text-xs fill-purple-600">
E
</text>
{/* Línea de precio máximo */}
{precioMaximo !== null && (
<g>
<line
x1="50"
y1={scaleY(precioMaximo)}
x2="450"
y2={scaleY(precioMaximo)}
stroke="#ef4444"
strokeWidth="2"
strokeDasharray="5,5"
/>
<text x="460" y={scaleY(precioMaximo)} className="text-xs fill-red-500">Pmáx</text>
{/* Zona de escasez */}
{analisis.excesoDemanda > 0 && (
<polygon
points={`
${scaleX(analisis.cantidadTransada)} ${scaleY(precioMaximo)}
${scaleX(analisis.cantidadTransada + analisis.excesoDemanda)} ${scaleY(precioMaximo)}
`}
fill="#fef3c7"
opacity="0.5"
/>
)}
</g>
)}
{/* Línea de precio mínimo */}
{precioMinimo !== null && (
<g>
<line
x1="50"
y1={scaleY(precioMinimo)}
x2="450"
y2={scaleY(precioMinimo)}
stroke="#f59e0b"
strokeWidth="2"
strokeDasharray="5,5"
/>
<text x="460" y={scaleY(precioMinimo)} className="text-xs fill-amber-500">Pmín</text>
</g>
)}
{/* Indicador de pérdida de peso muerto */}
{analisis.deadweightLoss > 0 && (
<motion.text
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
x={scaleX(equilibrio.Q / 2)}
y={scaleY((equilibrio.P + (precioMaximo || precioMinimo || 0)) / 2)}
textAnchor="middle"
className="text-xs fill-red-600 font-medium"
>
Pérdida de peso muerto
</motion.text>
)}
</svg>
</div>
<div className="space-y-6">
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-700 mb-4">Controles de Precio</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Precio Máximo (Pmáx)
</label>
<input
type="range"
min="0"
max={Math.round(equilibrio.P)}
value={precioMaximo ?? Math.round(equilibrio.P)}
onChange={(e) => {
setPrecioMaximo(Number(e.target.value) || null);
setPrecioMinimo(null);
setHasInteracted(true);
}}
className="w-full accent-red-500"
/>
<div className="flex justify-between text-sm text-gray-500 mt-1">
<span>$0</span>
<span className="font-medium text-red-600">
{precioMaximo !== null ? `$${precioMaximo}` : 'Desactivado'}
</span>
<span>${Math.round(equilibrio.P)}</span>
</div>
</div>
<div className="border-t border-gray-200 pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Precio Mínimo (Pmín)
</label>
<input
type="range"
min={Math.round(equilibrio.P)}
max="100"
value={precioMinimo ?? Math.round(equilibrio.P)}
onChange={(e) => {
setPrecioMinimo(Number(e.target.value) || null);
setPrecioMaximo(null);
setHasInteracted(true);
}}
className="w-full accent-amber-500"
/>
<div className="flex justify-between text-sm text-gray-500 mt-1">
<span>${Math.round(equilibrio.P)}</span>
<span className="font-medium text-amber-600">
{precioMinimo !== null ? `$${precioMinimo}` : 'Desactivado'}
</span>
<span>$100</span>
</div>
</div>
</div>
</div>
<AnimatePresence mode="wait">
<motion.div
key={analisis.tipo}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className={`p-4 rounded-lg border ${
analisis.tipo === 'equilibrio'
? 'bg-green-50 border-green-200'
: analisis.tipo === 'precio-maximo'
? 'bg-red-50 border-red-200'
: 'bg-amber-50 border-amber-200'
}`}
>
<div className="flex items-center gap-2 mb-3">
{analisis.tipo === 'equilibrio' ? (
<TrendingUp className="w-5 h-5 text-green-600" />
) : analisis.tipo === 'precio-maximo' ? (
<TrendingDown className="w-5 h-5 text-red-600" />
) : (
<AlertTriangle className="w-5 h-5 text-amber-600" />
)}
<h4 className={`font-semibold ${
analisis.tipo === 'equilibrio'
? 'text-green-800'
: analisis.tipo === 'precio-maximo'
? 'text-red-800'
: 'text-amber-800'
}`}>
{analisis.mensaje}
</h4>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-white rounded p-2">
<span className="text-gray-500">Precio de equilibrio:</span>
<p className="font-semibold text-gray-800">${equilibrio.P.toFixed(1)}</p>
</div>
<div className="bg-white rounded p-2">
<span className="text-gray-500">Cantidad de equilibrio:</span>
<p className="font-semibold text-gray-800">{equilibrio.Q.toFixed(1)} unidades</p>
</div>
{precioMaximo !== null && (
<div className="bg-white rounded p-2">
<span className="text-red-500">Precio máximo:</span>
<p className="font-semibold text-gray-800">${precioMaximo}</p>
</div>
)}
{precioMinimo !== null && (
<div className="bg-white rounded p-2">
<span className="text-amber-500">Precio mínimo:</span>
<p className="font-semibold text-gray-800">${precioMinimo}</p>
</div>
)}
{analisis.excesoDemanda > 0 && (
<div className="bg-red-100 rounded p-2 col-span-2">
<span className="text-red-700">Exceso de demanda (escasez):</span>
<p className="font-semibold text-red-800">{analisis.excesoDemanda.toFixed(1)} unidades</p>
</div>
)}
{analisis.excesoOferta > 0 && (
<div className="bg-amber-100 rounded p-2 col-span-2">
<span className="text-amber-700">Exceso de oferta (superávit):</span>
<p className="font-semibold text-amber-800">{analisis.excesoOferta.toFixed(1)} unidades</p>
</div>
)}
{analisis.deadweightLoss > 0 && (
<div className="bg-red-100 rounded p-2 col-span-2">
<span className="text-red-700">Pérdida de peso muerto:</span>
<p className="font-semibold text-red-800">${analisis.deadweightLoss.toFixed(1)}</p>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
<div className="p-4 bg-purple-50 rounded-lg">
<h4 className="font-semibold text-purple-800 mb-2">Resultado</h4>
<p className="text-sm text-purple-700">
Cantidad transada: <span className="font-bold">{analisis.cantidadTransada.toFixed(1)} unidades</span>
</p>
<p className="text-xs text-purple-600 mt-1">
{analisis.tipo === 'precio-maximo'
? 'Con precio máximo, los vendedores quieren vender menos cantidad.'
: analisis.tipo === 'precio-minimo'
? 'Con precio mínimo, los compradores quieren comprar menos cantidad.'
: 'En equilibrio, la cantidad demandada = cantidad ofrecida.'}
</p>
</div>
</div>
</div>
</div>
);
};
export default SimuladorPrecios;

View File

@@ -0,0 +1,3 @@
export { ConstructorCurvas } from './ConstructorCurvas';
export { SimuladorPrecios } from './SimuladorPrecios';
export { IdentificarShocks } from './IdentificarShocks';

View File

@@ -0,0 +1,266 @@
import { useState, useEffect, useCallback } from 'react';
import { Button } from '../../ui/Button';
import { Input } from '../../ui/Input';
import { Card, CardHeader } from '../../ui/Card';
type ElasticidadTipo = 'precio' | 'ingreso' | 'cruzada';
interface CalculadoraElasticidadProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
export function CalculadoraElasticidad({ ejercicioId: _ejercicioId, onComplete }: CalculadoraElasticidadProps) {
const [tipo, setTipo] = useState<ElasticidadTipo>('precio');
const [p1, setP1] = useState('');
const [p2, setP2] = useState('');
const [q1, setQ1] = useState('');
const [q2, setQ2] = useState('');
const [error, setError] = useState('');
const [resultado, setResultado] = useState<{
deltaQ: number;
deltaP: number;
qPromedio: number;
pPromedio: number;
porcentajeQ: number;
porcentajeP: number;
elasticidad: number;
interpretacion: string;
} | null>(null);
const calcular = useCallback(() => {
const numP1 = parseFloat(p1);
const numP2 = parseFloat(p2);
const numQ1 = parseFloat(q1);
const numQ2 = parseFloat(q2);
if (isNaN(numP1) || isNaN(numP2) || isNaN(numQ1) || isNaN(numQ2)) {
setError('Todos los valores deben ser numéricos');
setResultado(null);
return;
}
if (numP1 === numP2 && tipo === 'precio') {
setError('P1 y P2 no pueden ser iguales para calcular elasticidad');
setResultado(null);
return;
}
setError('');
// Método del punto medio
const deltaQ = numQ2 - numQ1;
const deltaP = numP2 - numP1;
const qPromedio = (numQ1 + numQ2) / 2;
const pPromedio = (numP1 + numP2) / 2;
const porcentajeQ = (deltaQ / qPromedio) * 100;
const porcentajeP = (deltaP / pPromedio) * 100;
const elasticidad = porcentajeQ / porcentajeP;
let interpretacion = '';
const absE = Math.abs(elasticidad);
if (tipo === 'precio') {
if (absE > 1) interpretacion = 'Demanda ELÁSTICA: |E| > 1. El consumo responde más que proporcionalmente al cambio de precio.';
else if (absE < 1) interpretacion = 'Demanda INELÁSTICA: |E| < 1. El consumo responde menos que proporcionalmente al cambio de precio.';
else interpretacion = 'Demanda UNITARIA: |E| = 1. El consumo responde exactamente proporcional al cambio de precio.';
} else if (tipo === 'ingreso') {
if (elasticidad > 1) interpretacion = 'Bien de LUJO: Ei > 1. El gasto en el bien aumenta más que proporcionalmente al ingreso.';
else if (elasticidad > 0 && elasticidad < 1) interpretacion = 'Bien NECESARIO: 0 < Ei < 1. El gasto aumenta menos que proporcionalmente al ingreso.';
else if (elasticidad < 0) interpretacion = 'Bien INFERIOR: Ei < 0. El consumo disminuye cuando aumenta el ingreso.';
else interpretacion = 'Bien NEUTRO: Ei = 0. El consumo no cambia con el ingreso.';
} else {
if (elasticidad > 0) interpretacion = 'BIENES SUSTITUTOS: Ecr > 0. El aumento del precio de Y aumenta la demanda de X.';
else if (elasticidad < 0) interpretacion = 'BIENES COMPLEMENTARIOS: Ecr < 0. El aumento del precio de Y disminuye la demanda de X.';
else interpretacion = 'BIENES INDEPENDIENTES: Ecr = 0. No existe relación entre los bienes.';
}
setResultado({
deltaQ,
deltaP,
qPromedio,
pPromedio,
porcentajeQ,
porcentajeP,
elasticidad,
interpretacion
});
if (onComplete) {
onComplete(100);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [p1, p2, q1, q2, tipo]);
useEffect(() => {
if (p1 && p2 && q1 && q2) {
calcular();
}
}, [p1, p2, q1, q2, tipo, calcular]);
const getLabelPrecio = () => {
if (tipo === 'cruzada') return 'P1/Py1 (Precio del otro bien)';
return 'P1 (Precio inicial)';
};
const getLabelCantidad = () => {
if (tipo === 'ingreso') return 'Q1 (Cantidad con ingreso I1)';
return 'Q1 (Cantidad inicial)';
};
const tipoLabels: Record<ElasticidadTipo, string> = {
precio: 'Elasticidad Precio de la Demanda',
ingreso: 'Elasticidad Ingreso',
cruzada: 'Elasticidad Cruzada'
};
return (
<Card className="max-w-2xl mx-auto">
<CardHeader
title="Calculadora de Elasticidad"
subtitle="Método del punto medio (Arco)"
/>
<div className="space-y-6">
<div className="flex flex-wrap gap-2">
{(['precio', 'ingreso', 'cruzada'] as ElasticidadTipo[]).map((t) => (
<Button
key={t}
variant={tipo === t ? 'primary' : 'outline'}
size="sm"
onClick={() => setTipo(t)}
>
{tipoLabels[t]}
</Button>
))}
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<p className="text-sm text-blue-800 font-medium">{tipoLabels[tipo]}</p>
<p className="text-xs text-blue-600 mt-1">
{tipo === 'precio' && 'Mide la sensibilidad de la cantidad demandada ante cambios en el precio del propio bien'}
{tipo === 'ingreso' && 'Mide la sensibilidad de la cantidad demandada ante cambios en el ingreso del consumidor'}
{tipo === 'cruzada' && 'Mide la sensibilidad de la cantidad demandada de X ante cambios en el precio de Y'}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={getLabelCantidad()}
type="number"
value={q1}
onChange={(e) => setQ1(e.target.value)}
placeholder="Ej: 100"
/>
<Input
label={tipo === 'ingreso' ? 'Q2 (Cantidad con ingreso I2)' : 'Q2 (Cantidad final)'}
type="number"
value={q2}
onChange={(e) => setQ2(e.target.value)}
placeholder="Ej: 80"
/>
<Input
label={getLabelPrecio()}
type="number"
value={p1}
onChange={(e) => setP1(e.target.value)}
placeholder="Ej: 10"
/>
<Input
label={tipo === 'cruzada' ? 'P2/Py2 (Precio final del otro bien)' : 'P2 (Precio final)'}
type="number"
value={p2}
onChange={(e) => setP2(e.target.value)}
placeholder="Ej: 12"
/>
</div>
{error && (
<p className="text-red-500 text-sm bg-red-50 p-3 rounded-lg">{error}</p>
)}
{resultado && (
<div className="space-y-4">
<div className="bg-gray-50 p-4 rounded-lg space-y-3">
<h4 className="font-semibold text-gray-700">Desarrollo paso a paso:</h4>
<div className="space-y-2 text-sm">
<div className="bg-white p-3 rounded border">
<p className="font-medium text-gray-600">Paso 1: Calcular cambios</p>
<p className="font-mono text-gray-800">
ΔQ = Q2 - Q1 = {q2} - {q1} = {resultado.deltaQ.toFixed(2)}
</p>
<p className="font-mono text-gray-800">
ΔP = P2 - P1 = {p2} - {p1} = {resultado.deltaP.toFixed(2)}
</p>
</div>
<div className="bg-white p-3 rounded border">
<p className="font-medium text-gray-600">Paso 2: Calcular promedios</p>
<p className="font-mono text-gray-800">
= (Q1 + Q2) / 2 = ({q1} + {q2}) / 2 = {resultado.qPromedio.toFixed(2)}
</p>
<p className="font-mono text-gray-800">
= (P1 + P2) / 2 = ({p1} + {p2}) / 2 = {resultado.pPromedio.toFixed(2)}
</p>
</div>
<div className="bg-white p-3 rounded border">
<p className="font-medium text-gray-600">Paso 3: Calcular variaciones porcentuales</p>
<p className="font-mono text-gray-800">
%ΔQ = (ΔQ / ) × 100 = ({resultado.deltaQ.toFixed(2)} / {resultado.qPromedio.toFixed(2)}) × 100 = {resultado.porcentajeQ.toFixed(2)}%
</p>
<p className="font-mono text-gray-800">
%ΔP = (ΔP / ) × 100 = ({resultado.deltaP.toFixed(2)} / {resultado.pPromedio.toFixed(2)}) × 100 = {resultado.porcentajeP.toFixed(2)}%
</p>
</div>
<div className="bg-blue-100 p-3 rounded border border-blue-300">
<p className="font-medium text-blue-800">Paso 4: Calcular elasticidad</p>
<p className="font-mono text-blue-900 text-lg">
E = %ΔQ / %ΔP = {resultado.porcentajeQ.toFixed(2)} / {resultado.porcentajeP.toFixed(2)} = <strong>{resultado.elasticidad.toFixed(4)}</strong>
</p>
</div>
</div>
</div>
<div className={`p-4 rounded-lg ${
tipo === 'precio'
? (Math.abs(resultado.elasticidad) > 1
? 'bg-green-100 border border-green-300'
: Math.abs(resultado.elasticidad) < 1
? 'bg-orange-100 border border-orange-300'
: 'bg-blue-100 border border-blue-300')
: tipo === 'ingreso'
? (resultado.elasticidad > 1
? 'bg-purple-100 border border-purple-300'
: resultado.elasticidad > 0
? 'bg-yellow-100 border border-yellow-300'
: 'bg-red-100 border border-red-300')
: (resultado.elasticidad > 0
? 'bg-green-100 border border-green-300'
: 'bg-red-100 border border-red-300')
}`}>
<p className="font-semibold text-gray-800">Interpretación:</p>
<p className="text-gray-700 mt-1">{resultado.interpretacion}</p>
{tipo === 'precio' && Math.abs(resultado.elasticidad) > 1 && (
<p className="text-sm text-gray-600 mt-2">
El ingreso total aumentará si se reduce el precio (efecto cantidad domina).
</p>
)}
{tipo === 'precio' && Math.abs(resultado.elasticidad) < 1 && (
<p className="text-sm text-gray-600 mt-2">
El ingreso total aumentará si se aumenta el precio (efecto precio domina).
</p>
)}
</div>
</div>
)}
</div>
</Card>
);
}
export default CalculadoraElasticidad;

View File

@@ -0,0 +1,228 @@
import { useState } from 'react';
import { Button } from '../../ui/Button';
import { Card, CardHeader } from '../../ui/Card';
interface Bien {
id: string;
nombre: string;
descripcion: string;
elasticidad: number;
categoriaCorrecta: Categoria;
}
type Categoria = 'lujo' | 'necesario' | 'inferior';
interface ClasificadorBienesProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
const bienes: Bien[] = [
{ id: '1', nombre: 'Caviar', descripcion: 'Alimento de lujo', elasticidad: 3.5, categoriaCorrecta: 'lujo' },
{ id: '2', nombre: 'Arroz', descripcion: 'Grano básico de consumo', elasticidad: 0.3, categoriaCorrecta: 'necesario' },
{ id: '3', nombre: 'Viajes en primera clase', descripcion: 'Transporte de lujo', elasticidad: 2.8, categoriaCorrecta: 'lujo' },
{ id: '4', nombre: 'Pasta de dientes', descripcion: 'Higiene personal básica', elasticidad: 0.15, categoriaCorrecta: 'necesario' },
{ id: '5', nombre: 'Autobuses', descripcion: 'Transporte público urbano', elasticidad: -0.5, categoriaCorrecta: 'inferior' },
{ id: '6', nombre: 'Frijoles', descripcion: 'Proteína básica', elasticidad: 0.4, categoriaCorrecta: 'necesario' },
{ id: '7', nombre: 'Yates privados', descripcion: 'Embarcaciones recreativas', elasticidad: 4.2, categoriaCorrecta: 'lujo' },
{ id: '8', nombre: 'Pan de bagazo', descripcion: 'Pan económico de baja calidad', elasticidad: -0.8, categoriaCorrecta: 'inferior' },
{ id: '9', nombre: 'Sal', descripcion: 'Condimento esencial', elasticidad: 0.05, categoriaCorrecta: 'necesario' },
{ id: '10', nombre: 'Joyería fina', descripcion: 'Accesorios de oro/plata', elasticidad: 2.2, categoriaCorrecta: 'lujo' },
{ id: '11', nombre: 'Comida rápida barata', descripcion: 'Hamburguesas de bajo costo', elasticidad: -0.3, categoriaCorrecta: 'inferior' },
{ id: '12', nombre: 'Medicinas genéricas', descripcion: 'Productos farmacéuticos básicos', elasticidad: 0.2, categoriaCorrecta: 'necesario' }
];
const categorias: { id: Categoria; nombre: string; descripcion: string; rango: string; color: string }[] = [
{
id: 'lujo',
nombre: 'Bienes de Lujo',
descripcion: 'El gasto aumenta más que proporcionalmente al ingreso',
rango: 'Ei > 1',
color: 'bg-purple-100 border-purple-300 text-purple-800'
},
{
id: 'necesario',
nombre: 'Bienes Necesarios',
descripcion: 'El gasto aumenta menos que proporcionalmente al ingreso',
rango: '0 < Ei < 1',
color: 'bg-blue-100 border-blue-300 text-blue-800'
},
{
id: 'inferior',
nombre: 'Bienes Inferiores',
descripcion: 'El consumo disminuye cuando aumenta el ingreso',
rango: 'Ei < 0',
color: 'bg-red-100 border-red-300 text-red-800'
}
];
export function ClasificadorBienes({ ejercicioId: _ejercicioId, onComplete }: ClasificadorBienesProps) {
const [clasificaciones, setClasificaciones] = useState<Record<string, Categoria | null>>({});
const [mostrarResultados, setMostrarResultados] = useState(false);
const [bienesActuales] = useState(() =>
[...bienes].sort(() => Math.random() - 0.5).slice(0, 8)
);
const seleccionarCategoria = (bienId: string, categoria: Categoria) => {
setClasificaciones(prev => ({
...prev,
[bienId]: categoria
}));
};
const verificarResultados = () => {
setMostrarResultados(true);
const correctas = bienesActuales.filter(
bien => clasificaciones[bien.id] === bien.categoriaCorrecta
).length;
const score = Math.round((correctas / bienesActuales.length) * 100);
if (onComplete) {
onComplete(score);
}
return score;
};
const reiniciar = () => {
setClasificaciones({});
setMostrarResultados(false);
};
const getEstadoBien = (bien: Bien) => {
if (!mostrarResultados || !clasificaciones[bien.id]) {
return 'bg-white border-gray-200';
}
const esCorrecto = clasificaciones[bien.id] === bien.categoriaCorrecta;
return esCorrecto
? 'bg-green-50 border-green-400'
: 'bg-red-50 border-red-400';
};
const getIconoEstado = (bien: Bien) => {
if (!mostrarResultados || !clasificaciones[bien.id]) return null;
const esCorrecto = clasificaciones[bien.id] === bien.categoriaCorrecta;
return esCorrecto ? (
<span className="text-green-600 text-xl"></span>
) : (
<span className="text-red-600 text-xl"></span>
);
};
const puntuacion = mostrarResultados
? bienesActuales.filter(bien => clasificaciones[bien.id] === bien.categoriaCorrecta).length
: 0;
return (
<Card className="max-w-4xl mx-auto">
<CardHeader
title="Clasificador de Bienes"
subtitle="Clasifica cada bien según su elasticidad ingreso"
/>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{categorias.map((cat) => (
<div key={cat.id} className={`p-4 rounded-lg border-2 ${cat.color}`}>
<h4 className="font-bold text-lg">{cat.nombre}</h4>
<p className="text-sm font-mono font-semibold">{cat.rango}</p>
<p className="text-xs mt-1 opacity-80">{cat.descripcion}</p>
</div>
))}
</div>
<div className="space-y-3">
{bienesActuales.map((bien) => (
<div
key={bien.id}
className={`p-4 rounded-lg border-2 transition-all ${getEstadoBien(bien)}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-gray-800">{bien.nombre}</h4>
{getIconoEstado(bien)}
</div>
<p className="text-sm text-gray-500">{bien.descripcion}</p>
{mostrarResultados && (
<p className="text-xs font-mono mt-1 text-gray-600">
Ei = {bien.elasticidad} {categorias.find(c => c.id === bien.categoriaCorrecta)?.nombre}
</p>
)}
</div>
<div className="flex gap-2">
{categorias.map((cat) => (
<button
key={cat.id}
onClick={() => !mostrarResultados && seleccionarCategoria(bien.id, cat.id)}
disabled={mostrarResultados}
className={`px-3 py-2 rounded text-xs font-medium transition-all ${
clasificaciones[bien.id] === cat.id
? cat.color + ' border-2'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border-2 border-transparent'
} ${mostrarResultados ? 'cursor-default' : 'cursor-pointer'}`}
>
{cat.nombre.split(' ')[1]}
</button>
))}
</div>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-gray-600">
Progreso: {Object.keys(clasificaciones).length} / {bienesActuales.length}
</div>
{!mostrarResultados ? (
<Button
onClick={verificarResultados}
disabled={Object.keys(clasificaciones).length < bienesActuales.length}
>
Verificar Resultados
</Button>
) : (
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-gray-600">Puntuación</p>
<p className="text-2xl font-bold text-gray-800">
{puntuacion} / {bienesActuales.length}
</p>
</div>
<Button variant="outline" onClick={reiniciar}>
Reiniciar
</Button>
</div>
)}
</div>
{mostrarResultados && (
<div className={`p-4 rounded-lg ${
puntuacion === bienesActuales.length
? 'bg-green-100 border border-green-300'
: puntuacion >= bienesActuales.length / 2
? 'bg-yellow-100 border border-yellow-300'
: 'bg-red-100 border border-red-300'
}`}>
<p className="font-semibold text-gray-800">
{puntuacion === bienesActuales.length
? '¡Perfecto! Has clasificado todos los bienes correctamente.'
: puntuacion >= bienesActuales.length / 2
? '¡Buen trabajo! Sigue practicando para mejorar.'
: 'Necesitas más práctica. Revisa las categorías y vuelve a intentar.'}
</p>
</div>
)}
</div>
</Card>
);
}
export default ClasificadorBienes;

View File

@@ -0,0 +1,404 @@
import { useState } from 'react';
import { Button } from '../../ui/Button';
import { Input } from '../../ui/Input';
import { Card, CardHeader } from '../../ui/Card';
interface Problema {
id: number;
titulo: string;
descripcion: string;
datos: { etiqueta: string; valor: string }[];
preguntas: {
id: string;
texto: string;
tipo: 'numero' | 'seleccion';
opciones?: string[];
respuestaCorrecta: number | string;
tolerancia?: number;
solucion: string[];
}[];
}
const problemas: Problema[] = [
{
id: 1,
titulo: "Elasticidad Precio de la Demanda",
descripcion: "Una tienda de electrónica observa que cuando el precio de un modelo de laptop aumenta de $800 a $900, la cantidad demandada disminuye de 500 a 400 unidades por mes.",
datos: [
{ etiqueta: "P1", valor: "$800" },
{ etiqueta: "P2", valor: "$900" },
{ etiqueta: "Q1", valor: "500 unidades" },
{ etiqueta: "Q2", valor: "400 unidades" }
],
preguntas: [
{
id: "p1_a",
texto: "Calcule la elasticidad precio de la demanda usando el método del punto medio.",
tipo: "numero",
respuestaCorrecta: -1.89,
tolerancia: 0.05,
solucion: [
"Paso 1: ΔQ = 400 - 500 = -100",
"Paso 2: ΔP = 900 - 800 = 100",
"Paso 3: Q̄ = (500 + 400) / 2 = 450",
"Paso 4: P̄ = (800 + 900) / 2 = 850",
"Paso 5: %ΔQ = (-100 / 450) × 100 = -22.22%",
"Paso 6: %ΔP = (100 / 850) × 100 = 11.76%",
"Paso 7: Ed = -22.22% / 11.76% = -1.89"
]
},
{
id: "p1_b",
texto: "¿Qué tipo de demanda presenta este producto?",
tipo: "seleccion",
opciones: [
"Elástica (|Ed| > 1)",
"Inelástica (|Ed| < 1)",
"Unitaria (|Ed| = 1)"
],
respuestaCorrecta: "Elástica (|Ed| > 1)",
solucion: [
"Como |Ed| = 1.89 > 1, la demanda es ELÁSTICA.",
"Esto significa que los consumidores son sensibles al cambio de precio.",
"Un aumento de precio del 1% reduce la cantidad demandada en aproximadamente 1.89%"
]
}
]
},
{
id: 2,
titulo: "Elasticidad Ingreso",
descripcion: "En una economía, cuando el ingreso promedio de los hogares aumenta de $2,000 a $2,500 mensuales, el consumo de restaurantes de alta categoría aumenta de 2 a 4 visitas mensuales por hogar.",
datos: [
{ etiqueta: "I1", valor: "$2,000" },
{ etiqueta: "I2", valor: "$2,500" },
{ etiqueta: "Q1", valor: "2 visitas" },
{ etiqueta: "Q2", valor: "4 visitas" }
],
preguntas: [
{
id: "p2_a",
texto: "Calcule la elasticidad ingreso.",
tipo: "numero",
respuestaCorrecta: 2.33,
tolerancia: 0.05,
solucion: [
"Paso 1: ΔQ = 4 - 2 = 2",
"Paso 2: ΔI = 2500 - 2000 = 500",
"Paso 3: Q̄ = (2 + 4) / 2 = 3",
"Paso 4: Ī = (2000 + 2500) / 2 = 2250",
"Paso 5: %ΔQ = (2 / 3) × 100 = 66.67%",
"Paso 6: %ΔI = (500 / 2250) × 100 = 22.22%",
"Paso 7: Ei = 66.67% / 22.22% = 3.00"
]
},
{
id: "p2_b",
texto: "¿Qué tipo de bien representa los restaurantes de alta categoría?",
tipo: "seleccion",
opciones: [
"Bien necesario (0 < Ei < 1)",
"Bien de lujo (Ei > 1)",
"Bien inferior (Ei < 0)"
],
respuestaCorrecta: "Bien de lujo (Ei > 1)",
solucion: [
"Como Ei = 3.00 > 1, se trata de un BIEN DE LUJO.",
"El gasto en este bien aumenta más que proporcionalmente al ingreso.",
"Cuando el ingreso crece 10%, el consumo de restaurantes crece 30%"
]
}
]
},
{
id: 3,
titulo: "Elasticidad Cruzada",
descripcion: "Cuando el precio del café aumenta de $4 a $6 por libra, la cantidad demandada de té aumenta de 100 a 150 libras mensuales en el mismo mercado.",
datos: [
{ etiqueta: "Pcafé1", valor: "$4/libra" },
{ etiqueta: "Pcafé2", valor: "$6/libra" },
{ etiqueta: "Qté1", valor: "100 libras" },
{ etiqueta: "Qté2", valor: "150 libras" }
],
preguntas: [
{
id: "p3_a",
texto: "Calcule la elasticidad cruzada entre café y té.",
tipo: "numero",
respuestaCorrecta: 1.0,
tolerancia: 0.05,
solucion: [
"Paso 1: ΔQté = 150 - 100 = 50",
"Paso 2: ΔPcafé = 6 - 4 = 2",
"Paso 3: Qté̄ = (100 + 150) / 2 = 125",
"Paso 4: Pcafé̄ = (4 + 6) / 2 = 5",
"Paso 5: %ΔQté = (50 / 125) × 100 = 40%",
"Paso 6: %ΔPcafé = (2 / 5) × 100 = 40%",
"Paso 7: Ecr = 40% / 40% = 1.0"
]
},
{
id: "p3_b",
texto: "¿Qué relación existe entre café y té?",
tipo: "seleccion",
opciones: [
"Son bienes sustitutos (Ecr > 0)",
"Son bienes complementarios (Ecr < 0)",
"Son bienes independientes (Ecr = 0)"
],
respuestaCorrecta: "Son bienes sustitutos (Ecr > 0)",
solucion: [
"Como Ecr = 1.0 > 0, café y té son BIENES SUSTITUTOS.",
"Cuando sube el precio del café, los consumidores compran más té.",
"Los consumidores pueden sustituir uno por otro según los precios"
]
}
]
}
];
interface Respuesta {
valor: string;
esCorrecta: boolean | null;
}
interface EjerciciosExamenProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
export function EjerciciosExamen({ ejercicioId: _ejercicioId, onComplete }: EjerciciosExamenProps) {
const [respuestas, setRespuestas] = useState<Record<string, Respuesta>>({});
const [mostrarSolucion, setMostrarSolucion] = useState<Record<number, boolean>>({});
const [problemaActual, setProblemaActual] = useState(0);
const handleRespuesta = (preguntaId: string, valor: string) => {
setRespuestas(prev => ({
...prev,
[preguntaId]: { valor, esCorrecta: null }
}));
};
const verificarRespuesta = (pregunta: Problema['preguntas'][0]) => {
const respuesta = respuestas[pregunta.id];
if (!respuesta) return;
let esCorrecta = false;
if (pregunta.tipo === 'numero') {
const valorNum = parseFloat(respuesta.valor);
const tolerancia = pregunta.tolerancia || 0.05;
esCorrecta = Math.abs(valorNum - (pregunta.respuestaCorrecta as number)) <= tolerancia;
} else {
esCorrecta = respuesta.valor === pregunta.respuestaCorrecta;
}
setRespuestas(prev => ({
...prev,
[pregunta.id]: { ...respuesta, esCorrecta }
}));
};
const toggleSolucion = (problemaId: number) => {
setMostrarSolucion(prev => ({
...prev,
[problemaId]: !prev[problemaId]
}));
};
const calcularPuntuacion = () => {
let correctas = 0;
let total = 0;
problemas.forEach(problema => {
problema.preguntas.forEach(pregunta => {
total++;
if (respuestas[pregunta.id]?.esCorrecta) {
correctas++;
}
});
});
return Math.round((correctas / total) * 100);
};
const finalizarExamen = () => {
const score = calcularPuntuacion();
if (onComplete) {
onComplete(score);
}
return score;
};
const problema = problemas[problemaActual];
const progreso = ((problemaActual + 1) / problemas.length) * 100;
return (
<Card className="max-w-3xl mx-auto">
<CardHeader
title="Ejercicios Tipo Examen"
subtitle={`Problema ${problemaActual + 1} de ${problemas.length}`}
/>
<div className="mb-4">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${progreso}%` }}
/>
</div>
</div>
<div className="space-y-6">
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-bold text-lg text-blue-900">{problema.titulo}</h3>
<p className="text-gray-700 mt-2">{problema.descripcion}</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mt-4">
{problema.datos.map((dato, idx) => (
<div key={idx} className="bg-white p-2 rounded text-center">
<span className="font-mono text-sm font-bold">{dato.etiqueta}</span>
<p className="text-xs text-gray-600">{dato.valor}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
{problema.preguntas.map((pregunta, idx) => {
const respuesta = respuestas[pregunta.id];
const estado = respuesta?.esCorrecta;
return (
<div key={pregunta.id} className="border rounded-lg p-4">
<div className="flex items-start gap-2">
<span className="bg-primary text-white rounded-full w-6 h-6 flex items-center justify-center text-sm flex-shrink-0">
{idx + 1}
</span>
<p className="text-gray-800 font-medium">{pregunta.texto}</p>
</div>
<div className="mt-3 ml-8">
{pregunta.tipo === 'numero' ? (
<div className="flex gap-2">
<Input
type="number"
step="0.01"
value={respuesta?.valor || ''}
onChange={(e) => handleRespuesta(pregunta.id, e.target.value)}
className="w-48"
placeholder="Respuesta numérica"
/>
<Button
variant="outline"
size="sm"
onClick={() => verificarRespuesta(pregunta)}
disabled={!respuesta?.valor}
>
Verificar
</Button>
</div>
) : (
<div className="space-y-2">
{pregunta.opciones?.map((opcion) => (
<label
key={opcion}
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
respuesta?.valor === opcion
? 'bg-blue-100 border-blue-300 border'
: 'hover:bg-gray-50 border border-transparent'
}`}
>
<input
type="radio"
name={pregunta.id}
value={opcion}
checked={respuesta?.valor === opcion}
onChange={(e) => handleRespuesta(pregunta.id, e.target.value)}
className="text-primary"
/>
<span className="text-sm">{opcion}</span>
</label>
))}
<Button
variant="outline"
size="sm"
onClick={() => verificarRespuesta(pregunta)}
disabled={!respuesta?.valor}
>
Verificar
</Button>
</div>
)}
{estado !== null && (
<div className={`mt-3 p-3 rounded ${
estado
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{estado ? '¡Correcto!' : 'Incorrecto. Intenta de nuevo.'}
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => toggleSolucion(parseInt(pregunta.id.split('_')[0]))}
className="mt-2"
>
{mostrarSolucion[parseInt(pregunta.id.split('_')[0])] ? 'Ocultar' : 'Ver'} solución
</Button>
{mostrarSolucion[parseInt(pregunta.id.split('_')[0])] && (
<div className="mt-2 bg-gray-50 p-3 rounded text-sm">
<p className="font-semibold mb-2">Solución paso a paso:</p>
<ul className="space-y-1 text-gray-700">
{pregunta.solucion.map((paso, i) => (
<li key={i} className="font-mono text-xs">{paso}</li>
))}
</ul>
</div>
)}
</div>
</div>
);
})}
</div>
<div className="flex justify-between pt-4 border-t">
<Button
variant="outline"
onClick={() => setProblemaActual(Math.max(0, problemaActual - 1))}
disabled={problemaActual === 0}
>
Anterior
</Button>
{problemaActual < problemas.length - 1 ? (
<Button
onClick={() => setProblemaActual(problemaActual + 1)}
>
Siguiente
</Button>
) : (
<Button
onClick={finalizarExamen}
variant="primary"
>
Finalizar Examen
</Button>
)}
</div>
{problemaActual === problemas.length - 1 && (
<div className="bg-gray-50 p-4 rounded-lg text-center">
<p className="text-gray-600">Puntuación actual: <strong>{calcularPuntuacion()}%</strong></p>
</div>
)}
</div>
</Card>
);
}
export default EjerciciosExamen;

View File

@@ -0,0 +1,3 @@
export { CalculadoraElasticidad } from './CalculadoraElasticidad';
export { ClasificadorBienes } from './ClasificadorBienes';
export { EjerciciosExamen } from './EjerciciosExamen';

View File

@@ -0,0 +1,328 @@
import { useState, useMemo } from 'react';
import { Card, CardHeader } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { CheckCircle, RotateCcw, Calculator } from 'lucide-react';
interface FilaCostos {
q: number;
cv: number;
}
interface FilaCalculada extends FilaCostos {
cf: number;
ct: number;
cfme: number;
cvme: number;
cme: number;
cmg: number | null;
}
interface CalculadoraCostosProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
export function CalculadoraCostos({ ejercicioId: _ejercicioId, onComplete }: CalculadoraCostosProps) {
const CF_BASE = 200;
const [filas, setFilas] = useState<FilaCostos[]>([
{ q: 0, cv: 0 },
{ q: 1, cv: 50 },
{ q: 2, cv: 90 },
{ q: 3, cv: 120 },
{ q: 4, cv: 160 },
{ q: 5, cv: 220 },
{ q: 6, cv: 300 },
{ q: 7, cv: 400 },
{ q: 8, cv: 520 },
]);
const [validado, setValidado] = useState(false);
const [errores, setErrores] = useState<string[]>([]);
const datosCalculados: FilaCalculada[] = useMemo(() => {
return filas.map((fila, index) => {
const ct = CF_BASE + fila.cv;
const cfme = fila.q > 0 ? CF_BASE / fila.q : 0;
const cvme = fila.q > 0 ? fila.cv / fila.q : 0;
const cme = fila.q > 0 ? ct / fila.q : 0;
const cmg = index > 0 ? ct - (CF_BASE + filas[index - 1].cv) : null;
return {
...fila,
cf: CF_BASE,
ct,
cfme,
cvme,
cme,
cmg,
};
});
}, [filas]);
const handleCvChange = (index: number, valor: string) => {
const numValor = parseFloat(valor) || 0;
const nuevasFilas = [...filas];
nuevasFilas[index] = { ...nuevasFilas[index], cv: numValor };
setFilas(nuevasFilas);
setValidado(false);
};
const validarCalculos = () => {
const nuevosErrores: string[] = [];
datosCalculados.forEach((fila, index) => {
if (fila.ct !== fila.cf + fila.cv) {
nuevosErrores.push(`Fila ${index + 1}: CT no coincide con CF + CV`);
}
if (fila.q > 0 && Math.abs(fila.cme - fila.ct / fila.q) > 0.01) {
nuevosErrores.push(`Fila ${index + 1}: CMe calculado incorrectamente`);
}
});
setErrores(nuevosErrores);
setValidado(true);
if (nuevosErrores.length === 0) {
if (onComplete) {
onComplete(100);
}
}
};
const reiniciar = () => {
setFilas([
{ q: 0, cv: 0 },
{ q: 1, cv: 50 },
{ q: 2, cv: 90 },
{ q: 3, cv: 120 },
{ q: 4, cv: 160 },
{ q: 5, cv: 220 },
{ q: 6, cv: 300 },
{ q: 7, cv: 400 },
{ q: 8, cv: 520 },
]);
setValidado(false);
setErrores([]);
};
const maxCT = Math.max(...datosCalculados.map(d => d.ct));
const maxCMe = Math.max(...datosCalculados.filter(d => d.q > 0).map(d => d.cme));
const maxCMg = Math.max(...datosCalculados.filter(d => d.cmg !== null).map(d => d.cmg || 0));
const escalaCT = maxCT > 0 ? 150 / maxCT : 1;
const escalaCMe = maxCMe > 0 ? 150 / maxCMe : 1;
const escalaCMg = maxCMg > 0 ? 150 / maxCMg : 1;
return (
<div className="space-y-6">
<Card>
<CardHeader
title="Calculadora de Costos"
subtitle="Ingresa los Costos Variables (CV) y observa los cálculos automáticos"
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b">
<th className="px-3 py-2 text-left font-medium text-gray-700">Q</th>
<th className="px-3 py-2 text-left font-medium text-gray-700">CF</th>
<th className="px-3 py-2 text-left font-medium text-gray-700 bg-blue-50">CV</th>
<th className="px-3 py-2 text-left font-medium text-gray-700">CT</th>
<th className="px-3 py-2 text-left font-medium text-gray-700">CFMe</th>
<th className="px-3 py-2 text-left font-medium text-gray-700">CVMe</th>
<th className="px-3 py-2 text-left font-medium text-gray-700">CMe</th>
<th className="px-3 py-2 text-left font-medium text-gray-700">CMg</th>
</tr>
</thead>
<tbody>
{datosCalculados.map((fila, index) => (
<tr key={index} className="border-b hover:bg-gray-50">
<td className="px-3 py-2 font-medium">{fila.q}</td>
<td className="px-3 py-2 text-gray-600">{fila.cf}</td>
<td className="px-3 py-2 bg-blue-50">
<input
type="number"
value={fila.cv}
onChange={(e) => handleCvChange(index, e.target.value)}
className="w-20 px-2 py-1 border rounded text-sm focus:ring-2 focus:ring-primary focus:border-transparent"
min="0"
disabled={fila.q === 0}
/>
</td>
<td className="px-3 py-2 font-medium text-primary">{fila.ct}</td>
<td className="px-3 py-2 text-gray-600">
{fila.q > 0 ? fila.cfme.toFixed(2) : '-'}
</td>
<td className="px-3 py-2 text-gray-600">
{fila.q > 0 ? fila.cvme.toFixed(2) : '-'}
</td>
<td className="px-3 py-2 font-medium text-secondary">
{fila.q > 0 ? fila.cme.toFixed(2) : '-'}
</td>
<td className="px-3 py-2 font-medium text-success">
{fila.cmg !== null ? fila.cmg : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex gap-3">
<Button onClick={validarCalculos} variant="primary">
<Calculator className="w-4 h-4 mr-2" />
Validar Cálculos
</Button>
<Button onClick={reiniciar} variant="outline">
<RotateCcw className="w-4 h-4 mr-2" />
Reiniciar
</Button>
</div>
{validado && errores.length === 0 && (
<div className="mt-4 p-4 bg-success/10 border border-success rounded-lg">
<div className="flex items-center gap-2 text-success">
<CheckCircle className="w-5 h-5" />
<span className="font-medium">¡Todos los cálculos son correctos!</span>
</div>
</div>
)}
{validado && errores.length > 0 && (
<div className="mt-4 p-4 bg-error/10 border border-error rounded-lg">
<p className="font-medium text-error mb-2">Se encontraron errores:</p>
<ul className="list-disc list-inside text-sm text-error">
{errores.map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
)}
</Card>
<Card>
<CardHeader
title="Visualización de Curvas de Costos"
subtitle="Gráfico de CT, CMe y CMg"
/>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Costo Total (CT)</h4>
<div className="h-40 bg-gray-50 rounded-lg p-4 relative">
<svg className="w-full h-full" viewBox="0 0 400 160">
<line x1="30" y1="140" x2="380" y2="140" stroke="#374151" strokeWidth="1" />
<line x1="30" y1="140" x2="30" y2="10" stroke="#374151" strokeWidth="1" />
<text x="200" y="155" textAnchor="middle" className="text-xs fill-gray-500">Cantidad (Q)</text>
<text x="10" y="75" textAnchor="middle" className="text-xs fill-gray-500" transform="rotate(-90 10 75)">CT</text>
{datosCalculados.map((d, i) => (
<text key={i} x={30 + i * 40} y="150" textAnchor="middle" className="text-xs fill-gray-500">
{d.q}
</text>
))}
<polyline
fill="none"
stroke="#2563eb"
strokeWidth="2"
points={datosCalculados.map((d, i) => `${30 + i * 40},${140 - d.ct * escalaCT}`).join(' ')}
/>
{datosCalculados.map((d, i) => (
<circle
key={i}
cx={30 + i * 40}
cy={140 - d.ct * escalaCT}
r="4"
fill="#2563eb"
/>
))}
</svg>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Costo Medio (CMe) vs Costo Marginal (CMg)</h4>
<div className="h-40 bg-gray-50 rounded-lg p-4 relative">
<svg className="w-full h-full" viewBox="0 0 400 160">
<line x1="30" y1="140" x2="380" y2="140" stroke="#374151" strokeWidth="1" />
<line x1="30" y1="140" x2="30" y2="10" stroke="#374151" strokeWidth="1" />
<text x="200" y="155" textAnchor="middle" className="text-xs fill-gray-500">Cantidad (Q)</text>
<text x="10" y="75" textAnchor="middle" className="text-xs fill-gray-500" transform="rotate(-90 10 75)">Costo</text>
{datosCalculados.filter(d => d.q > 0).map((d, i) => (
<text key={i} x={70 + i * 40} y="150" textAnchor="middle" className="text-xs fill-gray-500">
{d.q}
</text>
))}
<polyline
fill="none"
stroke="#7c3aed"
strokeWidth="2"
points={datosCalculados
.filter(d => d.q > 0)
.map((d, i) => `${70 + i * 40},${140 - d.cme * escalaCMe}`)
.join(' ')}
/>
<polyline
fill="none"
stroke="#16a34a"
strokeWidth="2"
strokeDasharray="4"
points={datosCalculados
.filter(d => d.cmg !== null)
.map((d, i) => `${70 + i * 40},${140 - (d.cmg || 0) * escalaCMg}`)
.join(' ')}
/>
{datosCalculados.filter(d => d.q > 0).map((d, i) => (
<circle
key={`cme-${i}`}
cx={70 + i * 40}
cy={140 - d.cme * escalaCMe}
r="4"
fill="#7c3aed"
/>
))}
{datosCalculados.filter(d => d.cmg !== null).map((d, i) => (
<circle
key={`cmg-${i}`}
cx={70 + i * 40}
cy={140 - (d.cmg || 0) * escalaCMg}
r="4"
fill="#16a34a"
/>
))}
<g transform="translate(280, 30)">
<line x1="0" y1="0" x2="20" y2="0" stroke="#7c3aed" strokeWidth="2" />
<text x="25" y="4" className="text-xs fill-gray-600">CMe</text>
<line x1="0" y1="15" x2="20" y2="15" stroke="#16a34a" strokeWidth="2" strokeDasharray="4" />
<text x="25" y="19" className="text-xs fill-gray-600">CMg</text>
</g>
</svg>
</div>
</div>
</div>
</Card>
<Card className="bg-blue-50 border-blue-200">
<h4 className="font-semibold text-blue-900 mb-2">Fórmulas utilizadas:</h4>
<ul className="space-y-1 text-sm text-blue-800">
<li><strong>CT</strong> = CF + CV (Costo Total)</li>
<li><strong>CFMe</strong> = CF / Q (Costo Fijo Medio)</li>
<li><strong>CVMe</strong> = CV / Q (Costo Variable Medio)</li>
<li><strong>CMe</strong> = CT / Q (Costo Medio)</li>
<li><strong>CMg</strong> = ΔCT / ΔQ (Costo Marginal)</li>
</ul>
</Card>
</div>
);
}
export default CalculadoraCostos;

View File

@@ -0,0 +1,318 @@
import { useState, useMemo } from 'react';
import { Card, CardHeader } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { Input } from '../../ui/Input';
import { CheckCircle, Target, TrendingUp, DollarSign } from 'lucide-react';
interface FilaProduccion {
q: number;
ct: number;
}
interface FilaCalculada {
q: number;
precio: number;
it: number;
ct: number;
bt: number;
img: number | null;
cmg: number | null;
}
interface SimuladorProduccionProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
export function SimuladorProduccion({ ejercicioId: _ejercicioId, onComplete }: SimuladorProduccionProps) {
const [precio, setPrecio] = useState(80);
const datosBase: FilaProduccion[] = [
{ q: 0, ct: 200 },
{ q: 1, ct: 250 },
{ q: 2, ct: 290 },
{ q: 3, ct: 320 },
{ q: 4, ct: 360 },
{ q: 5, ct: 420 },
{ q: 6, ct: 500 },
{ q: 7, ct: 600 },
{ q: 8, ct: 720 },
];
const datosCalculados: FilaCalculada[] = useMemo(() => {
return datosBase.map((fila, index) => {
const it = precio * fila.q;
const bt = it - fila.ct;
const img = index > 0 ? precio : null;
const cmg = index > 0 ? fila.ct - datosBase[index - 1].ct : null;
return {
q: fila.q,
precio,
it,
ct: fila.ct,
bt,
img,
cmg,
};
});
}, [precio]);
const qOptima = useMemo(() => {
let maxBT = -Infinity;
let qOpt = 0;
datosCalculados.forEach((fila) => {
if (fila.bt > maxBT) {
maxBT = fila.bt;
qOpt = fila.q;
}
});
return qOpt;
}, [datosCalculados]);
const verificacionIMgCMg = useMemo(() => {
const filasValidas = datosCalculados.filter(f => f.img !== null && f.cmg !== null);
const filaOptima = filasValidas.find(f => f.q === qOptima);
if (!filaOptima) return null;
return {
img: filaOptima.img,
cmg: filaOptima.cmg,
diferencia: Math.abs((filaOptima.img || 0) - (filaOptima.cmg || 0)),
cumple: Math.abs((filaOptima.img || 0) - (filaOptima.cmg || 0)) < 5,
};
}, [datosCalculados, qOptima]);
const maxValor = Math.max(
...datosCalculados.map(d => Math.max(d.it, d.ct, d.bt > 0 ? d.bt : 0))
);
const escala = maxValor > 0 ? 140 / maxValor : 1;
const handleCompletar = () => {
if (onComplete) {
onComplete(100);
}
return 100;
};
return (
<div className="space-y-6">
<Card>
<CardHeader
title="Simulador de Decisión de Producción"
subtitle="Encuentra la cantidad óptima que maximiza el beneficio"
/>
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
<label className="block text-sm font-medium text-gray-700 mb-2">
Precio de Mercado (P)
</label>
<div className="flex items-center gap-4">
<DollarSign className="w-5 h-5 text-gray-400" />
<Input
type="number"
value={precio}
onChange={(e) => setPrecio(parseFloat(e.target.value) || 0)}
className="w-32"
min="0"
/>
<span className="text-sm text-gray-500">
Ajusta el precio para ver cómo cambia la decisión óptima
</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b">
<th className="px-3 py-2 text-left font-medium text-gray-700">Q</th>
<th className="px-3 py-2 text-left font-medium text-gray-700">Precio (P)</th>
<th className="px-3 py-2 text-left font-medium text-primary">IT = P × Q</th>
<th className="px-3 py-2 text-left font-medium text-gray-700">CT</th>
<th className="px-3 py-2 text-left font-medium text-success">BT = IT - CT</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">IMg</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">CMg</th>
</tr>
</thead>
<tbody>
{datosCalculados.map((fila) => (
<tr
key={fila.q}
className={`border-b hover:bg-gray-50 ${
fila.q === qOptima ? 'bg-green-50' : ''
}`}
>
<td className="px-3 py-2 font-medium">{fila.q}</td>
<td className="px-3 py-2">{fila.precio}</td>
<td className="px-3 py-2 font-medium text-primary">{fila.it}</td>
<td className="px-3 py-2">{fila.ct}</td>
<td className={`px-3 py-2 font-bold ${fila.bt >= 0 ? 'text-success' : 'text-error'}`}>
{fila.bt}
</td>
<td className="px-3 py-2 text-gray-600">
{fila.img !== null ? fila.img : '-'}
</td>
<td className="px-3 py-2 text-gray-600">
{fila.cmg !== null ? fila.cmg : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-3">
<Target className="w-6 h-6 text-green-600" />
<div>
<p className="font-semibold text-green-800">
Cantidad Óptima: Q = {qOptima}
</p>
<p className="text-sm text-green-700">
Beneficio Máximo: BT = {datosCalculados.find(d => d.q === qOptima)?.bt}
{' '}(${precio} × {qOptima} - {datosCalculados.find(d => d.q === qOptima)?.ct})
</p>
</div>
</div>
</div>
{verificacionIMgCMg && (
<div className={`mt-4 p-4 rounded-lg ${
verificacionIMgCMg.cumple
? 'bg-success/10 border border-success'
: 'bg-yellow-50 border border-yellow-200'
}`}>
<div className="flex items-center gap-2">
{verificacionIMgCMg.cumple ? (
<CheckCircle className="w-5 h-5 text-success" />
) : (
<TrendingUp className="w-5 h-5 text-yellow-600" />
)}
<span className={`font-medium ${
verificacionIMgCMg.cumple ? 'text-success' : 'text-yellow-800'
}`}>
Verificación IMg CMg:
</span>
</div>
<p className="mt-1 text-sm text-gray-600">
IMg = {verificacionIMgCMg.img}, CMg = {verificacionIMgCMg.cmg}
{' '}(Diferencia: {verificacionIMgCMg.diferencia.toFixed(1)})
</p>
<p className="mt-1 text-sm text-gray-500">
{verificacionIMgCMg.cumple
? '✓ La condición de optimalidad se cumple: IMg ≈ CMg'
: 'La diferencia es significativa, pero el beneficio sigue siendo máximo en Q = ' + qOptima}
</p>
</div>
)}
</Card>
<Card>
<CardHeader
title="Gráfico de IT y CT"
subtitle="Visualiza el punto donde la distancia entre IT y CT es máxima"
/>
<div className="h-64 bg-gray-50 rounded-lg p-4">
<svg className="w-full h-full" viewBox="0 0 500 220">
<line x1="50" y1="190" x2="480" y2="190" stroke="#374151" strokeWidth="1" />
<line x1="50" y1="190" x2="50" y2="20" stroke="#374151" strokeWidth="1" />
<text x="265" y="210" textAnchor="middle" className="text-sm fill-gray-600">Cantidad (Q)</text>
<text x="20" y="105" textAnchor="middle" className="text-sm fill-gray-600" transform="rotate(-90 20 105)">$</text>
{datosCalculados.map((d, i) => (
<text key={i} x={80 + i * 45} y="205" textAnchor="middle" className="text-xs fill-gray-500">
{d.q}
</text>
))}
<polyline
fill="none"
stroke="#2563eb"
strokeWidth="3"
points={datosCalculados.map((d, i) => `${80 + i * 45},${190 - d.it * escala}`).join(' ')}
/>
<polyline
fill="none"
stroke="#dc2626"
strokeWidth="3"
points={datosCalculados.map((d, i) => `${80 + i * 45},${190 - d.ct * escala}`).join(' ')}
/>
{datosCalculados.map((d, i) => (
<g key={i}>
<circle
cx={80 + i * 45}
cy={190 - d.it * escala}
r="5"
fill="#2563eb"
/>
<circle
cx={80 + i * 45}
cy={190 - d.ct * escala}
r="5"
fill="#dc2626"
/>
</g>
))}
<g transform={`translate(${80 + datosCalculados.findIndex(d => d.q === qOptima) * 45}, ${
190 - (datosCalculados.find(d => d.q === qOptima)?.it || 0) * escala - 20
})`}>
<polygon points="0,0 -8,-15 8,-15" fill="#16a34a" />
<text x="0" y="-20" textAnchor="middle" className="text-xs fill-green-600 font-bold">
Óptimo Q={qOptima}
</text>
</g>
<g transform="translate(380, 40)">
<line x1="0" y1="0" x2="30" y2="0" stroke="#2563eb" strokeWidth="3" />
<text x="40" y="5" className="text-sm fill-gray-700">IT (Ingreso Total)</text>
<line x1="0" y1="20" x2="30" y2="20" stroke="#dc2626" strokeWidth="3" />
<text x="40" y="25" className="text-sm fill-gray-700">CT (Costo Total)</text>
</g>
</svg>
</div>
</Card>
<Card className="bg-gradient-to-r from-green-50 to-blue-50">
<h4 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
Conceptos Clave
</h4>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<p className="font-medium text-gray-700 mb-1">Ingreso Total (IT)</p>
<p className="text-gray-600">IT = P × Q</p>
</div>
<div>
<p className="font-medium text-gray-700 mb-1">Beneficio Total (BT)</p>
<p className="text-gray-600">BT = IT - CT</p>
</div>
<div>
<p className="font-medium text-gray-700 mb-1">Ingreso Marginal (IMg)</p>
<p className="text-gray-600">IMg = ΔIT / ΔQ = P (en competencia perfecta)</p>
</div>
<div>
<p className="font-medium text-gray-700 mb-1">Condición de Optimalidad</p>
<p className="text-gray-600">IMg = CMg (producir hasta que el ingreso marginal iguale al costo marginal)</p>
</div>
</div>
</Card>
<div className="flex justify-end">
<Button onClick={handleCompletar} size="lg">
<CheckCircle className="w-5 h-5 mr-2" />
Marcar como Completado
</Button>
</div>
</div>
);
}
export default SimuladorProduccion;

View File

@@ -0,0 +1,344 @@
import { useState, useMemo } from 'react';
import { Card, CardHeader } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { Input } from '../../ui/Input';
import { CheckCircle, Info, TrendingUp } from 'lucide-react';
interface VisualizadorExcedentesProps {
ejercicioId: string;
onComplete?: (puntuacion: number) => void;
}
export function VisualizadorExcedentes({ ejercicioId: _ejercicioId, onComplete }: VisualizadorExcedentesProps) {
const [precio, setPrecio] = useState(50);
const demandaParams = { a: 100, b: 1 };
const ofertaParams = { c: 10, d: 0.8 };
const puntoEquilibrio = useMemo(() => {
const { a, b } = demandaParams;
const { c, d } = ofertaParams;
const pEq = (a - c) / (b + d);
const qEq = a - b * pEq;
return { pEq, qEq };
}, []);
const datosCurvas = useMemo(() => {
const puntos = [];
const { a, b } = demandaParams;
const { c, d } = ofertaParams;
for (let q = 0; q <= 100; q += 5) {
const pDemanda = (a - q) / b;
const pOferta = q > 0 ? (q - c) / d : 0;
puntos.push({ q, pDemanda: Math.max(0, pDemanda), pOferta: Math.max(0, pOferta) });
}
return puntos;
}, []);
const excedentes = useMemo(() => {
const { a } = demandaParams;
const { c } = ofertaParams;
const qAlPrecio = Math.max(0, a - demandaParams.b * precio);
const qOfrecida = Math.max(0, c + ofertaParams.d * precio);
const excedenteConsumidor = 0.5 * qAlPrecio * (a - precio);
const excedenteProductor = 0.5 * qOfrecida * (precio - c);
return {
ec: excedenteConsumidor,
ep: excedenteProductor,
total: excedenteConsumidor + excedenteProductor,
qAlPrecio,
qOfrecida,
};
}, [precio]);
const excedentesEquilibrio = useMemo(() => {
const { pEq, qEq } = puntoEquilibrio;
const { a } = demandaParams;
const { c } = ofertaParams;
const ec = 0.5 * qEq * (a - pEq);
const ep = 0.5 * qEq * (pEq - c);
return { ec, ep, total: ec + ep };
}, [puntoEquilibrio]);
const maxP = 100;
const maxQ = 100;
const escalaX = 350 / maxQ;
const escalaY = 180 / maxP;
const handleCompletar = () => {
if (onComplete) {
onComplete(100);
}
return 100;
};
return (
<div className="space-y-6">
<Card>
<CardHeader
title="Visualizador de Excedentes"
subtitle="Ajusta el precio para ver cómo cambian los excedentes del consumidor y productor"
/>
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
<label className="block text-sm font-medium text-gray-700 mb-2">
Precio de Mercado (P)
</label>
<div className="flex items-center gap-4">
<Input
type="range"
min="20"
max="90"
value={precio}
onChange={(e) => setPrecio(parseFloat(e.target.value))}
className="flex-1"
/>
<span className="text-2xl font-bold text-primary min-w-[80px]">${precio}</span>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>$20</span>
<span>Precio de equilibrio: ${puntoEquilibrio.pEq.toFixed(1)}</span>
<span>$90</span>
</div>
</div>
<div className="relative h-80 bg-gray-50 rounded-lg p-4 overflow-hidden">
<svg className="w-full h-full" viewBox="0 0 400 220" preserveAspectRatio="xMidYMid meet">
<line x1="50" y1="200" x2="380" y2="200" stroke="#374151" strokeWidth="2" />
<line x1="50" y1="200" x2="50" y2="20" stroke="#374151" strokeWidth="2" />
<text x="215" y="215" textAnchor="middle" className="text-sm fill-gray-700 font-medium">Cantidad (Q)</text>
<text x="15" y="110" textAnchor="middle" className="text-sm fill-gray-700 font-medium" transform="rotate(-90 15 110)">Precio (P)</text>
{[0, 25, 50, 75, 100].map((q) => (
<g key={q}>
<line
x1={50 + q * escalaX}
y1="200"
x2={50 + q * escalaX}
y2="205"
stroke="#374151"
strokeWidth="1"
/>
<text
x={50 + q * escalaX}
y="215"
textAnchor="middle"
className="text-xs fill-gray-500"
>
{q}
</text>
</g>
))}
{[0, 25, 50, 75, 100].map((p) => (
<g key={p}>
<line
x1="45"
y1={200 - p * escalaY}
x2="50"
y2={200 - p * escalaY}
stroke="#374151"
strokeWidth="1"
/>
<text
x="40"
y={200 - p * escalaY + 4}
textAnchor="end"
className="text-xs fill-gray-500"
>
{p}
</text>
</g>
))}
<line
x1="50"
y1={200 - precio * escalaY}
x2="380"
y2={200 - precio * escalaY}
stroke="#7c3aed"
strokeWidth="2"
strokeDasharray="5,5"
opacity="0.7"
/>
<text x="385" y={200 - precio * escalaY + 4} className="text-xs fill-purple-600 font-medium">
P = {precio}
</text>
{precio > puntoEquilibrio.pEq && (
<polygon
points={`
50,${200 - precio * escalaY}
${50 + excedentes.qAlPrecio * escalaX},${200 - precio * escalaY}
${50 + excedentes.qAlPrecio * escalaX},${200 - ((100 - excedentes.qAlPrecio)) * escalaY}
`}
fill="rgba(37, 99, 235, 0.3)"
stroke="#2563eb"
strokeWidth="1"
/>
)}
{precio < puntoEquilibrio.pEq && (
<polygon
points={`
50,${200 - 12.5 * escalaY}
${50 + excedentes.qOfrecida * escalaX},${200 - precio * escalaY}
50,${200 - precio * escalaY}
`}
fill="rgba(22, 163, 74, 0.3)"
stroke="#16a34a"
strokeWidth="1"
/>
)}
{Math.abs(precio - puntoEquilibrio.pEq) < 2 && (
<>
<polygon
points={`
50,${200 - puntoEquilibrio.pEq * escalaY}
${50 + puntoEquilibrio.qEq * escalaX},${200 - puntoEquilibrio.pEq * escalaY}
${50 + puntoEquilibrio.qEq * escalaX},${200 - 100 * escalaY}
`}
fill="rgba(37, 99, 235, 0.3)"
stroke="#2563eb"
strokeWidth="1"
/>
<polygon
points={`
50,${200 - 12.5 * escalaY}
${50 + puntoEquilibrio.qEq * escalaX},${200 - puntoEquilibrio.pEq * escalaY}
50,${200 - puntoEquilibrio.pEq * escalaY}
`}
fill="rgba(22, 163, 74, 0.3)"
stroke="#16a34a"
strokeWidth="1"
/>
</>
)}
<polyline
fill="none"
stroke="#2563eb"
strokeWidth="3"
points={datosCurvas.map(d => `${50 + d.q * escalaX},${200 - d.pDemanda * escalaY}`).join(' ')}
/>
<polyline
fill="none"
stroke="#16a34a"
strokeWidth="3"
points={datosCurvas.filter(d => d.pOferta >= 0).map(d => `${50 + d.q * escalaX},${200 - d.pOferta * escalaY}`).join(' ')}
/>
<circle
cx={50 + puntoEquilibrio.qEq * escalaX}
cy={200 - puntoEquilibrio.pEq * escalaY}
r="6"
fill="#dc2626"
stroke="white"
strokeWidth="2"
/>
<text
x={50 + puntoEquilibrio.qEq * escalaX}
y={200 - puntoEquilibrio.pEq * escalaY - 12}
textAnchor="middle"
className="text-xs fill-red-600 font-bold"
>
E
</text>
<g transform="translate(320, 40)">
<line x1="0" y1="0" x2="25" y2="0" stroke="#2563eb" strokeWidth="3" />
<text x="30" y="5" className="text-xs fill-gray-700">Demanda</text>
<line x1="0" y1="15" x2="25" y2="15" stroke="#16a34a" strokeWidth="3" />
<text x="30" y="20" className="text-xs fill-gray-700">Oferta</text>
<rect x="0" y="30" width="15" height="15" fill="rgba(37, 99, 235, 0.3)" stroke="#2563eb" />
<text x="20" y="42" className="text-xs fill-gray-700">EC</text>
<rect x="0" y="50" width="15" height="15" fill="rgba(22, 163, 74, 0.3)" stroke="#16a34a" />
<text x="20" y="62" className="text-xs fill-gray-700">EP</text>
</g>
</svg>
</div>
</Card>
<div className="grid md:grid-cols-3 gap-4">
<Card className="bg-blue-50 border-blue-200">
<div className="flex items-center gap-2 mb-2">
<div className="w-4 h-4 bg-blue-500/30 border border-blue-500 rounded" />
<h4 className="font-semibold text-blue-900">Excedente del Consumidor</h4>
</div>
<p className="text-3xl font-bold text-blue-700">${excedentes.ec.toFixed(0)}</p>
<p className="text-sm text-blue-600 mt-1">
Área bajo la curva de demanda y sobre el precio
</p>
</Card>
<Card className="bg-green-50 border-green-200">
<div className="flex items-center gap-2 mb-2">
<div className="w-4 h-4 bg-green-500/30 border border-green-500 rounded" />
<h4 className="font-semibold text-green-900">Excedente del Productor</h4>
</div>
<p className="text-3xl font-bold text-green-700">${excedentes.ep.toFixed(0)}</p>
<p className="text-sm text-green-600 mt-1">
Área sobre la curva de oferta y bajo el precio
</p>
</Card>
<Card className="bg-purple-50 border-purple-200">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-purple-900">Excedente Total</h4>
</div>
<p className="text-3xl font-bold text-purple-700">${excedentes.total.toFixed(0)}</p>
<p className="text-sm text-purple-600 mt-1">
EC + EP = Bienestar social total
</p>
</Card>
</div>
<Card className="bg-yellow-50 border-yellow-200">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<h4 className="font-semibold text-yellow-900 mb-2">En el Equilibrio de Mercado:</h4>
<div className="grid md:grid-cols-3 gap-4 text-sm">
<div>
<span className="text-yellow-800">Precio: </span>
<span className="font-bold">${puntoEquilibrio.pEq.toFixed(1)}</span>
</div>
<div>
<span className="text-yellow-800">Cantidad: </span>
<span className="font-bold">{puntoEquilibrio.qEq.toFixed(1)}</span>
</div>
<div>
<span className="text-yellow-800">Excedente Total: </span>
<span className="font-bold">${excedentesEquilibrio.total.toFixed(0)}</span>
</div>
</div>
<p className="mt-3 text-yellow-800 text-sm">
El equilibrio de mercado maximiza el bienestar social (excedente total).
Cualquier desviación del precio de equilibrio genera pérdida de eficiencia.
</p>
</div>
</div>
</Card>
<div className="flex justify-end">
<Button onClick={handleCompletar} size="lg">
<CheckCircle className="w-5 h-5 mr-2" />
Marcar como Completado
</Button>
</div>
</div>
);
}
export default VisualizadorExcedentes;

View File

@@ -0,0 +1,3 @@
export { CalculadoraCostos } from './CalculadoraCostos';
export { SimuladorProduccion } from './SimuladorProduccion';
export { VisualizadorExcedentes } from './VisualizadorExcedentes';