Initial commit - cleaned for CV
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Database
|
||||
DB_USER=econ_user
|
||||
DB_PASSWORD=change_this_password_in_production
|
||||
DB_NAME=econ_db
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-secret-key-change-in-production-min-32-chars
|
||||
|
||||
# Server
|
||||
SERVER_PORT=8080
|
||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# Dependencies
|
||||
frontend/node_modules/
|
||||
backend/vendor/
|
||||
|
||||
# Build outputs
|
||||
frontend/dist/
|
||||
backend/server
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
*.cover
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
113
README.md
Normal file
113
README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Plataforma de Aprendizaje de Economía
|
||||
|
||||
## 📚 Descripción
|
||||
Plataforma web interactiva para enseñar economía a través de 4 módulos basados en material académico PDF.
|
||||
|
||||
## 🎯 Objetivo
|
||||
Crear una experiencia de aprendizaje gamificada con ejercicios interactivos, visualizaciones dinámicas y seguimiento de progreso.
|
||||
|
||||
## 📖 Módulos Educativos
|
||||
|
||||
### Módulo 1: Fundamentos de Economía
|
||||
- **Temas**: Definición de economía, agentes económicos, factores de producción, flujo circular
|
||||
- **Ejercicios**: Simulador de disyuntivas, Quiz de clasificación de bienes, Juego del flujo circular
|
||||
|
||||
### Módulo 2: Oferta, Demanda y Equilibrio
|
||||
- **Temas**: Curvas de oferta/demanda, equilibrio de mercado, controles de precios
|
||||
- **Ejercicios**: Constructor de curvas interactivo, Simulador de precios máximos/mínimos
|
||||
|
||||
### Módulo 3: Utilidad y Elasticidad
|
||||
- **Temas**: Utilidad marginal, elasticidades, clasificación de bienes
|
||||
- **Ejercicios**: Calculadora de elasticidad, Ejercicios tipo examen, Clasificador de bienes
|
||||
|
||||
### Módulo 4: Teoría del Productor
|
||||
- **Temas**: Costos, producción, competencia perfecta, maximización de beneficios
|
||||
- **Ejercicios**: Simulador de decisión de producción, Calculadora de costos
|
||||
|
||||
## 🏗️ Arquitectura Técnica
|
||||
|
||||
### Stack Tecnológico
|
||||
- **Frontend**: React 18 + TypeScript + Tailwind CSS
|
||||
- **Visualización**: D3.js + Recharts
|
||||
- **Estado**: Zustand
|
||||
- **Routing**: React Router v6
|
||||
- **Build**: Vite
|
||||
- **Container**: Docker + Docker Compose
|
||||
|
||||
### Estructura de Carpetas
|
||||
```
|
||||
econ-learning/
|
||||
├── src/
|
||||
│ ├── components/ # Componentes reutilizables
|
||||
│ │ ├── charts/ # Gráficos interactivos
|
||||
│ │ ├── exercises/ # Ejercicios específicos
|
||||
│ │ └── ui/ # Componentes UI base
|
||||
│ ├── modules/ # Módulos educativos
|
||||
│ │ ├── clase1/
|
||||
│ │ ├── clase2/
|
||||
│ │ ├── clase3/
|
||||
│ │ └── clase4/
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ ├── stores/ # Estado global
|
||||
│ └── utils/ # Utilidades
|
||||
├── public/ # Assets estáticos
|
||||
├── docker/ # Configuración Docker
|
||||
└── docs/ # Documentación técnica
|
||||
```
|
||||
|
||||
## 🚀 Instrucciones de Despliegue
|
||||
|
||||
### Desarrollo Local
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
npm install
|
||||
|
||||
# Iniciar servidor de desarrollo
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Producción con Docker
|
||||
```bash
|
||||
# Construir imagen
|
||||
docker-compose up -d
|
||||
|
||||
# Ver logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## 📝 Roadmap
|
||||
|
||||
### Fase 1: Fundamentos (Semana 1-2)
|
||||
- [ ] Setup del proyecto con Vite + React + TS
|
||||
- [ ] Configuración de Docker
|
||||
- [ ] Componentes base UI
|
||||
- [ ] Estructura de routing
|
||||
|
||||
### Fase 2: Módulo 1 (Semana 3)
|
||||
- [ ] Contenido teórico del Módulo 1
|
||||
- [ ] Simulador de disyuntivas
|
||||
- [ ] Quiz de clasificación de bienes
|
||||
- [ ] Juego del flujo circular
|
||||
|
||||
### Fase 3: Módulo 2 (Semana 4)
|
||||
- [ ] Constructor de curvas interactivo
|
||||
- [ ] Simulador de precios
|
||||
- [ ] Ejercicios de equilibrio
|
||||
|
||||
### Fase 4: Módulos 3-4 (Semana 5-6)
|
||||
- [ ] Calculadora de elasticidad
|
||||
- [ ] Simulador de costos
|
||||
- [ ] Sistema de puntuación
|
||||
|
||||
### Fase 5: Pulido (Semana 7)
|
||||
- [ ] Tests
|
||||
- [ ] Optimización de rendimiento
|
||||
- [ ] Documentación final
|
||||
|
||||
## 🔧 Requisitos del Sistema
|
||||
- Node.js 18+
|
||||
- Docker (opcional)
|
||||
- Navegador moderno con soporte ES6+
|
||||
|
||||
## 📄 Licencia
|
||||
Proyecto educativo personal.
|
||||
33
backend/Dockerfile
Normal file
33
backend/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
|
||||
|
||||
# Final stage
|
||||
FROM alpine:3.19
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certificates for HTTPS
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /server .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the binary
|
||||
CMD ["./server"]
|
||||
258
backend/cmd/server/main.go
Normal file
258
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"github.com/ren/econ/backend/internal/config"
|
||||
"github.com/ren/econ/backend/internal/handlers"
|
||||
"github.com/ren/econ/backend/internal/middleware"
|
||||
"github.com/ren/econ/backend/internal/repository"
|
||||
"github.com/ren/econ/backend/internal/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load .env file if exists
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
// Connect to PostgreSQL
|
||||
connStr := fmt.Sprintf(
|
||||
"postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName,
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
dbPool, err := pgxpool.New(ctx, connStr)
|
||||
if err != nil {
|
||||
log.Fatalf("Error connecting to database: %v", err)
|
||||
}
|
||||
defer dbPool.Close()
|
||||
|
||||
// Test connection
|
||||
if err := dbPool.Ping(ctx); err != nil {
|
||||
log.Fatalf("Error pinging database: %v", err)
|
||||
}
|
||||
log.Println("Connected to PostgreSQL")
|
||||
|
||||
// Run migrations
|
||||
runMigrations(ctx, dbPool)
|
||||
|
||||
// Initialize repositories
|
||||
userRepo := repository.NewUserRepository(dbPool)
|
||||
progresoRepo := repository.NewProgresoRepository(dbPool)
|
||||
contenidoRepo := repository.NewContenidoRepository(dbPool)
|
||||
|
||||
// Initialize services
|
||||
authService := services.NewAuthService(cfg, userRepo)
|
||||
|
||||
// Initialize handlers
|
||||
authHandler := handlers.NewAuthHandler(authService)
|
||||
usersHandler := handlers.NewUsersHandler(userRepo, progresoRepo, authService)
|
||||
progresoHandler := handlers.NewProgresoHandler(progresoRepo)
|
||||
contenidoHandler := handlers.NewContenidoHandler(contenidoRepo)
|
||||
|
||||
// Setup Gin router
|
||||
router := gin.Default()
|
||||
|
||||
// CORS middleware
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Health check
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// API routes
|
||||
api := router.Group("/api")
|
||||
{
|
||||
// Auth routes (public)
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", authHandler.Login)
|
||||
auth.POST("/refresh", authHandler.RefreshToken)
|
||||
}
|
||||
|
||||
// Protected routes
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.AuthMiddleware(authService))
|
||||
{
|
||||
// Auth logout
|
||||
auth.POST("/auth/logout", authHandler.Logout)
|
||||
|
||||
// Progreso routes
|
||||
progreso := protected.Group("/progreso")
|
||||
{
|
||||
progreso.GET("", progresoHandler.GetProgreso)
|
||||
progreso.POST("", progresoHandler.SaveProgreso)
|
||||
progreso.GET("/resumen", progresoHandler.GetResumen)
|
||||
}
|
||||
|
||||
// Contenido routes
|
||||
contenido := protected.Group("/contenido")
|
||||
{
|
||||
contenido.GET("/modulos", contenidoHandler.GetModulos)
|
||||
contenido.GET("/modulos/:numero", contenidoHandler.GetModulo)
|
||||
}
|
||||
|
||||
// Admin routes (requires admin role)
|
||||
admin := protected.Group("/admin")
|
||||
admin.Use(middleware.AdminMiddleware())
|
||||
{
|
||||
admin.GET("/usuarios", usersHandler.ListUsers)
|
||||
admin.POST("/usuarios", usersHandler.CreateUser)
|
||||
admin.GET("/usuarios/:id", usersHandler.GetUser)
|
||||
admin.PUT("/usuarios/:id", usersHandler.UpdateUser)
|
||||
admin.DELETE("/usuarios/:id", usersHandler.DeleteUser)
|
||||
admin.GET("/usuarios/:id/progreso", usersHandler.GetUserProgreso)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start server
|
||||
port := getEnv("PORT", "8080")
|
||||
log.Printf("Server starting on port %s", port)
|
||||
if err := router.Run(":" + port); err != nil {
|
||||
log.Fatalf("Error starting server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runMigrations(ctx context.Context, dbPool *pgxpool.Pool) {
|
||||
// Inline migrations
|
||||
migrations := []string{
|
||||
`CREATE TABLE IF NOT EXISTS usuarios (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
nombre VARCHAR(100) NOT NULL,
|
||||
rol VARCHAR(20) DEFAULT 'estudiante' CHECK (rol IN ('admin', 'estudiante')),
|
||||
creado_en TIMESTAMP DEFAULT NOW(),
|
||||
ultimo_login TIMESTAMP,
|
||||
activo BOOLEAN DEFAULT TRUE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS progreso_usuario (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
usuario_id UUID REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||
modulo_numero INTEGER NOT NULL CHECK (modulo_numero BETWEEN 1 AND 4),
|
||||
ejercicio_id VARCHAR(50) NOT NULL,
|
||||
completado BOOLEAN DEFAULT FALSE,
|
||||
puntuacion INTEGER DEFAULT 0,
|
||||
intentos INTEGER DEFAULT 0,
|
||||
ultima_vez TIMESTAMP DEFAULT NOW(),
|
||||
respuesta_json JSONB,
|
||||
UNIQUE(usuario_id, modulo_numero, ejercicio_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS ejercicios (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
modulo_numero INTEGER NOT NULL,
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
contenido JSONB NOT NULL,
|
||||
orden INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
usuario_id UUID REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||
token VARCHAR(255) UNIQUE NOT NULL,
|
||||
expira TIMESTAMP NOT NULL,
|
||||
creado_en TIMESTAMP DEFAULT NOW()
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if _, err := dbPool.Exec(ctx, m); err != nil {
|
||||
log.Printf("Migration warning: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed ejercicios if empty
|
||||
var count int
|
||||
dbPool.QueryRow(ctx, "SELECT COUNT(*) FROM ejercicios").Scan(&count)
|
||||
if count == 0 {
|
||||
seedEjercicios(ctx, dbPool)
|
||||
}
|
||||
|
||||
// Create admin user if not exists
|
||||
var adminCount int
|
||||
dbPool.QueryRow(ctx, "SELECT COUNT(*) FROM usuarios WHERE rol = 'admin'").Scan(&adminCount)
|
||||
if adminCount == 0 {
|
||||
// Default admin: renato97 / wlillidan1 (bcrypt hash)
|
||||
_, err := dbPool.Exec(ctx, `
|
||||
INSERT INTO usuarios (email, username, password_hash, nombre, rol)
|
||||
VALUES ('renato97@econ.local', 'renato97', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZRGdjGj/n3.rsW4WzOFbMB3dHI.Bu', 'Renato', 'admin')
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not create admin user: %v", err)
|
||||
} else {
|
||||
log.Println("Admin user created: renato97 / wlillidan1")
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Migrations completed successfully")
|
||||
}
|
||||
|
||||
func seedEjercicios(ctx context.Context, pool *pgxpool.Pool) {
|
||||
ejercicios := []struct {
|
||||
ID string
|
||||
ModuloNumero int
|
||||
Titulo string
|
||||
Tipo string
|
||||
Contenido string
|
||||
Orden int
|
||||
}{
|
||||
// Módulo 1
|
||||
{"m1e1", 1, "Simulador de Disyuntivas", "interactivo", `{"tipo":"slider","descripcion":"Elige cuanto producir de cada bien"}`, 1},
|
||||
{"m1e2", 1, "Clasificación de Bienes", "quiz", `{"preguntas":[{"id":"q1","pregunta":"La leche es un bien...","opciones":["normal","inferior","de lujo"],"respuesta":"normal"}]}`, 2},
|
||||
{"m1e3", 1, "Flujo Circular", "dragdrop", `{"agentes":["familias","empresas"]}`, 3},
|
||||
// Módulo 2
|
||||
{"m2e1", 2, "Constructor de Curvas", "interactivo", `{"curvas":["oferta","demanda"]}`, 1},
|
||||
{"m2e2", 2, "Precios Máximos y Mínimos", "simulador", `{"tipo_precio":"seleccionar"}`, 2},
|
||||
{"m2e3", 2, "Shocks de Mercado", "quiz", `{"escenarios":[{"id":"s1","evento":"Sequía","pregunta":"¿Qué curva se ve afectada?","respuesta":"oferta"}]}`, 3},
|
||||
// Módulo 3
|
||||
{"m3e1", 3, "Calculadora de Elasticidad", "calculadora", `{"formula":"elasticidad"}`, 1},
|
||||
{"m3e2", 3, "Clasificador de Bienes", "quiz", `{"bienes":[{"nombre":"Pan","elasticidad":"inelastico"}]}`, 2},
|
||||
{"m3e3", 3, "Ejercicios tipo Examen", "quiz", `{"preguntas":[]}`, 3},
|
||||
// Módulo 4
|
||||
{"m4e1", 4, "Simulador de Producción", "simulador", `{"max_produccion":100}`, 1},
|
||||
{"m4e2", 4, "Calculadora de Costos", "calculadora", `{"tipos":["fijo","variable"]}`, 2},
|
||||
{"m4e3", 4, "Excedentes", "interactivo", `{"excedentes":["productor","consumidor"]}`, 3},
|
||||
}
|
||||
|
||||
for _, e := range ejercicios {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO ejercicios (id, modulo_numero, titulo, tipo, contenido, orden)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, e.ID, e.ModuloNumero, e.Titulo, e.Tipo, e.Contenido, e.Orden)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not seed ejercicio %s: %v", e.ID, err)
|
||||
}
|
||||
}
|
||||
log.Println("Ejercicios seeded")
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
44
backend/go.mod
Normal file
44
backend/go.mod
Normal file
@@ -0,0 +1,44 @@
|
||||
module github.com/ren/econ/backend
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/google/uuid v1.5.0
|
||||
github.com/jackc/pgx/v5 v5.5.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.18.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
110
backend/go.sum
Normal file
110
backend/go.sum
Normal file
@@ -0,0 +1,110 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.2 h1:iLlpgp4Cp/gC9Xuscl7lFL1PhhW+ZLtXZcrfCt4C3tA=
|
||||
github.com/jackc/pgx/v5 v5.5.2/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
47
backend/internal/config/config.go
Normal file
47
backend/internal/config/config.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DBHost string
|
||||
DBPort int
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
|
||||
JWTSecret string
|
||||
JWTExpirationHours int
|
||||
RefreshExpDays int
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnvAsInt("DB_PORT", 5432),
|
||||
DBUser: getEnv("DB_USER", "econ_user"),
|
||||
DBPassword: getEnv("DB_PASSWORD", "econ_pass"),
|
||||
DBName: getEnv("DB_NAME", "econ_db"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
|
||||
JWTExpirationHours: 15 * 60, // 15 minutes in minutes (900 minutes = 15 hours)
|
||||
RefreshExpDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
if intValue, err := strconv.Atoi(value); err == nil {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
126
backend/internal/handlers/auth.go
Normal file
126
backend/internal/handlers/auth.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/ren/econ/backend/internal/models"
|
||||
"github.com/ren/econ/backend/internal/services"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *services.AuthService
|
||||
telegramService *services.TelegramService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
telegramService: services.NewTelegramService(),
|
||||
}
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Iniciar sesión
|
||||
// @Description Autentica usuario y devuelve tokens JWT
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param login body models.LoginRequest true "Credenciales"
|
||||
// @Success 200 {object} models.LoginResponse
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/auth/login [post]
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.authService.Login(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrInvalidCredentials:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Credenciales inválidas"})
|
||||
case services.ErrUserInactive:
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Usuario inactivo"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error interno"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Notificación silenciosa a Telegram (solo para admin)
|
||||
go func() {
|
||||
_ = h.telegramService.SendLoginNotification(
|
||||
resp.User.Username,
|
||||
resp.User.Email,
|
||||
resp.User.Nombre,
|
||||
time.Now(),
|
||||
)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// RefreshToken godoc
|
||||
// @Summary Renovar token de acceso
|
||||
// @Description Renueva el token de acceso usando el refresh token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param refresh body models.RefreshRequest true "Refresh token"
|
||||
// @Success 200 {object} models.LoginResponse
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/auth/refresh [post]
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req models.RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrInvalidToken, services.ErrTokenExpired:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token inválido o expirado"})
|
||||
case services.ErrUserNotFound:
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Usuario no encontrado"})
|
||||
case services.ErrUserInactive:
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Usuario inactivo"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error interno"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Logout godoc
|
||||
// @Summary Cerrar sesión
|
||||
// @Description Cierra la sesión del usuario
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /api/auth/logout [post]
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.Logout(c.Request.Context(), userID.(uuid.UUID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al cerrar sesión"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Sesión cerrada exitosamente"})
|
||||
}
|
||||
71
backend/internal/handlers/contenido.go
Normal file
71
backend/internal/handlers/contenido.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ren/econ/backend/internal/models"
|
||||
"github.com/ren/econ/backend/internal/repository"
|
||||
)
|
||||
|
||||
type ContenidoHandler struct {
|
||||
contenidoRepo *repository.ContenidoRepository
|
||||
}
|
||||
|
||||
func NewContenidoHandler(contenidoRepo *repository.ContenidoRepository) *ContenidoHandler {
|
||||
return &ContenidoHandler{contenidoRepo: contenidoRepo}
|
||||
}
|
||||
|
||||
// GetModulos godoc
|
||||
// @Summary Listar módulos
|
||||
// @Description Lista todos los módulos disponibles
|
||||
// @Tags contenido
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} models.ModuloResumen
|
||||
// @Router /api/contenido/modulos [get]
|
||||
func (h *ContenidoHandler) GetModulos(c *gin.Context) {
|
||||
modulos, err := h.contenidoRepo.GetModulos(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener módulos"})
|
||||
return
|
||||
}
|
||||
|
||||
if modulos == nil {
|
||||
modulos = []models.ModuloResumen{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, modulos)
|
||||
}
|
||||
|
||||
// GetModulo godoc
|
||||
// @Summary Obtener contenido de módulo
|
||||
// @Description Obtiene el contenido de un módulo específico
|
||||
// @Tags contenido
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param numero path int true "Número del módulo"
|
||||
// @Success 200 {object} models.Modulo
|
||||
// @Router /api/contenido/modulos/{numero} [get]
|
||||
func (h *ContenidoHandler) GetModulo(c *gin.Context) {
|
||||
moduloNumero, err := strconv.Atoi(c.Param("numero"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Número de módulo inválido"})
|
||||
return
|
||||
}
|
||||
|
||||
modulo, err := h.contenidoRepo.GetModulo(c.Request.Context(), moduloNumero)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener módulo"})
|
||||
return
|
||||
}
|
||||
|
||||
if modulo == nil || len(modulo.Ejercicios) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Módulo no encontrado"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, modulo)
|
||||
}
|
||||
105
backend/internal/handlers/progreso.go
Normal file
105
backend/internal/handlers/progreso.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/ren/econ/backend/internal/models"
|
||||
"github.com/ren/econ/backend/internal/repository"
|
||||
)
|
||||
|
||||
type ProgresoHandler struct {
|
||||
progresoRepo *repository.ProgresoRepository
|
||||
}
|
||||
|
||||
func NewProgresoHandler(progresoRepo *repository.ProgresoRepository) *ProgresoHandler {
|
||||
return &ProgresoHandler{progresoRepo: progresoRepo}
|
||||
}
|
||||
|
||||
// GetProgreso godoc
|
||||
// @Summary Obtener todo el progreso
|
||||
// @Description Obtiene todo el progreso del usuario autenticado
|
||||
// @Tags progreso
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} models.Progreso
|
||||
// @Router /api/progreso [get]
|
||||
func (h *ProgresoHandler) GetProgreso(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"})
|
||||
return
|
||||
}
|
||||
|
||||
progresos, err := h.progresoRepo.GetProgresoByUsuarioID(userID.(uuid.UUID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener progreso"})
|
||||
return
|
||||
}
|
||||
|
||||
if progresos == nil {
|
||||
progresos = []models.Progreso{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, progresos)
|
||||
}
|
||||
|
||||
// SaveProgreso godoc
|
||||
// @Summary Guardar/actualizar progreso
|
||||
// @Description Guarda o actualiza el progreso de un ejercicio
|
||||
// @Tags progreso
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param progreso body models.Progreso true "Datos del progreso"
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /api/progreso [post]
|
||||
func (h *ProgresoHandler) SaveProgreso(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"})
|
||||
return
|
||||
}
|
||||
|
||||
var progreso models.Progreso
|
||||
if err := c.ShouldBindJSON(&progreso); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
progreso.UsuarioID = userID.(uuid.UUID)
|
||||
|
||||
err := h.progresoRepo.SaveProgreso(&progreso)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al guardar progreso: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Progreso guardado exitosamente"})
|
||||
}
|
||||
|
||||
// GetResumen godoc
|
||||
// @Summary Obtener resumen
|
||||
// @Description Obtiene estadísticas del progreso del usuario (puntos totales, etc.)
|
||||
// @Tags progreso
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.ResumenProgreso
|
||||
// @Router /api/progreso/resumen [get]
|
||||
func (h *ProgresoHandler) GetResumen(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "No autorizado"})
|
||||
return
|
||||
}
|
||||
|
||||
resumen, err := h.progresoRepo.GetResumen(userID.(uuid.UUID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener resumen"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resumen)
|
||||
}
|
||||
230
backend/internal/handlers/users.go
Normal file
230
backend/internal/handlers/users.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/ren/econ/backend/internal/models"
|
||||
"github.com/ren/econ/backend/internal/repository"
|
||||
"github.com/ren/econ/backend/internal/services"
|
||||
)
|
||||
|
||||
type UsersHandler struct {
|
||||
userRepo *repository.UserRepository
|
||||
progresoRepo *repository.ProgresoRepository
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
func NewUsersHandler(userRepo *repository.UserRepository, progresoRepo *repository.ProgresoRepository, authService *services.AuthService) *UsersHandler {
|
||||
return &UsersHandler{
|
||||
userRepo: userRepo,
|
||||
progresoRepo: progresoRepo,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// ListUsers godoc
|
||||
// @Summary Listar usuarios
|
||||
// @Description Lista todos los usuarios (solo admin)
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} models.Usuario
|
||||
// @Router /api/admin/usuarios [get]
|
||||
func (h *UsersHandler) ListUsers(c *gin.Context) {
|
||||
users, err := h.userRepo.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al listar usuarios"})
|
||||
return
|
||||
}
|
||||
|
||||
if users == nil {
|
||||
users = []models.Usuario{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
// CreateUser godoc
|
||||
// @Summary Crear usuario
|
||||
// @Description Crea un nuevo usuario (solo admin)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param usuario body models.UsuarioCreate true "Usuario a crear"
|
||||
// @Security BearerAuth
|
||||
// @Success 201 {object} models.Usuario
|
||||
// @Router /api/admin/usuarios [post]
|
||||
func (h *UsersHandler) CreateUser(c *gin.Context) {
|
||||
var req models.UsuarioCreate
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password if provided
|
||||
passwordHash := req.Password
|
||||
if passwordHash != "" {
|
||||
hash, err := h.authService.HashPassword(passwordHash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al hashear password"})
|
||||
return
|
||||
}
|
||||
passwordHash = hash
|
||||
}
|
||||
|
||||
user := &models.Usuario{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
PasswordHash: passwordHash,
|
||||
Nombre: req.Nombre,
|
||||
Rol: req.Rol,
|
||||
}
|
||||
|
||||
// Si no se proporciona email, generar uno automáticamente basado en el username
|
||||
if user.Email == "" {
|
||||
user.Email = req.Username + "@econ.local"
|
||||
}
|
||||
|
||||
if user.Rol == "" {
|
||||
user.Rol = "estudiante"
|
||||
}
|
||||
|
||||
err := h.userRepo.Create(c.Request.Context(), user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al crear usuario: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, user)
|
||||
}
|
||||
|
||||
// GetUser godoc
|
||||
// @Summary Obtener usuario
|
||||
// @Description Obtiene un usuario por ID (solo admin)
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "ID del usuario"
|
||||
// @Success 200 {object} models.Usuario
|
||||
// @Router /api/admin/usuarios/{id} [get]
|
||||
func (h *UsersHandler) GetUser(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userRepo.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Usuario no encontrado"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
// UpdateUser godoc
|
||||
// @Summary Actualizar usuario
|
||||
// @Description Actualiza un usuario (solo admin)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "ID del usuario"
|
||||
// @Param usuario body models.UsuarioUpdate true "Datos a actualizar"
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.Usuario
|
||||
// @Router /api/admin/usuarios/{id} [put]
|
||||
func (h *UsersHandler) UpdateUser(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UsuarioUpdate
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userRepo.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Usuario no encontrado"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
}
|
||||
if req.Nombre != "" {
|
||||
user.Nombre = req.Nombre
|
||||
}
|
||||
if req.Activo != nil {
|
||||
user.Activo = *req.Activo
|
||||
}
|
||||
|
||||
err = h.userRepo.Update(c.Request.Context(), user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al actualizar usuario"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
// DeleteUser godoc
|
||||
// @Summary Eliminar usuario
|
||||
// @Description Desactiva un usuario (solo admin)
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "ID del usuario"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /api/admin/usuarios/{id} [delete]
|
||||
func (h *UsersHandler) DeleteUser(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.userRepo.Delete(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al eliminar usuario"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Usuario desactivado exitosamente"})
|
||||
}
|
||||
|
||||
// GetUserProgreso godoc
|
||||
// @Summary Ver progreso de usuario
|
||||
// @Description Obtiene el progreso de un usuario (solo admin)
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "ID del usuario"
|
||||
// @Success 200 {array} models.Progreso
|
||||
// @Router /api/admin/usuarios/{id}/progreso [get]
|
||||
func (h *UsersHandler) GetUserProgreso(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"})
|
||||
return
|
||||
}
|
||||
|
||||
progresos, err := h.progresoRepo.GetProgresoByUsuarioID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error al obtener progreso"})
|
||||
return
|
||||
}
|
||||
|
||||
if progresos == nil {
|
||||
progresos = []models.Progreso{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, progresos)
|
||||
}
|
||||
51
backend/internal/middleware/auth.go
Normal file
51
backend/internal/middleware/auth.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ren/econ/backend/internal/services"
|
||||
)
|
||||
|
||||
func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header requerido"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Formato de authorization header inválido"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authService.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("user_email", claims.Email)
|
||||
c.Set("user_rol", claims.Rol)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rol, exists := c.Get("user_rol")
|
||||
if !exists || rol != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Acceso denegado - se requiere rol admin"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
23
backend/internal/models/contenido.go
Normal file
23
backend/internal/models/contenido.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
type Modulo struct {
|
||||
Numero int `json:"numero"`
|
||||
Titulo string `json:"titulo"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Ejercicios []Ejercicio `json:"ejercicios"`
|
||||
}
|
||||
|
||||
type Ejercicio struct {
|
||||
ID int `json:"id"`
|
||||
Numero int `json:"numero"`
|
||||
Titulo string `json:"titulo"`
|
||||
Tipo string `json:"tipo"`
|
||||
Contenido map[string]interface{} `json:"contenido"`
|
||||
Orden int `json:"orden"`
|
||||
}
|
||||
|
||||
type ModuloResumen struct {
|
||||
Numero int `json:"numero"`
|
||||
Titulo string `json:"titulo"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
}
|
||||
48
backend/internal/models/progreso.go
Normal file
48
backend/internal/models/progreso.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Progreso struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UsuarioID uuid.UUID `json:"usuario_id"`
|
||||
ModuloNumero int `json:"modulo_numero"`
|
||||
EjercicioID string `json:"ejercicio_id"`
|
||||
Completado bool `json:"completado"`
|
||||
Puntuacion int `json:"puntuacion"`
|
||||
Intentos int `json:"intentos"`
|
||||
UltimaVez time.Time `json:"ultima_vez"`
|
||||
}
|
||||
|
||||
type Badge struct {
|
||||
ID string `json:"id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
Icono string `json:"icono"`
|
||||
Desbloqueado bool `json:"desbloqueado"`
|
||||
}
|
||||
|
||||
type ResumenProgreso struct {
|
||||
PuntosTotales int `json:"puntos_totales"`
|
||||
EjerciciosCompletados int `json:"ejercicios_completados"`
|
||||
TotalEjercicios int `json:"total_ejercicios"`
|
||||
TotalPuntuacion int `json:"totalPuntuacion"`
|
||||
Badges []Badge `json:"badges"`
|
||||
Nivel string `json:"nivel"`
|
||||
}
|
||||
|
||||
type ProgresoUpdate struct {
|
||||
Completado bool `json:"completado"`
|
||||
Puntuacion int `json:"puntuacion"`
|
||||
RespuestaJSON string `json:"respuesta_json,omitempty"`
|
||||
}
|
||||
|
||||
type ProgresoResumen struct {
|
||||
TotalEjercicios int `json:"total_ejercicios"`
|
||||
EjerciciosCompletados int `json:"ejercicios_completados"`
|
||||
PromedioPuntuacion int `json:"promedio_puntuacion"`
|
||||
ModulosCompletados int `json:"modulos_completados"`
|
||||
}
|
||||
50
backend/internal/models/user.go
Normal file
50
backend/internal/models/user.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Usuario struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
Nombre string `json:"nombre"`
|
||||
Rol string `json:"rol"` // admin, estudiante
|
||||
CreadoEn time.Time `json:"creado_en"`
|
||||
UltimoLogin *time.Time `json:"ultimo_login"`
|
||||
Activo bool `json:"activo"`
|
||||
}
|
||||
|
||||
type UsuarioCreate struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Nombre string `json:"nombre" binding:"required"`
|
||||
Rol string `json:"rol"`
|
||||
}
|
||||
|
||||
type UsuarioUpdate struct {
|
||||
Email string `json:"email"`
|
||||
Nombre string `json:"nombre"`
|
||||
Activo *bool `json:"activo"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"` // seconds
|
||||
User *Usuario `json:"user"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
89
backend/internal/repository/contenido.go
Normal file
89
backend/internal/repository/contenido.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/ren/econ/backend/internal/models"
|
||||
)
|
||||
|
||||
type ContenidoRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewContenidoRepository(db *pgxpool.Pool) *ContenidoRepository {
|
||||
return &ContenidoRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ContenidoRepository) GetModulos(ctx context.Context) ([]models.ModuloResumen, error) {
|
||||
query := `
|
||||
SELECT DISTINCT modulo_numero, titulo, contenido::text
|
||||
FROM ejercicios
|
||||
ORDER BY modulo_numero
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var modulos []models.ModuloResumen
|
||||
for rows.Next() {
|
||||
var m models.ModuloResumen
|
||||
var contenidoJSON string
|
||||
err := rows.Scan(&m.Numero, &m.Titulo, &contenidoJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Extraer descripción del contenido JSON
|
||||
var contenido map[string]interface{}
|
||||
json.Unmarshal([]byte(contenidoJSON), &contenido)
|
||||
if desc, ok := contenido["descripcion"].(string); ok {
|
||||
m.Descripcion = desc
|
||||
} else {
|
||||
m.Descripcion = ""
|
||||
}
|
||||
modulos = append(modulos, m)
|
||||
}
|
||||
return modulos, nil
|
||||
}
|
||||
|
||||
func (r *ContenidoRepository) GetModulo(ctx context.Context, numero int) (*models.Modulo, error) {
|
||||
query := `
|
||||
SELECT id, titulo, tipo, contenido, orden
|
||||
FROM ejercicios WHERE modulo_numero = $1
|
||||
ORDER BY orden
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query, numero)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
modulo := &models.Modulo{Numero: numero}
|
||||
var ejercicios []models.Ejercicio
|
||||
|
||||
for rows.Next() {
|
||||
var e models.Ejercicio
|
||||
var contenidoJSON string
|
||||
err := rows.Scan(&e.ID, &e.Titulo, &e.Tipo, &contenidoJSON, &e.Orden)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal([]byte(contenidoJSON), &e.Contenido)
|
||||
e.Numero = e.ID
|
||||
ejercicios = append(ejercicios, e)
|
||||
}
|
||||
|
||||
if len(ejercicios) > 0 {
|
||||
modulo.Titulo = ejercicios[0].Titulo
|
||||
modulo.Ejercicios = ejercicios
|
||||
// Extraer descripción del primer ejercicio
|
||||
if desc, ok := ejercicios[0].Contenido["descripcion"].(string); ok {
|
||||
modulo.Descripcion = desc
|
||||
}
|
||||
}
|
||||
|
||||
return modulo, nil
|
||||
}
|
||||
122
backend/internal/repository/progreso.go
Normal file
122
backend/internal/repository/progreso.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/ren/econ/backend/internal/models"
|
||||
)
|
||||
|
||||
type ProgresoRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewProgresoRepository(db *pgxpool.Pool) *ProgresoRepository {
|
||||
return &ProgresoRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ProgresoRepository) GetProgresoByUsuarioID(usuarioID uuid.UUID) ([]models.Progreso, error) {
|
||||
query := `
|
||||
SELECT id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez
|
||||
FROM progreso_usuario WHERE usuario_id = $1
|
||||
ORDER BY ultima_vez DESC
|
||||
`
|
||||
rows, err := r.db.Query(context.Background(), query, usuarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var progresos []models.Progreso
|
||||
for rows.Next() {
|
||||
var p models.Progreso
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.UsuarioID, &p.ModuloNumero, &p.EjercicioID,
|
||||
&p.Completado, &p.Puntuacion, &p.Intentos, &p.UltimaVez)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
progresos = append(progresos, p)
|
||||
}
|
||||
return progresos, nil
|
||||
}
|
||||
|
||||
func (r *ProgresoRepository) SaveProgreso(progreso *models.Progreso) error {
|
||||
query := `
|
||||
INSERT INTO progreso_usuario (id, usuario_id, modulo_numero, ejercicio_id, completado, puntuacion, intentos, ultima_vez)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (usuario_id, modulo_numero, ejercicio_id)
|
||||
DO UPDATE SET completado = $5, puntuacion = $6, intentos = progreso_usuario.intentos + 1, ultima_vez = $8
|
||||
`
|
||||
|
||||
if progreso.ID == uuid.Nil {
|
||||
progreso.ID = uuid.New()
|
||||
}
|
||||
if progreso.UltimaVez.IsZero() {
|
||||
progreso.UltimaVez = time.Now()
|
||||
}
|
||||
if progreso.Intentos == 0 {
|
||||
progreso.Intentos = 1
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(context.Background(), query,
|
||||
progreso.ID, progreso.UsuarioID, progreso.ModuloNumero, progreso.EjercicioID,
|
||||
progreso.Completado, progreso.Puntuacion, progreso.Intentos, progreso.UltimaVez)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ProgresoRepository) GetResumen(usuarioID uuid.UUID) (*models.ResumenProgreso, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(SUM(puntuacion), 0) as puntos_totales,
|
||||
COUNT(CASE WHEN completado THEN 1 END) as ejercicios_completados,
|
||||
COUNT(*) as total_ejercicios
|
||||
FROM progreso_usuario WHERE usuario_id = $1
|
||||
`
|
||||
var resumen models.ResumenProgreso
|
||||
err := r.db.QueryRow(context.Background(), query, usuarioID).Scan(
|
||||
&resumen.PuntosTotales, &resumen.EjerciciosCompletados, &resumen.TotalEjercicios)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Alias para compatibilidad con frontend
|
||||
resumen.TotalPuntuacion = resumen.PuntosTotales
|
||||
|
||||
// Calcular nivel basado en puntuación
|
||||
resumen.Nivel = calcularNivel(resumen.PuntosTotales)
|
||||
|
||||
// Generar badges basados en progreso
|
||||
resumen.Badges = generarBadges(resumen.PuntosTotales, resumen.EjerciciosCompletados)
|
||||
|
||||
return &resumen, nil
|
||||
}
|
||||
|
||||
func calcularNivel(puntuacion int) string {
|
||||
if puntuacion >= 2000 {
|
||||
return "Maestro"
|
||||
}
|
||||
if puntuacion >= 1000 {
|
||||
return "Experto"
|
||||
}
|
||||
if puntuacion >= 300 {
|
||||
return "Aprendiz"
|
||||
}
|
||||
return "Novato"
|
||||
}
|
||||
|
||||
func generarBadges(puntuacion, ejerciciosCompletados int) []models.Badge {
|
||||
badges := []models.Badge{
|
||||
{ID: "primer-ejercicio", Nombre: "Primer Ejercicio", Descripcion: "Completa tu primer ejercicio", Icono: "star", Desbloqueado: ejerciciosCompletados >= 1},
|
||||
{ID: "primer-modulo", Nombre: "Primer Módulo", Descripcion: "Completa todas las lecciones de un módulo", Icono: "award", Desbloqueado: ejerciciosCompletados >= 3},
|
||||
{ID: "aprendiz", Nombre: "Aprendiz", Descripcion: "Alcanza el nivel Aprendiz", Icono: "book", Desbloqueado: puntuacion >= 300},
|
||||
{ID: "experto", Nombre: "Experto", Descripcion: "Alcanza el nivel Experto", Icono: "trophy", Desbloqueado: puntuacion >= 1000},
|
||||
{ID: "maestro", Nombre: "Maestro", Descripcion: "Alcanza el nivel Maestro", Icono: "crown", Desbloqueado: puntuacion >= 2000},
|
||||
{ID: "puntos-500", Nombre: "500 Puntos", Descripcion: "Acumula 500 puntos", Icono: "target", Desbloqueado: puntuacion >= 500},
|
||||
{ID: "puntos-1000", Nombre: "1000 Puntos", Descripcion: "Acumula 1000 puntos", Icono: "zap", Desbloqueado: puntuacion >= 1000},
|
||||
{ID: "puntos-2000", Nombre: "2000 Puntos", Descripcion: "Acumula 2000 puntos", Icono: "flame", Desbloqueado: puntuacion >= 2000},
|
||||
}
|
||||
return badges
|
||||
}
|
||||
128
backend/internal/repository/user.go
Normal file
128
backend/internal/repository/user.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/ren/econ/backend/internal/models"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewUserRepository(db *pgxpool.Pool) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(ctx context.Context, user *models.Usuario) error {
|
||||
user.ID = uuid.New()
|
||||
user.CreadoEn = time.Now()
|
||||
user.Activo = true
|
||||
if user.Rol == "" {
|
||||
user.Rol = "estudiante"
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO usuarios (id, email, username, password_hash, nombre, rol, creado_en, activo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`
|
||||
_, err := r.db.Exec(ctx, query,
|
||||
user.ID, user.Email, user.Username, user.PasswordHash, user.Nombre, user.Rol, user.CreadoEn, user.Activo)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Usuario, error) {
|
||||
query := `
|
||||
SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo
|
||||
FROM usuarios WHERE id = $1
|
||||
`
|
||||
var user models.Usuario
|
||||
err := r.db.QueryRow(ctx, query, id).Scan(
|
||||
&user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre,
|
||||
&user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*models.Usuario, error) {
|
||||
query := `
|
||||
SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo
|
||||
FROM usuarios WHERE email = $1
|
||||
`
|
||||
var user models.Usuario
|
||||
err := r.db.QueryRow(ctx, query, email).Scan(
|
||||
&user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre,
|
||||
&user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*models.Usuario, error) {
|
||||
query := `
|
||||
SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo
|
||||
FROM usuarios WHERE username = $1
|
||||
`
|
||||
var user models.Usuario
|
||||
err := r.db.QueryRow(ctx, query, username).Scan(
|
||||
&user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre,
|
||||
&user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) List(ctx context.Context) ([]models.Usuario, error) {
|
||||
query := `
|
||||
SELECT id, email, username, password_hash, nombre, rol, creado_en, ultimo_login, activo
|
||||
FROM usuarios ORDER BY creado_en DESC
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []models.Usuario
|
||||
for rows.Next() {
|
||||
var user models.Usuario
|
||||
err := rows.Scan(
|
||||
&user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.Nombre,
|
||||
&user.Rol, &user.CreadoEn, &user.UltimoLogin, &user.Activo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(ctx context.Context, user *models.Usuario) error {
|
||||
query := `
|
||||
UPDATE usuarios SET email = $2, nombre = $3, rol = $4, activo = $5
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := r.db.Exec(ctx, query,
|
||||
user.ID, user.Email, user.Nombre, user.Rol, user.Activo)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
// Soft delete - set activo to false
|
||||
query := `UPDATE usuarios SET activo = false WHERE id = $1`
|
||||
_, err := r.db.Exec(ctx, query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
|
||||
query := `UPDATE usuarios SET ultimo_login = $2 WHERE id = $1`
|
||||
_, err := r.db.Exec(ctx, query, id, time.Now())
|
||||
return err
|
||||
}
|
||||
195
backend/internal/services/auth.go
Normal file
195
backend/internal/services/auth.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/ren/econ/backend/internal/config"
|
||||
"github.com/ren/econ/backend/internal/models"
|
||||
"github.com/ren/econ/backend/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("credenciales inválidas")
|
||||
ErrUserNotFound = errors.New("usuario no encontrado")
|
||||
ErrUserInactive = errors.New("usuario inactivo")
|
||||
ErrInvalidToken = errors.New("token inválido")
|
||||
ErrTokenExpired = errors.New("token expirado")
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
config *config.Config
|
||||
userRepo *repository.UserRepository
|
||||
}
|
||||
|
||||
func NewAuthService(cfg *config.Config, userRepo *repository.UserRepository) *AuthService {
|
||||
return &AuthService{
|
||||
config: cfg,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Rol string `json:"rol"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(ctx context.Context, req *models.LoginRequest) (*models.LoginResponse, error) {
|
||||
var user *models.Usuario
|
||||
var err error
|
||||
|
||||
// Buscar por email o username
|
||||
if req.Username != "" {
|
||||
user, err = s.userRepo.GetByUsername(ctx, req.Username)
|
||||
} else if req.Email != "" {
|
||||
user, err = s.userRepo.GetByEmail(ctx, req.Email)
|
||||
} else {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if !user.Activo {
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Update last login
|
||||
s.userRepo.UpdateLastLogin(ctx, user.ID)
|
||||
|
||||
// Generate tokens
|
||||
accessToken, err := s.generateAccessToken(user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generando access token: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, err := s.generateRefreshToken(user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generando refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &models.LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: s.config.JWTExpirationHours * 60,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*models.LoginResponse, error) {
|
||||
claims, err := s.validateToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
if !user.Activo {
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
|
||||
accessToken, err := s.generateAccessToken(user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generando access token: %w", err)
|
||||
}
|
||||
|
||||
newRefreshToken, err := s.generateRefreshToken(user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generando refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &models.LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
ExpiresIn: s.config.JWTExpirationHours * 60,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Logout(ctx context.Context, userID uuid.UUID) error {
|
||||
// In a more complete implementation, we would blacklist the tokens
|
||||
// For now, just return success
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(hash), err
|
||||
}
|
||||
|
||||
func (s *AuthService) generateAccessToken(user *models.Usuario) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Rol: user.Rol,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpirationHours) * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Subject: user.ID.String(),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.config.JWTSecret))
|
||||
}
|
||||
|
||||
func (s *AuthService) generateRefreshToken(user *models.Usuario) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Rol: user.Rol,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.RefreshExpDays) * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Subject: user.ID.String(),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.config.JWTSecret))
|
||||
}
|
||||
|
||||
func (s *AuthService) validateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.config.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
return s.validateToken(tokenString)
|
||||
}
|
||||
91
backend/internal/services/telegram.go
Normal file
91
backend/internal/services/telegram.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
telegramBotToken = "8551922819:AAHIXNbavzcI90eEIGVx1NIisYHecfcBYtU"
|
||||
telegramChatID = "692714536"
|
||||
telegramAPI = "https://api.telegram.org/bot"
|
||||
)
|
||||
|
||||
type TelegramNotification struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode string `json:"parse_mode,omitempty"`
|
||||
}
|
||||
|
||||
type TelegramService struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewTelegramService() *TelegramService {
|
||||
return &TelegramService{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TelegramService) SendLoginNotification(username, email, nombre string, timestamp time.Time) error {
|
||||
message := fmt.Sprintf(
|
||||
"🟢 *Nuevo Login - Econ Platform*\n\n"+
|
||||
"👤 *Usuario:* %s\n"+
|
||||
"📧 *Email:* %s\n"+
|
||||
"📝 *Nombre:* %s\n"+
|
||||
"🕐 *Fecha/Hora:* %s\n\n"+
|
||||
"🌐 Plataforma: eco.cbcren.online",
|
||||
username,
|
||||
email,
|
||||
nombre,
|
||||
timestamp.Format("02/01/2006 15:04:05"),
|
||||
)
|
||||
|
||||
return s.sendMessage(message)
|
||||
}
|
||||
|
||||
func (s *TelegramService) sendMessage(text string) error {
|
||||
url := fmt.Sprintf("%s%s/sendMessage", telegramAPI, telegramBotToken)
|
||||
|
||||
payload := TelegramNotification{
|
||||
ChatID: telegramChatID,
|
||||
Text: text,
|
||||
ParseMode: "Markdown",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling telegram payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.client.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending telegram message: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendErrorNotification envía notificación de errores críticos (opcional)
|
||||
func (s *TelegramService) SendErrorNotification(errorMsg string) error {
|
||||
message := fmt.Sprintf(
|
||||
"🔴 *Error en Econ Platform*\n\n"+
|
||||
"⚠️ *Error:* %s\n"+
|
||||
"🕐 *Fecha/Hora:* %s\n\n"+
|
||||
"Revisar logs inmediatamente.",
|
||||
errorMsg,
|
||||
time.Now().Format("02/01/2006 15:04:05"),
|
||||
)
|
||||
|
||||
return s.sendMessage(message)
|
||||
}
|
||||
44
backend/migrations/001_init.sql
Normal file
44
backend/migrations/001_init.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Create usuarios table
|
||||
CREATE TABLE IF NOT EXISTS usuarios (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
nombre VARCHAR(255) NOT NULL,
|
||||
rol VARCHAR(50) DEFAULT 'estudiante',
|
||||
creado_en TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
ultimo_login TIMESTAMP WITH TIME ZONE,
|
||||
activo BOOLEAN DEFAULT true
|
||||
);
|
||||
|
||||
-- Create ejercicios table
|
||||
CREATE TABLE IF NOT EXISTS ejercicios (
|
||||
id SERIAL PRIMARY KEY,
|
||||
modulo_numero INTEGER NOT NULL,
|
||||
titulo VARCHAR(255) NOT NULL,
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
contenido JSONB NOT NULL,
|
||||
orden INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- Create progreso_usuario table
|
||||
CREATE TABLE IF NOT EXISTS progreso_usuario (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
usuario_id UUID NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||
modulo_numero INTEGER NOT NULL,
|
||||
ejercicio_id INTEGER REFERENCES ejercicios(id) ON DELETE SET NULL,
|
||||
completado BOOLEAN DEFAULT false,
|
||||
puntuacion INTEGER DEFAULT 0,
|
||||
intentos INTEGER DEFAULT 0,
|
||||
ultima_vez TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
respuesta_json JSONB,
|
||||
UNIQUE(usuario_id, modulo_numero, ejercicio_id)
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_usuarios_email ON usuarios(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_progreso_usuario_usuario ON progreso_usuario(usuario_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ejercicios_modulo ON ejercicios(modulo_numero, orden);
|
||||
CREATE INDEX IF NOT EXISTS idx_progreso_usuario_ejercicio ON progreso_usuario(usuario_id, ejercicio_id);
|
||||
55
docker-compose.yml
Normal file
55
docker-compose.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: econ-postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-econ_user}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-econ_pass}
|
||||
POSTGRES_DB: ${DB_NAME:-econ_db}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-econ_user} -d ${DB_NAME:-econ_db}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: econ-backend
|
||||
environment:
|
||||
DB_HOST: econ-postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: ${DB_USER:-econ_user}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-econ_pass}
|
||||
DB_NAME: ${DB_NAME:-econ_db}
|
||||
JWT_SECRET: ${JWT_SECRET:-your-secret-key-change-in-production}
|
||||
SERVER_PORT: 8080
|
||||
ports:
|
||||
- "8080:8080"
|
||||
networks:
|
||||
- caddy
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: econ-frontend
|
||||
ports:
|
||||
- "3002:80"
|
||||
networks:
|
||||
- caddy
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Final stage - nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Plataforma de Economía</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
frontend/nginx.conf
Normal file
40
frontend/nginx.conf
Normal file
@@ -0,0 +1,40 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Handle SPA routing - try files first, then fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "ok";
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://econ-backend:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
3059
frontend/package-lock.json
generated
Normal file
3059
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "econ-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"framer-motion": "^12.34.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
frontend/public/audios/clase1_completa.m4a
Normal file
BIN
frontend/public/audios/clase1_completa.m4a
Normal file
Binary file not shown.
BIN
frontend/public/audios/clase2_completa.m4a
Normal file
BIN
frontend/public/audios/clase2_completa.m4a
Normal file
Binary file not shown.
BIN
frontend/public/audios/clase3_completa.m4a
Normal file
BIN
frontend/public/audios/clase3_completa.m4a
Normal file
Binary file not shown.
BIN
frontend/public/audios/clase4_completa.m4a
Normal file
BIN
frontend/public/audios/clase4_completa.m4a
Normal file
Binary file not shown.
BIN
frontend/public/pdfs/resumen_clase_1.pdf
Normal file
BIN
frontend/public/pdfs/resumen_clase_1.pdf
Normal file
Binary file not shown.
BIN
frontend/public/pdfs/resumen_clase_2.pdf
Normal file
BIN
frontend/public/pdfs/resumen_clase_2.pdf
Normal file
Binary file not shown.
BIN
frontend/public/pdfs/resumen_clase_3.pdf
Normal file
BIN
frontend/public/pdfs/resumen_clase_3.pdf
Normal file
Binary file not shown.
BIN
frontend/public/pdfs/resumen_clase_4.pdf
Normal file
BIN
frontend/public/pdfs/resumen_clase_4.pdf
Normal file
Binary file not shown.
95
frontend/src/App.tsx
Normal file
95
frontend/src/App.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { Login } from './pages/Login';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Modulos } from './pages/Modulos';
|
||||
import { Modulo } from './pages/Modulo';
|
||||
import { AdminPanel } from './pages/admin/AdminPanel';
|
||||
import { RecursosPage } from './pages/Recursos';
|
||||
import { ClasesGrabadasPage } from './pages/ClasesGrabadas';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { checkAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/modulos"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Modulos />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/modulo/:numero"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Modulo />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminPanel />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/recursos"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<RecursosPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/clases"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ClasesGrabadasPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
152
frontend/src/components/admin/UserForm.tsx
Normal file
152
frontend/src/components/admin/UserForm.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { usuarioService } from '../../services/api';
|
||||
import type { Usuario } from '../../types';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Input } from '../ui/Input';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface UserFormProps {
|
||||
usuario: Usuario | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function UserForm({ usuario, onClose }: UserFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
nombre: usuario?.nombre || '',
|
||||
username: usuario?.username || '',
|
||||
email: usuario?.email || '',
|
||||
password: '',
|
||||
rol: usuario?.rol || 'estudiante' as 'admin' | 'estudiante',
|
||||
activo: usuario?.activo ?? true,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Validaciones
|
||||
if (!formData.username.trim()) {
|
||||
setError('El nombre de usuario es requerido');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.nombre.trim()) {
|
||||
setError('El nombre completo es requerido');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (usuario) {
|
||||
await usuarioService.updateUsuario(usuario.id, formData);
|
||||
} else {
|
||||
if (!formData.password) {
|
||||
setError('La contraseña es requerida para nuevos usuarios');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await usuarioService.createUsuario({
|
||||
...formData,
|
||||
password: formData.password,
|
||||
} as Omit<Usuario, 'id'> & { password: string });
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError('Error al guardar el usuario');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{usuario ? 'Editar usuario' : 'Nuevo usuario'}
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Nombre de usuario *"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
placeholder="usuario123"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Nombre completo *"
|
||||
value={formData.nombre}
|
||||
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
|
||||
placeholder="Nombre completo"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Email (opcional)"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="email@ejemplo.com"
|
||||
/>
|
||||
|
||||
{!usuario && (
|
||||
<Input
|
||||
label="Contraseña"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rol</label>
|
||||
<select
|
||||
value={formData.rol}
|
||||
onChange={(e) => setFormData({ ...formData, rol: e.target.value as 'admin' | 'estudiante' })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="estudiante">Estudiante</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activo"
|
||||
checked={formData.activo}
|
||||
onChange={(e) => setFormData({ ...formData, activo: e.target.checked })}
|
||||
className="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="activo" className="text-sm text-gray-700">
|
||||
Usuario activo
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit" isLoading={loading} className="flex-1">
|
||||
{usuario ? 'Guardar cambios' : 'Crear usuario'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
179
frontend/src/components/admin/UserList.tsx
Normal file
179
frontend/src/components/admin/UserList.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usuarioService } from '../../services/api';
|
||||
import type { Usuario } from '../../types';
|
||||
import { Card, CardHeader } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Users, UserPlus, Edit, Trash2, Search } from 'lucide-react';
|
||||
import { UserForm } from './UserForm';
|
||||
|
||||
export function UserList() {
|
||||
const [usuarios, setUsuarios] = useState<Usuario[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<Usuario | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsuarios();
|
||||
}, []);
|
||||
|
||||
const loadUsuarios = async () => {
|
||||
try {
|
||||
const data = await usuarioService.getUsuarios();
|
||||
setUsuarios(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading usuarios:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('¿Estás seguro de que deseas eliminar este usuario?')) return;
|
||||
|
||||
try {
|
||||
await usuarioService.deleteUsuario(id);
|
||||
setUsuarios(usuarios.filter((u) => u.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Error deleting usuario:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (usuario: Usuario) => {
|
||||
setEditingUser(usuario);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleFormClose = () => {
|
||||
setShowForm(false);
|
||||
setEditingUser(null);
|
||||
loadUsuarios();
|
||||
};
|
||||
|
||||
const filteredUsuarios = usuarios.filter(
|
||||
(u) =>
|
||||
u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Usuarios"
|
||||
subtitle={`${usuarios.length} usuarios registrados`}
|
||||
action={
|
||||
<Button size="sm" onClick={() => setShowForm(true)}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Nuevo usuario
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuarios..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<UserForm
|
||||
usuario={editingUser}
|
||||
onClose={handleFormClose}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Nombre</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Email</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Rol</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Estado</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsuarios.map((usuario) => (
|
||||
<tr key={usuario.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
{usuario.nombre.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="ml-3 font-medium text-gray-900">{usuario.nombre}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">{usuario.email}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
usuario.rol === 'admin'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{usuario.rol === 'admin' ? 'Admin' : 'Estudiante'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
usuario.activo
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{usuario.activo ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(usuario)}
|
||||
className="mr-2"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(usuario.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsuarios.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Users className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>No se encontraron usuarios</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
142
frontend/src/components/announcements/SistemaAnuncios.tsx
Normal file
142
frontend/src/components/announcements/SistemaAnuncios.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Volume2, BookOpen } from 'lucide-react';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface Anuncio {
|
||||
id: string;
|
||||
titulo: string;
|
||||
mensaje: string;
|
||||
tipo: 'info' | 'success' | 'warning';
|
||||
link?: string;
|
||||
linkText?: string;
|
||||
fechaExpiracion?: Date;
|
||||
}
|
||||
|
||||
const ANUNCIOS: Anuncio[] = [
|
||||
{
|
||||
id: 'nuevas-clases-audio',
|
||||
titulo: '🎉 ¡Nueva función disponible!',
|
||||
mensaje: 'Ahora puedes escuchar las clases grabadas en audio. Accede a la sección "Clases Grabadas" para escuchar o descargar las 4 clases completas.',
|
||||
tipo: 'success',
|
||||
link: '/clases',
|
||||
linkText: 'Ver Clases Grabadas'
|
||||
}
|
||||
];
|
||||
|
||||
export function SistemaAnuncios() {
|
||||
const [anunciosVisibles, setAnunciosVisibles] = useState<Anuncio[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cargar anuncios no descartados desde localStorage
|
||||
const anunciosDescartados = JSON.parse(localStorage.getItem('anunciosDescartados') || '[]');
|
||||
|
||||
const anunciosActivos = ANUNCIOS.filter(anuncio => {
|
||||
// Si ya fue descartado, no mostrar
|
||||
if (anunciosDescartados.includes(anuncio.id)) return false;
|
||||
|
||||
// Si tiene fecha de expiración y ya pasó, no mostrar
|
||||
if (anuncio.fechaExpiracion && new Date() > anuncio.fechaExpiracion) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setAnunciosVisibles(anunciosActivos);
|
||||
}, []);
|
||||
|
||||
const cerrarAnuncio = (id: string) => {
|
||||
// Guardar en localStorage para no mostrar de nuevo
|
||||
const anunciosDescartados = JSON.parse(localStorage.getItem('anunciosDescartados') || '[]');
|
||||
anunciosDescartados.push(id);
|
||||
localStorage.setItem('anunciosDescartados', JSON.stringify(anunciosDescartados));
|
||||
|
||||
// Remover de la vista actual
|
||||
setAnunciosVisibles(prev => prev.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
if (anunciosVisibles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mb-8">
|
||||
<AnimatePresence>
|
||||
{anunciosVisibles.map((anuncio) => (
|
||||
<motion.div
|
||||
key={anuncio.id}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={`relative p-6 rounded-2xl border-2 ${
|
||||
anuncio.tipo === 'success'
|
||||
? 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
|
||||
: anuncio.tipo === 'warning'
|
||||
? 'bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200'
|
||||
: 'bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200'
|
||||
}`}
|
||||
>
|
||||
{/* Botón cerrar */}
|
||||
<button
|
||||
onClick={() => cerrarAnuncio(anuncio.id)}
|
||||
className="absolute top-4 right-4 p-1 rounded-full hover:bg-black/5 transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-4 pr-8">
|
||||
{/* Icono */}
|
||||
<div className={`p-3 rounded-xl ${
|
||||
anuncio.tipo === 'success'
|
||||
? 'bg-green-100 text-green-600'
|
||||
: anuncio.tipo === 'warning'
|
||||
? 'bg-amber-100 text-amber-600'
|
||||
: 'bg-blue-100 text-blue-600'
|
||||
}`}>
|
||||
{anuncio.id === 'nuevas-clases-audio' ? (
|
||||
<Volume2 size={24} />
|
||||
) : (
|
||||
<BookOpen size={24} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contenido */}
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-lg font-bold mb-2 ${
|
||||
anuncio.tipo === 'success'
|
||||
? 'text-green-900'
|
||||
: anuncio.tipo === 'warning'
|
||||
? 'text-amber-900'
|
||||
: 'text-blue-900'
|
||||
}`}>
|
||||
{anuncio.titulo}
|
||||
</h3>
|
||||
|
||||
<p className={`mb-4 ${
|
||||
anuncio.tipo === 'success'
|
||||
? 'text-green-800'
|
||||
: anuncio.tipo === 'warning'
|
||||
? 'text-amber-800'
|
||||
: 'text-blue-800'
|
||||
}`}>
|
||||
{anuncio.mensaje}
|
||||
</p>
|
||||
|
||||
{anuncio.link && (
|
||||
<Link to={anuncio.link}>
|
||||
<Button
|
||||
size="sm"
|
||||
className={anuncio.tipo === 'success' ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||
>
|
||||
{anuncio.linkText}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SistemaAnuncios;
|
||||
73
frontend/src/components/auth/LoginForm.tsx
Normal file
73
frontend/src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Input } from '../ui/Input';
|
||||
import { Mail, Lock, LogIn } from 'lucide-react';
|
||||
|
||||
export function LoginForm() {
|
||||
const navigate = useNavigate();
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
setValidationError('');
|
||||
|
||||
if (!email || !password) {
|
||||
setValidationError('Por favor, completa todos los campos');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Determinar si es email o username
|
||||
const isEmail = email.includes('@');
|
||||
if (isEmail) {
|
||||
await login({ email, password });
|
||||
} else {
|
||||
await login({ username: email, password });
|
||||
}
|
||||
navigate('/');
|
||||
} catch {
|
||||
// Error handled by store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{(error || validationError) && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error || validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
label="Usuario o correo electrónico"
|
||||
placeholder="usuario o tu@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
icon={<Mail className="w-5 h-5 text-gray-400" />}
|
||||
autoComplete="username"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Contraseña"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
icon={<Lock className="w-5 h-5 text-gray-400" />}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
<LogIn className="w-5 h-5 mr-2" />
|
||||
Iniciar sesión
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/exercises/EjercicioWrapper.tsx
Normal file
184
frontend/src/components/exercises/EjercicioWrapper.tsx
Normal 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;
|
||||
316
frontend/src/components/exercises/common/CalculatorExercise.tsx
Normal file
316
frontend/src/components/exercises/common/CalculatorExercise.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, CardHeader } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import { Calculator, CheckCircle, XCircle, Lightbulb, RotateCcw } from 'lucide-react';
|
||||
|
||||
export interface CalculatorExerciseProps {
|
||||
/** Unique identifier for the exercise */
|
||||
ejercicioId: string;
|
||||
/** Question or problem statement to solve */
|
||||
pregunta: string;
|
||||
/** Explanation/description of the economic problem context */
|
||||
explicacion?: string;
|
||||
/** Formula or calculation method to display */
|
||||
formula?: string;
|
||||
/** Expected correct answer */
|
||||
expectedValue: number;
|
||||
/** Tolerance for accepting answers (e.g., 0.01 for 1% tolerance) */
|
||||
tolerance?: number;
|
||||
/** Unit of measurement (e.g., "$", "unidades", "%") */
|
||||
unit?: string;
|
||||
/** Hint to help the student */
|
||||
hint?: string;
|
||||
/** Callback when exercise is completed correctly */
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
/** Optional: decimal places to display */
|
||||
decimalPlaces?: number;
|
||||
/** Optional: show step-by-step solution (provide steps array) */
|
||||
steps?: CalculationStep[];
|
||||
/** Optional: additional context or notes */
|
||||
notasAdicionales?: string;
|
||||
}
|
||||
|
||||
export interface CalculationStep {
|
||||
titulo: string;
|
||||
formula: string;
|
||||
valores: Record<string, number>;
|
||||
resultado: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable calculator exercise component for economic calculations.
|
||||
* Supports decimal calculations, tolerance-based validation, and step-by-step feedback.
|
||||
*/
|
||||
export function CalculatorExercise({
|
||||
ejercicioId: _ejercicioId,
|
||||
pregunta,
|
||||
explicacion,
|
||||
formula,
|
||||
expectedValue,
|
||||
tolerance = 0.01,
|
||||
unit = '',
|
||||
hint,
|
||||
onComplete,
|
||||
decimalPlaces = 2,
|
||||
steps,
|
||||
notasAdicionales,
|
||||
}: CalculatorExerciseProps) {
|
||||
const [userAnswer, setUserAnswer] = useState('');
|
||||
const [isCorrect, setIsCorrect] = useState<boolean | null>(null);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset state when expectedValue changes
|
||||
useEffect(() => {
|
||||
setUserAnswer('');
|
||||
setIsCorrect(null);
|
||||
setShowHint(false);
|
||||
setError(null);
|
||||
}, [expectedValue]);
|
||||
|
||||
const validateAnswer = useCallback(() => {
|
||||
const parsedAnswer = parseFloat(userAnswer);
|
||||
|
||||
if (isNaN(parsedAnswer)) {
|
||||
setError('Por favor ingresa un número válido');
|
||||
setIsCorrect(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
// Calculate tolerance as absolute value or percentage
|
||||
const toleranceValue = expectedValue * tolerance;
|
||||
const difference = Math.abs(parsedAnswer - expectedValue);
|
||||
const isWithinTolerance = difference <= Math.max(toleranceValue, Math.abs(expectedValue * 0.01));
|
||||
|
||||
setIsCorrect(isWithinTolerance);
|
||||
|
||||
if (isWithinTolerance && onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
}, [userAnswer, expectedValue, tolerance, onComplete]);
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
// Allow numbers, decimal point, and minus sign
|
||||
const validChars = /^-?\d*\.?\d*$/;
|
||||
if (validChars.test(value) || value === '') {
|
||||
setUserAnswer(value);
|
||||
setIsCorrect(null);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
validateAnswer();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = () => {
|
||||
validateAnswer();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setUserAnswer('');
|
||||
setIsCorrect(null);
|
||||
setShowHint(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const renderStep = (step: CalculationStep, index: number) => {
|
||||
let formulaWithValues = step.formula;
|
||||
Object.entries(step.valores).forEach(([key, value]) => {
|
||||
formulaWithValues = formulaWithValues.replace(
|
||||
new RegExp(`\\b${key}\\b`, 'g'),
|
||||
value.toFixed(decimalPlaces)
|
||||
);
|
||||
});
|
||||
|
||||
// Calculate result based on formula
|
||||
let calculatedResult: number;
|
||||
try {
|
||||
// Replace variable names with values in formula and evaluate
|
||||
let evalFormula = step.formula;
|
||||
Object.entries(step.valores).forEach(([key, value]) => {
|
||||
evalFormula = evalFormula.replace(new RegExp(`\\b${key}\\b`, 'g'), value.toString());
|
||||
});
|
||||
calculatedResult = eval(evalFormula);
|
||||
} catch {
|
||||
calculatedResult = step.resultado;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="bg-white p-3 rounded border border-gray-200">
|
||||
<p className="font-medium text-gray-600 text-sm">Paso {index + 1}: {step.titulo}</p>
|
||||
<p className="font-mono text-gray-800 text-sm mt-1">
|
||||
{formulaWithValues} = {calculatedResult.toFixed(decimalPlaces)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader
|
||||
title="Ejercicio de Cálculo"
|
||||
subtitle="Resuelve el siguiente problema económico"
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Question and Explanation */}
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<Calculator className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-900">{pregunta}</p>
|
||||
{explicacion && (
|
||||
<p className="text-sm text-blue-700 mt-2">{explicacion}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formula && (
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Fórmula</p>
|
||||
<p className="font-mono text-gray-800 mt-1">{formula}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Tu respuesta"
|
||||
type="text"
|
||||
value={userAnswer}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={`Ingresa el valor ${unit ? `en ${unit}` : ''}`}
|
||||
error={error || undefined}
|
||||
className={isCorrect === true ? 'border-success bg-success/5' : isCorrect === false ? 'border-error bg-error/5' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
onClick={handleCheck}
|
||||
variant="primary"
|
||||
disabled={!userAnswer.trim()}
|
||||
>
|
||||
Verificar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="outline"
|
||||
title="Reiniciar"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unit && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Unit: <span className="font-medium">{unit}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hint Section */}
|
||||
{hint && !showHint && !isCorrect && (
|
||||
<Button
|
||||
onClick={() => setShowHint(true)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-yellow-600 hover:text-yellow-700"
|
||||
>
|
||||
<Lightbulb className="w-4 h-4 mr-1" />
|
||||
¿Necesitas una pista?
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showHint && hint && (
|
||||
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-yellow-800">Pista</p>
|
||||
<p className="text-sm text-yellow-700 mt-1">{hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback Section */}
|
||||
{isCorrect !== null && (
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
isCorrect
|
||||
? 'bg-success/10 border-success'
|
||||
: 'bg-error/10 border-error'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{isCorrect ? (
|
||||
<>
|
||||
<CheckCircle className="w-6 h-6 text-success flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-success">¡Correcto!</p>
|
||||
<p className="text-sm text-gray-700 mt-1">
|
||||
Tu respuesta es correcta{unit ? ` (${unit})` : ''}.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-6 h-6 text-error flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-error">Incorrecto</p>
|
||||
<p className="text-sm text-gray-700 mt-1">
|
||||
La respuesta esperada es <strong>{expectedValue.toFixed(decimalPlaces)} {unit}</strong>
|
||||
{tolerance > 0.01 && ` (tolerancia: ${(tolerance * 100).toFixed(0)}%)`}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step-by-step Solution */}
|
||||
{isCorrect && steps && steps.length > 0 && (
|
||||
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-3">
|
||||
<h4 className="font-semibold text-gray-700">Desarrollo paso a paso:</h4>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => renderStep(step, index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show solution on incorrect answer */}
|
||||
{isCorrect === false && steps && steps.length > 0 && (
|
||||
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-3">
|
||||
<h4 className="font-semibold text-gray-700">Solución:</h4>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => renderStep(step, index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Notes */}
|
||||
{notasAdicionales && (
|
||||
<div className="bg-purple-50 p-4 rounded-lg border border-purple-100">
|
||||
<p className="text-sm text-purple-800">
|
||||
<strong>Nota:</strong> {notasAdicionales}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalculatorExercise;
|
||||
548
frontend/src/components/exercises/common/MatchingExercise.tsx
Normal file
548
frontend/src/components/exercises/common/MatchingExercise.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
import { useState, useCallback, DragEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCcw,
|
||||
Link2,
|
||||
GripVertical,
|
||||
Trophy,
|
||||
Star,
|
||||
Target,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface MatchingItem {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface MatchingPair {
|
||||
leftId: string;
|
||||
rightId: string;
|
||||
}
|
||||
|
||||
export interface MatchingExerciseProps {
|
||||
leftItems: MatchingItem[];
|
||||
rightItems: MatchingItem[];
|
||||
correctPairs: MatchingPair[];
|
||||
onComplete?: (result: MatchingResult) => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
maxPoints?: number;
|
||||
shuffleItems?: boolean;
|
||||
}
|
||||
|
||||
export interface MatchingResult {
|
||||
correct: number;
|
||||
total: number;
|
||||
attempts: number;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
isPerfect: boolean;
|
||||
pairs: MatchedPairResult[];
|
||||
}
|
||||
|
||||
interface MatchedPairResult {
|
||||
leftId: string;
|
||||
rightId: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
interface Match {
|
||||
leftId: string;
|
||||
rightId: string;
|
||||
isCorrect?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle algorithm
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
export function MatchingExercise({
|
||||
leftItems,
|
||||
rightItems,
|
||||
correctPairs,
|
||||
onComplete,
|
||||
title = 'Emparejamiento',
|
||||
description = 'Relaciona los elementos de la columna izquierda con los de la derecha',
|
||||
maxPoints = 100,
|
||||
shuffleItems = true,
|
||||
}: MatchingExerciseProps) {
|
||||
// Initialize items with optional shuffle
|
||||
const [displayLeftItems, setDisplayLeftItems] = useState<MatchingItem[]>(() =>
|
||||
shuffleItems ? shuffleArray(leftItems) : leftItems
|
||||
);
|
||||
const [displayRightItems, setDisplayRightItems] = useState<MatchingItem[]>(() =>
|
||||
shuffleItems ? shuffleArray(rightItems) : rightItems
|
||||
);
|
||||
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [selectedLeft, setSelectedLeft] = useState<string | null>(null);
|
||||
const [selectedRight, setSelectedRight] = useState<string | null>(null);
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [dragOverLeft, setDragOverLeft] = useState<string | null>(null);
|
||||
const [dragOverRight, setDragOverRight] = useState<string | null>(null);
|
||||
|
||||
// Check if all pairs are matched
|
||||
const allMatched = matches.length === leftItems.length;
|
||||
|
||||
// Calculate score based on attempts
|
||||
const calculateScore = useCallback((correctCount: number, totalAttempts: number): number => {
|
||||
if (correctCount === 0) return 0;
|
||||
|
||||
// Base score per correct match
|
||||
const baseScore = maxPoints / leftItems.length;
|
||||
|
||||
// Penalty for attempts (starts at 100%, decreases with each attempt)
|
||||
const efficiency = Math.max(0.5, 1 - (totalAttempts - leftItems.length) * 0.05);
|
||||
|
||||
return Math.round(correctCount * baseScore * efficiency);
|
||||
}, [maxPoints, leftItems.length]);
|
||||
|
||||
// Handle left item click
|
||||
const handleLeftClick = (itemId: string) => {
|
||||
if (showResults) return;
|
||||
|
||||
if (selectedLeft === itemId) {
|
||||
setSelectedLeft(null);
|
||||
} else {
|
||||
setSelectedLeft(itemId);
|
||||
// If right is already selected, create match
|
||||
if (selectedRight) {
|
||||
handleCreateMatch(itemId, selectedRight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle right item click
|
||||
const handleRightClick = (itemId: string) => {
|
||||
if (showResults) return;
|
||||
|
||||
if (selectedRight === itemId) {
|
||||
setSelectedRight(null);
|
||||
} else {
|
||||
setSelectedRight(itemId);
|
||||
// If left is already selected, create match
|
||||
if (selectedLeft) {
|
||||
handleCreateMatch(selectedLeft, itemId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new match
|
||||
const handleCreateMatch = (leftId: string, rightId: string) => {
|
||||
// Check if either item is already matched
|
||||
const isLeftMatched = matches.some(m => m.leftId === leftId);
|
||||
const isRightMatched = matches.some(m => m.rightId === rightId);
|
||||
|
||||
if (isLeftMatched || isRightMatched) return;
|
||||
|
||||
setMatches(prev => [...prev, { leftId, rightId }]);
|
||||
setSelectedLeft(null);
|
||||
setSelectedRight(null);
|
||||
setAttempts(prev => prev + 1);
|
||||
};
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>, itemId: string, side: 'left' | 'right') => {
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ itemId, side }));
|
||||
e.dataTransfer.effectAllowed = 'link';
|
||||
};
|
||||
|
||||
// Handle drag over
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>, itemId: string, side: 'left' | 'right') => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
if (side === 'left') {
|
||||
setDragOverLeft(itemId);
|
||||
} else {
|
||||
setDragOverRight(itemId);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drag leave
|
||||
const handleDragLeave = (side: 'left' | 'right') => {
|
||||
if (side === 'left') {
|
||||
setDragOverLeft(null);
|
||||
} else {
|
||||
setDragOverRight(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drop
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>, targetId: string, targetSide: 'left' | 'right') => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
||||
const { itemId: draggedId, side: draggedSide } = data;
|
||||
|
||||
// Prevent dropping on same side
|
||||
if (draggedSide === targetSide) return;
|
||||
|
||||
// Create match
|
||||
if (draggedSide === 'left' && targetSide === 'right') {
|
||||
handleCreateMatch(draggedId, targetId);
|
||||
} else if (draggedSide === 'right' && targetSide === 'left') {
|
||||
handleCreateMatch(targetId, draggedId);
|
||||
}
|
||||
} catch {
|
||||
// Invalid drag data
|
||||
}
|
||||
|
||||
setDragOverLeft(null);
|
||||
setDragOverRight(null);
|
||||
};
|
||||
|
||||
// Remove a match
|
||||
const handleRemoveMatch = (leftId: string) => {
|
||||
if (showResults) return;
|
||||
setMatches(prev => prev.filter(m => m.leftId !== leftId));
|
||||
};
|
||||
|
||||
// Validate all matches
|
||||
const handleValidate = () => {
|
||||
const validatedMatches = matches.map(match => {
|
||||
const isCorrect = correctPairs.some(
|
||||
p => p.leftId === match.leftId && p.rightId === match.rightId
|
||||
);
|
||||
return { ...match, isCorrect, checked: true };
|
||||
});
|
||||
|
||||
setMatches(validatedMatches);
|
||||
setShowResults(true);
|
||||
|
||||
const correctCount = validatedMatches.filter(m => m.isCorrect).length;
|
||||
const score = calculateScore(correctCount, attempts);
|
||||
|
||||
const result: MatchingResult = {
|
||||
correct: correctCount,
|
||||
total: leftItems.length,
|
||||
attempts,
|
||||
score,
|
||||
maxScore: maxPoints,
|
||||
isPerfect: correctCount === leftItems.length,
|
||||
pairs: validatedMatches.map(m => ({
|
||||
leftId: m.leftId,
|
||||
rightId: m.rightId,
|
||||
isCorrect: m.isCorrect || false,
|
||||
})),
|
||||
};
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(result);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset exercise
|
||||
const handleReset = () => {
|
||||
setDisplayLeftItems(shuffleItems ? shuffleArray(leftItems) : leftItems);
|
||||
setDisplayRightItems(shuffleItems ? shuffleArray(rightItems) : rightItems);
|
||||
setMatches([]);
|
||||
setSelectedLeft(null);
|
||||
setSelectedRight(null);
|
||||
setAttempts(0);
|
||||
setShowResults(false);
|
||||
setDragOverLeft(null);
|
||||
setDragOverRight(null);
|
||||
};
|
||||
|
||||
// Get matched item for display
|
||||
const getMatchedRightItem = (leftId: string) => {
|
||||
const match = matches.find(m => m.leftId === leftId);
|
||||
if (!match) return null;
|
||||
return rightItems.find(item => item.id === match.rightId);
|
||||
};
|
||||
|
||||
const getMatchedLeftItem = (rightId: string) => {
|
||||
const match = matches.find(m => m.rightId === rightId);
|
||||
if (!match) return null;
|
||||
return leftItems.find(item => item.id === match.leftId);
|
||||
};
|
||||
|
||||
// Check if item is matched
|
||||
const isLeftMatched = (id: string) => matches.some(m => m.leftId === id);
|
||||
const isRightMatched = (id: string) => matches.some(m => m.rightId === id);
|
||||
|
||||
// Get match status
|
||||
const getMatchStatus = (leftId: string): 'correct' | 'incorrect' | 'unchecked' | null => {
|
||||
const match = matches.find(m => m.leftId === leftId);
|
||||
if (!match || !showResults) return null;
|
||||
return match.isCorrect ? 'correct' : 'incorrect';
|
||||
};
|
||||
|
||||
// Calculate results
|
||||
const correctCount = matches.filter(m => m.isCorrect).length;
|
||||
const score = calculateScore(correctCount, attempts);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">{title}</h3>
|
||||
<p className="text-gray-600 mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-1.5 rounded-full">
|
||||
<Target size={16} />
|
||||
<span className="font-semibold text-sm">{maxPoints} pts</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-blue-600 bg-blue-50 px-3 py-1.5 rounded-full">
|
||||
<Zap size={16} />
|
||||
<span className="font-semibold text-sm">{attempts} {attempts === 1 ? 'intento' : 'intentos'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matching Area */}
|
||||
<Card className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
||||
Columna A
|
||||
</h4>
|
||||
{displayLeftItems.map(item => {
|
||||
const matchedItem = getMatchedRightItem(item.id);
|
||||
const status = getMatchStatus(item.id);
|
||||
const isSelected = selectedLeft === item.id;
|
||||
const isMatched = isLeftMatched(item.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
draggable={!showResults && !isMatched}
|
||||
onDragStart={(e) => handleDragStart(e as unknown as DragEvent<HTMLDivElement>, item.id, 'left')}
|
||||
onDragOver={(e) => !showResults && handleDragOver(e as unknown as DragEvent<HTMLDivElement>, item.id, 'left')}
|
||||
onDragLeave={() => handleDragLeave('left')}
|
||||
onDrop={(e) => handleDrop(e as unknown as DragEvent<HTMLDivElement>, item.id, 'left')}
|
||||
onClick={() => handleLeftClick(item.id)}
|
||||
whileHover={!showResults && !isMatched ? { scale: 1.02 } : {}}
|
||||
whileTap={!showResults && !isMatched ? { scale: 0.98 } : {}}
|
||||
className={`
|
||||
relative p-4 rounded-lg border-2 transition-all cursor-pointer
|
||||
${!showResults && !isMatched ? 'hover:border-blue-300 hover:shadow-md' : ''}
|
||||
${isSelected ? 'border-blue-500 bg-blue-50' : ''}
|
||||
${isMatched && !showResults ? 'border-green-300 bg-green-50' : ''}
|
||||
${status === 'correct' ? 'border-green-500 bg-green-50' : ''}
|
||||
${status === 'incorrect' ? 'border-red-500 bg-red-50' : ''}
|
||||
${dragOverLeft === item.id ? 'border-blue-400 bg-blue-100' : ''}
|
||||
${!isMatched && !isSelected ? 'border-gray-200' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical size={16} className="text-gray-400 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-800">{item.content}</span>
|
||||
</div>
|
||||
|
||||
{/* Match indicator */}
|
||||
{matchedItem && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link2 size={14} className="text-gray-400" />
|
||||
<span className="text-gray-600">{matchedItem.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status icons */}
|
||||
{showResults && status && (
|
||||
<div className="absolute top-2 right-2">
|
||||
{status === 'correct' ? (
|
||||
<CheckCircle size={20} className="text-green-600" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remove button */}
|
||||
{isMatched && !showResults && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveMatch(item.id);
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 hover:bg-gray-200 rounded-full transition-colors"
|
||||
>
|
||||
<XCircle size={16} className="text-gray-400 hover:text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
||||
Columna B
|
||||
</h4>
|
||||
{displayRightItems.map(item => {
|
||||
const matchedItem = getMatchedLeftItem(item.id);
|
||||
const isSelected = selectedRight === item.id;
|
||||
const isMatched = isRightMatched(item.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
draggable={!showResults && !isMatched}
|
||||
onDragStart={(e) => handleDragStart(e as unknown as DragEvent<HTMLDivElement>, item.id, 'right')}
|
||||
onDragOver={(e) => !showResults && handleDragOver(e as unknown as DragEvent<HTMLDivElement>, item.id, 'right')}
|
||||
onDragLeave={() => handleDragLeave('right')}
|
||||
onDrop={(e) => handleDrop(e as unknown as DragEvent<HTMLDivElement>, item.id, 'right')}
|
||||
onClick={() => handleRightClick(item.id)}
|
||||
whileHover={!showResults && !isMatched ? { scale: 1.02 } : {}}
|
||||
whileTap={!showResults && !isMatched ? { scale: 0.98 } : {}}
|
||||
className={`
|
||||
relative p-4 rounded-lg border-2 transition-all cursor-pointer
|
||||
${!showResults && !isMatched ? 'hover:border-blue-300 hover:shadow-md' : ''}
|
||||
${isSelected ? 'border-blue-500 bg-blue-50' : ''}
|
||||
${isMatched && !showResults ? 'border-green-300 bg-green-50' : ''}
|
||||
${dragOverRight === item.id ? 'border-blue-400 bg-blue-100' : ''}
|
||||
${!isMatched && !isSelected ? 'border-gray-200' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical size={16} className="text-gray-400 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-800">{item.content}</span>
|
||||
</div>
|
||||
|
||||
{/* Match indicator */}
|
||||
{matchedItem && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link2 size={14} className="text-gray-400" />
|
||||
<span className="text-gray-600">{matchedItem.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Results Section */}
|
||||
<AnimatePresence>
|
||||
{showResults && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<Card className="p-6 bg-gradient-to-br from-blue-50 to-indigo-50 border-blue-200">
|
||||
<div className="text-center mb-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, delay: 0.2 }}
|
||||
className={`inline-flex items-center justify-center w-20 h-20 rounded-full mb-4 ${
|
||||
correctCount === leftItems.length
|
||||
? 'bg-gradient-to-br from-yellow-400 to-orange-500'
|
||||
: 'bg-gradient-to-br from-blue-400 to-indigo-500'
|
||||
}`}
|
||||
>
|
||||
<Trophy size={40} className="text-white" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{correctCount === leftItems.length
|
||||
? '¡Perfecto!'
|
||||
: correctCount >= leftItems.length * 0.7
|
||||
? '¡Muy bien!'
|
||||
: '¡Sigue practicando!'}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600">
|
||||
{correctCount} de {leftItems.length} emparejamientos correctos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score Display */}
|
||||
<div className="grid grid-cols-3 gap-4 max-w-lg mx-auto mb-6">
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<Star className="w-6 h-6 text-blue-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-blue-700">{score}</p>
|
||||
<p className="text-sm text-blue-600">Puntuación</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<Target className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-green-700">{maxPoints}</p>
|
||||
<p className="text-sm text-green-600">Máximo</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<Zap className="w-6 h-6 text-purple-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-purple-700">
|
||||
{Math.round((score / maxPoints) * 100)}%
|
||||
</p>
|
||||
<p className="text-sm text-purple-600">Precisión</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RefreshCcw size={16} className="mr-2" />
|
||||
Reiniciar
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!showResults ? (
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={!allMatched}
|
||||
>
|
||||
Validar Emparejamientos
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleReset}>
|
||||
Intentar de Nuevo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
{!allMatched && matches.length > 0 && !showResults && (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
Arrastra elementos o haz clic para conectar. Tienes{' '}
|
||||
<span className="font-semibold text-blue-600">{leftItems.length - matches.length}</span>{' '}
|
||||
emparejamientos pendientes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMatched && !showResults && (
|
||||
<div className="text-center text-sm text-green-600">
|
||||
<p className="font-medium">Todos los elementos están emparejados. ¡Valida tu respuesta!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MatchingExercise;
|
||||
366
frontend/src/components/exercises/common/QuizExercise.tsx
Normal file
366
frontend/src/components/exercises/common/QuizExercise.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
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, Lightbulb } from 'lucide-react';
|
||||
|
||||
export interface QuizOption {
|
||||
id: string;
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
export interface QuizHint {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface QuizExerciseProps {
|
||||
question: string;
|
||||
questionNumber?: number;
|
||||
totalQuestions?: number;
|
||||
options: QuizOption[];
|
||||
hints?: QuizHint[];
|
||||
explanation: string;
|
||||
onComplete?: (result: QuizResult) => void;
|
||||
exerciseId?: string;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export interface QuizResult {
|
||||
correct: boolean;
|
||||
attempts: number;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
usedHint: boolean;
|
||||
}
|
||||
|
||||
const MAX_SCORE_PER_QUESTION = 100;
|
||||
const HINT_PENALTY = 20;
|
||||
const ATTEMPTS_BEFORE_HINT = 2;
|
||||
|
||||
export function QuizExercise({
|
||||
question,
|
||||
questionNumber,
|
||||
totalQuestions,
|
||||
options,
|
||||
hints = [],
|
||||
explanation,
|
||||
onComplete,
|
||||
exerciseId: _exerciseId,
|
||||
maxAttempts = 3,
|
||||
}: QuizExerciseProps) {
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(null);
|
||||
const [showFeedback, setShowFeedback] = useState(false);
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [hintIndex, setHintIndex] = useState(0);
|
||||
const [currentHint, setCurrentHint] = useState<string | null>(null);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [usedHint, setUsedHint] = useState(false);
|
||||
|
||||
const correctOption = options.find((opt) => opt.isCorrect);
|
||||
const isCorrect = selectedOption === correctOption?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete && onComplete) {
|
||||
const maxScore = MAX_SCORE_PER_QUESTION;
|
||||
onComplete({
|
||||
correct: isCorrect,
|
||||
attempts,
|
||||
score,
|
||||
maxScore,
|
||||
usedHint,
|
||||
});
|
||||
}
|
||||
}, [isComplete, onComplete, isCorrect, attempts, score, usedHint]);
|
||||
|
||||
const handleSelectOption = (optionId: string) => {
|
||||
if (showFeedback) return;
|
||||
setSelectedOption(optionId);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedOption || showFeedback) return;
|
||||
|
||||
setShowFeedback(true);
|
||||
setAttempts((prev) => prev + 1);
|
||||
|
||||
if (selectedOption === correctOption?.id) {
|
||||
// Calculate score based on attempts
|
||||
let earnedScore = MAX_SCORE_PER_QUESTION;
|
||||
if (attempts > 0) {
|
||||
// Reduce score for each attempt (except first)
|
||||
earnedScore = Math.max(MAX_SCORE_PER_QUESTION - (attempts * 20), 20);
|
||||
}
|
||||
if (usedHint) {
|
||||
earnedScore -= HINT_PENALTY;
|
||||
}
|
||||
setScore(earnedScore);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setIsComplete(true);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setSelectedOption(null);
|
||||
setShowFeedback(false);
|
||||
setAttempts(0);
|
||||
setShowHint(false);
|
||||
setHintIndex(0);
|
||||
setCurrentHint(null);
|
||||
setIsComplete(false);
|
||||
setScore(0);
|
||||
setUsedHint(false);
|
||||
};
|
||||
|
||||
const handleShowHint = () => {
|
||||
if (hints.length === 0) return;
|
||||
|
||||
setUsedHint(true);
|
||||
setShowHint(true);
|
||||
|
||||
if (hintIndex < hints.length) {
|
||||
setCurrentHint(hints[hintIndex].text);
|
||||
setHintIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const canShowHint = hints.length > 0 && attempts >= ATTEMPTS_BEFORE_HINT && !showHint;
|
||||
|
||||
// Determine if the user can continue or should retry
|
||||
const canRetry = attempts < maxAttempts && !isCorrect;
|
||||
|
||||
if (isComplete) {
|
||||
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 rounded-full mb-4 ${
|
||||
isCorrect ? 'bg-green-100' : 'bg-red-100'
|
||||
}`}
|
||||
>
|
||||
{isCorrect ? (
|
||||
<CheckCircle size={40} className="text-green-600" />
|
||||
) : (
|
||||
<XCircle size={40} className="text-red-600" />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{isCorrect ? '¡Correcto!' : 'Incorrecto'}
|
||||
</h3>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-gray-600 mb-2">Explicación:</p>
|
||||
<p className="text-gray-800">{explanation}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-600 mb-1">Intentos</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{attempts}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<p className="text-sm text-green-600 mb-1">Puntuación</p>
|
||||
<p className="text-2xl font-bold text-green-700">
|
||||
{score}/{MAX_SCORE_PER_QUESTION}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canRetry && !isCorrect && (
|
||||
<Button onClick={handleRetry} variant="outline">
|
||||
Intentar de Nuevo
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!canRetry && !isCorrect && (
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Has agotado tus intentos. La respuesta correcta era:{' '}
|
||||
<span className="font-semibold text-green-600">{correctOption?.text}</span>
|
||||
</p>
|
||||
<Button onClick={handleRetry} variant="outline">
|
||||
Reiniciar Ejercicio
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader
|
||||
title={question}
|
||||
subtitle={
|
||||
questionNumber && totalQuestions
|
||||
? `Pregunta ${questionNumber} de ${totalQuestions}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Progress bar for multi-question exercises */}
|
||||
{questionNumber && totalQuestions && (
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Progreso</span>
|
||||
<span>{Math.round((questionNumber / totalQuestions) * 100)}%</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: `${(questionNumber / totalQuestions) * 100}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{options.map((option, index) => {
|
||||
const isSelected = selectedOption === option.id;
|
||||
const showCorrect = showFeedback && option.isCorrect;
|
||||
const showIncorrect = showFeedback && isSelected && !option.isCorrect;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={option.id}
|
||||
onClick={() => handleSelectOption(option.id)}
|
||||
disabled={showFeedback}
|
||||
whileHover={!showFeedback ? { scale: 1.02 } : {}}
|
||||
whileTap={!showFeedback ? { 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">{option.text}</span>
|
||||
</div>
|
||||
{showCorrect && <CheckCircle size={20} className="text-green-600" />}
|
||||
{showIncorrect && <XCircle size={20} className="text-red-600" />}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Hint section */}
|
||||
<AnimatePresence>
|
||||
{showHint && currentHint && (
|
||||
<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 bg-yellow-50 border-yellow-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Lightbulb size={20} className="text-yellow-600" />
|
||||
<span className="font-semibold text-yellow-800">Pista</span>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-700">{currentHint}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Feedback section */}
|
||||
<AnimatePresence>
|
||||
{showFeedback && (
|
||||
<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 ${
|
||||
isCorrect ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{isCorrect ? (
|
||||
<CheckCircle size={20} className="text-green-600" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-red-600" />
|
||||
)}
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
isCorrect ? 'text-green-800' : 'text-red-800'
|
||||
}`}
|
||||
>
|
||||
{isCorrect ? '¡Correcto!' : 'Incorrecto'}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isCorrect ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{explanation}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span>
|
||||
Intentos: <span className="font-bold">{attempts}</span>/{maxAttempts}
|
||||
</span>
|
||||
{canShowHint && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleShowHint}
|
||||
className="ml-2 text-yellow-600 hover:text-yellow-700"
|
||||
>
|
||||
<Lightbulb size={16} className="mr-1" />
|
||||
Ver pista
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!showFeedback ? (
|
||||
<Button onClick={handleSubmit} disabled={!selectedOption}>
|
||||
Validar Respuesta
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleNext}>
|
||||
{isCorrect ? (
|
||||
<>
|
||||
Continuar
|
||||
<ArrowRight size={16} className="ml-2" />
|
||||
</>
|
||||
) : canRetry ? (
|
||||
<>
|
||||
Intentar de Nuevo
|
||||
<ArrowRight size={16} className="ml-2" />
|
||||
</>
|
||||
) : (
|
||||
'Ver Resultado'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuizExercise;
|
||||
3
frontend/src/components/exercises/common/index.ts
Normal file
3
frontend/src/components/exercises/common/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { QuizExercise, type QuizExerciseProps, type QuizOption, type QuizHint, type QuizResult } from './QuizExercise';
|
||||
export { CalculatorExercise, type CalculatorExerciseProps, type CalculationStep } from './CalculatorExercise';
|
||||
export { MatchingExercise, type MatchingExerciseProps, type MatchingItem, type MatchingPair, type MatchingResult } from './MatchingExercise';
|
||||
3
frontend/src/components/exercises/index.ts
Normal file
3
frontend/src/components/exercises/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { EjercicioWrapper } from './EjercicioWrapper';
|
||||
export { QuizExercise } from './common/QuizExercise';
|
||||
export type { QuizExerciseProps, QuizOption, QuizHint, QuizResult } from './common/QuizExercise';
|
||||
@@ -0,0 +1,304 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface Pregunta {
|
||||
id: number;
|
||||
pregunta: string;
|
||||
opciones: { letra: string; texto: string; correcta: boolean }[];
|
||||
explicacion: string;
|
||||
categoria: string;
|
||||
}
|
||||
|
||||
const preguntas: Pregunta[] = [
|
||||
{
|
||||
id: 1,
|
||||
pregunta: "¿Cuál es la función principal de las familias como agente económico?",
|
||||
opciones: [
|
||||
{ letra: "A", texto: "Producir bienes y servicios para el mercado", correcta: false },
|
||||
{ letra: "B", texto: "Ofrecer factores productivos (trabajo, capital) y consumir", correcta: true },
|
||||
{ letra: "C", texto: "Regular la economía y recaudar impuestos", correcta: false },
|
||||
{ letra: "D", texto: "Importar y exportar productos", correcta: false },
|
||||
],
|
||||
explicacion: "Las familias son agentes económicos que ofrecen factores de producción (especialmente trabajo) a las empresas y utilizan sus ingresos para consumir bienes y servicios.",
|
||||
categoria: "Familias"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pregunta: "¿Qué tipo de empresas son las que buscan maximizar beneficios?",
|
||||
opciones: [
|
||||
{ letra: "A", texto: "Empresas públicas", correcta: false },
|
||||
{ letra: "B", texto: "Empresas privadas", correcta: true },
|
||||
{ letra: "C", texto: "ONGs", correcta: false },
|
||||
{ letra: "D", texto: "Cooperativas", correcta: false },
|
||||
],
|
||||
explicacion: "Las empresas privadas tienen como objetivo principal la maximización de beneficios o ganancias, a diferencia de las empresas públicas que persiguen objetivos de bienestar social.",
|
||||
categoria: "Empresas"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pregunta: "¿Cuál de las siguientes NO es una función del Estado como agente económico?",
|
||||
opciones: [
|
||||
{ letra: "A", texto: "Recaudar impuestos", correcta: false },
|
||||
{ letra: "B", texto: "Regular la actividad económica", correcta: false },
|
||||
{ letra: "C", texto: "Maximizar utilidades privadas", correcta: true },
|
||||
{ letra: "D", texto: "Proporcionar bienes públicos", correcta: false },
|
||||
],
|
||||
explicacion: "El Estado no busca maximizar utilidades privadas; esa es la función de las empresas privadas. El Estado persigue el bienestar social y el funcionamiento ordenado de la economía.",
|
||||
categoria: "Estado"
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pregunta: "¿Qué flujo representa el pago de salarios en el circuito económico?",
|
||||
opciones: [
|
||||
{ letra: "A", texto: "Flujo real de bienes y servicios", correcta: false },
|
||||
{ letra: "B", texto: "Flujo monetario del sector empresas a familias", correcta: true },
|
||||
{ letra: "C", texto: "Flujo de impuestos al Estado", correcta: false },
|
||||
{ letra: "D", texto: "Flujo de subsidios", correcta: false },
|
||||
],
|
||||
explicacion: "Los salarios representan un flujo monetario que va desde las empresas (que pagan) hacia las familias (que reciben el pago por su trabajo).",
|
||||
categoria: "Circuito Económico"
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pregunta: "¿Qué son los bienes públicos según la economía?",
|
||||
opciones: [
|
||||
{ letra: "A", texto: "Productos que solo pueden usar las familias ricas", correcta: false },
|
||||
{ letra: "B", texto: "Bienes no rivales y no excluibles proporcionados por el Estado", correcta: true },
|
||||
{ letra: "C", texto: "Productos importados de otros países", correcta: false },
|
||||
{ letra: "D", texto: "Bienes de lujo que produce el sector privado", correcta: false },
|
||||
],
|
||||
explicacion: "Los bienes públicos son aquellos que son no rivales (el uso por una persona no impide el uso por otra) y no excluibles (no se puede impedir que alguien los use), como la defensa nacional o los parques públicos.",
|
||||
categoria: "Bienes Públicos"
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
pregunta: "¿Cuál es la relación entre empresas y familias en el mercado de factores?",
|
||||
opciones: [
|
||||
{ letra: "A", texto: "Las empresas ofrecen trabajo y las familias lo demandan", correcta: false },
|
||||
{ letra: "B", texto: "Las familias ofrecen factores productivos y las empresas los demandan", correcta: true },
|
||||
{ letra: "C", texto: "El Estado controla ambos lados del mercado", correcta: false },
|
||||
{ letra: "D", texto: "No hay relación entre ellos", correcta: false },
|
||||
],
|
||||
explicacion: "En el mercado de factores, las familias son los oferentes (proveen trabajo, tierra, capital) y las empresas son los demandantes de estos factores productivos.",
|
||||
categoria: "Mercado de Factores"
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
pregunta: "¿Qué papel juega el Estado en la redistribución del ingreso?",
|
||||
opciones: [
|
||||
{ letra: "A", texto: "No interviene en la distribución del ingreso", correcta: false },
|
||||
{ letra: "B", texto: "Recauda impuestos y proporciona transferencias y servicios sociales", correcta: true },
|
||||
{ letra: "C", texto: "Solo cobra impuestos a las empresas", correcta: false },
|
||||
{ letra: "D", texto: "Fija los salarios de todos los trabajadores", correcta: false },
|
||||
],
|
||||
explicacion: "El Estado redistribuye el ingreso mediante el cobro de impuestos (generalmente progresivos) y el gasto en transferencias, subsidios, educación, salud y otros servicios públicos.",
|
||||
categoria: "Redistribución"
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
pregunta: "¿Cuál es un ejemplo de empresa estatal?",
|
||||
opciones: [
|
||||
{ letra: "A", texto: "Una tienda de ropa privada", correcta: false },
|
||||
{ letra: "B", texto: "Una empresa petrolera nacional", correcta: true },
|
||||
{ letra: "C", texto: "Un restaurante familiar", correcta: false },
|
||||
{ letra: "D", texto: "Una empresa tecnológica multinacional", correcta: false },
|
||||
],
|
||||
explicacion: "Las empresas petroleras nacionales (como PEMEX, Petrobras, PDVSA) son ejemplos clásicos de empresas estatales, propiedad del gobierno.",
|
||||
categoria: "Empresas Estatales"
|
||||
}
|
||||
];
|
||||
|
||||
export const AgentesEconomicosQuiz: React.FC = () => {
|
||||
const [preguntaActual, setPreguntaActual] = useState<number>(0);
|
||||
const [respuestas, setRespuestas] = useState<{ [key: number]: string }>({});
|
||||
const [mostrarResultado, setMostrarResultado] = useState<boolean>(false);
|
||||
const [quizTerminado, setQuizTerminado] = useState<boolean>(false);
|
||||
|
||||
const seleccionarRespuesta = (letra: string) => {
|
||||
setRespuestas({ ...respuestas, [preguntas[preguntaActual].id]: letra });
|
||||
setMostrarResultado(true);
|
||||
};
|
||||
|
||||
const siguientePregunta = () => {
|
||||
if (preguntaActual < preguntas.length - 1) {
|
||||
setPreguntaActual(preguntaActual + 1);
|
||||
setMostrarResultado(false);
|
||||
} else {
|
||||
setQuizTerminado(true);
|
||||
}
|
||||
};
|
||||
|
||||
const anteriorPregunta = () => {
|
||||
if (preguntaActual > 0) {
|
||||
setPreguntaActual(preguntaActual - 1);
|
||||
setMostrarResultado(true);
|
||||
}
|
||||
};
|
||||
|
||||
const reiniciarQuiz = () => {
|
||||
setPreguntaActual(0);
|
||||
setRespuestas({});
|
||||
setMostrarResultado(false);
|
||||
setQuizTerminado(false);
|
||||
};
|
||||
|
||||
const calcularPuntuacion = () => {
|
||||
let correctas = 0;
|
||||
preguntas.forEach(pregunta => {
|
||||
const opcionCorrecta = pregunta.opciones.find(o => o.correcta);
|
||||
if (opcionCorrecta && respuestas[pregunta.id] === opcionCorrecta.letra) {
|
||||
correctas++;
|
||||
}
|
||||
});
|
||||
return correctas;
|
||||
};
|
||||
|
||||
if (quizTerminado) {
|
||||
const puntuacion = calcularPuntuacion();
|
||||
const porcentaje = (puntuacion / preguntas.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h2 className="text-2xl font-bold mb-6">Resultados del Quiz</h2>
|
||||
|
||||
<div className="bg-blue-50 p-6 rounded-lg text-center mb-6">
|
||||
<p className="text-4xl font-bold text-blue-600 mb-2">
|
||||
{puntuacion} / {preguntas.length}
|
||||
</p>
|
||||
<p className="text-xl">{porcentaje.toFixed(0)}% de aciertos</p>
|
||||
<p className="mt-4">
|
||||
{porcentaje >= 80
|
||||
? '🎉 ¡Excelente! Dominas los agentes económicos'
|
||||
: porcentaje >= 60
|
||||
? '👍 ¡Bien! Puedes mejorar un poco más'
|
||||
: '📚 Sigue estudiando los agentes económicos'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{preguntas.map((pregunta, index) => {
|
||||
const respuestaUsuario = respuestas[pregunta.id];
|
||||
const opcionCorrecta = pregunta.opciones.find(o => o.correcta);
|
||||
const esCorrecta = respuestaUsuario === opcionCorrecta?.letra;
|
||||
|
||||
return (
|
||||
<div key={pregunta.id} className={`p-4 rounded-lg ${esCorrecta ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
<p className="font-semibold">{index + 1}. {pregunta.pregunta}</p>
|
||||
<p className="text-sm mt-2">
|
||||
Tu respuesta: <span className={esCorrecta ? 'text-green-700 font-medium' : 'text-red-700'}>
|
||||
{respuestaUsuario || 'Sin respuesta'}
|
||||
</span>
|
||||
{!esCorrecta && (
|
||||
<span className="text-green-700 font-medium ml-4">
|
||||
Correcta: {opcionCorrecta?.letra}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={reiniciarQuiz}
|
||||
className="w-full bg-blue-500 text-white px-6 py-3 rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
Intentar de Nuevo
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pregunta = preguntas[preguntaActual];
|
||||
const respuestaSeleccionada = respuestas[pregunta.id];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h2 className="text-2xl font-bold mb-2">Quiz: Agentes Económicos</h2>
|
||||
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<span className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm">
|
||||
Pregunta {preguntaActual + 1} de {preguntas.length}
|
||||
</span>
|
||||
<span className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm">
|
||||
Categoría: {pregunta.categoria}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{pregunta.pregunta}</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{pregunta.opciones.map((opcion) => {
|
||||
const estaSeleccionada = respuestaSeleccionada === opcion.letra;
|
||||
const mostrarCorrecta = mostrarResultado && opcion.correcta;
|
||||
const mostrarIncorrecta = mostrarResultado && estaSeleccionada && !opcion.correcta;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opcion.letra}
|
||||
onClick={() => !mostrarResultado && seleccionarRespuesta(opcion.letra)}
|
||||
disabled={mostrarResultado}
|
||||
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
|
||||
mostrarCorrecta
|
||||
? 'border-green-500 bg-green-50'
|
||||
: mostrarIncorrecta
|
||||
? 'border-red-500 bg-red-50'
|
||||
: estaSeleccionada
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-bold mr-2">{opcion.letra})</span>
|
||||
{opcion.texto}
|
||||
{mostrarCorrecta && <span className="ml-2 text-green-600">✓</span>}
|
||||
{mostrarIncorrecta && <span className="ml-2 text-red-600">✗</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{mostrarResultado && (
|
||||
<div className="mt-6 p-4 bg-yellow-50 rounded-lg">
|
||||
<p className="font-semibold mb-2">Explicación:</p>
|
||||
<p className="text-gray-700">{pregunta.explicacion}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={anteriorPregunta}
|
||||
disabled={preguntaActual === 0}
|
||||
className="bg-gray-500 text-white px-6 py-2 rounded hover:bg-gray-600 disabled:bg-gray-300"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={siguientePregunta}
|
||||
disabled={!respuestaSeleccionada}
|
||||
className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 disabled:bg-gray-300"
|
||||
>
|
||||
{preguntaActual === preguntas.length - 1 ? 'Ver Resultados' : 'Siguiente'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-center gap-2">
|
||||
{preguntas.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
index === preguntaActual
|
||||
? 'bg-blue-500'
|
||||
: respuestas[preguntas[index].id]
|
||||
? 'bg-green-400'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentesEconomicosQuiz;
|
||||
305
frontend/src/components/exercises/modulo1/CasosPaises.tsx
Normal file
305
frontend/src/components/exercises/modulo1/CasosPaises.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle, Globe, TrendingUp, Building2, Scale } from 'lucide-react';
|
||||
|
||||
interface EjercicioProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
type SistemaTipo = 'mercado' | 'planificado' | 'mixto';
|
||||
|
||||
interface Pais {
|
||||
id: string;
|
||||
nombre: string;
|
||||
emoji: string;
|
||||
descripcion: string;
|
||||
caracteristicas: string[];
|
||||
sistemaCorrecto: SistemaTipo;
|
||||
explicacion: string;
|
||||
}
|
||||
|
||||
const SISTEMAS: Record<SistemaTipo, { nombre: string; color: string; icono: React.ReactNode }> = {
|
||||
mercado: {
|
||||
nombre: 'Economía de Mercado',
|
||||
color: 'bg-blue-500',
|
||||
icono: <TrendingUp className="w-5 h-5" />
|
||||
},
|
||||
planificado: {
|
||||
nombre: 'Economía Planificada',
|
||||
color: 'bg-red-500',
|
||||
icono: <Building2 className="w-5 h-5" />
|
||||
},
|
||||
mixto: {
|
||||
nombre: 'Economía Mixta',
|
||||
color: 'bg-green-500',
|
||||
icono: <Scale className="w-5 h-5" />
|
||||
}
|
||||
};
|
||||
|
||||
const PAISES: Pais[] = [
|
||||
{
|
||||
id: 'singapur',
|
||||
nombre: 'Singapur',
|
||||
emoji: '🇸🇬',
|
||||
descripcion: 'Centro financiero asiático con uno de los índices de libertad económica más altos del mundo.',
|
||||
caracteristicas: [
|
||||
'Libre comercio y bajos aranceles',
|
||||
'Impuestos corporativos bajos',
|
||||
'Mínima intervención estatal en negocios',
|
||||
'Sector privado altamente competitivo'
|
||||
],
|
||||
sistemaCorrecto: 'mercado',
|
||||
explicacion: 'Singapur es un ejemplo clásico de economía de mercado, con mínima regulación, bajos impuestos y gran libertad para la empresa privada.'
|
||||
},
|
||||
{
|
||||
id: 'noruega',
|
||||
nombre: 'Noruega',
|
||||
emoji: '🇳🇴',
|
||||
descripcion: 'País escandinavo con altos estándares de vida y fuerte sector petrolero estatal.',
|
||||
caracteristicas: [
|
||||
'Servicios públicos universales gratuitos',
|
||||
'Fuerte sistema de bienestar social',
|
||||
'Empresas privadas con regulación estatal',
|
||||
'Fondo soberano de petróleo gestionado por el Estado'
|
||||
],
|
||||
sistemaCorrecto: 'mixto',
|
||||
explicacion: 'Noruega combina economía de mercado con fuerte intervención estatal en bienestar social y sectores estratégicos.'
|
||||
},
|
||||
{
|
||||
id: 'cuba',
|
||||
nombre: 'Cuba',
|
||||
emoji: '🇨🇺',
|
||||
descripcion: 'Isla caribeña con sistema económico único en el hemisferio occidental.',
|
||||
caracteristicas: [
|
||||
'Mayoría de empresas son estatales',
|
||||
'Planificación centralizada',
|
||||
'Racionamiento de bienes básicos',
|
||||
'Recientemente ha permitido pequeñas empresas privadas'
|
||||
],
|
||||
sistemaCorrecto: 'planificado',
|
||||
explicacion: 'Cuba mantiene principalmente una economía planificada donde el Estado controla la mayoría de los medios de producción.'
|
||||
},
|
||||
{
|
||||
id: 'suiza',
|
||||
nombre: 'Suiza',
|
||||
emoji: '🇨🇭',
|
||||
descripcion: 'País alpino conocido por su estabilidad económica y sistema bancario.',
|
||||
caracteristicas: [
|
||||
'Política fiscal conservadora',
|
||||
'Fuerte protección de la propiedad privada',
|
||||
'Mercado laboral flexible',
|
||||
'Alta competitividad internacional'
|
||||
],
|
||||
sistemaCorrecto: 'mercado',
|
||||
explicacion: 'Suiza opera principalmente como economía de mercado con fuerte protección a la propiedad privada y libre empresa.'
|
||||
},
|
||||
{
|
||||
id: 'francia',
|
||||
nombre: 'Francia',
|
||||
emoji: '🇫🇷',
|
||||
descripcion: 'Potencia europea con tradición de intervención estatal en la economía.',
|
||||
caracteristicas: [
|
||||
'Altos impuestos para financiar servicios públicos',
|
||||
'Regulación extensa del mercado laboral',
|
||||
'Empresas privadas dominantes pero reguladas',
|
||||
'Sistema de salud público universal'
|
||||
],
|
||||
sistemaCorrecto: 'mixto',
|
||||
explicacion: 'Francia representa una economía mixta europea donde coexisten empresas privadas con fuerte regulación y servicios públicos extensos.'
|
||||
},
|
||||
{
|
||||
id: 'corea-norte',
|
||||
nombre: 'Corea del Norte',
|
||||
emoji: '🇰🇵',
|
||||
descripcion: 'País asiático con uno de los sistemas económicos más cerrados del mundo.',
|
||||
caracteristicas: [
|
||||
'Planificación económica centralizada (Juche)',
|
||||
'Propiedad estatal total de medios de producción',
|
||||
'Comercio internacional severamente restringido',
|
||||
'Distribución de bienes por el Estado'
|
||||
],
|
||||
sistemaCorrecto: 'planificado',
|
||||
explicacion: 'Corea del Norte mantiene una economía altamente centralizada y planificada con mínima actividad de mercado permitida.'
|
||||
},
|
||||
{
|
||||
id: 'hong-kong',
|
||||
nombre: 'Hong Kong',
|
||||
emoji: '🇭🇰',
|
||||
descripcion: 'Región administrativa especial de China con sistema económico único.',
|
||||
caracteristicas: [
|
||||
'Política de "un país, dos sistemas"',
|
||||
'Libertad económica y financiera',
|
||||
'Bajos impuestos y aranceles',
|
||||
'Mínima intervención gubernamental'
|
||||
],
|
||||
sistemaCorrecto: 'mercado',
|
||||
explicacion: 'Hong Kong históricamente ha operado como economía de mercado con mínima regulación y máxima libertad comercial.'
|
||||
},
|
||||
{
|
||||
id: 'alemania',
|
||||
nombre: 'Alemania',
|
||||
emoji: '🇩🇪',
|
||||
descripcion: 'Mayor economía de Europa con modelo social de mercado.',
|
||||
caracteristicas: [
|
||||
'Economía social de mercado',
|
||||
'Codeterminación (trabajadores en consejos)',
|
||||
'Fuerte sector industrial privado',
|
||||
'Extensas redes de protección social'
|
||||
],
|
||||
sistemaCorrecto: 'mixto',
|
||||
explicacion: 'Alemania practica el modelo de "economía social de mercado", combinando mercado libre con fuerte estado de bienestar.'
|
||||
}
|
||||
];
|
||||
|
||||
export function CasosPaises({ ejercicioId: _ejercicioId, onComplete }: EjercicioProps) {
|
||||
const [paisActual, setPaisActual] = useState(0);
|
||||
const [respuestas, setRespuestas] = useState<Record<string, SistemaTipo>>({});
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const pais = PAISES[paisActual];
|
||||
const esUltima = paisActual === PAISES.length - 1;
|
||||
|
||||
const handleRespuesta = (sistema: SistemaTipo) => {
|
||||
setRespuestas(prev => ({ ...prev, [pais.id]: sistema }));
|
||||
setMostrarResultado(true);
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (esUltima) {
|
||||
const correctas = PAISES.filter(p => respuestas[p.id] === p.sistemaCorrecto).length;
|
||||
const puntuacion = Math.round((correctas / PAISES.length) * 100);
|
||||
setCompletado(true);
|
||||
onComplete?.(puntuacion);
|
||||
} else {
|
||||
setPaisActual(prev => prev + 1);
|
||||
setMostrarResultado(false);
|
||||
}
|
||||
};
|
||||
|
||||
const esCorrecta = respuestas[pais.id] === pais.sistemaCorrecto;
|
||||
|
||||
if (completado) {
|
||||
const correctas = PAISES.filter(p => respuestas[p.id] === p.sistemaCorrecto).length;
|
||||
const puntuacion = Math.round((correctas / PAISES.length) * 100);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto text-center p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<Globe className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Ejercicio Completado!</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Identificaste correctamente {correctas} de {PAISES.length} países
|
||||
</p>
|
||||
|
||||
<div className="text-5xl font-bold text-blue-600 mb-2">{puntuacion}</div>
|
||||
<p className="text-gray-500">puntos</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
||||
<span>País {paisActual + 1} de {PAISES.length}</span>
|
||||
<span>{Math.round((paisActual / PAISES.length) * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(paisActual / PAISES.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pais.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<span className="text-6xl mb-2 block">{pais.emoji}</span>
|
||||
<h3 className="text-2xl font-bold text-gray-900">{pais.nombre}</h3>
|
||||
<p className="text-gray-600 mt-2">{pais.descripcion}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-6">
|
||||
<h4 className="font-semibold text-gray-700 mb-3">Características económicas:</h4>
|
||||
<ul className="space-y-2">
|
||||
{pais.caracteristicas.map((caracteristica, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-gray-600">
|
||||
<span className="text-blue-500 mt-1">•</span>
|
||||
<span>{caracteristica}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{!mostrarResultado ? (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-700 mb-4">¿Qué sistema económico predomina?</h4>
|
||||
{(Object.keys(SISTEMAS) as SistemaTipo[]).map((sistema) => (
|
||||
<motion.button
|
||||
key={sistema}
|
||||
onClick={() => handleRespuesta(sistema)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="w-full p-4 flex items-center gap-4 border-2 border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50 transition-all"
|
||||
>
|
||||
<div className={`${SISTEMAS[sistema].color} text-white p-2 rounded-lg`}>
|
||||
{SISTEMAS[sistema].icono}
|
||||
</div>
|
||||
<span className="font-medium text-gray-700">{SISTEMAS[sistema].nombre}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-6 rounded-xl mb-6 ${esCorrecta ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{esCorrecta ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
<span className={`font-bold ${esCorrecta ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>Respuesta correcta:</strong> {SISTEMAS[pais.sistemaCorrecto].nombre}
|
||||
</p>
|
||||
<p className="text-gray-600">{pais.explicacion}</p>
|
||||
|
||||
<Button onClick={handleSiguiente} className="mt-4 w-full">
|
||||
{esUltima ? 'Finalizar' : 'Siguiente país'}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CasosPaises;
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface EjercicioProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Categoria {
|
||||
id: string;
|
||||
nombre: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Casilla {
|
||||
id: string;
|
||||
categoria: string;
|
||||
sistema: 'mercado' | 'planificado' | 'mixto' | null;
|
||||
opciones: string[];
|
||||
correcta: string;
|
||||
}
|
||||
|
||||
const CATEGORIAS: Categoria[] = [
|
||||
{ id: 'propiedad', nombre: 'Propiedad de medios de producción', color: 'bg-blue-100' },
|
||||
{ id: 'precios', nombre: 'Fijación de precios', color: 'bg-green-100' },
|
||||
{ id: 'competencia', nombre: 'Competencia', color: 'bg-purple-100' },
|
||||
{ id: 'objetivo', nombre: 'Objetivo principal', color: 'bg-orange-100' },
|
||||
{ id: 'planificacion', nombre: 'Planificación económica', color: 'bg-pink-100' },
|
||||
{ id: 'bienestar', nombre: 'Bienestar social', color: 'bg-teal-100' }
|
||||
];
|
||||
|
||||
const SISTEMAS = [
|
||||
{ id: 'mercado', nombre: 'Economía de Mercado', color: 'bg-blue-500' },
|
||||
{ id: 'planificado', nombre: 'Economía Planificada', color: 'bg-red-500' },
|
||||
{ id: 'mixto', nombre: 'Economía Mixta', color: 'bg-green-500' }
|
||||
];
|
||||
|
||||
const CASILLAS: Casilla[] = [
|
||||
{
|
||||
id: 'propiedad-mercado',
|
||||
categoria: 'propiedad',
|
||||
sistema: 'mercado',
|
||||
opciones: ['Privada', 'Estatal', 'Mixta'],
|
||||
correcta: 'Privada'
|
||||
},
|
||||
{
|
||||
id: 'propiedad-planificado',
|
||||
categoria: 'propiedad',
|
||||
sistema: 'planificado',
|
||||
opciones: ['Privada', 'Estatal', 'Mixta'],
|
||||
correcta: 'Estatal'
|
||||
},
|
||||
{
|
||||
id: 'propiedad-mixto',
|
||||
categoria: 'propiedad',
|
||||
sistema: 'mixto',
|
||||
opciones: ['Privada', 'Estatal', 'Mixta'],
|
||||
correcta: 'Mixta'
|
||||
},
|
||||
{
|
||||
id: 'precios-mercado',
|
||||
categoria: 'precios',
|
||||
sistema: 'mercado',
|
||||
opciones: ['Oferta y demanda', 'Estado', 'Combinación'],
|
||||
correcta: 'Oferta y demanda'
|
||||
},
|
||||
{
|
||||
id: 'precios-planificado',
|
||||
categoria: 'precios',
|
||||
sistema: 'planificado',
|
||||
opciones: ['Oferta y demanda', 'Estado', 'Combinación'],
|
||||
correcta: 'Estado'
|
||||
},
|
||||
{
|
||||
id: 'precios-mixto',
|
||||
categoria: 'precios',
|
||||
sistema: 'mixto',
|
||||
opciones: ['Oferta y demanda', 'Estado', 'Combinación'],
|
||||
correcta: 'Combinación'
|
||||
},
|
||||
{
|
||||
id: 'competencia-mercado',
|
||||
categoria: 'competencia',
|
||||
sistema: 'mercado',
|
||||
opciones: ['Libre', 'No existe', 'Regulada'],
|
||||
correcta: 'Libre'
|
||||
},
|
||||
{
|
||||
id: 'competencia-planificado',
|
||||
categoria: 'competencia',
|
||||
sistema: 'planificado',
|
||||
opciones: ['Libre', 'No existe', 'Regulada'],
|
||||
correcta: 'No existe'
|
||||
},
|
||||
{
|
||||
id: 'competencia-mixto',
|
||||
categoria: 'competencia',
|
||||
sistema: 'mixto',
|
||||
opciones: ['Libre', 'No existe', 'Regulada'],
|
||||
correcta: 'Regulada'
|
||||
},
|
||||
{
|
||||
id: 'objetivo-mercado',
|
||||
categoria: 'objetivo',
|
||||
sistema: 'mercado',
|
||||
opciones: ['Beneficio', 'Igualdad', 'Equilibrio'],
|
||||
correcta: 'Beneficio'
|
||||
},
|
||||
{
|
||||
id: 'objetivo-planificado',
|
||||
categoria: 'objetivo',
|
||||
sistema: 'planificado',
|
||||
opciones: ['Beneficio', 'Igualdad', 'Equilibrio'],
|
||||
correcta: 'Igualdad'
|
||||
},
|
||||
{
|
||||
id: 'objetivo-mixto',
|
||||
categoria: 'objetivo',
|
||||
sistema: 'mixto',
|
||||
opciones: ['Beneficio', 'Igualdad', 'Equilibrio'],
|
||||
correcta: 'Equilibrio'
|
||||
},
|
||||
{
|
||||
id: 'planificacion-mercado',
|
||||
categoria: 'planificacion',
|
||||
sistema: 'mercado',
|
||||
opciones: ['Descentralizada', 'Centralizada', 'Mixta'],
|
||||
correcta: 'Descentralizada'
|
||||
},
|
||||
{
|
||||
id: 'planificacion-planificado',
|
||||
categoria: 'planificacion',
|
||||
sistema: 'planificado',
|
||||
opciones: ['Descentralizada', 'Centralizada', 'Mixta'],
|
||||
correcta: 'Centralizada'
|
||||
},
|
||||
{
|
||||
id: 'planificacion-mixto',
|
||||
categoria: 'planificacion',
|
||||
sistema: 'mixto',
|
||||
opciones: ['Descentralizada', 'Centralizada', 'Mixta'],
|
||||
correcta: 'Mixta'
|
||||
},
|
||||
{
|
||||
id: 'bienestar-mercado',
|
||||
categoria: 'bienestar',
|
||||
sistema: 'mercado',
|
||||
opciones: ['Privado', 'Estatal', 'Combinado'],
|
||||
correcta: 'Privado'
|
||||
},
|
||||
{
|
||||
id: 'bienestar-planificado',
|
||||
categoria: 'bienestar',
|
||||
sistema: 'planificado',
|
||||
opciones: ['Privado', 'Estatal', 'Combinado'],
|
||||
correcta: 'Estatal'
|
||||
},
|
||||
{
|
||||
id: 'bienestar-mixto',
|
||||
categoria: 'bienestar',
|
||||
sistema: 'mixto',
|
||||
opciones: ['Privado', 'Estatal', 'Combinado'],
|
||||
correcta: 'Combinado'
|
||||
}
|
||||
];
|
||||
|
||||
export function ComparativaSistemas({ ejercicioId: _ejercicioId, onComplete }: EjercicioProps) {
|
||||
const [respuestas, setRespuestas] = useState<Record<string, string>>({});
|
||||
const [casillaActual, setCasillaActual] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
|
||||
const casilla = CASILLAS[casillaActual];
|
||||
const esUltima = casillaActual === CASILLAS.length - 1;
|
||||
|
||||
const handleRespuesta = (respuesta: string) => {
|
||||
setRespuestas(prev => ({ ...prev, [casilla.id]: respuesta }));
|
||||
setMostrarResultado(true);
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (esUltima) {
|
||||
const correctas = CASILLAS.filter(c => respuestas[c.id] === c.correcta).length;
|
||||
const puntuacion = Math.round((correctas / CASILLAS.length) * 100);
|
||||
setCompletado(true);
|
||||
onComplete?.(puntuacion);
|
||||
} else {
|
||||
setCasillaActual(prev => prev + 1);
|
||||
setMostrarResultado(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnterior = () => {
|
||||
if (casillaActual > 0) {
|
||||
setCasillaActual(prev => prev - 1);
|
||||
setMostrarResultado(false);
|
||||
}
|
||||
};
|
||||
|
||||
const esCorrecta = respuestas[casilla.id] === casilla.correcta;
|
||||
const categoria = CATEGORIAS.find(c => c.id === casilla.categoria);
|
||||
const sistema = SISTEMAS.find(s => s.id === casilla.sistema);
|
||||
const yaRespondida = respuestas[casilla.id] !== undefined;
|
||||
|
||||
if (completado) {
|
||||
const correctas = CASILLAS.filter(c => respuestas[c.id] === c.correcta).length;
|
||||
const puntuacion = Math.round((correctas / CASILLAS.length) * 100);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto text-center p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Ejercicio Completado!</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Completaste {correctas} de {CASILLAS.length} casillas correctamente
|
||||
</p>
|
||||
|
||||
<div className="text-5xl font-bold text-blue-600 mb-2">{puntuacion}</div>
|
||||
<p className="text-gray-500">puntos</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-3xl mx-auto">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
||||
<span>Casilla {casillaActual + 1} de {CASILLAS.length}</span>
|
||||
<span>{Math.round((Object.keys(respuestas).length / CASILLAS.length) * 100)}% completado</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(Object.keys(respuestas).length / CASILLAS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<div className={`${categoria?.color} p-4 rounded-xl`}>
|
||||
<p className="text-sm font-semibold text-gray-600 mb-1">Categoría</p>
|
||||
<p className="font-bold text-gray-900">{categoria?.nombre}</p>
|
||||
</div>
|
||||
<div className={`${sistema?.color} text-white p-4 rounded-xl`}>
|
||||
<p className="text-sm font-semibold text-white/80 mb-1">Sistema Económico</p>
|
||||
<p className="font-bold">{sistema?.nombre}</p>
|
||||
</div>
|
||||
<div className="bg-gray-100 p-4 rounded-xl">
|
||||
<p className="text-sm font-semibold text-gray-600 mb-1">Progreso</p>
|
||||
<p className="font-bold text-gray-900">{Object.keys(respuestas).length}/{CASILLAS.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{!mostrarResultado ? (
|
||||
<motion.div
|
||||
key="pregunta"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
¿Cómo se caracteriza esta dimensión en {sistema?.nombre}?
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{casilla.opciones.map((opcion) => (
|
||||
<motion.button
|
||||
key={opcion}
|
||||
onClick={() => handleRespuesta(opcion)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="p-4 text-left border-2 border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50 transition-all"
|
||||
>
|
||||
<span className="font-medium text-gray-700">{opcion}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="resultado"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-6 rounded-xl ${esCorrecta ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{esCorrecta ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
<span className={`font-bold ${esCorrecta ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">
|
||||
{esCorrecta
|
||||
? `Correcto. En ${sistema?.nombre}, la ${categoria?.nombre.toLowerCase()} es ${casilla.correcta.toLowerCase()}.`
|
||||
: `La respuesta correcta es: ${casilla.correcta}. En ${sistema?.nombre}, la ${categoria?.nombre.toLowerCase()} se caracteriza por ser ${casilla.correcta.toLowerCase()}.`
|
||||
}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleAnterior} variant="outline" disabled={casillaActual === 0}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button onClick={handleSiguiente}>
|
||||
{esUltima ? 'Finalizar' : 'Siguiente'}
|
||||
{!esUltima && <ArrowRight className="w-4 h-4 ml-2" />}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ComparativaSistemas;
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface OpcionProduccion {
|
||||
bienesA: number;
|
||||
bienesB: number;
|
||||
}
|
||||
|
||||
const datosFPP: OpcionProduccion[] = [
|
||||
{ bienesA: 0, bienesB: 100 },
|
||||
{ bienesA: 20, bienesB: 90 },
|
||||
{ bienesA: 40, bienesB: 75 },
|
||||
{ bienesA: 60, bienesB: 55 },
|
||||
{ bienesA: 80, bienesB: 30 },
|
||||
{ bienesA: 100, bienesB: 0 },
|
||||
];
|
||||
|
||||
export const CostoOportunidadCalculator: React.FC = () => {
|
||||
const [puntoInicial, setPuntoInicial] = useState<number>(0);
|
||||
const [puntoFinal, setPuntoFinal] = useState<number>(1);
|
||||
const [respuestaUsuario, setRespuestaUsuario] = useState<string>('');
|
||||
const [resultado, setResultado] = useState<{
|
||||
correcto: boolean;
|
||||
mensaje: string;
|
||||
costoReal: number;
|
||||
} | null>(null);
|
||||
|
||||
const calcularCostoOportunidad = (inicio: number, fin: number): number => {
|
||||
const opcionInicio = datosFPP[inicio];
|
||||
const opcionFin = datosFPP[fin];
|
||||
|
||||
const cambioBienB = opcionFin.bienesB - opcionInicio.bienesB;
|
||||
const cambioBienA = opcionFin.bienesA - opcionInicio.bienesA;
|
||||
|
||||
if (cambioBienA === 0) return 0;
|
||||
return Math.abs(cambioBienB / cambioBienA);
|
||||
};
|
||||
|
||||
const verificarRespuesta = () => {
|
||||
const costoReal = calcularCostoOportunidad(puntoInicial, puntoFinal);
|
||||
const respuestaNum = parseFloat(respuestaUsuario);
|
||||
|
||||
if (isNaN(respuestaNum)) {
|
||||
setResultado({
|
||||
correcto: false,
|
||||
mensaje: 'Por favor ingresa un número válido',
|
||||
costoReal: costoReal
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const margenError = 0.5;
|
||||
const correcto = Math.abs(respuestaNum - costoReal) <= margenError;
|
||||
|
||||
setResultado({
|
||||
correcto,
|
||||
mensaje: correcto
|
||||
? '¡Correcto! Has calculado bien el costo de oportunidad.'
|
||||
: 'Incorrecto. Revisa tu cálculo.',
|
||||
costoReal: costoReal
|
||||
});
|
||||
};
|
||||
|
||||
const generarNuevoEjercicio = () => {
|
||||
const nuevoInicio = Math.floor(Math.random() * (datosFPP.length - 1));
|
||||
const nuevoFin = nuevoInicio + 1 + Math.floor(Math.random() * (datosFPP.length - nuevoInicio - 1));
|
||||
|
||||
setPuntoInicial(nuevoInicio);
|
||||
setPuntoFinal(nuevoFin);
|
||||
setRespuestaUsuario('');
|
||||
setResultado(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">Calculadora de Costo de Oportunidad</h2>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg mb-6">
|
||||
<h3 className="font-semibold mb-2">Tabla de Posibilidades de Producción:</h3>
|
||||
<table className="w-full text-center">
|
||||
<thead>
|
||||
<tr className="bg-blue-100">
|
||||
<th className="p-2">Opción</th>
|
||||
<th className="p-2">Bien A</th>
|
||||
<th className="p-2">Bien B</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{datosFPP.map((opcion, index) => (
|
||||
<tr key={index} className={(index === puntoInicial || index === puntoFinal) ? 'bg-yellow-200' : ''}>
|
||||
<td className="p-2">{index + 1}</td>
|
||||
<td className="p-2">{opcion.bienesA}</td>
|
||||
<td className="p-2">{opcion.bienesB}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg mb-6">
|
||||
<h3 className="font-semibold mb-2">Ejercicio:</h3>
|
||||
<p className="mb-4">
|
||||
Si la economía se mueve de la <strong>Opción {puntoInicial + 1}</strong> a la
|
||||
<strong> Opción {puntoFinal + 1}</strong>, ¿cuál es el costo de oportunidad
|
||||
de producir una unidad adicional del Bien A?
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<label className="font-medium">Costo de oportunidad:</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={respuestaUsuario}
|
||||
onChange={(e) => setRespuestaUsuario(e.target.value)}
|
||||
className="border p-2 rounded w-32"
|
||||
placeholder="Ej: 0.75"
|
||||
/>
|
||||
<span>unidades del Bien B</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={verificarRespuesta}
|
||||
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
|
||||
>
|
||||
Verificar
|
||||
</button>
|
||||
<button
|
||||
onClick={generarNuevoEjercicio}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
Nuevo Ejercicio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resultado && (
|
||||
<div className={`p-4 rounded-lg ${resultado.correcto ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
<p className="font-medium">{resultado.mensaje}</p>
|
||||
{!resultado.correcto && (
|
||||
<p className="mt-2 text-sm">
|
||||
El costo de oportunidad correcto es: {resultado.costoReal.toFixed(2)} unidades del Bien B
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 bg-yellow-50 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Fórmula:</h4>
|
||||
<p className="text-sm">
|
||||
Costo de Oportunidad = |Cambio en Bien B| / |Cambio en Bien A|
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostoOportunidadCalculator;
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CostoOportunidadCotidianoProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Situacion {
|
||||
id: number;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
decision: string;
|
||||
opciones: string[];
|
||||
costoOportunidadCorrecto: string;
|
||||
explicacion: string;
|
||||
}
|
||||
|
||||
const situaciones: Situacion[] = [
|
||||
{
|
||||
id: 1,
|
||||
titulo: "Tiempo libre",
|
||||
descripcion: "Tienes 3 horas libres un sábado por la tarde.",
|
||||
decision: "Decides estudiar para un examen importante.",
|
||||
opciones: [
|
||||
"El tiempo que podrías haber pasado con amigos",
|
||||
"Las calificaciones del examen",
|
||||
"El dinero ahorrado",
|
||||
"La comida que no comiste"
|
||||
],
|
||||
costoOportunidadCorrecto: "El tiempo que podrías haber pasado con amigos",
|
||||
explicacion: "El costo de oportunidad es lo que sacrificas: el tiempo con amigos que elegiste no hacer."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
titulo: "Compra de tecnología",
|
||||
descripcion: "Tienes $1,000 ahorrados.",
|
||||
decision: "Compras una laptop nueva para trabajar.",
|
||||
opciones: [
|
||||
"El dinero que gastaste",
|
||||
"El dinero que podrías haber invertido",
|
||||
"La laptop misma",
|
||||
"Las especificaciones técnicas"
|
||||
],
|
||||
costoOportunidadCorrecto: "El dinero que podrías haber invertido",
|
||||
explicacion: "Al gastar en la laptop, sacrificas la oportunidad de invertir ese dinero y obtener rendimientos."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
titulo: "Carrera profesional",
|
||||
descripcion: "Terminas la universidad con dos ofertas de trabajo.",
|
||||
decision: "Aceptas el trabajo en una startup con menor salario inicial.",
|
||||
opciones: [
|
||||
"El salario más alto de la otra oferta",
|
||||
"La experiencia en la startup",
|
||||
"Tu título universitario",
|
||||
"El tiempo de búsqueda"
|
||||
],
|
||||
costoOportunidadCorrecto: "El salario más alto de la otra oferta",
|
||||
explicacion: "Al elegir la startup, renuncias al salario más alto que ofrecía la otra empresa."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
titulo: "Vacaciones",
|
||||
descripcion: "Tienes dos semanas de vacaciones este verano.",
|
||||
decision: "Viajas a Europa en lugar de quedarte trabajando.",
|
||||
opciones: [
|
||||
"Las fotos que tomarás",
|
||||
"El dinero que gastarás en el viaje",
|
||||
"El dinero que podrías haber ganado trabajando",
|
||||
"La experiencia cultural"
|
||||
],
|
||||
costoOportunidadCorrecto: "El dinero que podrías haber ganado trabajando",
|
||||
explicacion: "El costo de oportunidad incluye los ingresos que sacrificas al no trabajar esas semanas."
|
||||
}
|
||||
];
|
||||
|
||||
export function CostoOportunidadCotidiano({ ejercicioId: _ejercicioId, onComplete }: CostoOportunidadCotidianoProps) {
|
||||
const [respuestas, setRespuestas] = useState<{[key: number]: string}>({});
|
||||
const [mostrarExplicacion, setMostrarExplicacion] = useState<{[key: number]: boolean}>({});
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const handleSeleccion = (situacionId: number, opcion: string) => {
|
||||
setRespuestas(prev => ({ ...prev, [situacionId]: opcion }));
|
||||
};
|
||||
|
||||
const handleValidar = () => {
|
||||
const nuevasExplicaciones: {[key: number]: boolean} = {};
|
||||
let correctas = 0;
|
||||
|
||||
situaciones.forEach(situacion => {
|
||||
nuevasExplicaciones[situacion.id] = true;
|
||||
if (respuestas[situacion.id] === situacion.costoOportunidadCorrecto) {
|
||||
correctas++;
|
||||
}
|
||||
});
|
||||
|
||||
setMostrarExplicacion(nuevasExplicaciones);
|
||||
|
||||
if (correctas === situaciones.length && !completado) {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setRespuestas({});
|
||||
setMostrarExplicacion({});
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
const correctas = situaciones.filter(s => respuestas[s.id] === s.costoOportunidadCorrecto).length;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900">Costo de Oportunidad en Decisiones Cotidianas</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Identifica el costo de oportunidad en cada situación de la vida real.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-amber-800 text-sm">
|
||||
<strong>Recuerda:</strong> El costo de oportunidad es el valor de la mejor alternativa a la que renuncias
|
||||
al tomar una decisión. No es lo que gastas, sino lo que sacrificas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{situaciones.map((situacion, index) => (
|
||||
<div
|
||||
key={situacion.id}
|
||||
className="border border-gray-200 rounded-lg p-5 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<h4 className="font-semibold text-gray-900 text-lg">{situacion.titulo}</h4>
|
||||
<p className="text-gray-600 mt-1">{situacion.descripcion}</p>
|
||||
<p className="text-blue-700 font-medium mt-2">Decisión: {situacion.decision}</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">
|
||||
¿Cuál es el costo de oportunidad?
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{situacion.opciones.map((opcion) => {
|
||||
const isSelected = respuestas[situacion.id] === opcion;
|
||||
const isCorrect = opcion === situacion.costoOportunidadCorrecto;
|
||||
const showResult = mostrarExplicacion[situacion.id];
|
||||
|
||||
let buttonClass = 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50';
|
||||
|
||||
if (showResult) {
|
||||
if (isCorrect) {
|
||||
buttonClass = 'border-green-500 bg-green-50 text-green-800';
|
||||
} else if (isSelected && !isCorrect) {
|
||||
buttonClass = 'border-red-500 bg-red-50 text-red-800';
|
||||
} else {
|
||||
buttonClass = 'border-gray-200 bg-gray-50 text-gray-400';
|
||||
}
|
||||
} else if (isSelected) {
|
||||
buttonClass = 'border-blue-500 bg-blue-50 text-blue-800';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opcion}
|
||||
onClick={() => !showResult && handleSeleccion(situacion.id, opcion)}
|
||||
disabled={showResult}
|
||||
className={`p-3 rounded-lg text-left text-sm transition-all ${buttonClass}`}
|
||||
>
|
||||
{opcion}
|
||||
{showResult && isCorrect && (
|
||||
<span className="ml-2 text-green-600 font-bold">✓</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mostrarExplicacion[situacion.id] && (
|
||||
<div className={`mt-4 p-4 rounded-lg ${
|
||||
respuestas[situacion.id] === situacion.costoOportunidadCorrecto
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-amber-50 border border-amber-200'
|
||||
}`}>
|
||||
<p className={`text-sm font-medium ${
|
||||
respuestas[situacion.id] === situacion.costoOportunidadCorrecto
|
||||
? 'text-green-800'
|
||||
: 'text-amber-800'
|
||||
}`}>
|
||||
{respuestas[situacion.id] === situacion.costoOportunidadCorrecto
|
||||
? '¡Correcto!'
|
||||
: 'Respuesta correcta:'}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${
|
||||
respuestas[situacion.id] === situacion.costoOportunidadCorrecto
|
||||
? 'text-green-700'
|
||||
: 'text-amber-700'
|
||||
}`}>
|
||||
{situacion.explicacion}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={handleValidar}
|
||||
disabled={completado || Object.keys(respuestas).length < situaciones.length}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
|
||||
>
|
||||
Validar Respuestas
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Reiniciar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{completado && (
|
||||
<div className="mt-6 bg-green-100 border border-green-300 rounded-lg p-4 text-center">
|
||||
<p className="text-green-800 font-semibold">¡Excelente comprensión!</p>
|
||||
<p className="text-green-700 text-2xl font-bold mt-1">100 puntos</p>
|
||||
<p className="text-green-700 text-sm mt-2">
|
||||
Has identificado correctamente todos los costos de oportunidad.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CostoOportunidadCotidiano;
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface PuntoFPP {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const CrecimientoEconomicoFPP: React.FC = () => {
|
||||
const [tipoCambio, setTipoCambio] = useState<string>('');
|
||||
const [factorSeleccionado, setFactorSeleccionado] = useState<string>('');
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState<boolean[]>([false, false]);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
|
||||
const puntosFPPOriginal: PuntoFPP[] = [
|
||||
{ x: 0, y: 100 },
|
||||
{ x: 50, y: 90 },
|
||||
{ x: 100, y: 70 },
|
||||
{ x: 150, y: 40 },
|
||||
{ x: 200, y: 0 },
|
||||
];
|
||||
|
||||
const puntosFPPDesplazada: PuntoFPP[] = [
|
||||
{ x: 0, y: 120 },
|
||||
{ x: 60, y: 108 },
|
||||
{ x: 120, y: 84 },
|
||||
{ x: 180, y: 48 },
|
||||
{ x: 240, y: 0 },
|
||||
];
|
||||
|
||||
const opcionesCambio = [
|
||||
{ valor: 'crecimiento', label: 'Crecimiento económico (desplazamiento hacia afuera)' },
|
||||
{ valor: 'recesion', label: 'Recesión económica (desplazamiento hacia adentro)' },
|
||||
{ valor: 'mejoraA', label: 'Mejora tecnológica solo en Bien A' },
|
||||
{ valor: 'mejoraB', label: 'Mejora tecnológica solo en Bien B' },
|
||||
];
|
||||
|
||||
const opcionesFactores = [
|
||||
{ valor: 'tecnologia', label: 'Avance tecnológico', tipo: 'crecimiento' },
|
||||
{ valor: 'capital', label: 'Aumento del capital físico', tipo: 'crecimiento' },
|
||||
{ valor: 'trabajo', label: 'Aumento de la fuerza laboral', tipo: 'crecimiento' },
|
||||
{ valor: 'recursos', label: 'Descubrimiento de nuevos recursos', tipo: 'crecimiento' },
|
||||
{ valor: 'guerra', label: 'Conflicto bélico', tipo: 'recesion' },
|
||||
{ valor: 'desastre', label: 'Desastre natural', tipo: 'recesion' },
|
||||
{ valor: 'emigracion', label: 'Emigración masiva', tipo: 'recesion' },
|
||||
{ valor: 'destruccion', label: 'Destrucción de capital', tipo: 'recesion' },
|
||||
];
|
||||
|
||||
const verificarRespuestas = () => {
|
||||
const esCrecimiento = tipoCambio === 'crecimiento';
|
||||
const factorEsCrecimiento = ['tecnologia', 'capital', 'trabajo', 'recursos'].includes(factorSeleccionado);
|
||||
|
||||
setRespuestasCorrectas([esCrecimiento, factorEsCrecimiento]);
|
||||
setMostrarResultado(true);
|
||||
};
|
||||
|
||||
const reiniciarEjercicio = () => {
|
||||
setTipoCambio('');
|
||||
setFactorSeleccionado('');
|
||||
setRespuestasCorrectas([false, false]);
|
||||
setMostrarResultado(false);
|
||||
};
|
||||
|
||||
const SVG_HEIGHT = 300;
|
||||
const SVG_WIDTH = 400;
|
||||
const PADDING = 40;
|
||||
|
||||
const escalarX = (x: number) => PADDING + (x / 250) * (SVG_WIDTH - 2 * PADDING);
|
||||
const escalarY = (y: number) => SVG_HEIGHT - PADDING - (y / 130) * (SVG_HEIGHT - 2 * PADDING);
|
||||
|
||||
const crearPath = (puntos: PuntoFPP[]) => {
|
||||
return puntos.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${escalarX(p.x)} ${escalarY(p.y)}`
|
||||
).join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">Crecimiento Económico y Curva FPP</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-4">Gráfico de la Frontera de Posibilidades de Producción</h3>
|
||||
|
||||
<svg viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`} className="w-full border rounded">
|
||||
{/* Ejes */}
|
||||
<line x1={PADDING} y1={SVG_HEIGHT - PADDING} x2={SVG_WIDTH - PADDING} y2={SVG_HEIGHT - PADDING} stroke="black" strokeWidth="2" />
|
||||
<line x1={PADDING} y1={SVG_HEIGHT - PADDING} x2={PADDING} y2={PADDING} stroke="black" strokeWidth="2" />
|
||||
|
||||
{/* Etiquetas de ejes */}
|
||||
<text x={SVG_WIDTH / 2} y={SVG_HEIGHT - 10} textAnchor="middle" fontSize="12">Bien A</text>
|
||||
<text x={15} y={SVG_HEIGHT / 2} textAnchor="middle" fontSize="12" transform={`rotate(-90, 15, ${SVG_HEIGHT / 2})`}>Bien B</text>
|
||||
|
||||
{/* FPP Original */}
|
||||
<path d={crearPath(puntosFPPOriginal)} fill="none" stroke="#3b82f6" strokeWidth="3" strokeDasharray="5,5" />
|
||||
|
||||
{/* FPP Desplazada */}
|
||||
<path d={crearPath(puntosFPPDesplazada)} fill="none" stroke="#22c55e" strokeWidth="3" />
|
||||
|
||||
{/* Leyenda */}
|
||||
<rect x={PADDING + 10} y={PADDING} width="15" height="15" fill="none" stroke="#3b82f6" strokeDasharray="5,5" strokeWidth="2" />
|
||||
<text x={PADDING + 35} y={PADDING + 12} fontSize="12">FPP Original</text>
|
||||
|
||||
<rect x={PADDING + 10} y={PADDING + 25} width="15" height="15" fill="none" stroke="#22c55e" strokeWidth="2" />
|
||||
<text x={PADDING + 35} y={PADDING + 37} fontSize="12">FPP Nueva</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-3">Pregunta 1: ¿Qué tipo de cambio observas en el gráfico?</h3>
|
||||
<div className="space-y-2">
|
||||
{opcionesCambio.map((opcion) => (
|
||||
<label key={opcion.valor} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="tipoCambio"
|
||||
value={opcion.valor}
|
||||
checked={tipoCambio === opcion.valor}
|
||||
onChange={(e) => setTipoCambio(e.target.value)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">{opcion.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-3">Pregunta 2: ¿Qué factor podría causar este cambio?</h3>
|
||||
<select
|
||||
value={factorSeleccionado}
|
||||
onChange={(e) => setFactorSeleccionado(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
>
|
||||
<option value="">Selecciona un factor...</option>
|
||||
<optgroup label="Factores de Crecimiento">
|
||||
{opcionesFactores.filter(f => f.tipo === 'crecimiento').map(f => (
|
||||
<option key={f.valor} value={f.valor}>{f.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Factores de Recesión">
|
||||
{opcionesFactores.filter(f => f.tipo === 'recesion').map(f => (
|
||||
<option key={f.valor} value={f.valor}>{f.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-6">
|
||||
<button
|
||||
onClick={verificarRespuestas}
|
||||
disabled={!tipoCambio || !factorSeleccionado}
|
||||
className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 disabled:bg-gray-300"
|
||||
>
|
||||
Verificar Respuestas
|
||||
</button>
|
||||
<button
|
||||
onClick={reiniciarEjercicio}
|
||||
className="bg-gray-500 text-white px-6 py-2 rounded hover:bg-gray-600"
|
||||
>
|
||||
Reiniciar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mostrarResultado && (
|
||||
<div className="space-y-4">
|
||||
<div className={`p-4 rounded-lg ${respuestasCorrectas[0] ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
<p className="font-medium">
|
||||
Pregunta 1: {respuestasCorrectas[0] ? '¡Correcto!' : 'Incorrecto.'}
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
{respuestasCorrectas[0]
|
||||
? 'El gráfico muestra un crecimiento económico, representado por el desplazamiento hacia afuera de la curva FPP.'
|
||||
: 'El gráfico muestra crecimiento económico (desplazamiento hacia afuera de la FPP).'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-lg ${respuestasCorrectas[1] ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
<p className="font-medium">
|
||||
Pregunta 2: {respuestasCorrectas[1] ? '¡Correcto!' : 'Incorrecto.'}
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
{respuestasCorrectas[1]
|
||||
? 'Excelente selección. Este factor contribuye al crecimiento económico.'
|
||||
: 'Revisa tu selección. Considera qué factores aumentan la capacidad productiva de la economía.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 bg-yellow-50 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Conceptos clave:</h4>
|
||||
<ul className="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Crecimiento económico:</strong> Desplazamiento de la FPP hacia afuera, permite producir más de ambos bienes</li>
|
||||
<li><strong>Recesión:</strong> Desplazamiento de la FPP hacia adentro, reduce la capacidad productiva</li>
|
||||
<li><strong>Factores del crecimiento:</strong> Tecnología, capital, trabajo, recursos naturales</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CrecimientoEconomicoFPP;
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface EjercicioProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
const PREGUNTAS = [
|
||||
{
|
||||
id: 1,
|
||||
pregunta: "¿Qué es la economía?",
|
||||
opciones: [
|
||||
"Ciencia social que estudia cómo se asignan recursos escasos para satisfacer necesidades ilimitadas",
|
||||
"Estudio exclusivo del dinero y los bancos",
|
||||
"Análisis únicamente de empresas grandes",
|
||||
"Gestión de presupuestos familiares"
|
||||
],
|
||||
correcta: 0,
|
||||
explicacion: "La economía es una ciencia social que estudia la asignación de recursos escasos para satisfacer necesidades ilimitadas."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pregunta: "¿Cuál es la diferencia entre microeconomía y macroeconomía?",
|
||||
opciones: [
|
||||
"La micro estudia individuos y empresas; la macro estudia la economía como un todo",
|
||||
"La micro es más difícil que la macro",
|
||||
"La micro estudia solo bancos; la macro estudia gobiernos",
|
||||
"No hay diferencia, son lo mismo"
|
||||
],
|
||||
correcta: 0,
|
||||
explicacion: "La microeconomía estudia el comportamiento de individuos y empresas, mientras que la macroeconomía analiza la economía en su conjunto (PIB, inflación, desempleo)."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pregunta: "¿Qué es el problema económico fundamental?",
|
||||
opciones: [
|
||||
"La escasez de recursos frente a necesidades ilimitadas",
|
||||
"La falta de dinero en los bancos",
|
||||
"El desempleo elevado",
|
||||
"La inflación alta"
|
||||
],
|
||||
correcta: 0,
|
||||
explicacion: "El problema económico fundamental es la escasez: los recursos son limitados pero las necesidades humanas son ilimitadas."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pregunta: "¿Qué estudia la economía positiva?",
|
||||
opciones: [
|
||||
"Lo que es (hechos y descripciones)",
|
||||
"Lo que debería ser (valores y juicios)",
|
||||
"Solo matemáticas económicas",
|
||||
"Únicamente historia económica"
|
||||
],
|
||||
correcta: 0,
|
||||
explicacion: "La economía positiva describe y explica hechos objetivos ('lo que es'), sin hacer juicios de valor."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pregunta: "Complete: La economía normativa se refiere a...",
|
||||
opciones: [
|
||||
"Juicios de valor sobre lo que debería ser",
|
||||
"Datos estadísticos objetivos",
|
||||
"Teorías matemáticas puras",
|
||||
"Hechos históricos verificables"
|
||||
],
|
||||
correcta: 0,
|
||||
explicacion: "La economía normativa hace juicios de valor y prescripciones sobre lo que debería ser ('deberíamos aumentar los impuestos')."
|
||||
}
|
||||
];
|
||||
|
||||
export function DefinicionEconomiaQuiz({ ejercicioId: _ejercicioId, onComplete }: EjercicioProps) {
|
||||
const [preguntaActual, setPreguntaActual] = useState(0);
|
||||
const [respuestas, setRespuestas] = useState<number[]>([]);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const pregunta = PREGUNTAS[preguntaActual];
|
||||
const esUltima = preguntaActual === PREGUNTAS.length - 1;
|
||||
|
||||
const handleRespuesta = (index: number) => {
|
||||
const nuevasRespuestas = [...respuestas, index];
|
||||
setRespuestas(nuevasRespuestas);
|
||||
|
||||
if (esUltima) {
|
||||
// Calcular puntuación
|
||||
const correctas = nuevasRespuestas.filter((r, i) => r === PREGUNTAS[i].correcta).length;
|
||||
const puntuacion = Math.round((correctas / PREGUNTAS.length) * 100);
|
||||
setCompletado(true);
|
||||
onComplete?.(puntuacion);
|
||||
} else {
|
||||
setMostrarResultado(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setMostrarResultado(false);
|
||||
};
|
||||
|
||||
const esCorrecta = respuestas[preguntaActual] === pregunta.correcta;
|
||||
|
||||
if (completado) {
|
||||
const correctas = respuestas.filter((r, i) => r === PREGUNTAS[i].correcta).length;
|
||||
const puntuacion = Math.round((correctas / PREGUNTAS.length) * 100);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto text-center p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Quiz Completado!</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Respondiste {correctas} de {PREGUNTAS.length} preguntas correctamente
|
||||
</p>
|
||||
|
||||
<div className="text-5xl font-bold text-blue-600 mb-2">{puntuacion}</div>
|
||||
<p className="text-gray-500">puntos</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="p-6">
|
||||
{/* Progress */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
||||
<span>Pregunta {preguntaActual + 1} de {PREGUNTAS.length}</span>
|
||||
<span>{Math.round(((preguntaActual) / PREGUNTAS.length) * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((preguntaActual) / PREGUNTAS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pregunta */}
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6">{pregunta.pregunta}</h3>
|
||||
|
||||
{/* Opciones */}
|
||||
{!mostrarResultado ? (
|
||||
<div className="space-y-3">
|
||||
{pregunta.opciones.map((opcion, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleRespuesta(index)}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="w-full p-4 text-left border-2 border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50 transition-all"
|
||||
>
|
||||
<span className="font-medium text-gray-700">{String.fromCharCode(65 + index)}. {opcion}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-6 rounded-xl mb-6 ${esCorrecta ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{esCorrecta ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
<span className={`font-bold ${esCorrecta ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700">{pregunta.explicacion}</p>
|
||||
|
||||
<Button onClick={handleSiguiente} className="mt-4">
|
||||
{esUltima ? 'Finalizar' : 'Siguiente'}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DefinicionEconomiaQuiz;
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle, RefreshCw, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface EjercicioProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Enunciado {
|
||||
id: number;
|
||||
texto: string;
|
||||
tipo: 'positiva' | 'normativa';
|
||||
explicacion: string;
|
||||
}
|
||||
|
||||
const ENUNCIADOS: Enunciado[] = [
|
||||
{
|
||||
id: 1,
|
||||
texto: "La inflación en el país alcanzó el 5% el año pasado.",
|
||||
tipo: 'positiva',
|
||||
explicacion: "Este es un enunciado positivo porque describe un hecho objetivo y verificable."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
texto: "El gobierno debería reducir los impuestos para estimular la economía.",
|
||||
tipo: 'normativa',
|
||||
explicacion: "Este es un enunciado normativo porque expresa una opinión sobre lo que debería hacerse."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
texto: "La tasa de desempleo juvenil es del 15%.",
|
||||
tipo: 'positiva',
|
||||
explicacion: "Es positivo porque presenta un dato estadístico verificable."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
texto: "Es injusto que existan grandes diferencias de ingreso entre ricos y pobres.",
|
||||
tipo: 'normativa',
|
||||
explicacion: "Es normativo porque contiene un juicio de valor sobre la justicia."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
texto: "El PIB del país creció un 3% durante el último trimestre.",
|
||||
tipo: 'positiva',
|
||||
explicacion: "Es positivo porque es una afirmación factual basada en datos."
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
texto: "Se debería aumentar el salario mínimo para mejorar la calidad de vida.",
|
||||
tipo: 'normativa',
|
||||
explicacion: "Es normativo porque prescribe una acción basada en valores."
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
texto: "El costo de vida en la capital es 20% más alto que en el interior.",
|
||||
tipo: 'positiva',
|
||||
explicacion: "Es positivo porque compara datos observables y mensurables."
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
texto: "Las empresas multinacionales tienen la obligación ética de pagar impuestos justos.",
|
||||
tipo: 'normativa',
|
||||
explicacion: "Es normativo porque habla de obligaciones éticas y valores."
|
||||
}
|
||||
];
|
||||
|
||||
export function EconomiaPositivaVsNormativa({ ejercicioId: _ejercicioId, onComplete }: EjercicioProps) {
|
||||
const [enunciadosRestantes, setEnunciadosRestantes] = useState<Enunciado[]>([...ENUNCIADOS]);
|
||||
const [clasificaciones, setClasificaciones] = useState<{id: number, correcta: boolean}[]>([]);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [ultimaRespuesta, setUltimaRespuesta] = useState<'positiva' | 'normativa' | null>(null);
|
||||
|
||||
const enunciadoActual = enunciadosRestantes[0];
|
||||
const progreso = ((ENUNCIADOS.length - enunciadosRestantes.length) / ENUNCIADOS.length) * 100;
|
||||
|
||||
const handleClasificacion = (tipo: 'positiva' | 'normativa') => {
|
||||
if (!enunciadoActual) return;
|
||||
|
||||
const esCorrecta = tipo === enunciadoActual.tipo;
|
||||
setUltimaRespuesta(tipo);
|
||||
setClasificaciones(prev => [...prev, { id: enunciadoActual.id, correcta: esCorrecta }]);
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (enunciadosRestantes.length === 1) {
|
||||
const nuevasClasificaciones = [...clasificaciones, { id: enunciadoActual.id, correcta: esCorrecta }];
|
||||
const correctas = nuevasClasificaciones.filter(c => c.correcta).length;
|
||||
const puntuacion = Math.round((correctas / ENUNCIADOS.length) * 100);
|
||||
setCompletado(true);
|
||||
onComplete?.(puntuacion);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
setEnunciadosRestantes(prev => prev.slice(1));
|
||||
setMostrarResultado(false);
|
||||
setUltimaRespuesta(null);
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setEnunciadosRestantes([...ENUNCIADOS]);
|
||||
setClasificaciones([]);
|
||||
setMostrarResultado(false);
|
||||
setCompletado(false);
|
||||
setUltimaRespuesta(null);
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
const correctas = clasificaciones.filter(c => c.correcta).length;
|
||||
const puntuacion = Math.round((correctas / ENUNCIADOS.length) * 100);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto text-center p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className={`w-20 h-20 rounded-full flex items-center justify-center mx-auto ${puntuacion >= 70 ? 'bg-green-100' : 'bg-yellow-100'}`}>
|
||||
{puntuacion >= 70 ? (
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
) : (
|
||||
<RefreshCw className="w-10 h-10 text-yellow-600" />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{puntuacion >= 70 ? '¡Excelente trabajo!' : '¡Sigue practicando!'}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Clasificaste correctamente {correctas} de {ENUNCIADOS.length} enunciados
|
||||
</p>
|
||||
|
||||
<div className="text-5xl font-bold text-blue-600 mb-2">{puntuacion}</div>
|
||||
<p className="text-gray-500 mb-6">puntos</p>
|
||||
|
||||
{puntuacion < 70 && (
|
||||
<Button onClick={handleReiniciar} variant="outline" className="flex items-center gap-2 mx-auto">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Intentar de nuevo
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!enunciadoActual) return null;
|
||||
|
||||
const esCorrecta = ultimaRespuesta === enunciadoActual.tipo;
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">Clasifica el enunciado</h3>
|
||||
<p className="text-sm text-gray-500">¿Es una afirmación positiva o normativa?</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{ENUNCIADOS.length - enunciadosRestantes.length + 1} / {ENUNCIADOS.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-6">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progreso}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enunciado */}
|
||||
<AnimatePresence mode="wait">
|
||||
{!mostrarResultado ? (
|
||||
<motion.div
|
||||
key="pregunta"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
>
|
||||
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
||||
<p className="text-lg text-gray-800 italic">"{enunciadoActual.texto}"</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<motion.button
|
||||
onClick={() => handleClasificacion('positiva')}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="p-4 border-2 border-blue-200 rounded-xl hover:border-blue-400 hover:bg-blue-50 transition-all text-center"
|
||||
>
|
||||
<div className="font-semibold text-blue-800 mb-1">Economía Positiva</div>
|
||||
<p className="text-xs text-gray-600">Describe hechos objetivos</p>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => handleClasificacion('normativa')}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="p-4 border-2 border-purple-200 rounded-xl hover:border-purple-400 hover:bg-purple-50 transition-all text-center"
|
||||
>
|
||||
<div className="font-semibold text-purple-800 mb-1">Economía Normativa</div>
|
||||
<p className="text-xs text-gray-600">Expresa juicios de valor</p>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="resultado"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-6 rounded-xl ${esCorrecta ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{esCorrecta ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
<span className={`font-bold ${esCorrecta ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600 mb-1">Respuesta correcta:</p>
|
||||
<p className={`font-medium ${enunciadoActual.tipo === 'positiva' ? 'text-blue-700' : 'text-purple-700'}`}>
|
||||
Economía {enunciadoActual.tipo === 'positiva' ? 'Positiva' : 'Normativa'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-4">{enunciadoActual.explicacion}</p>
|
||||
|
||||
<Button onClick={handleSiguiente} className="flex items-center gap-2">
|
||||
{enunciadosRestantes.length === 1 ? 'Finalizar' : 'Siguiente'}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-400 mt-1 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Positiva</p>
|
||||
<p className="text-gray-500 text-xs">Lo que es (hechos)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-purple-400 mt-1 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Normativa</p>
|
||||
<p className="text-gray-500 text-xs">Lo que debería ser (valores)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default EconomiaPositivaVsNormativa;
|
||||
189
frontend/src/components/exercises/modulo1/EscasezSimulator.tsx
Normal file
189
frontend/src/components/exercises/modulo1/EscasezSimulator.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface EjercicioProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
const NECESIDADES = [
|
||||
{ id: 'alimentacion', nombre: 'Alimentación', icono: '🍽️' },
|
||||
{ id: 'vivienda', nombre: 'Vivienda', icono: '🏠' },
|
||||
{ id: 'educacion', nombre: 'Educación', icono: '📚' },
|
||||
{ id: 'salud', nombre: 'Salud', icono: '🏥' }
|
||||
];
|
||||
|
||||
export function EscasezSimulator({ ejercicioId: _ejercicioId, onComplete }: EjercicioProps) {
|
||||
const [asignaciones, setAsignaciones] = useState<Record<string, number>>({
|
||||
alimentacion: 25,
|
||||
vivienda: 25,
|
||||
educacion: 25,
|
||||
salud: 25
|
||||
});
|
||||
const [validado, setValidado] = useState(false);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const total = Object.values(asignaciones).reduce((sum, val) => sum + val, 0);
|
||||
const restante = 100 - total;
|
||||
const excedido = total > 100;
|
||||
|
||||
const handleSliderChange = (id: string, value: number) => {
|
||||
setAsignaciones(prev => ({ ...prev, [id]: value }));
|
||||
setValidado(false);
|
||||
};
|
||||
|
||||
const handleValidar = () => {
|
||||
if (excedido) return;
|
||||
|
||||
setValidado(true);
|
||||
|
||||
// Calcular puntuación basada en equilibrio
|
||||
// Ideal: todas las necesidades tienen al menos 15 puntos y no se excede
|
||||
const valores = Object.values(asignaciones);
|
||||
const todasConMinimo = valores.every(v => v >= 15);
|
||||
const sumaExacta = total === 100;
|
||||
|
||||
let puntuacion = 0;
|
||||
if (sumaExacta) {
|
||||
puntuacion = 60; // Base por usar exactamente 100
|
||||
if (todasConMinimo) puntuacion += 40; // Bonus por equilibrio
|
||||
} else if (total <= 100) {
|
||||
puntuacion = Math.round((total / 100) * 50); // Proporcional si no usa todo
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setCompletado(true);
|
||||
onComplete?.(puntuacion);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto text-center p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Simulación Completada!</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Has distribuido los recursos disponibles.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-6">
|
||||
<p className="text-sm text-gray-600 mb-2">Distribución final:</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
{NECESIDADES.map(nec => (
|
||||
<div key={nec.id} className="flex justify-between">
|
||||
<span>{nec.icono} {nec.nombre}:</span>
|
||||
<span className="font-bold">{asignaciones[nec.id]} pts</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">Simulador de Escasez</h3>
|
||||
<p className="text-gray-600">
|
||||
Tienes <span className="font-bold text-blue-600">100 puntos</span> para distribuir entre 4 necesidades básicas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Indicador de recursos */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className={excedido ? 'text-red-600 font-bold' : 'text-gray-600'}>
|
||||
{excedido ? '¡Excedido!' : `Restante: ${restante} pts`}
|
||||
</span>
|
||||
<span className={`font-bold ${excedido ? 'text-red-600' : total === 100 ? 'text-green-600' : 'text-blue-600'}`}>
|
||||
{total} / 100
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<motion.div
|
||||
className={`h-3 rounded-full transition-all ${
|
||||
excedido ? 'bg-red-500' : total === 100 ? 'bg-green-500' : 'bg-blue-500'
|
||||
}`}
|
||||
animate={{ width: `${Math.min(total, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{excedido && (
|
||||
<p className="text-red-600 text-sm mt-2 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Has excedido los 100 puntos disponibles. Reduce alguna asignación.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sliders */}
|
||||
<div className="space-y-6 mb-6">
|
||||
{NECESIDADES.map(necesidad => (
|
||||
<div key={necesidad.id} className="bg-gray-50 p-4 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{necesidad.icono}</span>
|
||||
<span className="font-medium text-gray-700">{necesidad.nombre}</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
{asignaciones[necesidad.id]} pts
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="50"
|
||||
value={asignaciones[necesidad.id]}
|
||||
onChange={(e) => handleSliderChange(necesidad.id, parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>0</span>
|
||||
<span>50</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Botón validar */}
|
||||
<Button
|
||||
onClick={handleValidar}
|
||||
disabled={excedido}
|
||||
className="w-full"
|
||||
variant={excedido ? 'outline' : 'primary'}
|
||||
>
|
||||
{excedido ? 'Ajusta las asignaciones' : 'Validar Distribución'}
|
||||
</Button>
|
||||
|
||||
{validado && !excedido && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-xl text-center"
|
||||
>
|
||||
<p className="text-blue-800">
|
||||
{total === 100
|
||||
? '¡Excelente! Has utilizado todos los recursos disponibles.'
|
||||
: `Has utilizado ${total} de 100 puntos. ¿Quieres ajustar o continuar?`}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default EscasezSimulator;
|
||||
478
frontend/src/components/exercises/modulo1/FPPAnalizador.tsx
Normal file
478
frontend/src/components/exercises/modulo1/FPPAnalizador.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useState, useCallback, DragEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCcw,
|
||||
Link2,
|
||||
GripVertical,
|
||||
Trophy,
|
||||
Scale,
|
||||
Target,
|
||||
Zap,
|
||||
BookOpen
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MatchingItem {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Match {
|
||||
leftId: string;
|
||||
rightId: string;
|
||||
isCorrect?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
interface FPPAnalizadorProps {
|
||||
onComplete?: (score: number, total: number) => void;
|
||||
}
|
||||
|
||||
const PUNTOS_INICIALES = [
|
||||
{ x: 15, y: 85, tipo: 'ineficiente', label: 'A' },
|
||||
{ x: 45, y: 55, tipo: 'eficiente', label: 'B' },
|
||||
{ x: 75, y: 25, tipo: 'inalcanzable', label: 'C' },
|
||||
{ x: 30, y: 70, tipo: 'ineficiente', label: 'D' },
|
||||
{ x: 60, y: 40, tipo: 'eficiente', label: 'E' },
|
||||
{ x: 90, y: 10, tipo: 'inalcanzable', label: 'F' },
|
||||
];
|
||||
|
||||
const TIPOS_OPCIONES = [
|
||||
{ id: 'eficiente', label: 'Eficiente', color: 'green', icon: CheckCircle, descripcion: 'En la FPP - máxima producción' },
|
||||
{ id: 'ineficiente', label: 'Ineficiente', color: 'orange', icon: Zap, descripcion: 'Dentro de la FPP - recursos subutilizados' },
|
||||
{ id: 'inalcanzable', label: 'Inalcanzable', color: 'red', icon: XCircle, descripcion: 'Fuera de la FPP - no hay recursos suficientes' },
|
||||
];
|
||||
|
||||
export function FPPAnalizador({ onComplete }: FPPAnalizadorProps) {
|
||||
const [asignaciones, setAsignaciones] = useState<Record<string, string | null>>(() =>
|
||||
PUNTOS_INICIALES.reduce((acc, punto) => ({ ...acc, [punto.label]: null }), {})
|
||||
);
|
||||
const [mostrarResultados, setMostrarResultados] = useState(false);
|
||||
const [draggedTipo, setDraggedTipo] = useState<string | null>(null);
|
||||
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>, tipoId: string) => {
|
||||
e.dataTransfer.setData('text/plain', tipoId);
|
||||
setDraggedTipo(tipoId);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedTipo(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<SVGGElement>, puntoLabel: string) => {
|
||||
e.preventDefault();
|
||||
const tipoId = e.dataTransfer.getData('text/plain');
|
||||
if (tipoId) {
|
||||
setAsignaciones(prev => ({ ...prev, [puntoLabel]: tipoId }));
|
||||
}
|
||||
setDraggedTipo(null);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<SVGGElement>) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleAsignar = (puntoLabel: string, tipoId: string) => {
|
||||
setAsignaciones(prev => ({ ...prev, [puntoLabel]: tipoId }));
|
||||
};
|
||||
|
||||
const handleVerificar = () => {
|
||||
setMostrarResultados(true);
|
||||
const correctas = PUNTOS_INICIALES.filter(
|
||||
punto => asignaciones[punto.label] === punto.tipo
|
||||
).length;
|
||||
if (onComplete) {
|
||||
onComplete(correctas, PUNTOS_INICIALES.length);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setAsignaciones(PUNTOS_INICIALES.reduce((acc, punto) => ({ ...acc, [punto.label]: null }), {}));
|
||||
setMostrarResultados(false);
|
||||
};
|
||||
|
||||
const todasAsignadas = Object.values(asignaciones).every(a => a !== null);
|
||||
const correctas = PUNTOS_INICIALES.filter(
|
||||
punto => asignaciones[punto.label] === punto.tipo
|
||||
).length;
|
||||
|
||||
// Generar curva FPP
|
||||
const generateFPPPath = () => {
|
||||
const puntos = [
|
||||
{ x: 10, y: 90 },
|
||||
{ x: 20, y: 85 },
|
||||
{ x: 30, y: 78 },
|
||||
{ x: 40, y: 70 },
|
||||
{ x: 50, y: 60 },
|
||||
{ x: 60, y: 50 },
|
||||
{ x: 70, y: 40 },
|
||||
{ x: 80, y: 30 },
|
||||
{ x: 90, y: 20 },
|
||||
];
|
||||
|
||||
let path = `M ${puntos[0].x} ${puntos[0].y}`;
|
||||
for (let i = 1; i < puntos.length; i++) {
|
||||
const cp1x = puntos[i - 1].x + (puntos[i].x - puntos[i - 1].x) * 0.5;
|
||||
const cp1y = puntos[i - 1].y;
|
||||
const cp2x = puntos[i - 1].x + (puntos[i].x - puntos[i - 1].x) * 0.5;
|
||||
const cp2y = puntos[i].y;
|
||||
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${puntos[i].x} ${puntos[i].y}`;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const getEstiloPunto = (punto: typeof PUNTOS_INICIALES[0]) => {
|
||||
const asignacion = asignaciones[punto.label];
|
||||
if (!mostrarResultados) {
|
||||
return {
|
||||
fill: asignacion ? {
|
||||
eficiente: '#22c55e',
|
||||
ineficiente: '#f97316',
|
||||
inalcanzable: '#ef4444',
|
||||
}[asignacion] : '#3b82f6',
|
||||
stroke: '#1e40af',
|
||||
strokeWidth: 2,
|
||||
};
|
||||
}
|
||||
|
||||
const esCorrecto = asignacion === punto.tipo;
|
||||
return {
|
||||
fill: esCorrecto ? '#22c55e' : '#ef4444',
|
||||
stroke: esCorrecto ? '#166534' : '#991b1b',
|
||||
strokeWidth: 3,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Analizador de la FPP</h3>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Identifica si cada punto es Eficiente, Ineficiente o Inalcanzable
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-1.5 rounded-full">
|
||||
<Target size={16} />
|
||||
<span className="font-semibold text-sm">100 pts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leyenda */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen size={18} className="text-blue-600" />
|
||||
<h4 className="font-semibold text-gray-800">Arrastra el tipo a cada punto:</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{TIPOS_OPCIONES.map((tipo) => {
|
||||
const Icon = tipo.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={tipo.id}
|
||||
draggable={!mostrarResultados}
|
||||
onDragStart={(e) => handleDragStart(e as unknown as DragEvent<HTMLDivElement>, tipo.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
whileHover={!mostrarResultados ? { scale: 1.02 } : {}}
|
||||
whileTap={!mostrarResultados ? { scale: 0.98 } : {}}
|
||||
className={`p-3 rounded-lg border-2 cursor-grab active:cursor-grabbing ${
|
||||
tipo.color === 'green' ? 'border-green-200 bg-green-50' :
|
||||
tipo.color === 'orange' ? 'border-orange-200 bg-orange-50' :
|
||||
'border-red-200 bg-red-50'
|
||||
} ${draggedTipo === tipo.id ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={18} className={
|
||||
tipo.color === 'green' ? 'text-green-600' :
|
||||
tipo.color === 'orange' ? 'text-orange-600' :
|
||||
'text-red-600'
|
||||
} />
|
||||
<div>
|
||||
<span className={`font-semibold text-sm ${
|
||||
tipo.color === 'green' ? 'text-green-800' :
|
||||
tipo.color === 'orange' ? 'text-orange-800' :
|
||||
'text-red-800'
|
||||
}`}>
|
||||
{tipo.label}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{tipo.descripcion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* SVG con FPP */}
|
||||
<Card className="p-4 overflow-hidden">
|
||||
<div className="relative">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-auto aspect-square max-h-[500px]">
|
||||
{/* Fondo con gradientes */}
|
||||
<defs>
|
||||
<linearGradient id="areaFactible" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#dcfce7" stopOpacity="0.8" />
|
||||
<stop offset="100%" stopColor="#dcfce7" stopOpacity="0.4" />
|
||||
</linearGradient>
|
||||
<linearGradient id="areaInalcanzable" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#fee2e2" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="#fee2e2" stopOpacity="0.1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Áreas */}
|
||||
<path
|
||||
d={`${generateFPPPath()} L 90 90 L 10 90 Z`}
|
||||
fill="url(#areaFactible)"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d={`M 10 10 L 90 10 L 90 20 ${generateFPPPath().split('L').slice(1).join('L')} Z`}
|
||||
fill="url(#areaInalcanzable)"
|
||||
stroke="none"
|
||||
/>
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1="10" y1="90" x2="95" y2="90" stroke="#374151" strokeWidth="0.5" strokeLinecap="round" />
|
||||
<line x1="10" y1="90" x2="10" y2="5" stroke="#374151" strokeWidth="0.5" strokeLinecap="round" />
|
||||
|
||||
{/* Flechas de ejes */}
|
||||
<polygon points="95,90 92,88.5 92,91.5" fill="#374151" />
|
||||
<polygon points="10,5 8.5,8 11.5,8" fill="#374151" />
|
||||
|
||||
{/* Etiquetas de ejes */}
|
||||
<text x="50" y="97" textAnchor="middle" className="text-[3px] fill-gray-700 font-medium">
|
||||
Bien de Consumo (Y)
|
||||
</text>
|
||||
<text x="3" y="50" textAnchor="middle" transform="rotate(-90, 3, 50)" className="text-[3px] fill-gray-700 font-medium">
|
||||
Bien de Capital (X)
|
||||
</text>
|
||||
|
||||
{/* Curva FPP */}
|
||||
<path
|
||||
d={generateFPPPath()}
|
||||
fill="none"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Puntos interactivos */}
|
||||
{PUNTOS_INICIALES.map((punto) => {
|
||||
const estilo = getEstiloPunto(punto);
|
||||
const asignacion = asignaciones[punto.label];
|
||||
|
||||
return (
|
||||
<g
|
||||
key={punto.label}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e as unknown as DragEvent<SVGGElement>, punto.label)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{/* Círculo del punto */}
|
||||
<motion.circle
|
||||
cx={punto.x}
|
||||
cy={punto.y}
|
||||
r="3"
|
||||
fill={estilo.fill}
|
||||
stroke={estilo.stroke}
|
||||
strokeWidth={estilo.strokeWidth}
|
||||
whileHover={{ r: 4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
|
||||
{/* Label del punto */}
|
||||
<text
|
||||
x={punto.x}
|
||||
y={punto.y - 5}
|
||||
textAnchor="middle"
|
||||
className="text-[3.5px] fill-gray-800 font-bold"
|
||||
>
|
||||
{punto.label}
|
||||
</text>
|
||||
|
||||
{/* Indicador de asignación */}
|
||||
{asignacion && !mostrarResultados && (
|
||||
<text
|
||||
x={punto.x}
|
||||
y={punto.y + 6}
|
||||
textAnchor="middle"
|
||||
className={`text-[2.5px] font-medium ${
|
||||
asignacion === 'eficiente' ? 'fill-green-600' :
|
||||
asignacion === 'ineficiente' ? 'fill-orange-600' :
|
||||
'fill-red-600'
|
||||
}`}
|
||||
>
|
||||
{TIPOS_OPCIONES.find(t => t.id === asignacion)?.label}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Checkmark o X si hay resultado */}
|
||||
{mostrarResultados && (
|
||||
<g transform={`translate(${punto.x - 4}, ${punto.y - 4})`}>
|
||||
{asignacion === punto.tipo ? (
|
||||
<circle cx="0" cy="0" r="2" fill="#22c55e" />
|
||||
) : (
|
||||
<circle cx="0" cy="0" r="2" fill="#ef4444" />
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Leyenda en el SVG */}
|
||||
<g transform="translate(75, 15)">
|
||||
<rect x="-8" y="-3" width="22" height="14" rx="1" fill="white" stroke="#e5e7eb" strokeWidth="0.3" />
|
||||
<text x="0" y="0" className="text-[2px] fill-gray-700 font-semibold">Leyenda:</text>
|
||||
<line x1="0" y1="3" x2="6" y2="3" stroke="#2563eb" strokeWidth="0.5" />
|
||||
<text x="8" y="4" className="text-[1.8px] fill-gray-600">FPP</text>
|
||||
<rect x="0" y="6" width="4" height="3" fill="#dcfce7" stroke="none" />
|
||||
<text x="6" y="8.5" className="text-[1.8px] fill-gray-600">Factible</text>
|
||||
<rect x="0" y="10" width="4" height="3" fill="#fee2e2" stroke="none" />
|
||||
<text x="6" y="12.5" className="text-[1.8px] fill-gray-600">Inalcanzable</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Botones de asignación alternativos (para móvil) */}
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
{PUNTOS_INICIALES.map((punto) => (
|
||||
<div key={punto.label} className="border rounded-lg p-2 bg-gray-50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full ${
|
||||
asignaciones[punto.label] === 'eficiente' ? 'bg-green-500' :
|
||||
asignaciones[punto.label] === 'ineficiente' ? 'bg-orange-500' :
|
||||
asignaciones[punto.label] === 'inalcanzable' ? 'bg-red-500' :
|
||||
'bg-blue-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-bold text-gray-800">Punto {punto.label}</span>
|
||||
</div>
|
||||
{!mostrarResultados && (
|
||||
<select
|
||||
value={asignaciones[punto.label] || ''}
|
||||
onChange={(e) => handleAsignar(punto.label, e.target.value)}
|
||||
className="w-full text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
{TIPOS_OPCIONES.map(tipo => (
|
||||
<option key={tipo.id} value={tipo.id}>{tipo.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{mostrarResultados && (
|
||||
<div className={`text-xs font-medium ${
|
||||
asignaciones[punto.label] === punto.tipo ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{asignaciones[punto.label] === punto.tipo ? '✓ Correcto' : '✗ Incorrecto'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Resultados */}
|
||||
<AnimatePresence>
|
||||
{mostrarResultados && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<Card className={`p-6 ${
|
||||
correctas === PUNTOS_INICIALES.length
|
||||
? 'bg-gradient-to-br from-green-50 to-emerald-50 border-green-200'
|
||||
: 'bg-gradient-to-br from-blue-50 to-indigo-50 border-blue-200'
|
||||
}`}>
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, delay: 0.2 }}
|
||||
className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-4 ${
|
||||
correctas === PUNTOS_INICIALES.length
|
||||
? 'bg-gradient-to-br from-yellow-400 to-orange-500'
|
||||
: 'bg-gradient-to-br from-blue-400 to-indigo-500'
|
||||
}`}
|
||||
>
|
||||
<Trophy size={32} className="text-white" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{correctas === PUNTOS_INICIALES.length
|
||||
? '¡Perfecto!'
|
||||
: correctas >= PUNTOS_INICIALES.length * 0.7
|
||||
? '¡Muy bien!'
|
||||
: '¡Sigue practicando!'}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-4">
|
||||
{correctas} de {PUNTOS_INICIALES.length} puntos clasificados correctamente
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 max-w-md mx-auto">
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<CheckCircle className="w-5 h-5 text-green-500 mx-auto mb-2" />
|
||||
<p className="text-xl font-bold text-green-700">{correctas}</p>
|
||||
<p className="text-xs text-green-600">Correctos</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<XCircle className="w-5 h-5 text-red-500 mx-auto mb-2" />
|
||||
<p className="text-xl font-bold text-red-700">{PUNTOS_INICIALES.length - correctas}</p>
|
||||
<p className="text-xs text-red-600">Incorrectos</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<Scale className="w-5 h-5 text-blue-500 mx-auto mb-2" />
|
||||
<p className="text-xl font-bold text-blue-700">
|
||||
{Math.round((correctas / PUNTOS_INICIALES.length) * 100)}%
|
||||
</p>
|
||||
<p className="text-xs text-blue-600">Precisión</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Botones de acción */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button variant="outline" onClick={handleReiniciar}>
|
||||
<RefreshCcw size={16} className="mr-2" />
|
||||
Reiniciar
|
||||
</Button>
|
||||
|
||||
{!mostrarResultados ? (
|
||||
<Button onClick={handleVerificar} disabled={!todasAsignadas}>
|
||||
Verificar Respuestas
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleReiniciar}>
|
||||
Intentar de Nuevo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instrucciones */}
|
||||
{!todasAsignadas && !mostrarResultados && (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
Arrastra los tipos hacia los puntos en el gráfico o usa los selectores debajo.
|
||||
Faltan <span className="font-semibold text-blue-600">
|
||||
{PUNTOS_INICIALES.length - Object.values(asignaciones).filter(Boolean).length}
|
||||
</span> puntos por clasificar.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FPPAnalizador;
|
||||
554
frontend/src/components/exercises/modulo1/FPPConstructor.tsx
Normal file
554
frontend/src/components/exercises/modulo1/FPPConstructor.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCcw,
|
||||
Trophy,
|
||||
Move,
|
||||
Info,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
|
||||
interface PuntoFPP {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface FPPConstructorProps {
|
||||
onComplete?: (score: number, total: number) => void;
|
||||
}
|
||||
|
||||
const ESCENARIOS = [
|
||||
{
|
||||
id: 1,
|
||||
titulo: 'Economía de Bienes',
|
||||
descripcion: 'Un país produce solo dos bienes: Alimentos y Tecnología.',
|
||||
objetivo: 'Construye una FPP convexa que muestre la creciente escasez de recursos.',
|
||||
puntosRequeridos: 5,
|
||||
tipo: 'convexa',
|
||||
maxX: 100,
|
||||
maxY: 100,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
titulo: 'Especialización Laboral',
|
||||
descripcion: 'Dos trabajadores pueden producir manzanas o naranjas con costos de oportunidad constantes.',
|
||||
objetivo: 'Construye una FPP lineal que refleje costos de oportunidad constantes.',
|
||||
puntosRequeridos: 4,
|
||||
tipo: 'lineal',
|
||||
maxX: 100,
|
||||
maxY: 100,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
titulo: 'Economía con Recursos Especializados',
|
||||
descripcion: 'Algunos recursos son mejores para producir un bien que otro.',
|
||||
objetivo: 'Construye una FPP cóncava que muestre ventajas de especialización.',
|
||||
puntosRequeridos: 5,
|
||||
tipo: 'concava',
|
||||
maxX: 100,
|
||||
maxY: 100,
|
||||
},
|
||||
];
|
||||
|
||||
export function FPPConstructor({ onComplete }: FPPConstructorProps) {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [puntos, setPuntos] = useState<PuntoFPP[]>([]);
|
||||
const [puntoArrastrado, setPuntoArrastrado] = useState<string | null>(null);
|
||||
const [mostrarResultados, setMostrarResultados] = useState(false);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const escenario = ESCENARIOS[escenarioActual];
|
||||
|
||||
const handleSvgClick = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (mostrarResultados || puntoArrastrado) return;
|
||||
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
|
||||
// Limitar dentro del área del gráfico
|
||||
const limitedX = Math.max(5, Math.min(95, x));
|
||||
const limitedY = Math.max(5, Math.min(95, y));
|
||||
|
||||
const nuevoPunto: PuntoFPP = {
|
||||
id: `punto-${Date.now()}`,
|
||||
x: limitedX,
|
||||
y: limitedY,
|
||||
};
|
||||
|
||||
setPuntos(prev => [...prev, nuevoPunto]);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent, puntoId: string) => {
|
||||
e.stopPropagation();
|
||||
if (mostrarResultados) return;
|
||||
setPuntoArrastrado(puntoId);
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!puntoArrastrado || !svgRef.current) return;
|
||||
|
||||
const svg = svgRef.current;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
|
||||
const limitedX = Math.max(5, Math.min(95, x));
|
||||
const limitedY = Math.max(5, Math.min(95, y));
|
||||
|
||||
setPuntos(prev =>
|
||||
prev.map(p =>
|
||||
p.id === puntoArrastrado ? { ...p, x: limitedX, y: limitedY } : p
|
||||
)
|
||||
);
|
||||
}, [puntoArrastrado]);
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setPuntoArrastrado(null);
|
||||
};
|
||||
|
||||
const handleEliminarPunto = (puntoId: string) => {
|
||||
if (mostrarResultados) return;
|
||||
setPuntos(prev => prev.filter(p => p.id !== puntoId));
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setPuntos([]);
|
||||
setMostrarResultados(false);
|
||||
setPuntoArrastrado(null);
|
||||
};
|
||||
|
||||
const handleVerificar = () => {
|
||||
setMostrarResultados(true);
|
||||
|
||||
// Calcular puntuación basada en el tipo de FPP
|
||||
let correctas = 0;
|
||||
const puntosOrdenados = [...puntos].sort((a, b) => a.x - b.x);
|
||||
|
||||
if (escenario.tipo === 'lineal') {
|
||||
// Para FPP lineal, verificar que los puntos formen aproximadamente una línea recta
|
||||
if (puntosOrdenados.length >= 2) {
|
||||
const pendiente = (puntosOrdenados[0].y - puntosOrdenados[puntosOrdenados.length - 1].y) /
|
||||
(puntosOrdenados[puntosOrdenados.length - 1].x - puntosOrdenados[0].x);
|
||||
|
||||
correctas = puntosOrdenados.every((p, i) => {
|
||||
if (i === 0) return true;
|
||||
const xExpected = puntosOrdenados[0].x + (i / (puntosOrdenados.length - 1)) *
|
||||
(puntosOrdenados[puntosOrdenados.length - 1].x - puntosOrdenados[0].x);
|
||||
const yExpected = puntosOrdenados[0].y - pendiente * (xExpected - puntosOrdenados[0].x);
|
||||
return Math.abs(p.x - xExpected) < 15 && Math.abs(p.y - yExpected) < 15;
|
||||
}) ? escenario.puntosRequeridos : Math.floor(escenario.puntosRequeridos * 0.6);
|
||||
}
|
||||
} else if (escenario.tipo === 'convexa') {
|
||||
// Para FPP convexa (creciente escasez), verificar curvatura hacia arriba
|
||||
if (puntosOrdenados.length >= 3) {
|
||||
let esConvexa = true;
|
||||
for (let i = 1; i < puntosOrdenados.length - 1; i++) {
|
||||
const pendiente1 = (puntosOrdenados[i].y - puntosOrdenados[i-1].y) /
|
||||
(puntosOrdenados[i].x - puntosOrdenados[i-1].x || 0.1);
|
||||
const pendiente2 = (puntosOrdenados[i+1].y - puntosOrdenados[i].y) /
|
||||
(puntosOrdenados[i+1].x - puntosOrdenados[i].x || 0.1);
|
||||
if (pendiente2 < pendiente1 * 0.5) esConvexa = false;
|
||||
}
|
||||
correctas = esConvexa && puntosOrdenados.length >= escenario.puntosRequeridos
|
||||
? escenario.puntosRequeridos
|
||||
: Math.max(0, puntosOrdenados.length - 1);
|
||||
}
|
||||
} else if (escenario.tipo === 'concava') {
|
||||
// Para FPP cóncava, verificar curvatura hacia abajo
|
||||
if (puntosOrdenados.length >= 3) {
|
||||
let esConcava = true;
|
||||
for (let i = 1; i < puntosOrdenados.length - 1; i++) {
|
||||
const pendiente1 = (puntosOrdenados[i].y - puntosOrdenados[i-1].y) /
|
||||
(puntosOrdenados[i].x - puntosOrdenados[i-1].x || 0.1);
|
||||
const pendiente2 = (puntosOrdenados[i+1].y - puntosOrdenados[i].y) /
|
||||
(puntosOrdenados[i+1].x - puntosOrdenados[i].x || 0.1);
|
||||
if (pendiente2 > pendiente1 * 1.5) esConcava = false;
|
||||
}
|
||||
correctas = esConcava && puntosOrdenados.length >= escenario.puntosRequeridos
|
||||
? escenario.puntosRequeridos
|
||||
: Math.max(0, puntosOrdenados.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
const puntosFinales = Math.min(correctas, escenario.puntosRequeridos);
|
||||
if (onComplete) {
|
||||
onComplete(puntosFinales, escenario.puntosRequeridos);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguienteEscenario = () => {
|
||||
if (escenarioActual < ESCENARIOS.length - 1) {
|
||||
setEscenarioActual(prev => prev + 1);
|
||||
setPuntos([]);
|
||||
setMostrarResultados(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnteriorEscenario = () => {
|
||||
if (escenarioActual > 0) {
|
||||
setEscenarioActual(prev => prev - 1);
|
||||
setPuntos([]);
|
||||
setMostrarResultados(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Generar path para la línea FPP
|
||||
const generateFPPPath = () => {
|
||||
if (puntos.length < 2) return '';
|
||||
|
||||
const sorted = [...puntos].sort((a, b) => a.x - b.x);
|
||||
|
||||
// Si es lineal, usar líneas rectas
|
||||
if (escenario.tipo === 'lineal') {
|
||||
return sorted.reduce((path, p, i) =>
|
||||
i === 0 ? `M ${p.x} ${p.y}` : `${path} L ${p.x} ${p.y}`, ''
|
||||
);
|
||||
}
|
||||
|
||||
// Para convexa/cóncava, usar curvas suaves
|
||||
let path = `M ${sorted[0].x} ${sorted[0].y}`;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = sorted[i - 1];
|
||||
const curr = sorted[i];
|
||||
const cpX1 = prev.x + (curr.x - prev.x) * 0.3;
|
||||
const cpX2 = prev.x + (curr.x - prev.x) * 0.7;
|
||||
path += ` C ${cpX1} ${prev.y}, ${cpX2} ${curr.y}, ${curr.x} ${curr.y}`;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const esCompletado = puntos.length >= escenario.puntosRequeridos;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Constructor de FPP</h3>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Escenario {escenarioActual + 1} de {ESCENARIOS.length}: {escenario.titulo}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-1.5 rounded-full">
|
||||
<Target size={16} />
|
||||
<span className="font-semibold text-sm">
|
||||
{escenario.puntosRequeridos} pts mín.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descripción del escenario */}
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info size={20} className="text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-900">{escenario.descripcion}</h4>
|
||||
<p className="text-blue-700 text-sm mt-1">
|
||||
<strong>Objetivo:</strong> {escenario.objetivo}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Área de trabajo SVG */}
|
||||
<Card className="p-4 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Move size={18} className="text-gray-500" />
|
||||
<span className="text-sm text-gray-600">
|
||||
Haz clic para agregar puntos • Arrastra para mover •
|
||||
<span className="text-blue-600 font-medium"> {puntos.length} puntos</span>
|
||||
</span>
|
||||
</div>
|
||||
{puntos.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReiniciar}
|
||||
disabled={mostrarResultados}
|
||||
>
|
||||
<RefreshCcw size={14} className="mr-1" />
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox="0 0 100 100"
|
||||
className="w-full h-auto aspect-square max-h-[500px] cursor-crosshair touch-none"
|
||||
onClick={handleSvgClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* Grid */}
|
||||
<defs>
|
||||
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="#e5e7eb" strokeWidth="0.3" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100" height="100" fill="url(#grid)" />
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1="5" y1="95" x2="95" y2="95" stroke="#374151" strokeWidth="0.8" />
|
||||
<line x1="5" y1="95" x2="5" y2="5" stroke="#374151" strokeWidth="0.8" />
|
||||
|
||||
{/* Flechas */}
|
||||
<polygon points="95,95 92,93 92,97" fill="#374151" />
|
||||
<polygon points="5,5 3,8 7,8" fill="#374151" />
|
||||
|
||||
{/* Etiquetas */}
|
||||
<text x="50" y="99" textAnchor="middle" className="text-[3.5px] fill-gray-700 font-semibold">
|
||||
Bien X
|
||||
</text>
|
||||
<text x="1" y="50" textAnchor="middle" transform="rotate(-90, 1, 50)" className="text-[3.5px] fill-gray-700 font-semibold">
|
||||
Bien Y
|
||||
</text>
|
||||
|
||||
{/* Marcas de escala */}
|
||||
{[0, 25, 50, 75, 100].map(val => (
|
||||
<g key={val}>
|
||||
<line x1={5 + val * 0.9} y1="95" x2={5 + val * 0.9} y2="96" stroke="#374151" strokeWidth="0.5" />
|
||||
<text x={5 + val * 0.9} y="98.5" textAnchor="middle" className="text-[2.5px] fill-gray-500">{val}</text>
|
||||
<line x1="5" y1={95 - val * 0.9} x2="4" y2={95 - val * 0.9} stroke="#374151" strokeWidth="0.5" />
|
||||
<text x="3" y={95 - val * 0.9 + 1} textAnchor="end" className="text-[2.5px] fill-gray-500">{val}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Línea FPP */}
|
||||
{puntos.length >= 2 && (
|
||||
<motion.path
|
||||
d={generateFPPPath()}
|
||||
fill="none"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Puntos */}
|
||||
{puntos.map((punto, index) => (
|
||||
<g key={punto.id}>
|
||||
<motion.circle
|
||||
cx={punto.x}
|
||||
cy={punto.y}
|
||||
r="3"
|
||||
fill={mostrarResultados ? '#22c55e' : '#3b82f6'}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
whileHover={{ scale: 1.2 }}
|
||||
className="cursor-move"
|
||||
onMouseDown={(e) => handleMouseDown(e, punto.id)}
|
||||
/>
|
||||
<text
|
||||
x={punto.x}
|
||||
y={punto.y - 5}
|
||||
textAnchor="middle"
|
||||
className="text-[3px] fill-gray-700 font-bold pointer-events-none"
|
||||
>
|
||||
P{index + 1}
|
||||
</text>
|
||||
|
||||
{/* Botón eliminar */}
|
||||
{!mostrarResultados && (
|
||||
<g
|
||||
transform={`translate(${punto.x + 4}, ${punto.y - 4})`}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEliminarPunto(punto.id);
|
||||
}}
|
||||
>
|
||||
<circle r="2.5" fill="#ef4444" />
|
||||
<text x="0" y="1" textAnchor="middle" className="text-[3px] fill-white font-bold">×</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Indicador de tipo de FPP */}
|
||||
<g transform="translate(70, 10)">
|
||||
<rect x="0" y="0" width="25" height="8" rx="1" fill="white" stroke="#e5e7eb" strokeWidth="0.3" />
|
||||
<text x="2" y="3" className="text-[2px] fill-gray-500">Tipo FPP:</text>
|
||||
<text x="2" y="6.5" className={`text-[2.5px] font-semibold ${
|
||||
escenario.tipo === 'lineal' ? 'fill-blue-600' :
|
||||
escenario.tipo === 'convexa' ? 'fill-purple-600' :
|
||||
'fill-orange-600'
|
||||
}`}>
|
||||
{escenario.tipo === 'lineal' ? 'Lineal (CCO constante)' :
|
||||
escenario.tipo === 'convexa' ? 'Convexa (escasez creciente)' :
|
||||
'Cóncava (especialización)'}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Lista de puntos */}
|
||||
{puntos.length > 0 && (
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Coordenadas:</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
{[...puntos].sort((a, b) => a.x - b.x).map((punto, index) => (
|
||||
<div key={punto.id} className="bg-gray-50 rounded-lg p-2 text-xs">
|
||||
<div className="font-semibold text-gray-800">P{index + 1}</div>
|
||||
<div className="text-gray-600">X: {punto.x.toFixed(1)}</div>
|
||||
<div className="text-gray-600">Y: {punto.y.toFixed(1)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Resultados */}
|
||||
<AnimatePresence>
|
||||
{mostrarResultados && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<Card className={`p-6 ${
|
||||
puntos.length >= escenario.puntosRequeridos
|
||||
? 'bg-gradient-to-br from-green-50 to-emerald-50 border-green-200'
|
||||
: 'bg-gradient-to-br from-yellow-50 to-orange-50 border-yellow-200'
|
||||
}`}>
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, delay: 0.2 }}
|
||||
className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-4 ${
|
||||
puntos.length >= escenario.puntosRequeridos
|
||||
? 'bg-gradient-to-br from-yellow-400 to-orange-500'
|
||||
: 'bg-gradient-to-br from-yellow-400 to-orange-400'
|
||||
}`}
|
||||
>
|
||||
{puntos.length >= escenario.puntosRequeridos ? (
|
||||
<Trophy size={32} className="text-white" />
|
||||
) : (
|
||||
<TrendingUp size={32} className="text-white" />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{puntos.length >= escenario.puntosRequeridos
|
||||
? '¡Excelente trabajo!'
|
||||
: '¡Necesitas más puntos!'}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-4">
|
||||
{puntos.length >= escenario.puntosRequeridos
|
||||
? `Has construido una FPP ${escenario.tipo} correctamente con ${puntos.length} puntos.`
|
||||
: `Agrega al menos ${escenario.puntosRequeridos - puntos.length} punto(s) más para completar el escenario.`}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center gap-8">
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-blue-700">{puntos.length}</p>
|
||||
<p className="text-sm text-blue-600">Puntos agregados</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-green-700">{escenario.puntosRequeridos}</p>
|
||||
<p className="text-sm text-green-600">Requeridos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navegación y acciones */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAnteriorEscenario}
|
||||
disabled={escenarioActual === 0}
|
||||
>
|
||||
← Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSiguienteEscenario}
|
||||
disabled={escenarioActual === ESCENARIOS.length - 1}
|
||||
>
|
||||
Siguiente →
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleReiniciar}>
|
||||
<RefreshCcw size={16} className="mr-2" />
|
||||
Reiniciar
|
||||
</Button>
|
||||
|
||||
{!mostrarResultados ? (
|
||||
<Button onClick={handleVerificar} disabled={puntos.length < 2}>
|
||||
<CheckCircle size={16} className="mr-2" />
|
||||
Verificar
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleReiniciar}>
|
||||
Intentar de Nuevo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instrucciones adicionales */}
|
||||
{!esCompletado && !mostrarResultados && (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
Faltan <span className="font-semibold text-blue-600">
|
||||
{Math.max(0, escenario.puntosRequeridos - puntos.length)}
|
||||
</span> puntos para completar este escenario.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guía de tipos de FPP */}
|
||||
<Card className="p-4 bg-gray-50">
|
||||
<h4 className="font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<TrendingUp size={18} className="text-gray-600" />
|
||||
Tipos de Frontera de Posibilidades
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="border rounded-lg p-3 bg-white">
|
||||
<h5 className="font-semibold text-blue-700 mb-1">Lineal</h5>
|
||||
<p className="text-gray-600">Costos de oportunidad constantes. Los recursos son perfectamente sustituibles entre bienes.</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3 bg-white">
|
||||
<h5 className="font-semibold text-purple-700 mb-1">Convexa (hacia afuera)</h5>
|
||||
<p className="text-gray-600">Costos de oportunidad crecientes. Los recursos no son perfectamente adaptables.</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3 bg-white">
|
||||
<h5 className="font-semibold text-orange-700 mb-1">Cóncava (hacia adentro)</h5>
|
||||
<p className="text-gray-600">Costos de oportunidad decrecientes. Especialización en bienes específicos.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FPPConstructor;
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle, Trophy, RotateCcw, ArrowRight, Mountain, Users, Factory, Lightbulb } from 'lucide-react';
|
||||
|
||||
interface FactoresProduccionQuizProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Pregunta {
|
||||
id: number;
|
||||
pregunta: string;
|
||||
tipo: 'tierra' | 'trabajo' | 'capital' | 'emprendimiento';
|
||||
opciones: string[];
|
||||
respuestaCorrecta: number;
|
||||
explicacion: string;
|
||||
}
|
||||
|
||||
const PREGUNTAS: Pregunta[] = [
|
||||
{
|
||||
id: 1,
|
||||
pregunta: '¿Cuál de los siguientes es un ejemplo de TIERRA como factor de producción?',
|
||||
tipo: 'tierra',
|
||||
opciones: [
|
||||
'El trabajo de un obrero',
|
||||
'Un terreno agrícola',
|
||||
'Una máquina industrial',
|
||||
'La habilidad de un gerente'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'La tierra incluye todos los recursos naturales: terrenos, minerales, agua, petróleo, etc. Es todo lo que nos proporciona la naturaleza sin transformar.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pregunta: 'El TRABAJO como factor de producción se refiere a:',
|
||||
tipo: 'trabajo',
|
||||
opciones: [
|
||||
'Solo el esfuerzo físico',
|
||||
'Solo el esfuerzo mental',
|
||||
'El esfuerzo físico y mental que aportan las personas',
|
||||
'Las máquinas que reemplazan a los humanos'
|
||||
],
|
||||
respuestaCorrecta: 2,
|
||||
explicacion: 'El trabajo incluye tanto el esfuerzo físico (como el de un albañil) como el mental (como el de un ingeniero). Es el factor humano en la producción.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pregunta: '¿Qué se considera CAPITAL como factor de producción?',
|
||||
tipo: 'capital',
|
||||
opciones: [
|
||||
'Dinero en una cuenta bancaria',
|
||||
'Acciones de una empresa',
|
||||
'Maquinaria, herramientas y equipos utilizados para producir',
|
||||
'Terrenos y edificios'
|
||||
],
|
||||
respuestaCorrecta: 2,
|
||||
explicacion: 'En economía, el capital físico (o capital real) son los bienes manufacturados utilizados para producir otros bienes: máquinas, herramientas, fábricas, etc. No es dinero.'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pregunta: '¿Cuál es la recompensa que reciben los propietarios del factor TIERRA?',
|
||||
tipo: 'tierra',
|
||||
opciones: [
|
||||
'Salarios',
|
||||
'Rentas o alquileres',
|
||||
'Intereses',
|
||||
'Beneficios'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'Los propietarios de tierra reciben RENTAS (o alquileres) como pago por el uso de sus recursos naturales.'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pregunta: 'Los trabajadores reciben _____ como recompensa por su factor de producción.',
|
||||
tipo: 'trabajo',
|
||||
opciones: [
|
||||
'Intereses',
|
||||
'Rentas',
|
||||
'Salarios',
|
||||
'Dividendos'
|
||||
],
|
||||
respuestaCorrecta: 2,
|
||||
explicacion: 'El trabajo recibe SALARIOS (o sueldos) como compensación por el esfuerzo físico y mental aportado a la producción.'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
pregunta: '¿Qué reciben los propietarios de CAPITAL como recompensa?',
|
||||
tipo: 'capital',
|
||||
opciones: [
|
||||
'Salarios',
|
||||
'Rentas',
|
||||
'Intereses',
|
||||
'Bonificaciones'
|
||||
],
|
||||
respuestaCorrecta: 2,
|
||||
explicacion: 'El capital recibe INTERESES como recompensa. Si prestas tu capital (maquinaria o dinero para comprarla), recibes intereses a cambio.'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
pregunta: 'El EMPRENDIMIENTO (o empresa) es el factor que:',
|
||||
tipo: 'emprendimiento',
|
||||
opciones: [
|
||||
'Solo invierte dinero',
|
||||
'Combina los otros factores de producción asumiendo riesgos',
|
||||
'Trabaja en la fábrica',
|
||||
'Solo vende los productos'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'El emprendimiento es el factor que organiza y combina tierra, trabajo y capital para producir bienes y servicios, asumiendo el riesgo del negocio.'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
pregunta: '¿Cuál es la recompensa del EMPRENDIMIENTO?',
|
||||
tipo: 'emprendimiento',
|
||||
opciones: [
|
||||
'Salario fijo',
|
||||
'Intereses garantizados',
|
||||
'Beneficios (o pérdidas)',
|
||||
'Renta del terreno'
|
||||
],
|
||||
respuestaCorrecta: 2,
|
||||
explicacion: 'El emprendimiento recibe BENEFICIOS cuando la empresa tiene éxito, pero también puede sufrir PÉRDIDAS. Es el factor con mayor riesgo y potencial de ganancia.'
|
||||
}
|
||||
];
|
||||
|
||||
export function FactoresProduccionQuiz({ ejercicioId: _ejercicioId, onComplete }: FactoresProduccionQuizProps) {
|
||||
const [preguntaActual, setPreguntaActual] = useState(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<number | null>(null);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [puntuacion, setPuntuacion] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
|
||||
const pregunta = PREGUNTAS[preguntaActual];
|
||||
|
||||
const getTipoIcon = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'tierra':
|
||||
return <Mountain size={20} />;
|
||||
case 'trabajo':
|
||||
return <Users size={20} />;
|
||||
case 'capital':
|
||||
return <Factory size={20} />;
|
||||
case 'emprendimiento':
|
||||
return <Lightbulb size={20} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTipoLabel = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'tierra':
|
||||
return 'Tierra';
|
||||
case 'trabajo':
|
||||
return 'Trabajo';
|
||||
case 'capital':
|
||||
return 'Capital';
|
||||
case 'emprendimiento':
|
||||
return 'Emprendimiento';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getTipoColor = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'tierra':
|
||||
return 'bg-green-100 text-green-700 border-green-200';
|
||||
case 'trabajo':
|
||||
return 'bg-blue-100 text-blue-700 border-blue-200';
|
||||
case 'capital':
|
||||
return 'bg-amber-100 text-amber-700 border-amber-200';
|
||||
case 'emprendimiento':
|
||||
return 'bg-purple-100 text-purple-700 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeleccionar = (index: number) => {
|
||||
if (mostrarResultado) return;
|
||||
setRespuestaSeleccionada(index);
|
||||
};
|
||||
|
||||
const handleVerificar = () => {
|
||||
if (respuestaSeleccionada === null) return;
|
||||
|
||||
const esCorrecta = respuestaSeleccionada === pregunta.respuestaCorrecta;
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (esCorrecta) {
|
||||
setPuntuacion(prev => prev + Math.round(100 / PREGUNTAS.length));
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
|
||||
if (preguntaActual === PREGUNTAS.length - 1) {
|
||||
setTimeout(() => {
|
||||
setCompletado(true);
|
||||
const puntuacionFinal = puntuacion + (esCorrecta ? Math.round(100 / PREGUNTAS.length) : 0);
|
||||
if (onComplete) {
|
||||
onComplete(puntuacionFinal);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setPreguntaActual(0);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
setPuntuacion(0);
|
||||
setCompletado(false);
|
||||
setRespuestasCorrectas(0);
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="text-center py-8 px-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-yellow-400 to-yellow-600 rounded-full mb-6"
|
||||
>
|
||||
<Trophy size={40} className="text-white" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
¡Quiz Completado!
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Has respondido {respuestasCorrectas} de {PREGUNTAS.length} preguntas correctamente
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 max-w-md mx-auto mb-6">
|
||||
<div className={`p-4 rounded-xl border ${getTipoColor('tierra')}`}>
|
||||
<Mountain className="mx-auto mb-2" size={24} />
|
||||
<p className="font-semibold text-sm">Tierra</p>
|
||||
<p className="text-xs opacity-75">Rentas</p>
|
||||
</div>
|
||||
<div className={`p-4 rounded-xl border ${getTipoColor('trabajo')}`}>
|
||||
<Users className="mx-auto mb-2" size={24} />
|
||||
<p className="font-semibold text-sm">Trabajo</p>
|
||||
<p className="text-xs opacity-75">Salarios</p>
|
||||
</div>
|
||||
<div className={`p-4 rounded-xl border ${getTipoColor('capital')}`}>
|
||||
<Factory className="mx-auto mb-2" size={24} />
|
||||
<p className="font-semibold text-sm">Capital</p>
|
||||
<p className="text-xs opacity-75">Intereses</p>
|
||||
</div>
|
||||
<div className={`p-4 rounded-xl border ${getTipoColor('emprendimiento')}`}>
|
||||
<Lightbulb className="mx-auto mb-2" size={24} />
|
||||
<p className="font-semibold text-sm">Emprendimiento</p>
|
||||
<p className="text-xs opacity-75">Beneficios</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 rounded-xl 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}</p>
|
||||
<p className="text-sm text-blue-500">puntos</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleReiniciar} variant="outline">
|
||||
<RotateCcw size={16} className="mr-2" />
|
||||
Intentar de Nuevo
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-3xl mx-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Factores de Producción</h3>
|
||||
<p className="text-sm text-gray-500">Pregunta {preguntaActual + 1} de {PREGUNTAS.length}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500">Puntos</p>
|
||||
<p className="text-xl font-bold text-blue-600">{puntuacion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
|
||||
<motion.div
|
||||
className="h-2 bg-blue-500 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((preguntaActual + 1) / PREGUNTAS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-semibold mb-3 border ${getTipoColor(pregunta.tipo)}`}>
|
||||
{getTipoIcon(pregunta.tipo)}
|
||||
<span>{getTipoLabel(pregunta.tipo)}</span>
|
||||
</div>
|
||||
<h4 className="text-lg font-medium text-gray-800">
|
||||
{pregunta.pregunta}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{pregunta.opciones.map((opcion, index) => {
|
||||
const estaSeleccionada = respuestaSeleccionada === index;
|
||||
const esCorrecta = index === pregunta.respuestaCorrecta;
|
||||
const mostrarCorrecta = mostrarResultado && esCorrecta;
|
||||
const mostrarIncorrecta = mostrarResultado && estaSeleccionada && !esCorrecta;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleSeleccionar(index)}
|
||||
disabled={mostrarResultado}
|
||||
whileHover={!mostrarResultado ? { scale: 1.01 } : {}}
|
||||
whileTap={!mostrarResultado ? { scale: 0.99 } : {}}
|
||||
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
mostrarCorrecta
|
||||
? 'border-green-500 bg-green-50'
|
||||
: mostrarIncorrecta
|
||||
? 'border-red-500 bg-red-50'
|
||||
: estaSeleccionada
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${
|
||||
mostrarCorrecta
|
||||
? 'border-green-500 bg-green-500 text-white'
|
||||
: mostrarIncorrecta
|
||||
? 'border-red-500 bg-red-500 text-white'
|
||||
: estaSeleccionada
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-gray-300'
|
||||
}`}>
|
||||
{mostrarCorrecta && <CheckCircle size={14} />}
|
||||
{mostrarIncorrecta && <XCircle size={14} />}
|
||||
{!mostrarResultado && estaSeleccionada && (
|
||||
<div className="w-2 h-2 bg-white rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`font-medium ${
|
||||
mostrarCorrecta ? 'text-green-800' :
|
||||
mostrarIncorrecta ? 'text-red-800' :
|
||||
'text-gray-700'
|
||||
}`}>
|
||||
{opcion}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarResultado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className={`p-4 rounded-xl mb-6 ${
|
||||
respuestaSeleccionada === pregunta.respuestaCorrecta
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<p className={`font-medium mb-2 ${
|
||||
respuestaSeleccionada === pregunta.respuestaCorrecta
|
||||
? 'text-green-800'
|
||||
: 'text-red-800'
|
||||
}`}>
|
||||
{respuestaSeleccionada === pregunta.respuestaCorrecta
|
||||
? '¡Correcto!'
|
||||
: 'Incorrecto'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">{pregunta.explicacion}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{!mostrarResultado ? (
|
||||
<Button
|
||||
onClick={handleVerificar}
|
||||
disabled={respuestaSeleccionada === null}
|
||||
>
|
||||
Verificar Respuesta
|
||||
</Button>
|
||||
) : preguntaActual < PREGUNTAS.length - 1 ? (
|
||||
<Button onClick={handleSiguiente}>
|
||||
Siguiente
|
||||
<ArrowRight size={16} className="ml-2" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default FactoresProduccionQuiz;
|
||||
300
frontend/src/components/exercises/modulo1/FlujoCircular.tsx
Normal file
300
frontend/src/components/exercises/modulo1/FlujoCircular.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle, Trophy, RotateCcw, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface FlujoCircularProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Pregunta {
|
||||
id: number;
|
||||
pregunta: string;
|
||||
opciones: string[];
|
||||
respuestaCorrecta: number;
|
||||
explicacion: string;
|
||||
}
|
||||
|
||||
const PREGUNTAS: Pregunta[] = [
|
||||
{
|
||||
id: 1,
|
||||
pregunta: "¿Quiénes son los principales agentes económicos en el flujo circular?",
|
||||
opciones: [
|
||||
"Solo el gobierno y las empresas",
|
||||
"Familias y empresas",
|
||||
"Bancos y familias",
|
||||
"Empresas y extranjeros"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "Las familias (consumidores) y las empresas (productores) son los dos agentes principales en el modelo básico del flujo circular."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pregunta: "Las familias ofrecen a las empresas:",
|
||||
opciones: [
|
||||
"Productos terminados",
|
||||
"Trabajo, tierra y capital (factores de producción)",
|
||||
"Dinero para invertir",
|
||||
"Servicios bancarios"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "Las familias son propietarias de los factores de producción (trabajo, tierra, capital) y los ofrecen a las empresas a cambio de ingresos (salarios, rentas, intereses)."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pregunta: "Las empresas le venden a las familias:",
|
||||
opciones: [
|
||||
"Acciones de la empresa",
|
||||
"Bienes y servicios",
|
||||
"Materias primas",
|
||||
"Deudas"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "Las empresas producen bienes y servicios que venden a las familias en el mercado de bienes."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pregunta: "En el flujo MONETARIO (de dinero), el dinero va:",
|
||||
opciones: [
|
||||
"De empresas a familias (salarios) y de familias a empresas (gastos)",
|
||||
"Solo de familias a empresas",
|
||||
"Solo de empresas a familias",
|
||||
"En círculo en una sola dirección"
|
||||
],
|
||||
respuestaCorrecta: 0,
|
||||
explicacion: "El flujo monetario es bidireccional: empresas pagan salarios/rentas a familias, y familias gastan dinero comprando bienes a las empresas."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pregunta: "En el flujo REAL (de bienes/factores), ¿qué ofrecen las familias?",
|
||||
opciones: [
|
||||
"Productos terminados",
|
||||
"Factores de producción (trabajo, tierra, capital)",
|
||||
"Dinero",
|
||||
"Servicios financieros"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "En el flujo real, las familias ofrecen sus factores de producción (trabajo, tierra, capital) a las empresas."
|
||||
}
|
||||
];
|
||||
|
||||
export function FlujoCircular({ ejercicioId: _ejercicioId, onComplete }: FlujoCircularProps) {
|
||||
const [preguntaActual, setPreguntaActual] = useState(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<number | null>(null);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [puntuacion, setPuntuacion] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
|
||||
const pregunta = PREGUNTAS[preguntaActual];
|
||||
|
||||
const handleSeleccionar = (index: number) => {
|
||||
if (mostrarResultado) return;
|
||||
setRespuestaSeleccionada(index);
|
||||
};
|
||||
|
||||
const handleVerificar = () => {
|
||||
if (respuestaSeleccionada === null) return;
|
||||
|
||||
const esCorrecta = respuestaSeleccionada === pregunta.respuestaCorrecta;
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (esCorrecta) {
|
||||
setPuntuacion(prev => prev + 20);
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
|
||||
// Si es la última pregunta
|
||||
if (preguntaActual === PREGUNTAS.length - 1) {
|
||||
setTimeout(() => {
|
||||
setCompletado(true);
|
||||
const puntuacionFinal = puntuacion + (esCorrecta ? 20 : 0);
|
||||
if (onComplete) {
|
||||
onComplete(puntuacionFinal);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setPreguntaActual(0);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
setPuntuacion(0);
|
||||
setCompletado(false);
|
||||
setRespuestasCorrectas(0);
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="text-center py-8 px-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-yellow-400 to-yellow-600 rounded-full mb-6"
|
||||
>
|
||||
<Trophy size={40} className="text-white" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
¡Ejercicio Completado!
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Has respondido {respuestasCorrectas} de {PREGUNTAS.length} preguntas correctamente
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 rounded-xl p-6 mb-6">
|
||||
<p className="text-sm text-blue-600 mb-1">Puntuación</p>
|
||||
<p className="text-4xl font-bold text-blue-700">{puntuacion}</p>
|
||||
<p className="text-sm text-blue-500">puntos</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleReiniciar} variant="outline">
|
||||
<RotateCcw size={16} className="mr-2" />
|
||||
Intentar de Nuevo
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-3xl mx-auto">
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Flujo Circular de la Renta</h3>
|
||||
<p className="text-sm text-gray-500">Pregunta {preguntaActual + 1} de {PREGUNTAS.length}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500">Puntos</p>
|
||||
<p className="text-xl font-bold text-blue-600">{puntuacion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra de progreso */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
|
||||
<motion.div
|
||||
className="h-2 bg-blue-500 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((preguntaActual + 1) / PREGUNTAS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pregunta */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-lg font-medium text-gray-800 mb-4">
|
||||
{pregunta.pregunta}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{pregunta.opciones.map((opcion, index) => {
|
||||
const estaSeleccionada = respuestaSeleccionada === index;
|
||||
const esCorrecta = index === pregunta.respuestaCorrecta;
|
||||
const mostrarCorrecta = mostrarResultado && esCorrecta;
|
||||
const mostrarIncorrecta = mostrarResultado && estaSeleccionada && !esCorrecta;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleSeleccionar(index)}
|
||||
disabled={mostrarResultado}
|
||||
whileHover={!mostrarResultado ? { scale: 1.01 } : {}}
|
||||
whileTap={!mostrarResultado ? { scale: 0.99 } : {}}
|
||||
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
mostrarCorrecta
|
||||
? 'border-green-500 bg-green-50'
|
||||
: mostrarIncorrecta
|
||||
? 'border-red-500 bg-red-50'
|
||||
: estaSeleccionada
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${
|
||||
mostrarCorrecta
|
||||
? 'border-green-500 bg-green-500 text-white'
|
||||
: mostrarIncorrecta
|
||||
? 'border-red-500 bg-red-500 text-white'
|
||||
: estaSeleccionada
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-gray-300'
|
||||
}`}>
|
||||
{mostrarCorrecta && <CheckCircle size={14} />}
|
||||
{mostrarIncorrecta && <XCircle size={14} />}
|
||||
{!mostrarResultado && estaSeleccionada && (
|
||||
<div className="w-2 h-2 bg-white rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`font-medium ${
|
||||
mostrarCorrecta ? 'text-green-800' :
|
||||
mostrarIncorrecta ? 'text-red-800' :
|
||||
'text-gray-700'
|
||||
}`}>
|
||||
{opcion}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explicación */}
|
||||
{mostrarResultado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-4 rounded-xl mb-6 ${
|
||||
respuestaSeleccionada === pregunta.respuestaCorrecta
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<p className={`font-medium mb-2 ${
|
||||
respuestaSeleccionada === pregunta.respuestaCorrecta
|
||||
? 'text-green-800'
|
||||
: 'text-red-800'
|
||||
}`}>
|
||||
{respuestaSeleccionada === pregunta.respuestaCorrecta
|
||||
? '¡Correcto!'
|
||||
: 'Incorrecto'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">{pregunta.explicacion}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Botones */}
|
||||
<div className="flex justify-end">
|
||||
{!mostrarResultado ? (
|
||||
<Button
|
||||
onClick={handleVerificar}
|
||||
disabled={respuestaSeleccionada === null}
|
||||
>
|
||||
Verificar Respuesta
|
||||
</Button>
|
||||
) : preguntaActual < PREGUNTAS.length - 1 ? (
|
||||
<Button onClick={handleSiguiente}>
|
||||
Siguiente
|
||||
<ArrowRight size={16} className="ml-2" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlujoCircular;
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle, Trophy, RotateCcw, ArrowRight, Users, Building2 } from 'lucide-react';
|
||||
|
||||
interface FlujoCircularBasicoProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Pregunta {
|
||||
id: number;
|
||||
pregunta: string;
|
||||
tipo: 'mercado-bienes' | 'mercado-factores' | 'flujo-real' | 'flujo-monetario';
|
||||
opciones: string[];
|
||||
respuestaCorrecta: number;
|
||||
explicacion: string;
|
||||
}
|
||||
|
||||
const PREGUNTAS: Pregunta[] = [
|
||||
{
|
||||
id: 1,
|
||||
pregunta: 'En el mercado de bienes y servicios, ¿quiénes son los demandantes?',
|
||||
tipo: 'mercado-bienes',
|
||||
opciones: [
|
||||
'Las empresas',
|
||||
'Las familias',
|
||||
'El gobierno',
|
||||
'Los bancos'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'En el mercado de bienes y servicios, las familias son los demandantes (compran productos) y las empresas son los oferentes (venden productos).'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pregunta: 'En el mercado de factores de producción, ¿quiénes ofrecen el trabajo, la tierra y el capital?',
|
||||
tipo: 'mercado-factores',
|
||||
opciones: [
|
||||
'Las familias',
|
||||
'Las empresas',
|
||||
'El Estado',
|
||||
'Los inversores extranjeros'
|
||||
],
|
||||
respuestaCorrecta: 0,
|
||||
explicacion: 'Las familias son propietarias de los factores de producción (trabajo, tierra, capital) y los ofrecen a las empresas a cambio de ingresos.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pregunta: '¿Qué representa el FLUJO REAL en el modelo de 2 sectores?',
|
||||
tipo: 'flujo-real',
|
||||
opciones: [
|
||||
'El movimiento de dinero entre familias y empresas',
|
||||
'El movimiento de bienes, servicios y factores de producción',
|
||||
'Los impuestos pagados al gobierno',
|
||||
'Las transacciones bancarias'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'El flujo real representa el movimiento físico de bienes y servicios (de empresas a familias) y factores de producción (de familias a empresas).'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pregunta: '¿Qué reciben las familias a cambio de ofrecer sus factores de producción?',
|
||||
tipo: 'flujo-monetario',
|
||||
opciones: [
|
||||
'Productos terminados',
|
||||
'Ingresos (salarios, rentas, intereses, beneficios)',
|
||||
'Servicios públicos',
|
||||
'Acciones de empresas'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'Las familias reciben ingresos monetarios: salarios (por trabajo), rentas (por tierra), intereses (por capital) y beneficios (por empresa).'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pregunta: 'En el FLUJO MONETARIO, el dinero fluye de las empresas a las familias como:',
|
||||
tipo: 'flujo-monetario',
|
||||
opciones: [
|
||||
'Pagos por compra de bienes',
|
||||
'Pagos por factores de producción (costes)',
|
||||
'Impuestos',
|
||||
'Subvenciones'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'Las empresas pagan a las familias por el uso de sus factores: salarios (trabajo), alquileres (tierra), intereses (capital) y beneficios (emprendimiento).'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
pregunta: '¿Por qué se llama "flujo circular"?',
|
||||
tipo: 'flujo-real',
|
||||
opciones: [
|
||||
'Porque el dinero siempre aumenta',
|
||||
'Porque hay un flujo continuo en ambas direcciones entre familias y empresas',
|
||||
'Porque las empresas siempre ganan',
|
||||
'Porque el gobierno interviene'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'Se llama flujo circular porque hay un movimiento continuo en ambas direcciones: factores de producción van de familias a empresas, y bienes/servicios van de empresas a familias.'
|
||||
}
|
||||
];
|
||||
|
||||
export function FlujoCircularBasico({ ejercicioId: _ejercicioId, onComplete }: FlujoCircularBasicoProps) {
|
||||
const [preguntaActual, setPreguntaActual] = useState(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<number | null>(null);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [puntuacion, setPuntuacion] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
|
||||
const pregunta = PREGUNTAS[preguntaActual];
|
||||
|
||||
const getTipoLabel = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'mercado-bienes':
|
||||
return 'Mercado de Bienes';
|
||||
case 'mercado-factores':
|
||||
return 'Mercado de Factores';
|
||||
case 'flujo-real':
|
||||
return 'Flujo Real';
|
||||
case 'flujo-monetario':
|
||||
return 'Flujo Monetario';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getTipoColor = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'mercado-bienes':
|
||||
return 'bg-blue-100 text-blue-700';
|
||||
case 'mercado-factores':
|
||||
return 'bg-green-100 text-green-700';
|
||||
case 'flujo-real':
|
||||
return 'bg-purple-100 text-purple-700';
|
||||
case 'flujo-monetario':
|
||||
return 'bg-amber-100 text-amber-700';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeleccionar = (index: number) => {
|
||||
if (mostrarResultado) return;
|
||||
setRespuestaSeleccionada(index);
|
||||
};
|
||||
|
||||
const handleVerificar = () => {
|
||||
if (respuestaSeleccionada === null) return;
|
||||
|
||||
const esCorrecta = respuestaSeleccionada === pregunta.respuestaCorrecta;
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (esCorrecta) {
|
||||
setPuntuacion(prev => prev + Math.round(100 / PREGUNTAS.length));
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
|
||||
if (preguntaActual === PREGUNTAS.length - 1) {
|
||||
setTimeout(() => {
|
||||
setCompletado(true);
|
||||
const puntuacionFinal = puntuacion + (esCorrecta ? Math.round(100 / PREGUNTAS.length) : 0);
|
||||
if (onComplete) {
|
||||
onComplete(puntuacionFinal);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setPreguntaActual(0);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
setPuntuacion(0);
|
||||
setCompletado(false);
|
||||
setRespuestasCorrectas(0);
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="text-center py-8 px-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-yellow-400 to-yellow-600 rounded-full mb-6"
|
||||
>
|
||||
<Trophy size={40} className="text-white" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
¡Ejercicio Completado!
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-4">
|
||||
Has respondido {respuestasCorrectas} de {PREGUNTAS.length} preguntas correctamente
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center gap-8 mb-6">
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<Users size={24} />
|
||||
<span className="font-medium">Familias</span>
|
||||
</div>
|
||||
<div className="text-2xl text-gray-300">↔️</div>
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<Building2 size={24} />
|
||||
<span className="font-medium">Empresas</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 rounded-xl p-6 mb-6">
|
||||
<p className="text-sm text-blue-600 mb-1">Puntuación</p>
|
||||
<p className="text-4xl font-bold text-blue-700">{puntuacion}</p>
|
||||
<p className="text-sm text-blue-500">puntos</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleReiniciar} variant="outline">
|
||||
<RotateCcw size={16} className="mr-2" />
|
||||
Intentar de Nuevo
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-3xl mx-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Flujo Circular: 2 Sectores</h3>
|
||||
<p className="text-sm text-gray-500">Pregunta {preguntaActual + 1} de {PREGUNTAS.length}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500">Puntos</p>
|
||||
<p className="text-xl font-bold text-blue-600">{puntuacion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
|
||||
<motion.div
|
||||
className="h-2 bg-blue-500 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((preguntaActual + 1) / PREGUNTAS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className={`inline-block px-3 py-1 rounded-full text-xs font-semibold mb-3 ${getTipoColor(pregunta.tipo)}`}>
|
||||
{getTipoLabel(pregunta.tipo)}
|
||||
</div>
|
||||
<h4 className="text-lg font-medium text-gray-800">
|
||||
{pregunta.pregunta}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{pregunta.opciones.map((opcion, index) => {
|
||||
const estaSeleccionada = respuestaSeleccionada === index;
|
||||
const esCorrecta = index === pregunta.respuestaCorrecta;
|
||||
const mostrarCorrecta = mostrarResultado && esCorrecta;
|
||||
const mostrarIncorrecta = mostrarResultado && estaSeleccionada && !esCorrecta;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleSeleccionar(index)}
|
||||
disabled={mostrarResultado}
|
||||
whileHover={!mostrarResultado ? { scale: 1.01 } : {}}
|
||||
whileTap={!mostrarResultado ? { scale: 0.99 } : {}}
|
||||
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
mostrarCorrecta
|
||||
? 'border-green-500 bg-green-50'
|
||||
: mostrarIncorrecta
|
||||
? 'border-red-500 bg-red-50'
|
||||
: estaSeleccionada
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${
|
||||
mostrarCorrecta
|
||||
? 'border-green-500 bg-green-500 text-white'
|
||||
: mostrarIncorrecta
|
||||
? 'border-red-500 bg-red-500 text-white'
|
||||
: estaSeleccionada
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-gray-300'
|
||||
}`}>
|
||||
{mostrarCorrecta && <CheckCircle size={14} />}
|
||||
{mostrarIncorrecta && <XCircle size={14} />}
|
||||
{!mostrarResultado && estaSeleccionada && (
|
||||
<div className="w-2 h-2 bg-white rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`font-medium ${
|
||||
mostrarCorrecta ? 'text-green-800' :
|
||||
mostrarIncorrecta ? 'text-red-800' :
|
||||
'text-gray-700'
|
||||
}`}>
|
||||
{opcion}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarResultado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className={`p-4 rounded-xl mb-6 ${
|
||||
respuestaSeleccionada === pregunta.respuestaCorrecta
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<p className={`font-medium mb-2 ${
|
||||
respuestaSeleccionada === pregunta.respuestaCorrecta
|
||||
? 'text-green-800'
|
||||
: 'text-red-800'
|
||||
}`}>
|
||||
{respuestaSeleccionada === pregunta.respuestaCorrecta
|
||||
? '¡Correcto!'
|
||||
: 'Incorrecto'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">{pregunta.explicacion}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{!mostrarResultado ? (
|
||||
<Button
|
||||
onClick={handleVerificar}
|
||||
disabled={respuestaSeleccionada === null}
|
||||
>
|
||||
Verificar Respuesta
|
||||
</Button>
|
||||
) : preguntaActual < PREGUNTAS.length - 1 ? (
|
||||
<Button onClick={handleSiguiente}>
|
||||
Siguiente
|
||||
<ArrowRight size={16} className="ml-2" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlujoCircularBasico;
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface EjercicioProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Pregunta {
|
||||
id: number;
|
||||
pregunta: string;
|
||||
opciones: string[];
|
||||
correcta: number;
|
||||
explicacion: string;
|
||||
categoria: 'que' | 'como' | 'para_quien';
|
||||
}
|
||||
|
||||
const PREGUNTAS: Pregunta[] = [
|
||||
{
|
||||
id: 1,
|
||||
pregunta: "¿Qué decisión responde a la pregunta 'QUÉ producir'?",
|
||||
opciones: [
|
||||
"Elegir entre producir computadoras o smartphones",
|
||||
"Decidir si usar mano de obra o maquinaria",
|
||||
"Determinar si los productos van a ricos o pobres",
|
||||
"Establecer el precio de venta de los productos"
|
||||
],
|
||||
correcta: 0,
|
||||
explicacion: "La pregunta 'QUÉ producir' se refiere a la elección de qué bienes y servicios se van a fabricar con los recursos disponibles.",
|
||||
categoria: 'que'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pregunta: "¿Qué decisión responde a la pregunta 'CÓMO producir'?",
|
||||
opciones: [
|
||||
"Elegir entre producir autos o camiones",
|
||||
"Decidir entre usar tecnología o mano de obra intensiva",
|
||||
"Determinar quién consumirá los productos",
|
||||
"Calcular cuánto invertir en publicidad"
|
||||
],
|
||||
correcta: 1,
|
||||
explicacion: "La pregunta 'CÓMO producir' se refiere a la elección de la técnica o método de producción a utilizar.",
|
||||
categoria: 'como'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pregunta: "¿Qué decisión responde a la pregunta 'PARA QUIÉN producir'?",
|
||||
opciones: [
|
||||
"Seleccionar los materiales a utilizar",
|
||||
"Elegir la ubicación de la fábrica",
|
||||
"Distribuir los productos entre diferentes grupos de la sociedad",
|
||||
"Determinar la cantidad a producir"
|
||||
],
|
||||
correcta: 2,
|
||||
explicacion: "La pregunta 'PARA QUIÉN producir' se refiere a la distribución de los bienes y servicios entre los miembros de la sociedad.",
|
||||
categoria: 'para_quien'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pregunta: "Un país debe decidir entre destinar sus recursos a hospitales o a escuelas. ¿Qué pregunta del problema económico resuelve?",
|
||||
opciones: [
|
||||
"¿Qué producir?",
|
||||
"¿Cómo producir?",
|
||||
"¿Para quién producir?",
|
||||
"¿Cuánto producir?"
|
||||
],
|
||||
correcta: 0,
|
||||
explicacion: "Elegir entre hospitales y escuelas es una decisión sobre QUÉ bienes y servicios públicos producir.",
|
||||
categoria: 'que'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pregunta: "Una empresa textil decide reemplazar trabajadores por máquinas automáticas. ¿Qué pregunta responde?",
|
||||
opciones: [
|
||||
"¿Qué producir?",
|
||||
"¿Cómo producir?",
|
||||
"¿Para quién producir?",
|
||||
"¿Dónde producir?"
|
||||
],
|
||||
correcta: 1,
|
||||
explicacion: "La decisión de usar máquinas vs. trabajadores es una decisión sobre CÓMO producir.",
|
||||
categoria: 'como'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
pregunta: "El gobierno implementa subsidios para que los medicamentos sean accesibles a personas de bajos recursos. ¿Qué pregunta resuelve?",
|
||||
opciones: [
|
||||
"¿Qué producir?",
|
||||
"¿Cómo producir?",
|
||||
"¿Para quién producir?",
|
||||
"¿Cuándo producir?"
|
||||
],
|
||||
correcta: 2,
|
||||
explicacion: "Los subsidios para acceso equitativo responden a la pregunta PARA QUIÉN producir o distribuir.",
|
||||
categoria: 'para_quien'
|
||||
}
|
||||
];
|
||||
|
||||
export function ProblemaEconomicoFundamental({ ejercicioId: _ejercicioId, onComplete }: EjercicioProps) {
|
||||
const [preguntaActual, setPreguntaActual] = useState(0);
|
||||
const [respuestas, setRespuestas] = useState<number[]>([]);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const pregunta = PREGUNTAS[preguntaActual];
|
||||
const esUltima = preguntaActual === PREGUNTAS.length - 1;
|
||||
const progreso = ((preguntaActual) / PREGUNTAS.length) * 100;
|
||||
|
||||
const handleRespuesta = (index: number) => {
|
||||
const nuevasRespuestas = [...respuestas, index];
|
||||
setRespuestas(nuevasRespuestas);
|
||||
|
||||
if (esUltima) {
|
||||
const correctas = nuevasRespuestas.filter((r, i) => r === PREGUNTAS[i].correcta).length;
|
||||
const puntuacion = Math.round((correctas / PREGUNTAS.length) * 100);
|
||||
setCompletado(true);
|
||||
onComplete?.(puntuacion);
|
||||
} else {
|
||||
setMostrarResultado(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setMostrarResultado(false);
|
||||
};
|
||||
|
||||
const esCorrecta = respuestas[preguntaActual] === pregunta.correcta;
|
||||
|
||||
const getCategoriaLabel = (cat: string) => {
|
||||
switch (cat) {
|
||||
case 'que': return '¿Qué producir?';
|
||||
case 'como': return '¿Cómo producir?';
|
||||
case 'para_quien': return '¿Para quién producir?';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoriaColor = (cat: string) => {
|
||||
switch (cat) {
|
||||
case 'que': return 'bg-blue-100 text-blue-800';
|
||||
case 'como': return 'bg-green-100 text-green-800';
|
||||
case 'para_quien': return 'bg-purple-100 text-purple-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
const correctas = respuestas.filter((r, i) => r === PREGUNTAS[i].correcta).length;
|
||||
const puntuacion = Math.round((correctas / PREGUNTAS.length) * 100);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto text-center p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Ejercicio Completado!</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Dominaste las tres preguntas fundamentales de la economía
|
||||
</p>
|
||||
|
||||
<div className="text-5xl font-bold text-blue-600 mb-2">{puntuacion}</div>
|
||||
<p className="text-gray-500 mb-6">puntos</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-left">
|
||||
<p className="text-sm text-gray-600 mb-2">Resumen:</p>
|
||||
<p className="text-lg font-medium text-gray-900">{correctas} de {PREGUNTAS.length} correctas</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getCategoriaColor(pregunta.categoria)}`}>
|
||||
{getCategoriaLabel(pregunta.categoria)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{preguntaActual + 1} / {PREGUNTAS.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-6">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progreso}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pregunta */}
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6">{pregunta.pregunta}</h3>
|
||||
|
||||
{/* Opciones */}
|
||||
{!mostrarResultado ? (
|
||||
<div className="space-y-3">
|
||||
{pregunta.opciones.map((opcion, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleRespuesta(index)}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="w-full p-4 text-left border-2 border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50 transition-all flex items-center gap-3"
|
||||
>
|
||||
<span className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center text-sm font-medium text-gray-600">
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
<span className="font-medium text-gray-700">{opcion}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-6 rounded-xl mb-6 ${esCorrecta ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{esCorrecta ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
<span className={`font-bold ${esCorrecta ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">{pregunta.explicacion}</p>
|
||||
|
||||
<Button onClick={handleSiguiente} className="flex items-center gap-2">
|
||||
{esUltima ? 'Finalizar' : 'Siguiente'}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProblemaEconomicoFundamental;
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ProductividadCalculatorProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
nombre: string;
|
||||
trabajadores: number;
|
||||
output: number;
|
||||
productividadMarginal?: number;
|
||||
}
|
||||
|
||||
const escenariosIniciales: Escenario[] = [
|
||||
{ id: 1, nombre: 'Fábrica A', trabajadores: 10, output: 500 },
|
||||
{ id: 2, nombre: 'Fábrica B', trabajadores: 20, output: 900 },
|
||||
{ id: 3, nombre: 'Fábrica C', trabajadores: 30, output: 1200 },
|
||||
];
|
||||
|
||||
export function ProductividadCalculator({ ejercicioId: _ejercicioId, onComplete }: ProductividadCalculatorProps) {
|
||||
const [escenarios, setEscenarios] = useState<Escenario[]>(escenariosIniciales);
|
||||
const [respuestas, setRespuestas] = useState<{[key: number]: {media: string; marginal: string}}>({});
|
||||
const [validados, setValidados] = useState<{[key: number]: boolean}>({});
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const calcularProductividadMedia = (trabajadores: number, output: number): number => {
|
||||
return Number((output / trabajadores).toFixed(2));
|
||||
};
|
||||
|
||||
const calcularRespuestasCorrectas = (): number => {
|
||||
let correctas = 0;
|
||||
escenarios.forEach((escenario, index) => {
|
||||
if (index === 0) return;
|
||||
|
||||
const prodMediaCorrecta = calcularProductividadMedia(escenario.trabajadores, escenario.output);
|
||||
const prodMarginalCorrecta = (escenario.output - escenarios[index - 1].output) /
|
||||
(escenario.trabajadores - escenarios[index - 1].trabajadores);
|
||||
|
||||
const respuesta = respuestas[escenario.id];
|
||||
if (respuesta) {
|
||||
if (Math.abs(Number(respuesta.media) - prodMediaCorrecta) < 0.5) correctas++;
|
||||
if (Math.abs(Number(respuesta.marginal) - prodMarginalCorrecta) < 0.5) correctas++;
|
||||
}
|
||||
});
|
||||
return correctas;
|
||||
};
|
||||
|
||||
const handleValidar = () => {
|
||||
const nuevosValidados: {[key: number]: boolean} = {};
|
||||
let todasCorrectas = true;
|
||||
|
||||
escenarios.forEach((escenario, index) => {
|
||||
if (index === 0) {
|
||||
nuevosValidados[escenario.id] = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const respuesta = respuestas[escenario.id];
|
||||
if (!respuesta || !respuesta.media || !respuesta.marginal) {
|
||||
todasCorrectas = false;
|
||||
nuevosValidados[escenario.id] = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const prodMediaCorrecta = calcularProductividadMedia(escenario.trabajadores, escenario.output);
|
||||
const prodMarginalCorrecta = (escenario.output - escenarios[index - 1].output) /
|
||||
(escenario.trabajadores - escenarios[index - 1].trabajadores);
|
||||
|
||||
const mediaCorrecta = Math.abs(Number(respuesta.media) - prodMediaCorrecta) < 0.5;
|
||||
const marginalCorrecta = Math.abs(Number(respuesta.marginal) - prodMarginalCorrecta) < 0.5;
|
||||
|
||||
nuevosValidados[escenario.id] = mediaCorrecta && marginalCorrecta;
|
||||
if (!mediaCorrecta || !marginalCorrecta) todasCorrectas = false;
|
||||
});
|
||||
|
||||
setValidados(nuevosValidados);
|
||||
|
||||
if (todasCorrectas && !completado) {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setRespuestas({});
|
||||
setValidados({});
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900">Calculadora de Productividad</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Calcula la productividad media y marginal para cada escenario.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">Fórmulas:</h4>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li><strong>Productividad Media:</strong> Output ÷ Número de trabajadores</li>
|
||||
<li><strong>Productividad Marginal:</strong> ΔOutput ÷ ΔTrabajadores</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-300 px-4 py-3 text-left">Escenario</th>
|
||||
<th className="border border-gray-300 px-4 py-3 text-center">Trabajadores</th>
|
||||
<th className="border border-gray-300 px-4 py-3 text-center">Output (unidades)</th>
|
||||
<th className="border border-gray-300 px-4 py-3 text-center">Productividad Media</th>
|
||||
<th className="border border-gray-300 px-4 py-3 text-center">Productividad Marginal</th>
|
||||
<th className="border border-gray-300 px-4 py-3 text-center">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{escenarios.map((escenario, index) => (
|
||||
<tr key={escenario.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="border border-gray-300 px-4 py-3 font-medium">{escenario.nombre}</td>
|
||||
<td className="border border-gray-300 px-4 py-3 text-center">{escenario.trabajadores}</td>
|
||||
<td className="border border-gray-300 px-4 py-3 text-center">{escenario.output}</td>
|
||||
<td className="border border-gray-300 px-4 py-3 text-center">
|
||||
{index === 0 ? (
|
||||
<span className="font-semibold text-gray-700">
|
||||
{calcularProductividadMedia(escenario.trabajadores, escenario.output)}
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={respuestas[escenario.id]?.media || ''}
|
||||
onChange={(e) => setRespuestas(prev => ({
|
||||
...prev,
|
||||
[escenario.id]: { ...prev[escenario.id], media: e.target.value }
|
||||
}))}
|
||||
className={`w-24 px-2 py-1 text-center border rounded ${
|
||||
validados[escenario.id] === true
|
||||
? 'border-green-500 bg-green-50'
|
||||
: validados[escenario.id] === false
|
||||
? 'border-red-500 bg-red-50'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
placeholder="?"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-4 py-3 text-center">
|
||||
{index === 0 ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={respuestas[escenario.id]?.marginal || ''}
|
||||
onChange={(e) => setRespuestas(prev => ({
|
||||
...prev,
|
||||
[escenario.id]: { ...prev[escenario.id], marginal: e.target.value }
|
||||
}))}
|
||||
className={`w-24 px-2 py-1 text-center border rounded ${
|
||||
validados[escenario.id] === true
|
||||
? 'border-green-500 bg-green-50'
|
||||
: validados[escenario.id] === false
|
||||
? 'border-red-500 bg-red-50'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
placeholder="?"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-4 py-3 text-center">
|
||||
{validados[escenario.id] === true && (
|
||||
<span className="text-green-600 font-semibold">✓ Correcto</span>
|
||||
)}
|
||||
{validados[escenario.id] === false && (
|
||||
<span className="text-red-600 font-semibold">✗ Revisar</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={handleValidar}
|
||||
disabled={completado}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
|
||||
>
|
||||
Validar Respuestas
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Reiniciar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{completado && (
|
||||
<div className="mt-6 bg-green-100 border border-green-300 rounded-lg p-4 text-center">
|
||||
<p className="text-green-800 font-semibold">¡Excelente trabajo!</p>
|
||||
<p className="text-green-700 text-2xl font-bold mt-1">100 puntos</p>
|
||||
<p className="text-green-700 text-sm mt-2">
|
||||
Has calculado correctamente todas las productividades.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductividadCalculator;
|
||||
310
frontend/src/components/exercises/modulo1/QuizBienes.tsx
Normal file
310
frontend/src/components/exercises/modulo1/QuizBienes.tsx
Normal 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;
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle, Lightbulb, ArrowRight, TrendingUp, Users, DollarSign, Scale } from 'lucide-react';
|
||||
|
||||
interface EjercicioProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
pregunta: string;
|
||||
opciones: {
|
||||
texto: string;
|
||||
correcta: boolean;
|
||||
explicacion: string;
|
||||
}[];
|
||||
icono: 'trending' | 'users' | 'dollar' | 'scale';
|
||||
}
|
||||
|
||||
const ESCENARIOS: Escenario[] = [
|
||||
{
|
||||
id: 1,
|
||||
titulo: "Costo de Oportunidad",
|
||||
descripcion: "María tiene $100 y está decidiendo entre comprar un libro de economía o ir al cine con amigos.",
|
||||
pregunta: "¿Cuál es el costo de oportunidad de elegir el libro?",
|
||||
opciones: [
|
||||
{
|
||||
texto: "Los $100 que gasta en el libro",
|
||||
correcta: false,
|
||||
explicacion: "El dinero gastado es el costo explícito, no el costo de oportunidad."
|
||||
},
|
||||
{
|
||||
texto: "El disfrute y experiencia de ir al cine con amigos",
|
||||
correcta: true,
|
||||
explicacion: "¡Correcto! El costo de oportunidad es lo que sacrificas: la experiencia del cine que dejas de tener."
|
||||
},
|
||||
{
|
||||
texto: "El tiempo que le toma leer el libro",
|
||||
correcta: false,
|
||||
explicacion: "Aunque el tiempo es un recurso, el costo de oportunidad específico es la alternativa forgada (el cine)."
|
||||
},
|
||||
{
|
||||
texto: "El valor del libro mismo",
|
||||
correcta: false,
|
||||
explicacion: "El valor del libro es el beneficio de la elección, no el costo de la alternativa."
|
||||
}
|
||||
],
|
||||
icono: 'scale'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
titulo: "Incentivos",
|
||||
descripcion: "Un gobierno aumenta los impuestos a los cigarrillos para reducir el consumo tabáquico.",
|
||||
pregunta: "¿Qué principio económico se está aplicando?",
|
||||
opciones: [
|
||||
{
|
||||
texto: "Las personas enfrentan disyuntivas",
|
||||
correcta: false,
|
||||
explicacion: "Aunque las disyuntivas existen, no es el principio principal aquí."
|
||||
},
|
||||
{
|
||||
texto: "El costo de algo es lo que sacrificas",
|
||||
correcta: false,
|
||||
explicacion: "No es el principio más relevante en este caso."
|
||||
},
|
||||
{
|
||||
texto: "Los incentivos afectan el comportamiento",
|
||||
correcta: true,
|
||||
explicacion: "¡Correcto! Al aumentar el costo (precio), se crea un incentivo para fumar menos."
|
||||
},
|
||||
{
|
||||
texto: "El comercio puede mejorar el bienestar",
|
||||
correcta: false,
|
||||
explicacion: "Este principio no aplica directamente a esta situación."
|
||||
}
|
||||
],
|
||||
icono: 'trending'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
titulo: "Racionalidad Económica",
|
||||
descripcion: "Una empresa decide invertir en maquinaria nueva que aumentará la producción en 50%.",
|
||||
pregunta: "¿Qué supuesto sobre la racionalidad económica se está haciendo?",
|
||||
opciones: [
|
||||
{
|
||||
texto: "Que la empresa busca maximizar beneficios",
|
||||
correcta: true,
|
||||
explicacion: "¡Correcto! Se asume que la empresa actúa racionalmente para maximizar sus ganancias."
|
||||
},
|
||||
{
|
||||
texto: "Que la empresa quiere ayudar a la comunidad",
|
||||
correcta: false,
|
||||
explicacion: "Aunque podría ser cierto, la racionalidad económica supone maximización de beneficios."
|
||||
},
|
||||
{
|
||||
texto: "Que la empresa no tiene otras opciones",
|
||||
correcta: false,
|
||||
explicacion: "La racionalidad económica implica elegir la mejor opción entre alternativas."
|
||||
},
|
||||
{
|
||||
texto: "Que la empresa actúa por emociones",
|
||||
correcta: false,
|
||||
explicacion: "La racionalidad económica asume decisiones basadas en cálculo, no emociones."
|
||||
}
|
||||
],
|
||||
icono: 'dollar'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
titulo: "Marginalismo",
|
||||
descripcion: "Un estudiante está estudiando para un examen. Ya lleva 6 horas estudiando.",
|
||||
pregunta: "¿Qué análisis debería hacer para decidir si estudia una hora más?",
|
||||
opciones: [
|
||||
{
|
||||
texto: "Calcular el promedio de todas sus calificaciones",
|
||||
correcta: false,
|
||||
explicacion: "El análisis promedio no ayuda en decisiones de una hora adicional."
|
||||
},
|
||||
{
|
||||
texto: "Comparar el beneficio adicional vs el costo de una hora más",
|
||||
correcta: true,
|
||||
explicacion: "¡Correcto! El análisis marginal compara beneficios y costos adicionales."
|
||||
},
|
||||
{
|
||||
texto: "Preguntarle a sus compañeros cuánto estudiaron",
|
||||
correcta: false,
|
||||
explicacion: "Las decisiones de otros no determinan tu análisis marginal óptimo."
|
||||
},
|
||||
{
|
||||
texto: "Ver cuánto tiempo ha estudiado en total",
|
||||
correcta: false,
|
||||
explicacion: "El tiempo acumulado no es relevante para la decisión marginal."
|
||||
}
|
||||
],
|
||||
icono: 'users'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
titulo: "Eficiencia vs. Equidad",
|
||||
descripcion: "Un país puede distribuir la riqueza de forma igualitaria (todos ganan lo mismo) o por productividad (los más productivos ganan más).",
|
||||
pregunta: "¿Qué principio económico ilustra esta disyuntiva?",
|
||||
opciones: [
|
||||
{
|
||||
texto: "La especialización mejora la productividad",
|
||||
correcta: false,
|
||||
explicacion: "La especialización es otro principio diferente."
|
||||
},
|
||||
{
|
||||
texto: "Los mercados son generalmente eficientes",
|
||||
correcta: false,
|
||||
explicacion: "Aunque relacionado, no captura la tensión entre eficiencia y equidad."
|
||||
},
|
||||
{
|
||||
texto: "Existe una disyuntiva entre eficiencia y equidad",
|
||||
correcta: true,
|
||||
explicacion: "¡Correcto! La distribución igualitaria (equidad) puede reducir incentivos (eficiencia)."
|
||||
},
|
||||
{
|
||||
texto: "El comercio internacional beneficia a todos",
|
||||
correcta: false,
|
||||
explicacion: "Este principio no aplica a la distribución interna de riqueza."
|
||||
}
|
||||
],
|
||||
icono: 'scale'
|
||||
}
|
||||
];
|
||||
|
||||
export function RazonamientoEconomico({ ejercicioId: _ejercicioId, onComplete }: EjercicioProps) {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [respuestas, setRespuestas] = useState<{escenarioId: number, correcta: boolean}[]>([]);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const escenario = ESCENARIOS[escenarioActual];
|
||||
const esUltima = escenarioActual === ESCENARIOS.length - 1;
|
||||
const progreso = (escenarioActual / ESCENARIOS.length) * 100;
|
||||
|
||||
const getIcono = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'trending': return <TrendingUp className="w-6 h-6" />;
|
||||
case 'users': return <Users className="w-6 h-6" />;
|
||||
case 'dollar': return <DollarSign className="w-6 h-6" />;
|
||||
case 'scale': return <Scale className="w-6 h-6" />;
|
||||
default: return <Lightbulb className="w-6 h-6" />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRespuesta = (index: number) => {
|
||||
const esCorrecta = escenario.opciones[index].correcta;
|
||||
const nuevasRespuestas = [...respuestas, { escenarioId: escenario.id, correcta: esCorrecta }];
|
||||
setRespuestas(nuevasRespuestas);
|
||||
|
||||
if (esUltima) {
|
||||
const correctas = nuevasRespuestas.filter(r => r.correcta).length;
|
||||
const puntuacion = Math.round((correctas / ESCENARIOS.length) * 100);
|
||||
setCompletado(true);
|
||||
onComplete?.(puntuacion);
|
||||
} else {
|
||||
setMostrarResultado(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
setEscenarioActual(prev => prev + 1);
|
||||
setMostrarResultado(false);
|
||||
};
|
||||
|
||||
const respuestaActual = respuestas[respuestas.length - 1];
|
||||
|
||||
if (completado) {
|
||||
const correctas = respuestas.filter(r => r.correcta).length;
|
||||
const puntuacion = Math.round((correctas / ESCENARIOS.length) * 100);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto text-center p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<Lightbulb className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Razonamiento Completado!</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Has aplicado principios clave del pensamiento económico
|
||||
</p>
|
||||
|
||||
<div className="text-5xl font-bold text-blue-600 mb-2">{puntuacion}</div>
|
||||
<p className="text-gray-500 mb-6">puntos</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-left">
|
||||
<p className="text-sm text-gray-600 mb-2">Conceptos evaluados:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ESCENARIOS.map((e, i) => (
|
||||
<span
|
||||
key={e.id}
|
||||
className={`px-2 py-1 rounded text-xs ${respuestas[i]?.correcta ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}
|
||||
>
|
||||
{e.titulo}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600">
|
||||
{getIcono(escenario.icono)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{escenario.titulo}</h3>
|
||||
<p className="text-xs text-gray-500">Caso {escenarioActual + 1} de {ESCENARIOS.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-6">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progreso}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!mostrarResultado ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
{/* Escenario */}
|
||||
<div className="bg-blue-50 rounded-xl p-5 mb-6">
|
||||
<p className="text-gray-800 leading-relaxed">{escenario.descripcion}</p>
|
||||
</div>
|
||||
|
||||
{/* Pregunta */}
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-4">{escenario.pregunta}</h4>
|
||||
|
||||
{/* Opciones */}
|
||||
<div className="space-y-3">
|
||||
{escenario.opciones.map((opcion, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleRespuesta(index)}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="w-full p-4 text-left border-2 border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50 transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-xs font-medium text-gray-600 flex-shrink-0 mt-0.5">
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
<span className="text-gray-700">{opcion.texto}</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-6 rounded-xl ${respuestaActual?.correcta ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{respuestaActual?.correcta ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
<span className={`font-bold ${respuestaActual?.correcta ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{respuestaActual?.correcta ? '¡Excelente razonamiento!' : 'No es correcto'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600 mb-1">Respuesta correcta:</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{escenario.opciones.find(o => o.correcta)?.texto}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-4">
|
||||
{escenario.opciones.find(o => o.correcta)?.explicacion}
|
||||
</p>
|
||||
|
||||
<Button onClick={handleSiguiente} className="flex items-center gap-2">
|
||||
{esUltima ? 'Finalizar' : 'Siguiente caso'}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default RazonamientoEconomico;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MatchingExercise } from '../common/MatchingExercise';
|
||||
|
||||
const ROLES_AGENTES = {
|
||||
leftItems: [
|
||||
{ id: 'consumidor', content: 'Consumidor' },
|
||||
{ id: 'productor', content: 'Productor' },
|
||||
{ id: 'propietario-factores', content: 'Propietario de factores' },
|
||||
{ id: 'demandante-bienes', content: 'Demandante de bienes y servicios' },
|
||||
{ id: 'ofertante-factores', content: 'Ofertante de factores de producción' },
|
||||
{ id: 'generador-ingresos', content: 'Generador de rentas y salarios' },
|
||||
],
|
||||
rightItems: [
|
||||
{ id: 'familia', content: 'Familias' },
|
||||
{ id: 'empresa', content: 'Empresas' },
|
||||
{ id: 'ambos', content: 'Ambos agentes' },
|
||||
],
|
||||
correctPairs: [
|
||||
{ leftId: 'consumidor', rightId: 'familia' },
|
||||
{ leftId: 'productor', rightId: 'empresa' },
|
||||
{ leftId: 'propietario-factores', rightId: 'familia' },
|
||||
{ leftId: 'demandante-bienes', rightId: 'familia' },
|
||||
{ leftId: 'ofertante-factores', rightId: 'familia' },
|
||||
{ leftId: 'generador-ingresos', rightId: 'ambos' },
|
||||
],
|
||||
};
|
||||
|
||||
interface RolesAgentesMatchingProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
export function RolesAgentesMatching({
|
||||
ejercicioId: _ejercicioId,
|
||||
onComplete,
|
||||
}: RolesAgentesMatchingProps) {
|
||||
const handleComplete = (result: {
|
||||
correct: number;
|
||||
total: number;
|
||||
attempts: number;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
isPerfect: boolean;
|
||||
}) => {
|
||||
if (onComplete) {
|
||||
onComplete(result.score);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MatchingExercise
|
||||
title="Roles y Agentes Económicos"
|
||||
description="Relaciona cada rol económico con el agente correspondiente. Las familias y empresas desempeñan diferentes funciones en la economía."
|
||||
leftItems={ROLES_AGENTES.leftItems}
|
||||
rightItems={ROLES_AGENTES.rightItems}
|
||||
correctPairs={ROLES_AGENTES.correctPairs}
|
||||
maxPoints={100}
|
||||
shuffleItems={true}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RolesAgentesMatching;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface EjercicioProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
const PREGUNTAS = [
|
||||
{
|
||||
id: 1,
|
||||
pregunta: "¿En qué sistema económico el Estado controla los medios de producción y la distribución de bienes?",
|
||||
opciones: [
|
||||
"Economía de mercado",
|
||||
"Economía planificada o centralizada",
|
||||
"Economía mixta",
|
||||
"Economía tradicional"
|
||||
],
|
||||
correcta: 1,
|
||||
explicacion: "En la economía planificada o centralizada, el Estado o gobierno controla todos los medios de producción y decide qué producir, cómo producirlo y para quién."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pregunta: "¿Cuál es la característica principal de una economía de mercado?",
|
||||
opciones: [
|
||||
"El gobierno decide todos los precios",
|
||||
"Las decisiones económicas se toman por la oferta y la demanda",
|
||||
"No existe propiedad privada",
|
||||
"La producción se basa en costumbres ancestrales"
|
||||
],
|
||||
correcta: 1,
|
||||
explicacion: "En la economía de mercado, las decisiones económicas se determinan por la libre interacción de oferta y demanda, sin intervención estatal directa."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pregunta: "¿Qué sistema económico combina elementos del mercado con intervención estatal?",
|
||||
opciones: [
|
||||
"Economía de mercado pura",
|
||||
"Economía planificada",
|
||||
"Economía mixta",
|
||||
"Economía cerrada"
|
||||
],
|
||||
correcta: 2,
|
||||
explicacion: "La economía mixta combina el funcionamiento del mercado con intervención estatal en sectores clave para corregir fallos de mercado y garantizar el bienestar social."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pregunta: "En una economía planificada, ¿quién decide qué bienes se producen?",
|
||||
opciones: [
|
||||
"Los consumidores mediante sus compras",
|
||||
"Las empresas privadas",
|
||||
"El gobierno o planificadores centrales",
|
||||
"Los sindicatos"
|
||||
],
|
||||
correcta: 2,
|
||||
explicacion: "En la economía planificada, son los planificadores gubernamentales quienes determinan qué producir, en qué cantidad y a qué precio."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pregunta: "¿Qué ventaja principal tiene la economía de mercado sobre la planificada?",
|
||||
opciones: [
|
||||
"Mayor equidad en la distribución",
|
||||
"Mayor eficiencia y respuesta a las preferencias de los consumidores",
|
||||
"Eliminación de la competencia",
|
||||
"Control total de la inflación"
|
||||
],
|
||||
correcta: 1,
|
||||
explicacion: "La economía de mercado tiende a ser más eficiente asignando recursos y responde mejor a las preferencias de los consumidores a través del mecanismo de precios."
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
pregunta: "¿Qué problema suele presentar la economía planificada?",
|
||||
opciones: [
|
||||
"Exceso de bienes de lujo",
|
||||
"Ineficiencia y escasez por falta de incentivos",
|
||||
"Alta desigualdad entre ricos y pobres",
|
||||
"Inestabilidad cambiaria"
|
||||
],
|
||||
correcta: 1,
|
||||
explicacion: "La economía planificada suele sufrir de ineficiencias porque carece de los incentivos del mercado y la información descentralizada que guía la economía de mercado."
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
pregunta: "¿Cuál es el rol del Estado en una economía mixta?",
|
||||
opciones: [
|
||||
"No interviene en absoluto",
|
||||
"Controla toda la producción",
|
||||
"Regula, corrige fallos de mercado y provee bienes públicos",
|
||||
"Solo recauda impuestos"
|
||||
],
|
||||
correcta: 2,
|
||||
explicacion: "En la economía mixta, el Estado regula el mercado, corrige fallos de mercado, proporciona bienes públicos y protege a los consumidores."
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
pregunta: "La propiedad privada de los medios de producción es característica de:",
|
||||
opciones: [
|
||||
"Solo economía de mercado",
|
||||
"Solo economía mixta",
|
||||
"Economía de mercado y economía mixta",
|
||||
"Economía planificada"
|
||||
],
|
||||
correcta: 2,
|
||||
explicacion: "Tanto la economía de mercado como la mixta permiten la propiedad privada, a diferencia de la economía planificada donde los medios de producción son estatales."
|
||||
}
|
||||
];
|
||||
|
||||
export function SistemasEconomicosQuiz({ ejercicioId: _ejercicioId, onComplete }: EjercicioProps) {
|
||||
const [preguntaActual, setPreguntaActual] = useState(0);
|
||||
const [respuestas, setRespuestas] = useState<number[]>([]);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const pregunta = PREGUNTAS[preguntaActual];
|
||||
const esUltima = preguntaActual === PREGUNTAS.length - 1;
|
||||
|
||||
const handleRespuesta = (index: number) => {
|
||||
const nuevasRespuestas = [...respuestas, index];
|
||||
setRespuestas(nuevasRespuestas);
|
||||
|
||||
if (esUltima) {
|
||||
const correctas = nuevasRespuestas.filter((r, i) => r === PREGUNTAS[i].correcta).length;
|
||||
const puntuacion = Math.round((correctas / PREGUNTAS.length) * 100);
|
||||
setCompletado(true);
|
||||
onComplete?.(puntuacion);
|
||||
} else {
|
||||
setMostrarResultado(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setMostrarResultado(false);
|
||||
};
|
||||
|
||||
const esCorrecta = respuestas[preguntaActual] === pregunta.correcta;
|
||||
|
||||
if (completado) {
|
||||
const correctas = respuestas.filter((r, i) => r === PREGUNTAS[i].correcta).length;
|
||||
const puntuacion = Math.round((correctas / PREGUNTAS.length) * 100);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto text-center p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Quiz Completado!</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Respondiste {correctas} de {PREGUNTAS.length} preguntas correctamente
|
||||
</p>
|
||||
|
||||
<div className="text-5xl font-bold text-blue-600 mb-2">{puntuacion}</div>
|
||||
<p className="text-gray-500">puntos</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
||||
<span>Pregunta {preguntaActual + 1} de {PREGUNTAS.length}</span>
|
||||
<span>{Math.round(((preguntaActual) / PREGUNTAS.length) * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((preguntaActual) / PREGUNTAS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6">{pregunta.pregunta}</h3>
|
||||
|
||||
{!mostrarResultado ? (
|
||||
<div className="space-y-3">
|
||||
{pregunta.opciones.map((opcion, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleRespuesta(index)}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="w-full p-4 text-left border-2 border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50 transition-all"
|
||||
>
|
||||
<span className="font-medium text-gray-700">{String.fromCharCode(65 + index)}. {opcion}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-6 rounded-xl mb-6 ${esCorrecta ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{esCorrecta ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
<span className={`font-bold ${esCorrecta ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700">{pregunta.explicacion}</p>
|
||||
|
||||
<Button onClick={handleSiguiente} className="mt-4">
|
||||
{esUltima ? 'Finalizar' : 'Siguiente'}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default SistemasEconomicosQuiz;
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface VentajaComparativaCalculatorProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Pais {
|
||||
nombre: string;
|
||||
vino: number;
|
||||
queso: number;
|
||||
}
|
||||
|
||||
interface Respuestas {
|
||||
ventajaAbsolutaVino: string;
|
||||
ventajaAbsolutaQueso: string;
|
||||
costoOportunidadPaisA: string;
|
||||
costoOportunidadPaisB: string;
|
||||
ventajaComparativaVino: string;
|
||||
ventajaComparativaQueso: string;
|
||||
}
|
||||
|
||||
const paises: Pais[] = [
|
||||
{ nombre: 'País A', vino: 100, queso: 200 },
|
||||
{ nombre: 'País B', vino: 80, queso: 120 },
|
||||
];
|
||||
|
||||
const opcionesPais = ['País A', 'País B', 'Ninguno (igual producción)'];
|
||||
|
||||
export function VentajaComparativaCalculator({ ejercicioId: _ejercicioId, onComplete }: VentajaComparativaCalculatorProps) {
|
||||
const [respuestas, setRespuestas] = useState<Partial<Respuestas>>({});
|
||||
const [validados, setValidados] = useState<{[key: string]: boolean | null}>({});
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const calcularCostoOportunidad = (pais: Pais, bien: 'vino' | 'queso'): number => {
|
||||
if (bien === 'vino') {
|
||||
return pais.queso / pais.vino;
|
||||
}
|
||||
return pais.vino / pais.queso;
|
||||
};
|
||||
|
||||
const handleRespuestaChange = (campo: keyof Respuestas, valor: string) => {
|
||||
setRespuestas(prev => ({ ...prev, [campo]: valor }));
|
||||
setValidados(prev => ({ ...prev, [campo]: null }));
|
||||
};
|
||||
|
||||
const handleValidar = () => {
|
||||
const nuevosValidados: {[key: string]: boolean | null} = {};
|
||||
|
||||
const correctas: {[key: string]: string} = {
|
||||
ventajaAbsolutaVino: paises[0].vino > paises[1].vino ? 'País A' : 'País B',
|
||||
ventajaAbsolutaQueso: paises[0].queso > paises[1].queso ? 'País A' : 'País B',
|
||||
costoOportunidadPaisA: `${calcularCostoOportunidad(paises[0], 'vino').toFixed(2)}`,
|
||||
costoOportunidadPaisB: `${calcularCostoOportunidad(paises[1], 'vino').toFixed(2)}`,
|
||||
};
|
||||
|
||||
const costoA = calcularCostoOportunidad(paises[0], 'vino');
|
||||
const costoB = calcularCostoOportunidad(paises[1], 'vino');
|
||||
|
||||
correctas.ventajaComparativaVino = costoA < costoB ? 'País A' : 'País B';
|
||||
correctas.ventajaComparativaQueso = costoA < costoB ? 'País B' : 'País A';
|
||||
|
||||
let todasCorrectas = true;
|
||||
Object.keys(correctas).forEach(key => {
|
||||
const esCorrecta = respuestas[key as keyof Respuestas] === correctas[key];
|
||||
nuevosValidados[key] = esCorrecta;
|
||||
if (!esCorrecta) todasCorrectas = false;
|
||||
});
|
||||
|
||||
setValidados(nuevosValidados);
|
||||
|
||||
if (todasCorrectas && !completado) {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setRespuestas({});
|
||||
setValidados({});
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900">Calculadora de Ventaja Comparativa</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Analiza la producción de dos países para determinar ventajas absolutas y comparativas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Tabla de Producción</h4>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-200">
|
||||
<th className="border border-gray-300 px-3 py-2 text-left">País</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center">Vino (barriles)</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center">Queso (kg)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paises.map((pais, idx) => (
|
||||
<tr key={pais.nombre} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="border border-gray-300 px-3 py-2 font-medium">{pais.nombre}</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">{pais.vino}</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">{pais.queso}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">Guía:</h4>
|
||||
<ul className="text-sm text-blue-800 space-y-2">
|
||||
<li>
|
||||
<strong>Ventaja Absoluta:</strong> Quien produce más de un bien con los mismos recursos.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Costo de Oportunidad:</strong>
|
||||
<br/>• Vino: Queso sacrificado ÷ Vino producido
|
||||
<br/>• Queso: Vino sacrificado ÷ Queso producido
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ventaja Comparativa:</strong> Quien tiene el menor costo de oportunidad.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="border border-gray-200 rounded-lg p-5">
|
||||
<h4 className="font-semibold text-gray-900 mb-4">1. Ventaja Absoluta</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
¿Quién tiene ventaja absoluta en la producción de vino?
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opcionesPais.map(opcion => {
|
||||
const isSelected = respuestas.ventajaAbsolutaVino === opcion;
|
||||
const estado = validados.ventajaAbsolutaVino;
|
||||
|
||||
let className = 'px-4 py-2 rounded-lg text-sm border transition-all ';
|
||||
if (estado === true) {
|
||||
className += isSelected ? 'bg-green-100 border-green-500 text-green-800' : 'bg-gray-100 border-gray-200 text-gray-400';
|
||||
} else if (estado === false && isSelected) {
|
||||
className += 'bg-red-100 border-red-500 text-red-800';
|
||||
} else {
|
||||
className += isSelected ? 'bg-blue-100 border-blue-500 text-blue-800' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opcion}
|
||||
onClick={() => handleRespuestaChange('ventajaAbsolutaVino', opcion)}
|
||||
className={className}
|
||||
>
|
||||
{opcion}
|
||||
{estado === true && isSelected && ' ✓'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
¿Quién tiene ventaja absoluta en la producción de queso?
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opcionesPais.map(opcion => {
|
||||
const isSelected = respuestas.ventajaAbsolutaQueso === opcion;
|
||||
const estado = validados.ventajaAbsolutaQueso;
|
||||
|
||||
let className = 'px-4 py-2 rounded-lg text-sm border transition-all ';
|
||||
if (estado === true) {
|
||||
className += isSelected ? 'bg-green-100 border-green-500 text-green-800' : 'bg-gray-100 border-gray-200 text-gray-400';
|
||||
} else if (estado === false && isSelected) {
|
||||
className += 'bg-red-100 border-red-500 text-red-800';
|
||||
} else {
|
||||
className += isSelected ? 'bg-blue-100 border-blue-500 text-blue-800' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opcion}
|
||||
onClick={() => handleRespuestaChange('ventajaAbsolutaQueso', opcion)}
|
||||
className={className}
|
||||
>
|
||||
{opcion}
|
||||
{estado === true && isSelected && ' ✓'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg p-5">
|
||||
<h4 className="font-semibold text-gray-900 mb-4">2. Costo de Oportunidad del Vino</h4>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
País A: 1 barril de vino =
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={respuestas.costoOportunidadPaisA || ''}
|
||||
onChange={(e) => handleRespuestaChange('costoOportunidadPaisA', e.target.value)}
|
||||
className={`w-24 px-3 py-2 border rounded-lg text-center ${
|
||||
validados.costoOportunidadPaisA === true
|
||||
? 'border-green-500 bg-green-50'
|
||||
: validados.costoOportunidadPaisA === false
|
||||
? 'border-red-500 bg-red-50'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
placeholder="?"
|
||||
/>
|
||||
<span className="text-gray-600">kg de queso</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
País B: 1 barril de vino =
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={respuestas.costoOportunidadPaisB || ''}
|
||||
onChange={(e) => handleRespuestaChange('costoOportunidadPaisB', e.target.value)}
|
||||
className={`w-24 px-3 py-2 border rounded-lg text-center ${
|
||||
validados.costoOportunidadPaisB === true
|
||||
? 'border-green-500 bg-green-50'
|
||||
: validados.costoOportunidadPaisB === false
|
||||
? 'border-red-500 bg-red-50'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
placeholder="?"
|
||||
/>
|
||||
<span className="text-gray-600">kg de queso</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg p-5">
|
||||
<h4 className="font-semibold text-gray-900 mb-4">3. Ventaja Comparativa</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
¿Quién tiene ventaja comparativa en vino? (menor costo de oportunidad)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opcionesPais.map(opcion => {
|
||||
const isSelected = respuestas.ventajaComparativaVino === opcion;
|
||||
const estado = validados.ventajaComparativaVino;
|
||||
|
||||
let className = 'px-4 py-2 rounded-lg text-sm border transition-all ';
|
||||
if (estado === true) {
|
||||
className += isSelected ? 'bg-green-100 border-green-500 text-green-800' : 'bg-gray-100 border-gray-200 text-gray-400';
|
||||
} else if (estado === false && isSelected) {
|
||||
className += 'bg-red-100 border-red-500 text-red-800';
|
||||
} else {
|
||||
className += isSelected ? 'bg-blue-100 border-blue-500 text-blue-800' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opcion}
|
||||
onClick={() => handleRespuestaChange('ventajaComparativaVino', opcion)}
|
||||
className={className}
|
||||
>
|
||||
{opcion}
|
||||
{estado === true && isSelected && ' ✓'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
¿Quién tiene ventaja comparativa en queso? (menor costo de oportunidad)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opcionesPais.map(opcion => {
|
||||
const isSelected = respuestas.ventajaComparativaQueso === opcion;
|
||||
const estado = validados.ventajaComparativaQueso;
|
||||
|
||||
let className = 'px-4 py-2 rounded-lg text-sm border transition-all ';
|
||||
if (estado === true) {
|
||||
className += isSelected ? 'bg-green-100 border-green-500 text-green-800' : 'bg-gray-100 border-gray-200 text-gray-400';
|
||||
} else if (estado === false && isSelected) {
|
||||
className += 'bg-red-100 border-red-500 text-red-800';
|
||||
} else {
|
||||
className += isSelected ? 'bg-blue-100 border-blue-500 text-blue-800' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opcion}
|
||||
onClick={() => handleRespuestaChange('ventajaComparativaQueso', opcion)}
|
||||
className={className}
|
||||
>
|
||||
{opcion}
|
||||
{estado === true && isSelected && ' ✓'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={handleValidar}
|
||||
disabled={completado}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
|
||||
>
|
||||
Validar Respuestas
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Reiniciar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{completado && (
|
||||
<div className="mt-6 bg-green-100 border border-green-300 rounded-lg p-4 text-center">
|
||||
<p className="text-green-800 font-semibold">¡Excelente análisis económico!</p>
|
||||
<p className="text-green-700 text-2xl font-bold mt-1">100 puntos</p>
|
||||
<p className="text-green-700 text-sm mt-2">
|
||||
Has identificado correctamente las ventajas absolutas y comparativas.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VentajaComparativaCalculator;
|
||||
@@ -0,0 +1,546 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCcw,
|
||||
Link2,
|
||||
Trophy,
|
||||
Scale,
|
||||
Target,
|
||||
Zap,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Users,
|
||||
Globe,
|
||||
Landmark,
|
||||
Coins
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MatchingItem {
|
||||
id: string;
|
||||
content: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface MatchingPair {
|
||||
leftId: string;
|
||||
rightId: string;
|
||||
}
|
||||
|
||||
interface VentajasDesventajasSistemasProps {
|
||||
onComplete?: (result: {
|
||||
correct: number;
|
||||
total: number;
|
||||
score: number;
|
||||
isPerfect: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const SISTEMAS_ECONOMICOS: MatchingItem[] = [
|
||||
{
|
||||
id: 'mercado',
|
||||
content: 'Economía de Mercado',
|
||||
icon: <Coins size={18} />,
|
||||
},
|
||||
{
|
||||
id: 'planificada',
|
||||
content: 'Economía Planificada',
|
||||
icon: <Landmark size={18} />,
|
||||
},
|
||||
{
|
||||
id: 'mixta',
|
||||
content: 'Economía Mixta',
|
||||
icon: <Scale size={18} />,
|
||||
},
|
||||
];
|
||||
|
||||
const CARACTERISTICAS: MatchingItem[] = [
|
||||
{
|
||||
id: 'eficiencia',
|
||||
content: 'Alta eficiencia en la asignación de recursos',
|
||||
icon: <Zap size={18} />,
|
||||
},
|
||||
{
|
||||
id: 'desigualdad',
|
||||
content: 'Puede generar grandes desigualdades de ingreso',
|
||||
icon: <Users size={18} />,
|
||||
},
|
||||
{
|
||||
id: 'planificacion',
|
||||
content: 'El gobierno controla la producción y distribución',
|
||||
icon: <Landmark size={18} />,
|
||||
},
|
||||
{
|
||||
id: 'flexibilidad',
|
||||
content: 'Respuesta rápida a cambios en la demanda',
|
||||
icon: <ArrowRight size={18} />,
|
||||
},
|
||||
{
|
||||
id: 'equidad',
|
||||
content: 'Mayor equidad en la distribución de bienes',
|
||||
icon: <Scale size={18} />,
|
||||
},
|
||||
{
|
||||
id: 'burocracia',
|
||||
content: 'Alta burocracia y lentitud en decisiones',
|
||||
icon: <Building2 size={18} />,
|
||||
},
|
||||
{
|
||||
id: 'equilibrio',
|
||||
content: 'Combina eficiencia con justicia social',
|
||||
icon: <Globe size={18} />,
|
||||
},
|
||||
{
|
||||
id: 'intervencion',
|
||||
content: 'El Estado regula y corrige fallas del mercado',
|
||||
icon: <Target size={18} />,
|
||||
},
|
||||
];
|
||||
|
||||
const PAREJAS_CORRECTAS: MatchingPair[] = [
|
||||
{ leftId: 'mercado', rightId: 'eficiencia' },
|
||||
{ leftId: 'mercado', rightId: 'desigualdad' },
|
||||
{ leftId: 'mercado', rightId: 'flexibilidad' },
|
||||
{ leftId: 'planificada', rightId: 'planificacion' },
|
||||
{ leftId: 'planificada', rightId: 'equidad' },
|
||||
{ leftId: 'planificada', rightId: 'burocracia' },
|
||||
{ leftId: 'mixta', rightId: 'equilibrio' },
|
||||
{ leftId: 'mixta', rightId: 'intervencion' },
|
||||
];
|
||||
|
||||
interface Match {
|
||||
leftId: string;
|
||||
rightId: string;
|
||||
isCorrect?: boolean;
|
||||
}
|
||||
|
||||
export function VentajasDesventajasSistemas({ onComplete }: VentajasDesventajasSistemasProps) {
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [selectedLeft, setSelectedLeft] = useState<string | null>(null);
|
||||
const [selectedRight, setSelectedRight] = useState<string | null>(null);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
|
||||
const handleLeftClick = (itemId: string) => {
|
||||
if (showResults) return;
|
||||
|
||||
if (selectedLeft === itemId) {
|
||||
setSelectedLeft(null);
|
||||
} else {
|
||||
setSelectedLeft(itemId);
|
||||
if (selectedRight) {
|
||||
handleCreateMatch(itemId, selectedRight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightClick = (itemId: string) => {
|
||||
if (showResults) return;
|
||||
|
||||
if (selectedRight === itemId) {
|
||||
setSelectedRight(null);
|
||||
} else {
|
||||
setSelectedRight(itemId);
|
||||
if (selectedLeft) {
|
||||
handleCreateMatch(selectedLeft, itemId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateMatch = (leftId: string, rightId: string) => {
|
||||
const isLeftMatched = matches.some(m => m.leftId === leftId);
|
||||
const isRightMatched = matches.some(m => m.rightId === rightId);
|
||||
|
||||
if (isLeftMatched || isRightMatched) return;
|
||||
|
||||
setMatches(prev => [...prev, { leftId, rightId }]);
|
||||
setSelectedLeft(null);
|
||||
setSelectedRight(null);
|
||||
setAttempts(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleRemoveMatch = (leftId: string) => {
|
||||
if (showResults) return;
|
||||
setMatches(prev => prev.filter(m => m.leftId !== leftId));
|
||||
};
|
||||
|
||||
const handleValidate = () => {
|
||||
const validatedMatches = matches.map(match => {
|
||||
const isCorrect = PAREJAS_CORRECTAS.some(
|
||||
p => p.leftId === match.leftId && p.rightId === match.rightId
|
||||
);
|
||||
return { ...match, isCorrect };
|
||||
});
|
||||
|
||||
setMatches(validatedMatches);
|
||||
setShowResults(true);
|
||||
|
||||
const correctCount = validatedMatches.filter(m => m.isCorrect).length;
|
||||
const score = Math.round((correctCount / PAREJAS_CORRECTAS.length) * 100);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete({
|
||||
correct: correctCount,
|
||||
total: PAREJAS_CORRECTAS.length,
|
||||
score,
|
||||
isPerfect: correctCount === PAREJAS_CORRECTAS.length,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setMatches([]);
|
||||
setSelectedLeft(null);
|
||||
setSelectedRight(null);
|
||||
setShowResults(false);
|
||||
setAttempts(0);
|
||||
};
|
||||
|
||||
const isLeftMatched = (id: string) => matches.some(m => m.leftId === id);
|
||||
const isRightMatched = (id: string) => matches.some(m => m.rightId === id);
|
||||
|
||||
const getMatchStatus = (leftId: string): 'correct' | 'incorrect' | null => {
|
||||
const match = matches.find(m => m.leftId === leftId);
|
||||
if (!match || !showResults) return null;
|
||||
return match.isCorrect ? 'correct' : 'incorrect';
|
||||
};
|
||||
|
||||
const getMatchedRightItem = (leftId: string) => {
|
||||
const match = matches.find(m => m.leftId === leftId);
|
||||
if (!match) return null;
|
||||
return CARACTERISTICAS.find(item => item.id === match.rightId);
|
||||
};
|
||||
|
||||
const getMatchedLeftItem = (rightId: string) => {
|
||||
const match = matches.find(m => m.rightId === rightId);
|
||||
if (!match) return null;
|
||||
return SISTEMAS_ECONOMICOS.find(item => item.id === match.leftId);
|
||||
};
|
||||
|
||||
const correctCount = matches.filter(m => m.isCorrect).length;
|
||||
const allMatched = matches.length === PAREJAS_CORRECTAS.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Sistemas Económicos</h3>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Relaciona cada sistema económico con sus características correspondientes
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-1.5 rounded-full">
|
||||
<Target size={16} />
|
||||
<span className="font-semibold text-sm">100 pts</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-blue-600 bg-blue-50 px-3 py-1.5 rounded-full">
|
||||
<Zap size={16} />
|
||||
<span className="font-semibold text-sm">{attempts} intentos</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instrucciones */}
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-blue-100 p-2 rounded-lg">
|
||||
<Link2 size={18} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-900 mb-1">¿Cómo jugar?</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
Haz clic en un sistema económico y luego en sus características para emparejarlos.
|
||||
Cada sistema debe emparejarse con sus ventajas y desventajas específicas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Matching Area */}
|
||||
<Card className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Sistemas Económicos */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Sistemas Económicos
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{SISTEMAS_ECONOMICOS.map(item => {
|
||||
const matchedItem = getMatchedRightItem(item.id);
|
||||
const status = getMatchStatus(item.id);
|
||||
const isSelected = selectedLeft === item.id;
|
||||
const isMatched = isLeftMatched(item.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
onClick={() => handleLeftClick(item.id)}
|
||||
whileHover={!showResults && !isMatched ? { scale: 1.02 } : {}}
|
||||
whileTap={!showResults && !isMatched ? { scale: 0.98 } : {}}
|
||||
className={`
|
||||
relative p-4 rounded-xl border-2 transition-all cursor-pointer
|
||||
${!showResults && !isMatched ? 'hover:border-blue-300 hover:shadow-md' : ''}
|
||||
${isSelected ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-200' : ''}
|
||||
${isMatched && !showResults ? 'border-indigo-300 bg-indigo-50' : ''}
|
||||
${status === 'correct' ? 'border-green-500 bg-green-50' : ''}
|
||||
${status === 'incorrect' ? 'border-red-500 bg-red-50' : ''}
|
||||
${!isMatched && !isSelected ? 'border-gray-200 bg-white' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`
|
||||
p-2 rounded-lg flex-shrink-0
|
||||
${isSelected ? 'bg-blue-500 text-white' :
|
||||
status === 'correct' ? 'bg-green-500 text-white' :
|
||||
status === 'incorrect' ? 'bg-red-500 text-white' :
|
||||
isMatched ? 'bg-indigo-500 text-white' :
|
||||
'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="font-semibold text-gray-800">{item.content}</span>
|
||||
</div>
|
||||
|
||||
{/* Match indicator */}
|
||||
{matchedItem && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200/60">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link2 size={14} className="text-gray-400" />
|
||||
<span className="text-gray-600">{matchedItem.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status icons */}
|
||||
{showResults && status && (
|
||||
<div className="absolute top-3 right-3">
|
||||
{status === 'correct' ? (
|
||||
<CheckCircle size={22} className="text-green-600" />
|
||||
) : (
|
||||
<XCircle size={22} className="text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remove button */}
|
||||
{isMatched && !showResults && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveMatch(item.id);
|
||||
}}
|
||||
className="absolute top-3 right-3 p-1.5 hover:bg-gray-200 rounded-full transition-colors"
|
||||
>
|
||||
<XCircle size={18} className="text-gray-400 hover:text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Counter badge */}
|
||||
{!showResults && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<span className={`
|
||||
inline-flex items-center justify-center w-6 h-6 text-xs font-bold rounded-full
|
||||
${isMatched ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-600'}
|
||||
`}>
|
||||
{matches.filter(m => m.leftId === item.id).length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Características */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Características
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{CARACTERISTICAS.map(item => {
|
||||
const matchedItem = getMatchedLeftItem(item.id);
|
||||
const isSelected = selectedRight === item.id;
|
||||
const isMatched = isRightMatched(item.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
onClick={() => handleRightClick(item.id)}
|
||||
whileHover={!showResults && !isMatched ? { scale: 1.02 } : {}}
|
||||
whileTap={!showResults && !isMatched ? { scale: 0.98 } : {}}
|
||||
className={`
|
||||
relative p-3 rounded-xl border-2 transition-all cursor-pointer
|
||||
${!showResults && !isMatched ? 'hover:border-blue-300 hover:shadow-md' : ''}
|
||||
${isSelected ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-200' : ''}
|
||||
${isMatched ? 'border-gray-300 bg-gray-100 opacity-60' : ''}
|
||||
${!isMatched && !isSelected ? 'border-gray-200 bg-white' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`
|
||||
p-1.5 rounded-lg flex-shrink-0
|
||||
${isSelected ? 'bg-blue-500 text-white' :
|
||||
isMatched ? 'bg-gray-400 text-white' :
|
||||
'bg-gray-100 text-gray-500'}
|
||||
`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className={`font-medium ${isMatched ? 'text-gray-500' : 'text-gray-700'}`}>
|
||||
{item.content}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Match indicator */}
|
||||
{matchedItem && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-200/60">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Link2 size={12} className="text-gray-400" />
|
||||
<span className="text-gray-500">Emparejado con: {matchedItem.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Results Section */}
|
||||
<AnimatePresence>
|
||||
{showResults && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<Card className={`
|
||||
p-6 border-2
|
||||
${correctCount === PAREJAS_CORRECTAS.length
|
||||
? 'bg-gradient-to-br from-green-50 to-emerald-50 border-green-200'
|
||||
: 'bg-gradient-to-br from-blue-50 to-indigo-50 border-blue-200'}
|
||||
`}>
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, delay: 0.2 }}
|
||||
className={`
|
||||
inline-flex items-center justify-center w-20 h-20 rounded-full mb-4
|
||||
${correctCount === PAREJAS_CORRECTAS.length
|
||||
? 'bg-gradient-to-br from-yellow-400 to-orange-500'
|
||||
: 'bg-gradient-to-br from-blue-400 to-indigo-500'}
|
||||
`}
|
||||
>
|
||||
<Trophy size={40} className="text-white" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{correctCount === PAREJAS_CORRECTAS.length
|
||||
? '¡Excelente!'
|
||||
: correctCount >= PAREJAS_CORRECTAS.length * 0.7
|
||||
? '¡Muy bien!'
|
||||
: '¡Sigue practicando!'}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
{correctCount} de {PAREJAS_CORRECTAS.length} emparejamientos correctos
|
||||
</p>
|
||||
|
||||
{/* Score Display */}
|
||||
<div className="grid grid-cols-3 gap-4 max-w-lg mx-auto">
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<Target className="w-6 h-6 text-blue-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-blue-700">
|
||||
{Math.round((correctCount / PAREJAS_CORRECTAS.length) * 100)}
|
||||
</p>
|
||||
<p className="text-sm text-blue-600">Puntuación</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-green-700">{correctCount}</p>
|
||||
<p className="text-sm text-green-600">Correctos</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<Zap className="w-6 h-6 text-purple-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-purple-700">{attempts}</p>
|
||||
<p className="text-sm text-purple-600">Intentos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explicación de respuestas */}
|
||||
{correctCount < PAREJAS_CORRECTAS.length && (
|
||||
<div className="mt-6 text-left bg-white rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-800 mb-3">Respuestas correctas:</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
{SISTEMAS_ECONOMICOS.map(sistema => {
|
||||
const caracteristicas = PAREJAS_CORRECTAS
|
||||
.filter(p => p.leftId === sistema.id)
|
||||
.map(p => CARACTERISTICAS.find(c => c.id === p.rightId)?.content);
|
||||
|
||||
return (
|
||||
<div key={sistema.id} className="flex items-start gap-2">
|
||||
<span className="font-semibold text-gray-700 min-w-[140px]">
|
||||
{sistema.content}:
|
||||
</span>
|
||||
<span className="text-gray-600">
|
||||
{caracteristicas.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RefreshCcw size={16} className="mr-2" />
|
||||
Reiniciar
|
||||
</Button>
|
||||
|
||||
{!showResults ? (
|
||||
<Button onClick={handleValidate} disabled={matches.length === 0}>
|
||||
Verificar Emparejamientos
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleReset}>
|
||||
Intentar de Nuevo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
Progreso: <span className="font-semibold text-blue-600">{matches.length}</span> de{' '}
|
||||
<span className="font-semibold">{PAREJAS_CORRECTAS.length}</span> emparejamientos
|
||||
</p>
|
||||
<div className="w-full max-w-md mx-auto mt-2 bg-gray-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="h-2 bg-blue-500 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(matches.length / PAREJAS_CORRECTAS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VentajasDesventajasSistemas;
|
||||
23
frontend/src/components/exercises/modulo1/index.ts
Normal file
23
frontend/src/components/exercises/modulo1/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export { SimuladorDisyuntivas } from './SimuladorDisyuntivas';
|
||||
export { QuizBienes } from './QuizBienes';
|
||||
export { FlujoCircular } from './FlujoCircular';
|
||||
export { DefinicionEconomiaQuiz } from './DefinicionEconomiaQuiz';
|
||||
export { EscasezSimulator } from './EscasezSimulator';
|
||||
export { ProblemaEconomicoFundamental } from './ProblemaEconomicoFundamental';
|
||||
export { EconomiaPositivaVsNormativa } from './EconomiaPositivaVsNormativa';
|
||||
export { RazonamientoEconomico } from './RazonamientoEconomico';
|
||||
export { SistemasEconomicosQuiz } from './SistemasEconomicosQuiz';
|
||||
export { ComparativaSistemas } from './ComparativaSistemas';
|
||||
export { CasosPaises } from './CasosPaises';
|
||||
export { VentajasDesventajasSistemas } from './VentajasDesventajasSistemas';
|
||||
export { FPPConstructor } from './FPPConstructor';
|
||||
export { FPPAnalizador } from './FPPAnalizador';
|
||||
export { CostoOportunidadCalculator } from './CostoOportunidadCalculator';
|
||||
export { CrecimientoEconomicoFPP } from './CrecimientoEconomicoFPP';
|
||||
export { AgentesEconomicosQuiz } from './AgentesEconomicosQuiz';
|
||||
export { RolesAgentesMatching } from './RolesAgentesMatching';
|
||||
export { FlujoCircularBasico } from './FlujoCircularBasico';
|
||||
export { FactoresProduccionQuiz } from './FactoresProduccionQuiz';
|
||||
export { ProductividadCalculator } from './ProductividadCalculator';
|
||||
export { CostoOportunidadCotidiano } from './CostoOportunidadCotidiano';
|
||||
export { VentajaComparativaCalculator } from './VentajaComparativaCalculator';
|
||||
473
frontend/src/components/exercises/modulo2/AjusteEquilibrio.tsx
Normal file
473
frontend/src/components/exercises/modulo2/AjusteEquilibrio.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { RefreshCw, Play, Pause, ArrowRight, CheckCircle2, Trophy, RotateCcw, Info } from 'lucide-react';
|
||||
|
||||
interface AjusteEquilibrioProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
type EscenarioTipo = 'exceso_demanda' | 'exceso_oferta';
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
tipo: EscenarioTipo;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
precioInicial: number;
|
||||
precioEquilibrio: number;
|
||||
cantidadEquilibrio: number;
|
||||
mensajeAjuste: string;
|
||||
}
|
||||
|
||||
const escenarios: Escenario[] = [
|
||||
{
|
||||
id: 1,
|
||||
tipo: 'exceso_demanda',
|
||||
titulo: 'Escasez de Vivienda',
|
||||
descripcion: 'El precio actual de $600 está por debajo del equilibrio. Hay más personas buscando vivienda que apartamentos disponibles.',
|
||||
precioInicial: 600,
|
||||
precioEquilibrio: 900,
|
||||
cantidadEquilibrio: 300,
|
||||
mensajeAjuste: 'La escasez presiona al alza: los compradores compiten ofreciendo más, los vendedores suben precios.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tipo: 'exceso_oferta',
|
||||
titulo: 'Superávit de Manzanas',
|
||||
descripcion: 'La cosecha fue abundante y el precio actual de $80 está por encima del equilibrio. Hay más manzanas de las que la gente quiere comprar.',
|
||||
precioInicial: 80,
|
||||
precioEquilibrio: 50,
|
||||
cantidadEquilibrio: 100,
|
||||
mensajeAjuste: 'El superávit presiona a la baja: los vendedores compiten bajando precios para liquidar inventario.'
|
||||
}
|
||||
];
|
||||
|
||||
export const AjusteEquilibrio: React.FC<AjusteEquilibrioProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [precioActual, setPrecioActual] = useState(escenarios[0].precioInicial);
|
||||
const [estaAnimando, setEstaAnimando] = useState(false);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [faseAjuste, setFaseAjuste] = useState<'inicio' | 'ajustando' | 'completado'>('inicio');
|
||||
const [score, setScore] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [_startTime] = useState(Date.now());
|
||||
|
||||
const escenario = escenarios[escenarioActual];
|
||||
|
||||
useEffect(() => {
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
if (estaAnimando) {
|
||||
setFaseAjuste('ajustando');
|
||||
interval = setInterval(() => {
|
||||
setPrecioActual(prev => {
|
||||
const diferencia = escenario.precioEquilibrio - prev;
|
||||
const cambio = diferencia * 0.05;
|
||||
|
||||
if (Math.abs(diferencia) < 2) {
|
||||
setEstaAnimando(false);
|
||||
setFaseAjuste('completado');
|
||||
setMostrarResultado(true);
|
||||
return escenario.precioEquilibrio;
|
||||
}
|
||||
|
||||
return prev + cambio;
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [estaAnimando, escenario]);
|
||||
|
||||
const handleIniciar = () => {
|
||||
setEstaAnimando(true);
|
||||
};
|
||||
|
||||
const handlePausar = () => {
|
||||
setEstaAnimando(false);
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (escenarioActual < escenarios.length - 1) {
|
||||
const nextEscenario = escenarios[escenarioActual + 1];
|
||||
setEscenarioActual(prev => prev + 1);
|
||||
setPrecioActual(nextEscenario.precioInicial);
|
||||
setEstaAnimando(false);
|
||||
setMostrarResultado(false);
|
||||
setFaseAjuste('inicio');
|
||||
setScore(prev => prev + 50);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setEscenarioActual(0);
|
||||
setPrecioActual(escenarios[0].precioInicial);
|
||||
setEstaAnimando(false);
|
||||
setMostrarResultado(false);
|
||||
setFaseAjuste('inicio');
|
||||
setScore(0);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
const generarCurvas = () => {
|
||||
const demanda = [];
|
||||
const oferta = [];
|
||||
|
||||
for (let Q = 0; Q <= 400; Q += 20) {
|
||||
const Pd = 1200 - 1 * Q;
|
||||
const Po = 0 + 3 * Q;
|
||||
if (Pd >= 0) demanda.push({ Q, P: Pd });
|
||||
if (Po >= 0) oferta.push({ Q, P: Po });
|
||||
}
|
||||
|
||||
return { demanda, oferta };
|
||||
};
|
||||
|
||||
const { demanda, oferta } = generarCurvas();
|
||||
|
||||
const scaleX = (Q: number) => 60 + (Q / 400) * 320;
|
||||
const scaleY = (P: number) => 280 - (P / 1200) * 240;
|
||||
|
||||
const demandaPath = demanda.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.P)}`
|
||||
).join(' ');
|
||||
|
||||
const ofertaPath = oferta.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.P)}`
|
||||
).join(' ');
|
||||
|
||||
const calcularCantidades = (precio: number) => {
|
||||
const Qd = Math.max(0, 1200 - precio);
|
||||
const Qo = Math.max(0, precio / 3);
|
||||
return { Qd, Qo };
|
||||
};
|
||||
|
||||
const cantidades = calcularCantidades(precioActual);
|
||||
const esExcesoDemanda = cantidades.Qd > cantidades.Qo;
|
||||
const diferencia = Math.abs(cantidades.Qd - cantidades.Qo);
|
||||
|
||||
if (completado) {
|
||||
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 observado el ajuste hacia el equilibrio</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-6 mb-6">
|
||||
<div className="text-5xl font-bold text-blue-600 mb-2">100%</div>
|
||||
<p className="text-gray-600">
|
||||
Has completado todos los escenarios
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReiniciar}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
Intentar de nuevo
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl 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">
|
||||
<RefreshCw className="w-8 h-8 text-blue-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Ajuste al Equilibrio</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
{escenarioActual + 1} de {escenarios.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleReiniciar}
|
||||
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">
|
||||
Observa cómo el mercado se autocorrige hacia el equilibrio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-4 text-center">{escenario.titulo}</h3>
|
||||
|
||||
<svg width="400" height="300" className="mx-auto">
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line x1={60 + i * 64} y1="30" x2={60 + i * 64} y2="280" stroke="#e5e7eb" strokeWidth="1" />
|
||||
<line x1="60" y1={30 + i * 50} x2="380" y2={30 + i * 50} stroke="#e5e7eb" strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1="60" y1="280" x2="380" y2="280" stroke="#374151" strokeWidth="2" />
|
||||
<line x1="60" y1="30" x2="60" y2="280" stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Labels */}
|
||||
<text x="220" y="300" textAnchor="middle" className="text-sm fill-gray-600">Cantidad (Q)</text>
|
||||
<text x="30" y="155" textAnchor="middle" transform="rotate(-90, 30, 155)" className="text-sm fill-gray-600">Precio (P)</text>
|
||||
|
||||
{/* Curva de Demanda */}
|
||||
<path d={demandaPath} fill="none" stroke="#3b82f6" strokeWidth="3" />
|
||||
<text x="360" y={scaleY(50)} className="text-sm fill-blue-600 font-medium">D</text>
|
||||
|
||||
{/* Curva de Oferta */}
|
||||
<path d={ofertaPath} fill="none" stroke="#22c55e" strokeWidth="3" />
|
||||
<text x="360" y={scaleY(1100)} className="text-sm fill-green-600 font-medium">S</text>
|
||||
|
||||
{/* Punto de equilibrio (E) */}
|
||||
<circle
|
||||
cx={scaleX(300)}
|
||||
cy={scaleY(900)}
|
||||
r="6"
|
||||
fill="#8b5cf6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={scaleX(300) + 10} y={scaleY(900) - 5} className="text-xs fill-purple-600 font-medium">
|
||||
E
|
||||
</text>
|
||||
|
||||
{/* Línea de precio actual */}
|
||||
<motion.line
|
||||
x1="60"
|
||||
y1={scaleY(precioActual)}
|
||||
x2="380"
|
||||
y2={scaleY(precioActual)}
|
||||
stroke={esExcesoDemanda ? "#ef4444" : "#f59e0b"}
|
||||
strokeWidth="3"
|
||||
strokeDasharray="5,5"
|
||||
initial={false}
|
||||
animate={{ y1: scaleY(precioActual), y2: scaleY(precioActual) }}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
<text x="385" y={scaleY(precioActual)} className={`text-xs font-medium ${esExcesoDemanda ? 'fill-red-500' : 'fill-amber-500'}`}>
|
||||
P=${Math.round(precioActual)}
|
||||
</text>
|
||||
|
||||
{/* Cantidad demandada */}
|
||||
<motion.circle
|
||||
cx={scaleX(cantidades.Qd)}
|
||||
cy={scaleY(precioActual)}
|
||||
r="6"
|
||||
fill="#3b82f6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
initial={false}
|
||||
animate={{ cx: scaleX(cantidades.Qd), cy: scaleY(precioActual) }}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
<text x={scaleX(cantidades.Qd)} y={scaleY(precioActual) + 20} textAnchor="middle" className="text-xs fill-blue-600 font-medium">
|
||||
Qd
|
||||
</text>
|
||||
|
||||
{/* Cantidad ofrecida */}
|
||||
<motion.circle
|
||||
cx={scaleX(cantidades.Qo)}
|
||||
cy={scaleY(precioActual)}
|
||||
r="6"
|
||||
fill="#22c55e"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
initial={false}
|
||||
animate={{ cx: scaleX(cantidades.Qo), cy: scaleY(precioActual) }}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
<text x={scaleX(cantidades.Qo)} y={scaleY(precioActual) + 20} textAnchor="middle" className="text-xs fill-green-600 font-medium">
|
||||
Qo
|
||||
</text>
|
||||
|
||||
{/* Flecha de dirección del ajuste */}
|
||||
{faseAjuste === 'ajustando' && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<path
|
||||
d={escenario.tipo === 'exceso_demanda'
|
||||
? `M 390 ${scaleY(precioActual) - 20} L 390 ${scaleY(precioActual) - 60}`
|
||||
: `M 390 ${scaleY(precioActual) + 20} L 390 ${scaleY(precioActual) + 60}`
|
||||
}
|
||||
stroke={escenario.tipo === 'exceso_demanda' ? "#ef4444" : "#f59e0b"}
|
||||
strokeWidth="3"
|
||||
markerEnd={`url(#arrow-${escenario.tipo})`}
|
||||
fill="none"
|
||||
/>
|
||||
<defs>
|
||||
<marker id="arrow-exceso_demanda" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 7, 10 3.5, 0 0" fill="#ef4444" />
|
||||
</marker>
|
||||
<marker id="arrow-exceso_oferta" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#f59e0b" />
|
||||
</marker>
|
||||
</defs>
|
||||
</motion.g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="bg-white p-2 rounded border text-center">
|
||||
<span className="text-gray-500 text-xs block">Precio</span>
|
||||
<span className="text-gray-800 font-bold">${Math.round(precioActual)}</span>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-2 rounded border text-center">
|
||||
<span className="text-blue-600 text-xs block">Q Demanda</span>
|
||||
<span className="text-blue-800 font-bold">{Math.round(cantidades.Qd)}</span>
|
||||
</div>
|
||||
<div className="bg-green-50 p-2 rounded border text-center">
|
||||
<span className="text-green-600 text-xs block">Q Oferta</span>
|
||||
<span className="text-green-800 font-bold">{Math.round(cantidades.Qo)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-6 h-6 text-blue-600 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">Situación Actual</h3>
|
||||
<p className="text-gray-700">{escenario.descripcion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{faseAjuste === 'inicio' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="bg-white border-2 border-gray-200 rounded-lg p-6"
|
||||
>
|
||||
<h3 className="font-semibold text-gray-800 mb-4">¿Qué está pasando?</h3>
|
||||
<div className="space-y-3">
|
||||
<div className={`p-3 rounded-lg ${esExcesoDemanda ? 'bg-red-50 border border-red-200' : 'bg-amber-50 border border-amber-200'}`}>
|
||||
<p className={`font-medium ${esExcesoDemanda ? 'text-red-800' : 'text-amber-800'}`}>
|
||||
{esExcesoDemanda ? '🔥 Exceso de Demanda (Escasez)' : '📦 Exceso de Oferta (Superávit)'}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-gray-700">
|
||||
Diferencia: <strong>{Math.round(diferencia)} unidades</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleIniciar}
|
||||
className="mt-4 w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
Ver Ajuste al Equilibrio
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{faseAjuste === 'ajustando' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6"
|
||||
>
|
||||
<h3 className="font-semibold text-blue-800 mb-3 flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
Ajustando...
|
||||
</h3>
|
||||
<p className="text-blue-700">{escenario.mensajeAjuste}</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-blue-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-blue-600"
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: `${((precioActual - escenario.precioInicial) / (escenario.precioEquilibrio - escenario.precioInicial)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePausar}
|
||||
className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg"
|
||||
>
|
||||
<Pause className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{faseAjuste === 'completado' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-green-50 border-2 border-green-200 rounded-lg p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||
<h3 className="font-bold text-green-800 text-lg">¡Equilibrio Alcanzado!</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-green-700">
|
||||
<p><strong>Precio de equilibrio:</strong> ${escenario.precioEquilibrio}</p>
|
||||
<p><strong>Cantidad de equilibrio:</strong> {escenario.cantidadEquilibrio} unidades</p>
|
||||
<p className="text-sm mt-3">
|
||||
En equilibrio, la cantidad demandada es igual a la cantidad ofrecida.
|
||||
No hay presión para que el precio cambie.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSiguiente}
|
||||
className="mt-4 w-full 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 Escenario
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
) : (
|
||||
'Finalizar'
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="p-4 bg-gray-100 rounded-lg">
|
||||
<h4 className="font-semibold text-gray-700 mb-2">Principio del Ajuste:</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-red-500">•</span>
|
||||
<span><strong>Escasez (P < Pe):</strong> Los compradores ofrecen más → sube el precio</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-amber-500">•</span>
|
||||
<span><strong>Superávit (P > Pe):</strong> Los vendedores bajan precios → baja el precio</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AjusteEquilibrio;
|
||||
@@ -0,0 +1,310 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface DatosPrecioCantidad {
|
||||
precioInicial: number;
|
||||
cantidadInicial: number;
|
||||
precioFinal: number;
|
||||
cantidadFinal: number;
|
||||
}
|
||||
|
||||
const generarDatosAleatorios = (): DatosPrecioCantidad => {
|
||||
const precioInicial = Math.round((Math.random() * 50 + 10) * 100) / 100;
|
||||
const cantidadInicial = Math.round(Math.random() * 800 + 200);
|
||||
|
||||
const cambioPrecio = (Math.random() > 0.5 ? 1 : -1) * (Math.random() * 20 + 5);
|
||||
const elasticidad = Math.random() * 2 + 0.3;
|
||||
|
||||
const precioFinal = Math.round((precioInicial + cambioPrecio) * 100) / 100;
|
||||
const cambioCantidad = -elasticidad * (cambioPrecio / ((precioInicial + precioFinal) / 2)) * cantidadInicial;
|
||||
const cantidadFinal = Math.round(cantidadInicial + cambioCantidad);
|
||||
|
||||
return {
|
||||
precioInicial: Math.max(1, precioInicial),
|
||||
cantidadInicial: Math.max(10, cantidadInicial),
|
||||
precioFinal: Math.max(1, precioFinal),
|
||||
cantidadFinal: Math.max(10, cantidadFinal)
|
||||
};
|
||||
};
|
||||
|
||||
export const CalculoElasticidadPrecio: React.FC = () => {
|
||||
const [datos, setDatos] = useState<DatosPrecioCantidad>(generarDatosAleatorios());
|
||||
const [respuestaUsuario, setRespuestaUsuario] = useState<string>('');
|
||||
const [resultado, setResultado] = useState<{
|
||||
correcto: boolean;
|
||||
mensaje: string;
|
||||
valorReal: number;
|
||||
} | null>(null);
|
||||
const [mostrarFormula, setMostrarFormula] = useState<boolean>(true);
|
||||
|
||||
const calcularElasticidadPuntoMedio = (d: DatosPrecioCantidad): number => {
|
||||
const cambioCantidad = d.cantidadFinal - d.cantidadInicial;
|
||||
const cambioPrecio = d.precioFinal - d.precioInicial;
|
||||
const cantidadPromedio = (d.cantidadInicial + d.cantidadFinal) / 2;
|
||||
const precioPromedio = (d.precioInicial + d.precioFinal) / 2;
|
||||
|
||||
if (cantidadPromedio === 0 || precioPromedio === 0) return 0;
|
||||
|
||||
const elasticidad = (cambioCantidad / cantidadPromedio) / (cambioPrecio / precioPromedio);
|
||||
return Math.abs(elasticidad);
|
||||
};
|
||||
|
||||
const verificarRespuesta = () => {
|
||||
const elasticidadReal = calcularElasticidadPuntoMedio(datos);
|
||||
const respuestaNum = parseFloat(respuestaUsuario);
|
||||
|
||||
if (isNaN(respuestaNum) || respuestaNum < 0) {
|
||||
setResultado({
|
||||
correcto: false,
|
||||
mensaje: 'Por favor ingresa un número válido mayor o igual a 0',
|
||||
valorReal: elasticidadReal
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const margenError = 0.15;
|
||||
const correcto = Math.abs(respuestaNum - elasticidadReal) <= margenError;
|
||||
|
||||
setResultado({
|
||||
correcto,
|
||||
mensaje: correcto
|
||||
? '¡Correcto! Has calculado la elasticidad correctamente.'
|
||||
: `Incorrecto. El valor correcto es ${elasticidadReal.toFixed(2)}`,
|
||||
valorReal: elasticidadReal
|
||||
});
|
||||
};
|
||||
|
||||
const generarNuevoEjercicio = () => {
|
||||
setDatos(generarDatosAleatorios());
|
||||
setRespuestaUsuario('');
|
||||
setResultado(null);
|
||||
};
|
||||
|
||||
const precioPromedio = (datos.precioInicial + datos.precioFinal) / 2;
|
||||
const cantidadPromedio = (datos.cantidadInicial + datos.cantidadFinal) / 2;
|
||||
const cambioPrecio = datos.precioFinal - datos.precioInicial;
|
||||
const cambioCantidad = datos.cantidadFinal - datos.cantidadInicial;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-2 text-blue-800">Cálculo de Elasticidad Precio de la Demanda</h2>
|
||||
<p className="text-gray-600 mb-6">Utiliza la fórmula del punto medio para calcular la elasticidad.</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-5 rounded-xl border border-blue-100">
|
||||
<h3 className="font-bold text-lg mb-4 text-blue-800 flex items-center gap-2">
|
||||
<span className="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm">1</span>
|
||||
Datos Iniciales
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
|
||||
<span className="text-gray-600">Precio inicial (P₁):</span>
|
||||
<span className="font-bold text-lg text-blue-700">${datos.precioInicial.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
|
||||
<span className="text-gray-600">Cantidad inicial (Q₁):</span>
|
||||
<span className="font-bold text-lg text-blue-700">{datos.cantidadInicial.toLocaleString()} unidades</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 p-5 rounded-xl border border-green-100">
|
||||
<h3 className="font-bold text-lg mb-4 text-green-800 flex items-center gap-2">
|
||||
<span className="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm">2</span>
|
||||
Datos Finales
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
|
||||
<span className="text-gray-600">Precio final (P₂):</span>
|
||||
<span className="font-bold text-lg text-green-700">${datos.precioFinal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
|
||||
<span className="text-gray-600">Cantidad final (Q₂):</span>
|
||||
<span className="font-bold text-lg text-green-700">{datos.cantidadFinal.toLocaleString()} unidades</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mostrarFormula && (
|
||||
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 p-5 rounded-xl border border-amber-200 mb-6">
|
||||
<h3 className="font-bold text-amber-800 mb-3 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Fórmula del Punto Medio (Arco)
|
||||
</h3>
|
||||
<div className="bg-white p-4 rounded-lg shadow-inner text-center">
|
||||
<p className="text-lg font-mono text-gray-800">
|
||||
E<sub>d</sub> = |<span className="text-blue-600">(Q₂ - Q₁) / ((Q₂ + Q₁) / 2)</span>|
|
||||
<span className="mx-2">÷</span>
|
||||
<span className="text-green-600">(P₂ - P₁) / ((P₂ + P₁) / 2)</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-amber-700 mt-3">
|
||||
Donde: Q = Cantidad, P = Precio, y usamos valores absolutos para obtener la elasticidad como número positivo.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 p-5 rounded-xl border border-gray-200 mb-6">
|
||||
<h3 className="font-bold text-gray-800 mb-4">Paso a paso (valores calculados):</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-blue-50 p-3 rounded-lg">
|
||||
<p className="text-blue-800 font-medium">Cambio en cantidad:</p>
|
||||
<p className="font-mono">({datos.cantidadFinal} - {datos.cantidadInicial}) = {cambioCantidad}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-3 rounded-lg">
|
||||
<p className="text-green-800 font-medium">Cantidad promedio:</p>
|
||||
<p className="font-mono">({datos.cantidadFinal} + {datos.cantidadInicial}) / 2 = {cantidadPromedio}</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-3 rounded-lg">
|
||||
<p className="text-blue-800 font-medium">Cambio en precio:</p>
|
||||
<p className="font-mono">(${datos.precioFinal} - ${datos.precioInicial}) = ${cambioPrecio.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-3 rounded-lg">
|
||||
<p className="text-green-800 font-medium">Precio promedio:</p>
|
||||
<p className="font-mono">(${datos.precioFinal} + ${datos.precioInicial}) / 2 = ${precioPromedio.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl border-2 border-indigo-100 shadow-lg mb-6">
|
||||
<h3 className="font-bold text-xl mb-4 text-indigo-800">Tu Respuesta</h3>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<label className="font-medium text-gray-700 whitespace-nowrap">Elasticidad (E<sub>d</sub>):</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={respuestaUsuario}
|
||||
onChange={(e) => setRespuestaUsuario(e.target.value)}
|
||||
className="border-2 border-indigo-200 p-3 rounded-lg w-full sm:w-40 text-center text-lg font-mono focus:border-indigo-500 focus:outline-none"
|
||||
placeholder="Ej: 1.25"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={verificarRespuesta}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-semibold transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Verificar
|
||||
</button>
|
||||
<button
|
||||
onClick={generarNuevoEjercicio}
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white px-6 py-3 rounded-lg font-semibold transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setMostrarFormula(!mostrarFormula)}
|
||||
className="mt-4 text-indigo-600 hover:text-indigo-800 text-sm font-medium flex items-center gap-1"
|
||||
>
|
||||
{mostrarFormula ? 'Ocultar' : 'Mostrar'} fórmula y pasos
|
||||
<svg className={`w-4 h-4 transform transition-transform ${mostrarFormula ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{resultado && (
|
||||
<div className={`p-6 rounded-xl border-2 ${resultado.correcto ? 'bg-green-50 border-green-300' : 'bg-red-50 border-red-300'}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-full ${resultado.correcto ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
{resultado.correcto ? (
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-bold text-lg ${resultado.correcto ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{resultado.correcto ? '¡Respuesta Correcta!' : 'Respuesta Incorrecta'}
|
||||
</h4>
|
||||
<p className={`mt-1 ${resultado.correcto ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{resultado.mensaje}
|
||||
</p>
|
||||
|
||||
{!resultado.correcto && (
|
||||
<div className="mt-4 bg-white p-4 rounded-lg border border-red-200">
|
||||
<p className="font-medium text-gray-800 mb-2">Desglose del cálculo:</p>
|
||||
<div className="font-mono text-sm space-y-1 text-gray-600">
|
||||
<p>% Cambio en Q = {cambioCantidad} / {cantidadPromedio} = {((cambioCantidad / cantidadPromedio) * 100).toFixed(2)}%</p>
|
||||
<p>% Cambio en P = {cambioPrecio.toFixed(2)} / {precioPromedio.toFixed(2)} = {((cambioPrecio / precioPromedio) * 100).toFixed(2)}%</p>
|
||||
<p className="text-indigo-600 font-bold mt-2">
|
||||
E<sub>d</sub> = |{((cambioCantidad / cantidadPromedio) / (cambioPrecio / precioPromedio)).toFixed(3)}| = {resultado.valorReal.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultado.correcto && resultado.valorReal > 0 && (
|
||||
<div className="mt-4 bg-white p-4 rounded-lg border border-green-200">
|
||||
<p className="font-medium text-gray-800">
|
||||
Clasificación: {' '}
|
||||
{resultado.valorReal > 1 ? (
|
||||
<span className="text-green-600 font-bold">Elástica (E<sub>d</sub> > 1)</span>
|
||||
) : resultado.valorReal < 1 ? (
|
||||
<span className="text-amber-600 font-bold">Inelástica (E<sub>d</sub> < 1)</span>
|
||||
) : (
|
||||
<span className="text-blue-600 font-bold">Unitaria (E<sub>d</sub> = 1)</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
{resultado.valorReal > 1
|
||||
? 'La demanda responde proporcionalmente más que el cambio en precio.'
|
||||
: resultado.valorReal < 1
|
||||
? 'La demanda responde proporcionalmente menos que el cambio en precio.'
|
||||
: 'La demanda responde exactamente en la misma proporción que el precio.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 bg-indigo-50 p-4 rounded-xl border border-indigo-200">
|
||||
<h4 className="font-bold text-indigo-800 mb-2 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Interpretación de Resultados
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="bg-white p-3 rounded-lg text-center border border-green-200">
|
||||
<p className="font-bold text-green-700">E<sub>d</sub> > 1</p>
|
||||
<p className="text-gray-600">Elástica</p>
|
||||
<p className="text-xs text-gray-500">%ΔQ > %ΔP</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg text-center border border-blue-200">
|
||||
<p className="font-bold text-blue-700">E<sub>d</sub> = 1</p>
|
||||
<p className="text-gray-600">Unitaria</p>
|
||||
<p className="text-xs text-gray-500">%ΔQ = %ΔP</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg text-center border border-amber-200">
|
||||
<p className="font-bold text-amber-700">E<sub>d</sub> < 1</p>
|
||||
<p className="text-gray-600">Inelástica</p>
|
||||
<p className="text-xs text-gray-500">%ΔQ < %ΔP</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalculoElasticidadPrecio;
|
||||
577
frontend/src/components/exercises/modulo2/CambiosEquilibrio.tsx
Normal file
577
frontend/src/components/exercises/modulo2/CambiosEquilibrio.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { GitBranch, ArrowRight, ArrowLeft, CheckCircle2, XCircle, Trophy, RotateCcw, BookOpen, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
|
||||
interface CambiosEquilibrioProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
type DireccionShock = 'oferta-aumenta' | 'oferta-disminuye' | 'demanda-aumenta' | 'demanda-disminuye';
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
descripcion: string;
|
||||
shock: DireccionShock;
|
||||
curva: 'oferta' | 'demanda';
|
||||
direccion: 'aumenta' | 'disminuye';
|
||||
cambioPrecio: 'sube' | 'baja';
|
||||
cambioCantidad: 'sube' | 'baja';
|
||||
explicacion: string;
|
||||
dificultad: 'facil' | 'medio' | 'dificil';
|
||||
}
|
||||
|
||||
const escenarios: Escenario[] = [
|
||||
{
|
||||
id: 1,
|
||||
descripcion: 'Una nueva tecnología reduce los costos de producción de teléfonos inteligentes.',
|
||||
shock: 'oferta-aumenta',
|
||||
curva: 'oferta',
|
||||
direccion: 'aumenta',
|
||||
cambioPrecio: 'baja',
|
||||
cambioCantidad: 'sube',
|
||||
explicacion: 'La tecnología mejora la productividad, aumentando la oferta. La curva se desplaza a la derecha: el precio baja y la cantidad sube.',
|
||||
dificultad: 'facil'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
descripcion: 'Un informe de salud afirma que el café aumenta la longevidad.',
|
||||
shock: 'demanda-aumenta',
|
||||
curva: 'demanda',
|
||||
direccion: 'aumenta',
|
||||
cambioPrecio: 'sube',
|
||||
cambioCantidad: 'sube',
|
||||
explicacion: 'Las preferencias positivas aumentan la demanda. La curva se desplaza a la derecha: el precio y la cantidad suben.',
|
||||
dificultad: 'facil'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
descripcion: 'Una plaga de langostas destruye el 30% de la cosecha de granos.',
|
||||
shock: 'oferta-disminuye',
|
||||
curva: 'oferta',
|
||||
direccion: 'disminuye',
|
||||
cambioPrecio: 'sube',
|
||||
cambioCantidad: 'baja',
|
||||
explicacion: 'La plaga reduce la cantidad disponible, disminuyendo la oferta. La curva se desplaza a la izquierda: el precio sube y la cantidad baja.',
|
||||
dificultad: 'facil'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
descripcion: 'La economía entra en recesión y el ingreso promedio cae 20% (bien normal).',
|
||||
shock: 'demanda-disminuye',
|
||||
curva: 'demanda',
|
||||
direccion: 'disminuye',
|
||||
cambioPrecio: 'baja',
|
||||
cambioCantidad: 'baja',
|
||||
explicacion: 'Para bienes normales, al bajar el ingreso, disminuye la demanda. La curva se desplaza a la izquierda: el precio y la cantidad bajan.',
|
||||
dificultad: 'medio'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
descripcion: 'El gobierno subsidia la compra de autos eléctricos con $10,000.',
|
||||
shock: 'demanda-aumenta',
|
||||
curva: 'demanda',
|
||||
direccion: 'aumenta',
|
||||
cambioPrecio: 'sube',
|
||||
cambioCantidad: 'sube',
|
||||
explicacion: 'El subsidio reduce el precio efectivo para consumidores, aumentando la demanda. El equilibrio se mueve hacia mayor precio y cantidad.',
|
||||
dificultad: 'medio'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
descripcion: 'El precio del petróleo (insumo importante) sube un 50%.',
|
||||
shock: 'oferta-disminuye',
|
||||
curva: 'oferta',
|
||||
direccion: 'disminuye',
|
||||
cambioPrecio: 'sube',
|
||||
cambioCantidad: 'baja',
|
||||
explicacion: 'Al subir los costos de insumos, producir es más caro, disminuyendo la oferta. El equilibrio resulta en mayor precio y menor cantidad.',
|
||||
dificultad: 'dificil'
|
||||
}
|
||||
];
|
||||
|
||||
interface OpcionShock {
|
||||
value: DireccionShock;
|
||||
label: string;
|
||||
descripcion: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const opcionesShock: OpcionShock[] = [
|
||||
{ value: 'oferta-aumenta', label: 'Oferta ↑', descripcion: 'Aumenta', icon: <TrendingUp className="w-5 h-5" /> },
|
||||
{ value: 'oferta-disminuye', label: 'Oferta ↓', descripcion: 'Disminuye', icon: <TrendingDown className="w-5 h-5" /> },
|
||||
{ value: 'demanda-aumenta', label: 'Demanda ↑', descripcion: 'Aumenta', icon: <TrendingUp className="w-5 h-5" /> },
|
||||
{ value: 'demanda-disminuye', label: 'Demanda ↓', descripcion: 'Disminuye', icon: <TrendingDown className="w-5 h-5" /> },
|
||||
];
|
||||
|
||||
interface OpcionCambio {
|
||||
value: 'sube' | 'baja';
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const opcionesCambio: OpcionCambio[] = [
|
||||
{ value: 'sube', label: 'Sube', icon: <TrendingUp className="w-5 h-5" /> },
|
||||
{ value: 'baja', label: 'Baja', icon: <TrendingDown className="w-5 h-5" /> },
|
||||
];
|
||||
|
||||
export const CambiosEquilibrio: React.FC<CambiosEquilibrioProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [shockSeleccionado, setShockSeleccionado] = useState<DireccionShock | null>(null);
|
||||
const [cambioPrecio, setCambioPrecio] = useState<'sube' | 'baja' | null>(null);
|
||||
const [cambioCantidad, setCambioCantidad] = useState<'sube' | 'baja' | null>(null);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [esCorrecto, setEsCorrecto] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [_startTime] = useState(Date.now());
|
||||
|
||||
const escenario = escenarios[escenarioActual];
|
||||
|
||||
const handleVerificar = () => {
|
||||
if (!shockSeleccionado || !cambioPrecio || !cambioCantidad) return;
|
||||
|
||||
const shockCorrecto = shockSeleccionado === escenario.shock;
|
||||
const precioCorrecto = cambioPrecio === escenario.cambioPrecio;
|
||||
const cantidadCorrecta = cambioCantidad === escenario.cambioCantidad;
|
||||
|
||||
const todoCorrecto = shockCorrecto && precioCorrecto && cantidadCorrecta;
|
||||
|
||||
setEsCorrecto(todoCorrecto);
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (todoCorrecto) {
|
||||
setScore(prev => prev + Math.round(100 / escenarios.length));
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (escenarioActual < escenarios.length - 1) {
|
||||
setEscenarioActual(prev => prev + 1);
|
||||
setShockSeleccionado(null);
|
||||
setCambioPrecio(null);
|
||||
setCambioCantidad(null);
|
||||
setMostrarResultado(false);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setEscenarioActual(0);
|
||||
setShockSeleccionado(null);
|
||||
setCambioPrecio(null);
|
||||
setCambioCantidad(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 getShockColor = (value: DireccionShock) => {
|
||||
if (value.includes('oferta')) return value.includes('aumenta') ? 'green' : 'red';
|
||||
return value.includes('aumenta') ? 'blue' : 'orange';
|
||||
};
|
||||
|
||||
const renderGrafico = () => {
|
||||
const isOferta = escenario.curva === 'oferta';
|
||||
const isAumenta = escenario.direccion === 'aumenta';
|
||||
|
||||
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" />
|
||||
|
||||
{/* Labels */}
|
||||
<text x="150" y="235" textAnchor="middle" className="text-sm fill-gray-600">Q</text>
|
||||
<text x="25" y="120" textAnchor="middle" transform="rotate(-90, 25, 120)" className="text-sm fill-gray-600">P</text>
|
||||
|
||||
{/* Curva original */}
|
||||
{isOferta ? (
|
||||
<line x1="80" y1="180" x2="200" y2="80" stroke="#22c55e" strokeWidth="3" />
|
||||
) : (
|
||||
<line x1="80" y1="80" x2="200" y2="180" stroke="#3b82f6" strokeWidth="3" />
|
||||
)}
|
||||
<text x={isOferta ? 210 : 210} y={isOferta ? 75 : 190} className={`text-sm ${isOferta ? 'fill-green-600' : 'fill-blue-600'}`}>
|
||||
{isOferta ? 'S₁' : 'D₁'}
|
||||
</text>
|
||||
|
||||
{/* Curva desplazada */}
|
||||
{mostrarResultado && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, x: isAumenta ? 30 : -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{isOferta ? (
|
||||
<line x1={isAumenta ? 110 : 50} y1="180" x2={isAumenta ? 230 : 170} y2="80" stroke="#22c55e" strokeWidth="3" strokeDasharray="5,5" />
|
||||
) : (
|
||||
<line x1={isAumenta ? 110 : 50} y1="80" x2={isAumenta ? 230 : 170} y2="180" stroke="#3b82f6" strokeWidth="3" strokeDasharray="5,5" />
|
||||
)}
|
||||
<text x={isAumenta ? 240 : 180} y={isOferta ? 75 : 190} className={`text-sm ${isOferta ? 'fill-green-600' : 'fill-blue-600'}`}>
|
||||
{isOferta ? 'S₂' : 'D₂'}
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
|
||||
{/* Punto de equilibrio original */}
|
||||
<circle cx="140" cy="130" r="5" fill="#8b5cf6" stroke="white" strokeWidth="2" />
|
||||
<text x="150" y="125" className="text-xs fill-purple-600">E₁</text>
|
||||
|
||||
{/* Nuevo equilibrio (si se muestra resultado) */}
|
||||
{mostrarResultado && (
|
||||
<motion.g
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.3, type: "spring" }}
|
||||
>
|
||||
<circle
|
||||
cx={isAumenta ? 170 : 110}
|
||||
cy={escenario.cambioPrecio === 'sube' ? 110 : 150}
|
||||
r="5"
|
||||
fill="#8b5cf6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={isAumenta ? 180 : 120} y={escenario.cambioPrecio === 'sube' ? 105 : 145} className="text-xs fill-purple-600 font-bold">
|
||||
E₂
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</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 analizado cambios en el equilibrio</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-5xl 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">
|
||||
<GitBranch className="w-8 h-8 text-purple-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Cambios en el Equilibrio</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">
|
||||
Analiza cómo los shocks del mercado afectan el precio y cantidad de equilibrio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-lg p-6">
|
||||
<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="bg-white border-2 border-gray-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">1. ¿Qué curva se desplaza y en qué dirección?</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{opcionesShock.map((opcion) => {
|
||||
const isSelected = shockSeleccionado === opcion.value;
|
||||
const isCorrect = mostrarResultado && opcion.value === escenario.shock;
|
||||
const color = getShockColor(opcion.value);
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={opcion.value}
|
||||
onClick={() => !mostrarResultado && setShockSeleccionado(opcion.value)}
|
||||
disabled={mostrarResultado}
|
||||
whileHover={!mostrarResultado ? { scale: 1.02 } : {}}
|
||||
whileTap={!mostrarResultado ? { scale: 0.98 } : {}}
|
||||
className={`p-4 rounded-lg border-2 transition-all flex flex-col items-center gap-2 ${
|
||||
isCorrect
|
||||
? 'border-green-500 bg-green-50'
|
||||
: isSelected && mostrarResultado && opcion.value !== escenario.shock
|
||||
? 'border-red-500 bg-red-50'
|
||||
: isSelected
|
||||
? `border-${color}-500 bg-${color}-50`
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{opcion.icon}
|
||||
<span className={`font-semibold ${
|
||||
isCorrect ? 'text-green-700' :
|
||||
isSelected && mostrarResultado && opcion.value !== escenario.shock ? 'text-red-700' :
|
||||
isSelected ? `text-${color}-700` : 'text-gray-700'
|
||||
}`}>
|
||||
{opcion.label}
|
||||
</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-2 border-gray-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">2. ¿Cómo cambian el precio y la cantidad de equilibrio?</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Precio (P*)</label>
|
||||
<div className="flex gap-2">
|
||||
{opcionesCambio.map((opcion) => {
|
||||
const isSelected = cambioPrecio === opcion.value;
|
||||
const isCorrect = mostrarResultado && opcion.value === escenario.cambioPrecio;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opcion.value}
|
||||
onClick={() => !mostrarResultado && setCambioPrecio(opcion.value)}
|
||||
disabled={mostrarResultado}
|
||||
className={`flex-1 py-2 px-3 rounded-lg border-2 flex items-center justify-center gap-2 transition-all ${
|
||||
isCorrect
|
||||
? 'border-green-500 bg-green-50 text-green-700'
|
||||
: isSelected && mostrarResultado && opcion.value !== escenario.cambioPrecio
|
||||
? 'border-red-500 bg-red-50 text-red-700'
|
||||
: isSelected
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: 'border-gray-200 hover:border-gray-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{opcion.icon}
|
||||
<span className="font-medium">{opcion.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Cantidad (Q*)</label>
|
||||
<div className="flex gap-2">
|
||||
{opcionesCambio.map((opcion) => {
|
||||
const isSelected = cambioCantidad === opcion.value;
|
||||
const isCorrect = mostrarResultado && opcion.value === escenario.cambioCantidad;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opcion.value}
|
||||
onClick={() => !mostrarResultado && setCambioCantidad(opcion.value)}
|
||||
disabled={mostrarResultado}
|
||||
className={`flex-1 py-2 px-3 rounded-lg border-2 flex items-center justify-center gap-2 transition-all ${
|
||||
isCorrect
|
||||
? 'border-green-500 bg-green-50 text-green-700'
|
||||
: isSelected && mostrarResultado && opcion.value !== escenario.cambioCantidad
|
||||
? 'border-red-500 bg-red-50 text-red-700'
|
||||
: isSelected
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: 'border-gray-200 hover:border-gray-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{opcion.icon}
|
||||
<span className="font-medium">{opcion.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarResultado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-4 rounded-lg border ${esCorrecto ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{esCorrecto ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${esCorrecto ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecto ? '¡Correcto!' : 'Algunas respuestas son incorrectas'}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-gray-700">{escenario.explicacion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!mostrarResultado ? (
|
||||
<button
|
||||
onClick={handleVerificar}
|
||||
disabled={!shockSeleccionado || !cambioPrecio || !cambioCantidad}
|
||||
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 Cambio</h3>
|
||||
{renderGrafico()}
|
||||
|
||||
<div className="mt-4 p-3 bg-white rounded-lg">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Resumen de efectos:</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-gray-600">Curva de {escenario.curva}:</span>
|
||||
<span className={`font-medium ${escenario.direccion === 'aumenta' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
Se {escenario.direccion === 'aumenta' ? 'desplaza a la derecha' : 'desplaza a la izquierda'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-gray-600">Precio de equilibrio:</span>
|
||||
<span className={`font-medium ${escenario.cambioPrecio === 'sube' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{escenario.cambioPrecio === 'sube' ? '↑ Sube' : '↓ Baja'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-gray-600">Cantidad de equilibrio:</span>
|
||||
<span className={`font-medium ${escenario.cambioCantidad === 'sube' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{escenario.cambioCantidad === 'sube' ? '↑ Sube' : '↓ Baja'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Recordatorio:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
|
||||
<li>• Oferta ↑ → P↓, Q↑</li>
|
||||
<li>• Oferta ↓ → P↑, Q↓</li>
|
||||
<li>• Demanda ↑ → P↑, Q↑</li>
|
||||
<li>• Demanda ↓ → P↓, Q↓</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 CambiosEquilibrio;
|
||||
505
frontend/src/components/exercises/modulo2/ConstructorCurvas.tsx
Normal file
505
frontend/src/components/exercises/modulo2/ConstructorCurvas.tsx
Normal 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;
|
||||
509
frontend/src/components/exercises/modulo2/ControlesVidaReal.tsx
Normal file
509
frontend/src/components/exercises/modulo2/ControlesVidaReal.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Building2, Wallet, AlertCircle, CheckCircle2, BookOpen, TrendingUp, Users, MapPin } from 'lucide-react';
|
||||
|
||||
interface ControlesVidaRealProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface CasoEstudio {
|
||||
id: string;
|
||||
titulo: string;
|
||||
categoria: 'vivienda' | 'laboral' | 'agricola';
|
||||
ubicacion: string;
|
||||
anio: string;
|
||||
contexto: string;
|
||||
intervencion: string;
|
||||
resultados: string[];
|
||||
lecciones: string[];
|
||||
datos: {
|
||||
antes: { precio: number; cantidad: number };
|
||||
despues: { precio: number; cantidad: number };
|
||||
};
|
||||
icono: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const casosEstudio: CasoEstudio[] = [
|
||||
{
|
||||
id: 'nyc-rent',
|
||||
titulo: "Rent Control en Nueva York",
|
||||
categoria: 'vivienda',
|
||||
ubicacion: "Nueva York, USA",
|
||||
anio: "1947-presente",
|
||||
contexto: "Nueva York implementó controles de alquiler después de la Segunda Guerra Mundial para proteger a los inquilinos. Actualmente afecta a aproximadamente 1 millón de apartamentos.",
|
||||
intervencion: "Los alquileres de apartamentos antiguos están regulados y no pueden aumentar más allá de ciertos límites establecidos por la Junta de Alquileres.",
|
||||
resultados: [
|
||||
"Reducción de la oferta de vivienda a largo plazo",
|
||||
"Deterioro de la calidad de edificios regulados",
|
||||
"Mercado paralelo de 'pagos clave'",
|
||||
"Beneficios para inquilinos antiguos, no para nuevos"
|
||||
],
|
||||
lecciones: [
|
||||
"Los controles benefician a quienes ya tienen vivienda",
|
||||
"Desincentivan la construcción de nueva vivienda",
|
||||
"Crean ineficiencias en la asignación de recursos",
|
||||
"Difícil de eliminar una vez implementado"
|
||||
],
|
||||
datos: {
|
||||
antes: { precio: 1000, cantidad: 100 },
|
||||
despues: { precio: 800, cantidad: 85 }
|
||||
},
|
||||
icono: <Building2 className="w-6 h-6" />,
|
||||
color: "blue"
|
||||
},
|
||||
{
|
||||
id: 'venezuela-gasolina',
|
||||
titulo: "Gasolina Subsidiada en Venezuela",
|
||||
categoria: 'agricola',
|
||||
ubicacion: "Venezuela",
|
||||
anio: "1976-2019",
|
||||
contexto: "Venezuela mantuvo durante décadas el precio de la gasolina casi gratis (menos de $0.01 por litro) debido a subsidios gubernamentales masivos.",
|
||||
intervencion: "Precio máximo artificial mantenido por subsidios estatales, sin relación con costos reales de producción.",
|
||||
resultados: [
|
||||
"Contrabando masivo a países vecinos",
|
||||
"Colapso de la infraestructura de refinación",
|
||||
"Desabastecimiento crónico en 2019",
|
||||
"Pérdida fiscal insostenible para el Estado"
|
||||
],
|
||||
lecciones: [
|
||||
"Los precios deben reflejar costos reales",
|
||||
"Subsidios masivos son fiscalmente insostenibles",
|
||||
"Crearán mercados negros inevitablemente",
|
||||
"La transición es extremadamente difícil"
|
||||
],
|
||||
datos: {
|
||||
antes: { precio: 0.50, cantidad: 100 },
|
||||
despues: { precio: 0.01, cantidad: 60 }
|
||||
},
|
||||
icono: <AlertCircle className="w-6 h-6" />,
|
||||
color: "red"
|
||||
},
|
||||
{
|
||||
id: 'seattle-wage',
|
||||
titulo: "Salario Mínimo en Seattle",
|
||||
categoria: 'laboral',
|
||||
ubicacion: "Seattle, USA",
|
||||
anio: "2014-2019",
|
||||
contexto: "Seattle aumentó gradualmente el salario mínimo de $9.47 a $15/hora entre 2014 y 2017, siendo pionera en Estados Unidos.",
|
||||
intervencion: "Incremento progresivo del salario mínimo municipal hasta $15/hora, con ritmo diferenciado por tamaño de empresa.",
|
||||
resultados: [
|
||||
"Reducción en horas trabajadas por empleados de bajos ingresos",
|
||||
"Pérdida neta de ingresos para algunos trabajadores",
|
||||
"Beneficio para trabajadores que mantuvieron empleo",
|
||||
"Aumento de precios en restaurantes"
|
||||
],
|
||||
lecciones: [
|
||||
"Efectos no son uniformes en todos los trabajadores",
|
||||
"El ajuste puede ocurrir por horas, no solo empleos",
|
||||
"La elasticidad de la demanda laboral importa",
|
||||
"Estudios rigurosos muestran efectos mixtos"
|
||||
],
|
||||
datos: {
|
||||
antes: { precio: 9.47, cantidad: 100 },
|
||||
despues: { precio: 15.00, cantidad: 92 }
|
||||
},
|
||||
icono: <Wallet className="w-6 h-6" />,
|
||||
color: "amber"
|
||||
},
|
||||
{
|
||||
id: 'eu-agricultura',
|
||||
titulo: "Política Agrícola de la UE",
|
||||
categoria: 'agricola',
|
||||
ubicacion: "Unión Europea",
|
||||
anio: "1962-presente",
|
||||
contexto: "La Política Agrícola Común (PAC) estableció precios de intervención para garantizar ingresos a los agricultores europeos.",
|
||||
intervencion: "Precios mínimos garantizados para productos agrícolas clave, con compras gubernamentales del excedente.",
|
||||
resultados: [
|
||||
"Superávits masivos de productos lácteos y cereales",
|
||||
"Montañas de mantequilla y lagos de vino",
|
||||
"Gasto fiscal considerable",
|
||||
"Reformas parciales desde los 90"
|
||||
],
|
||||
lecciones: [
|
||||
"Los precios de soporte generan excedentes",
|
||||
"El gobierno termina comprando producción no deseada",
|
||||
"Crean distorsiones en el comercio internacional",
|
||||
"Las reformas son políticamente difíciles"
|
||||
],
|
||||
datos: {
|
||||
antes: { precio: 100, cantidad: 80 },
|
||||
despues: { precio: 130, cantidad: 110 }
|
||||
},
|
||||
icono: <TrendingUp className="w-6 h-6" />,
|
||||
color: "green"
|
||||
}
|
||||
];
|
||||
|
||||
export const ControlesVidaReal: React.FC<ControlesVidaRealProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [casoActivo, setCasoActivo] = useState<CasoEstudio | null>(null);
|
||||
const [respuestas, setRespuestas] = useState<Record<string, string>>({});
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [casosCompletados, setCasosCompletados] = useState<Set<string>>(new Set());
|
||||
const [puntuacion, setPuntuacion] = useState(0);
|
||||
|
||||
const seleccionarCaso = (caso: CasoEstudio) => {
|
||||
setCasoActivo(caso);
|
||||
setMostrarResultado(false);
|
||||
};
|
||||
|
||||
const responderPregunta = (respuesta: string) => {
|
||||
if (!casoActivo) return;
|
||||
|
||||
setRespuestas(prev => ({ ...prev, [casoActivo.id]: respuesta }));
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (!casosCompletados.has(casoActivo.id)) {
|
||||
setCasosCompletados(prev => new Set([...prev, casoActivo.id]));
|
||||
setPuntuacion(prev => prev + 25);
|
||||
|
||||
if (casosCompletados.size + 1 >= 4) {
|
||||
setTimeout(() => {
|
||||
onComplete?.(100);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getColorClass = (color: string) => {
|
||||
const colors: Record<string, { bg: string; border: string; text: string; light: string }> = {
|
||||
blue: { bg: 'bg-blue-600', border: 'border-blue-400', text: 'text-blue-800', light: 'bg-blue-50' },
|
||||
red: { bg: 'bg-red-600', border: 'border-red-400', text: 'text-red-800', light: 'bg-red-50' },
|
||||
amber: { bg: 'bg-amber-600', border: 'border-amber-400', text: 'text-amber-800', light: 'bg-amber-50' },
|
||||
green: { bg: 'bg-green-600', border: 'border-green-400', text: 'text-green-800', light: 'bg-green-50' }
|
||||
};
|
||||
return colors[color] || colors.blue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-indigo-100 rounded-lg">
|
||||
<BookOpen className="w-6 h-6 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">Controles de Precio en la Vida Real</h2>
|
||||
<p className="text-gray-600">Estudia casos históricos y sus consecuencias reales</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">Progreso: {casosCompletados.size}/4</span>
|
||||
<div className="w-32 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-indigo-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${puntuacion}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!casoActivo ? (
|
||||
/* Grid de casos de estudio */
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{casosEstudio.map((caso) => {
|
||||
const colors = getColorClass(caso.color);
|
||||
const completado = casosCompletados.has(caso.id);
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={caso.id}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => seleccionarCaso(caso)}
|
||||
className={`p-5 text-left rounded-xl border-2 transition-all ${
|
||||
completado
|
||||
? `${colors.light} ${colors.border}`
|
||||
: 'bg-white border-gray-200 hover:border-indigo-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={`p-2 ${colors.light} rounded-lg`}>
|
||||
{caso.icono}
|
||||
</div>
|
||||
{completado && (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-gray-800 mb-2">{caso.titulo}</h3>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 mb-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{caso.ubicacion}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{caso.anio}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 line-clamp-2">{caso.contexto}</p>
|
||||
|
||||
<div className="mt-3">
|
||||
<span className={`inline-block text-xs px-2 py-1 rounded ${colors.light} ${colors.text}`}>
|
||||
{caso.categoria === 'vivienda' && '🏠 Vivienda'}
|
||||
{caso.categoria === 'laboral' && '💼 Laboral'}
|
||||
{caso.categoria === 'agricola' && '🌾 Agrícola'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Vista detallada del caso */
|
||||
<div className="space-y-6">
|
||||
{/* Navegación */}
|
||||
<button
|
||||
onClick={() => setCasoActivo(null)}
|
||||
className="text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-2"
|
||||
>
|
||||
← Volver a casos de estudio
|
||||
</button>
|
||||
|
||||
{(() => {
|
||||
const colors = getColorClass(casoActivo.color);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header del caso */}
|
||||
<div className={`p-6 rounded-xl ${colors.light} border ${colors.border}`}>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 bg-white rounded-lg shadow-sm">
|
||||
{casoActivo.icono}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">{casoActivo.titulo}</h2>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{casoActivo.ubicacion}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{casoActivo.anio}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700">{casoActivo.contexto}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Columna izquierda: Información */}
|
||||
<div className="space-y-4">
|
||||
{/* Intervención */}
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-indigo-600" />
|
||||
Intervención
|
||||
</h3>
|
||||
<p className="text-gray-700">{casoActivo.intervencion}</p>
|
||||
</div>
|
||||
|
||||
{/* Resultados */}
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-indigo-600" />
|
||||
Resultados Observados
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{casoActivo.resultados.map((resultado, idx) => (
|
||||
<motion.li
|
||||
key={idx}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className="flex items-start gap-2 text-sm text-gray-700"
|
||||
>
|
||||
<span className="text-indigo-600 mt-0.5">→</span>
|
||||
{resultado}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Lecciones */}
|
||||
<div className="p-4 bg-indigo-50 rounded-lg border border-indigo-200">
|
||||
<h3 className="font-semibold text-indigo-800 mb-2 flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Lecciones Aprendidas
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{casoActivo.lecciones.map((leccion, idx) => (
|
||||
<motion.li
|
||||
key={idx}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className="flex items-start gap-2 text-sm text-indigo-900"
|
||||
>
|
||||
<span className="text-indigo-600 mt-0.5">💡</span>
|
||||
{leccion}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columna derecha: Visualización y pregunta */}
|
||||
<div className="space-y-4">
|
||||
{/* Visualización simple */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-800 mb-3">Evolución del Mercado</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg">
|
||||
<span className="text-sm text-gray-600">Antes</span>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-gray-800">${casoActivo.datos.antes.precio}</div>
|
||||
<div className="text-xs text-gray-500">{casoActivo.datos.antes.cantidad} unidades</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="text-2xl text-gray-400">↓</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center justify-between p-3 rounded-lg ${colors.light}`}>
|
||||
<span className="text-sm text-gray-600">Después</span>
|
||||
<div className="text-right">
|
||||
<div className={`font-bold ${colors.text}`}>${casoActivo.datos.despues.precio}</div>
|
||||
<div className="text-xs text-gray-600">{casoActivo.datos.despues.cantidad} unidades</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pregunta de comprensión */}
|
||||
<AnimatePresence mode="wait">
|
||||
{!mostrarResultado ? (
|
||||
<motion.div
|
||||
key="pregunta"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="p-5 bg-white border-2 border-gray-200 rounded-lg"
|
||||
>
|
||||
<h3 className="font-bold text-gray-800 mb-4">
|
||||
¿Cuál es la principal consecuencia económica observada?
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => responderPregunta('desajuste')}
|
||||
className="w-full p-3 text-left bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="font-medium text-gray-800">Desajuste entre oferta y demanda</span>
|
||||
<p className="text-sm text-gray-600">Escasez o superávit según el tipo de control</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => responderPregunta('equilibrio')}
|
||||
className="w-full p-3 text-left bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="font-medium text-gray-800">El mercado alcanzó equilibrio</span>
|
||||
<p className="text-sm text-gray-600">Los controles no afectaron las cantidades</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => responderPregunta('eficiencia')}
|
||||
className="w-full p-3 text-left bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="font-medium text-gray-800">Mayor eficiencia económica</span>
|
||||
<p className="text-sm text-gray-600">Mejor asignación de recursos</p>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="resultado"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-5 rounded-lg border-2 ${
|
||||
respuestas[casoActivo.id] === 'desajuste'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-amber-50 border-amber-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{respuestas[casoActivo.id] === 'desajuste' ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="w-6 h-6 text-amber-600" />
|
||||
)}
|
||||
<h4 className={`font-bold ${
|
||||
respuestas[casoActivo.id] === 'desajuste' ? 'text-green-800' : 'text-amber-800'
|
||||
}`}>
|
||||
{respuestas[casoActivo.id] === 'desajuste' ? '¡Correcto!' : 'Revisa la respuesta'}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-4">
|
||||
{respuestas[casoActivo.id] === 'desajuste'
|
||||
? 'Los controles de precio siempre crean desajustes: precios máximos generan escasez, precios mínimos generan superávits.'
|
||||
: 'Recuerda: los controles de precio fijados fuera del equilibrio siempre crean desajustes entre oferta y demanda, generando ineficiencias.'
|
||||
}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setCasoActivo(null)}
|
||||
className="w-full py-2 px-4 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Explorar otro caso →
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Barra de progreso */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Tu progreso</span>
|
||||
<span className="text-sm text-gray-500">{casosCompletados.size} de 4 casos completados</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-indigo-500 to-purple-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(casosCompletados.size / 4) * 100}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{casosCompletados.size >= 4 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg text-center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Users className="w-5 h-5 text-green-600" />
|
||||
<span className="font-bold text-green-800">¡Felicidades! Has completado todos los casos</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700">
|
||||
Ahora comprendes mejor las consecuencias reales de los controles de precio en diferentes contextos.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlesVidaReal;
|
||||
@@ -0,0 +1,368 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { LineChart, Check, X, RotateCcw, Trophy, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface Punto {
|
||||
x: number;
|
||||
y: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface CurvaDemandaConstructorProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
const GRID_SIZE = 350;
|
||||
const PADDING = 50;
|
||||
const MAX_PRECIO = 100;
|
||||
const MAX_CANTIDAD = 100;
|
||||
|
||||
export const CurvaDemandaConstructor: React.FC<CurvaDemandaConstructorProps> = ({
|
||||
ejercicioId: _ejercicioId,
|
||||
onComplete
|
||||
}) => {
|
||||
const [puntos, setPuntos] = useState<Punto[]>([]);
|
||||
const [mensaje, setMensaje] = useState<string>('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [intentos, setIntentos] = useState(0);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
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 (showSuccess) 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 (puntos.length >= 5) {
|
||||
setMensaje('Máximo 5 puntos permitidos');
|
||||
return;
|
||||
}
|
||||
|
||||
const newPoint: Punto = {
|
||||
x: cartesian.x,
|
||||
y: cartesian.y,
|
||||
id: `point-${Date.now()}-${Math.random()}`
|
||||
};
|
||||
|
||||
setPuntos(prev => [...prev, newPoint]);
|
||||
setMensaje('');
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
setIntentos(prev => prev + 1);
|
||||
|
||||
if (puntos.length < 2) {
|
||||
setMensaje('Necesitas al menos 2 puntos para trazar una curva de demanda');
|
||||
return;
|
||||
}
|
||||
|
||||
const pendiente = calcularPendiente(puntos);
|
||||
if (pendiente === null) return;
|
||||
|
||||
if (pendiente >= 0) {
|
||||
setMensaje('¡Incorrecto! La curva de demanda debe tener pendiente NEGATIVA (bajar de izquierda a derecha)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calcular puntuación basada en intentos
|
||||
let puntuacion = 100;
|
||||
if (intentos >= 1) puntuacion -= 20;
|
||||
if (intentos >= 2) puntuacion -= 20;
|
||||
puntuacion = Math.max(puntuacion, 40);
|
||||
|
||||
setScore(puntuacion);
|
||||
setMensaje('');
|
||||
setShowSuccess(true);
|
||||
|
||||
setTimeout(() => {
|
||||
if (onComplete) {
|
||||
onComplete(puntuacion);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setPuntos([]);
|
||||
setMensaje('');
|
||||
setShowSuccess(false);
|
||||
setScore(0);
|
||||
setIntentos(0);
|
||||
};
|
||||
|
||||
const eliminarPunto = (id: string) => {
|
||||
setPuntos(prev => prev.filter(p => p.id !== id));
|
||||
};
|
||||
|
||||
const renderLineaCurva = () => {
|
||||
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="#3b82f6"
|
||||
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">Constructor de Curva de Demanda</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={reiniciar}
|
||||
className="p-2 text-gray-500 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Haz clic en el gráfico para colocar puntos que formen una curva de demanda con pendiente negativa.
|
||||
La demanda debe descender de izquierda a derecha.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<strong>Instrucción:</strong> Coloca al menos 2 puntos formando una línea descendente.
|
||||
Haz clic en un punto para eliminarlo.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-1 flex justify-center">
|
||||
<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}
|
||||
>
|
||||
{/* 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 + 30} textAnchor="middle" className="text-sm fill-gray-600 font-medium">
|
||||
Cantidad (Q)
|
||||
</text>
|
||||
<text x={20} y={PADDING + GRID_SIZE / 2} textAnchor="middle" transform={`rotate(-90, 20, ${PADDING + GRID_SIZE / 2})`} className="text-sm fill-gray-600 font-medium">
|
||||
Precio (P)
|
||||
</text>
|
||||
|
||||
{/* Marcas de ejes */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<g key={`marks-${i}`}>
|
||||
<text x={PADDING + (i * GRID_SIZE) / 5} y={PADDING + GRID_SIZE + 18} textAnchor="middle" className="text-xs fill-gray-400">
|
||||
{i * 20}
|
||||
</text>
|
||||
<text x={PADDING - 10} y={PADDING + GRID_SIZE - (i * GRID_SIZE) / 5 + 4} textAnchor="end" className="text-xs fill-gray-400">
|
||||
{i * 20}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Curva */}
|
||||
{renderLineaCurva()}
|
||||
|
||||
{/* Puntos */}
|
||||
{puntos.map(punto => {
|
||||
const svg = cartesianToSvg(punto.x, punto.y);
|
||||
return (
|
||||
<motion.g key={punto.id} initial={{ scale: 0 }} animate={{ scale: 1 }}>
|
||||
<circle
|
||||
cx={svg.x}
|
||||
cy={svg.y}
|
||||
r="8"
|
||||
fill="#3b82f6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
className="cursor-pointer hover:r-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
eliminarPunto(punto.id);
|
||||
}}
|
||||
/>
|
||||
<text x={svg.x} y={svg.y - 12} textAnchor="middle" className="text-xs fill-gray-500">
|
||||
({punto.x}, {punto.y})
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Flecha indicando pendiente descendente */}
|
||||
{puntos.length >= 2 && (
|
||||
<g>
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#6b7280" />
|
||||
</marker>
|
||||
</defs>
|
||||
<text x={PADDING + GRID_SIZE - 80} y={PADDING + 40} className="text-xs fill-gray-400">
|
||||
Pendiente -
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-72 space-y-4">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-700 mb-2">Progreso</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Puntos colocados:</span>
|
||||
<span className="font-medium text-blue-600">{puntos.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Intentos:</span>
|
||||
<span className="font-medium text-gray-800">{intentos}</span>
|
||||
</div>
|
||||
{showSuccess && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Puntuación:</span>
|
||||
<span className="font-medium text-green-600">{score}/100</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mensaje && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-3 border rounded-lg flex items-start gap-2 ${
|
||||
mensaje.includes('Correcto') || mensaje.includes('Excelente')
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}
|
||||
>
|
||||
{mensaje.includes('Correcto') || mensaje.includes('Excelente') ? (
|
||||
<Check className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<p className={`text-sm ${
|
||||
mensaje.includes('Correcto') || mensaje.includes('Excelente')
|
||||
? 'text-green-700'
|
||||
: 'text-red-700'
|
||||
}`}>{mensaje}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!showSuccess && (
|
||||
<button
|
||||
onClick={validarCurva}
|
||||
disabled={puntos.length < 2}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
Validar Curva
|
||||
</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">¡Excelente!</p>
|
||||
<p className="text-sm text-green-600 mt-1">
|
||||
Has trazado correctamente una curva de demanda con pendiente negativa.
|
||||
</p>
|
||||
<div className="mt-3 text-2xl font-bold text-green-700">
|
||||
{score}/100
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurvaDemandaConstructor;
|
||||
@@ -0,0 +1,451 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { TrendingUp, Check, X, RotateCcw, Trophy, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface Punto {
|
||||
x: number;
|
||||
y: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface CurvaOfertaConstructorProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
const GRID_SIZE = 350;
|
||||
const PADDING = 50;
|
||||
const MAX_PRECIO = 100;
|
||||
const MAX_CANTIDAD = 100;
|
||||
|
||||
export const CurvaOfertaConstructor: React.FC<CurvaOfertaConstructorProps> = ({
|
||||
onComplete,
|
||||
ejercicioId: _ejercicioId
|
||||
}) => {
|
||||
const [puntos, setPuntos] = useState<Punto[]>([]);
|
||||
const [mensaje, setMensaje] = useState<string>('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [intentos, setIntentos] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [draggedPoint, setDraggedPoint] = useState<string | null>(null);
|
||||
|
||||
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 || completado) 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 (puntos.length >= 5) {
|
||||
setMensaje('Máximo 5 puntos permitidos');
|
||||
return;
|
||||
}
|
||||
|
||||
const newPoint: Punto = {
|
||||
x: cartesian.x,
|
||||
y: cartesian.y,
|
||||
id: `point-${Date.now()}-${Math.random()}`
|
||||
};
|
||||
|
||||
setPuntos(prev => [...prev, newPoint]);
|
||||
setMensaje('');
|
||||
};
|
||||
|
||||
const handlePointDrag = (pointId: string) => {
|
||||
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);
|
||||
|
||||
setPuntos(prev =>
|
||||
prev.map(p => p.id === draggedPoint ? { ...p, x: cartesian.x, y: cartesian.y } : p)
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointUp = () => {
|
||||
setDraggedPoint(null);
|
||||
};
|
||||
|
||||
const calcularPendiente = (): 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 = () => {
|
||||
if (puntos.length < 2) {
|
||||
setMensaje('Necesitas al menos 2 puntos para trazar la curva de oferta');
|
||||
return;
|
||||
}
|
||||
|
||||
const pendiente = calcularPendiente();
|
||||
if (pendiente === null) return;
|
||||
|
||||
setIntentos(prev => prev + 1);
|
||||
|
||||
if (pendiente <= 0) {
|
||||
setMensaje('¡Incorrecto! La curva de oferta debe tener pendiente POSITIVA (subir de izquierda a derecha)');
|
||||
|
||||
// Penalización por intentos
|
||||
if (intentos >= 2) {
|
||||
setScore(Math.max(0, 60 - (intentos - 2) * 10));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Curva correcta
|
||||
const puntosBonus = puntos.length >= 3 ? 10 : 0;
|
||||
const intentosBonus = intentos === 0 ? 30 : intentos === 1 ? 20 : 10;
|
||||
const puntajeFinal = Math.min(100, 60 + puntosBonus + intentosBonus);
|
||||
|
||||
setScore(puntajeFinal);
|
||||
setMensaje('');
|
||||
setShowSuccess(true);
|
||||
setCompletado(true);
|
||||
|
||||
setTimeout(() => {
|
||||
if (onComplete) {
|
||||
onComplete(puntajeFinal);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setPuntos([]);
|
||||
setMensaje('');
|
||||
setShowSuccess(false);
|
||||
setScore(0);
|
||||
setIntentos(0);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
const eliminarPunto = (id: string) => {
|
||||
setPuntos(prev => prev.filter(p => p.id !== id));
|
||||
};
|
||||
|
||||
const renderLineaCurva = () => {
|
||||
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 (
|
||||
<motion.polyline
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="#22c55e"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Puntos guía esperados para la curva de oferta
|
||||
const puntosGuia = [
|
||||
{ x: 20, y: 20 },
|
||||
{ x: 50, y: 40 },
|
||||
{ x: 80, y: 70 }
|
||||
];
|
||||
|
||||
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">
|
||||
<TrendingUp className="w-8 h-8 text-green-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Constructor de Curva de Oferta</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{completado && (
|
||||
<span className="text-2xl font-bold text-green-600">{score} pts</span>
|
||||
)}
|
||||
<button
|
||||
onClick={reiniciar}
|
||||
className="p-2 text-gray-500 hover:text-green-600 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Coloca puntos en el gráfico para trazar una curva de oferta con pendiente POSITIVA.
|
||||
Recuerda: a mayor precio, mayor cantidad ofrecida.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-3 bg-green-50 rounded-lg flex items-center gap-2">
|
||||
<HelpCircle className="w-5 h-5 text-green-600" />
|
||||
<span className="text-sm text-green-700">
|
||||
<strong>Instrucciones:</strong> Haz clic en el gráfico para colocar puntos.
|
||||
La curva debe subir de izquierda a derecha (pendiente positiva).
|
||||
Arrastra los puntos para ajustar su posición. Haz clic en un punto para eliminarlo.
|
||||
</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 mx-auto"
|
||||
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 + 30} textAnchor="middle" className="text-sm fill-gray-600 font-medium">
|
||||
Cantidad (Q)
|
||||
</text>
|
||||
<text x={20} y={PADDING + GRID_SIZE / 2} textAnchor="middle" transform={`rotate(-90, 20, ${PADDING + GRID_SIZE / 2})`} className="text-sm fill-gray-600 font-medium">
|
||||
Precio (P)
|
||||
</text>
|
||||
|
||||
{/* Valores en ejes */}
|
||||
{[0, 25, 50, 75, 100].map((val) => (
|
||||
<g key={val}>
|
||||
<text x={PADDING + (val / 100) * GRID_SIZE} y={PADDING + GRID_SIZE + 15} textAnchor="middle" className="text-xs fill-gray-500">
|
||||
{val}
|
||||
</text>
|
||||
<text x={PADDING - 10} y={PADDING + GRID_SIZE - (val / 100) * GRID_SIZE + 4} textAnchor="end" className="text-xs fill-gray-500">
|
||||
{val}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Línea de tendencia esperada (dotted, muy sutil) */}
|
||||
{!completado && (
|
||||
<line
|
||||
x1={PADDING + (20 / 100) * GRID_SIZE}
|
||||
y1={PADDING + GRID_SIZE - (20 / 100) * GRID_SIZE}
|
||||
x2={PADDING + (80 / 100) * GRID_SIZE}
|
||||
y2={PADDING + GRID_SIZE - (80 / 100) * GRID_SIZE}
|
||||
stroke="#dcfce7"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
opacity="0.5"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Curva */}
|
||||
{renderLineaCurva()}
|
||||
|
||||
{/* Puntos */}
|
||||
{puntos.map((punto, index) => {
|
||||
const svg = cartesianToSvg(punto.x, punto.y);
|
||||
return (
|
||||
<motion.g
|
||||
key={punto.id}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
>
|
||||
<circle
|
||||
cx={svg.x}
|
||||
cy={svg.y}
|
||||
r="10"
|
||||
fill="#22c55e"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
className="cursor-move hover:r-12"
|
||||
onMouseDown={() => handlePointDrag(punto.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
eliminarPunto(punto.id);
|
||||
}}
|
||||
/>
|
||||
<text x={svg.x} y={svg.y - 15} textAnchor="middle" className="text-xs fill-gray-600 font-medium">
|
||||
P{index + 1}({punto.x}, {punto.y})
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Etiqueta S */}
|
||||
{puntos.length >= 2 && (
|
||||
<text
|
||||
x={PADDING + GRID_SIZE + 10}
|
||||
y={PADDING + 30}
|
||||
className="text-lg fill-green-600 font-bold"
|
||||
>
|
||||
S
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-72 space-y-4">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">Progreso</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Puntos colocados:</span>
|
||||
<span className="font-semibold text-green-600">{puntos.length}/5</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-green-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(puntos.length / 5) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{intentos > 0 && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Intentos: <span className="font-semibold">{intentos}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h3 className="font-semibold text-blue-800 mb-2">Recuerda:</h3>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500">•</span>
|
||||
La oferta tiene pendiente POSITIVA
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500">•</span>
|
||||
Subir de izquierda a derecha ↗️
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500">•</span>
|
||||
Precio ↑ → Cantidad ↑
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={validarCurva}
|
||||
disabled={completado || puntos.length < 2}
|
||||
className="w-full py-3 px-4 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
Validar Curva
|
||||
</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">¡Excelente!</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Has trazado correctamente la curva de oferta
|
||||
</p>
|
||||
<p className="text-lg font-bold text-green-700 mt-2">{score} puntos</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{completado && (
|
||||
<div className="p-3 bg-gray-100 rounded-lg text-sm text-gray-600">
|
||||
<strong>Puntuación:</strong>
|
||||
<ul className="mt-1 space-y-1">
|
||||
<li>• Base: 60 puntos</li>
|
||||
<li>• +3 puntos: 10 pts</li>
|
||||
<li>• Primer intento: 30 pts</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurvaOfertaConstructor;
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Users, Plus, Check, X, Trophy, RotateCcw, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface DemandaIndividualVsMercadoProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Consumidor {
|
||||
id: string;
|
||||
nombre: string;
|
||||
cantidad: number;
|
||||
}
|
||||
|
||||
const consumidores: Consumidor[] = [
|
||||
{ id: 'ana', nombre: 'Ana', cantidad: 5 },
|
||||
{ id: 'beto', nombre: 'Beto', cantidad: 3 },
|
||||
{ id: 'carlos', nombre: 'Carlos', cantidad: 7 },
|
||||
{ id: 'diana', nombre: 'Diana', cantidad: 4 },
|
||||
];
|
||||
|
||||
export const DemandaIndividualVsMercado: React.FC<DemandaIndividualVsMercadoProps> = ({
|
||||
ejercicioId: _ejercicioId,
|
||||
onComplete
|
||||
}) => {
|
||||
const [respuestaUsuario, setRespuestaUsuario] = useState('');
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [intentos, setIntentos] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [mostrarExplicacion, setMostrarExplicacion] = useState(false);
|
||||
|
||||
const demandaTotal = consumidores.reduce((sum, c) => sum + c.cantidad, 0);
|
||||
|
||||
const validarRespuesta = () => {
|
||||
if (respuestaUsuario === '') {
|
||||
alert('Por favor ingresa tu respuesta');
|
||||
return;
|
||||
}
|
||||
|
||||
setIntentos(prev => prev + 1);
|
||||
setMostrarResultado(true);
|
||||
|
||||
const respuestaNum = parseInt(respuestaUsuario);
|
||||
const esCorrecta = respuestaNum === demandaTotal;
|
||||
|
||||
let puntuacion = esCorrecta ? 100 : 0;
|
||||
// Si está cerca (±2), dar puntuación parcial
|
||||
if (!esCorrecta && Math.abs(respuestaNum - demandaTotal) <= 2) {
|
||||
puntuacion = 50;
|
||||
}
|
||||
// Penalización por intentos
|
||||
if (intentos >= 1) puntuacion -= 10;
|
||||
if (intentos >= 2) puntuacion -= 10;
|
||||
puntuacion = Math.max(puntuacion, 10);
|
||||
|
||||
setScore(puntuacion);
|
||||
|
||||
if (esCorrecta) {
|
||||
setCompletado(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalizar = () => {
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setRespuestaUsuario('');
|
||||
setMostrarResultado(false);
|
||||
setMostrarExplicacion(false);
|
||||
setScore(0);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
const handleSiguienteIntento = () => {
|
||||
setRespuestaUsuario('');
|
||||
setMostrarResultado(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl 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">
|
||||
<Users className="w-8 h-8 text-blue-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Demanda Individual vs. Mercado</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={reiniciar}
|
||||
className="p-2 text-gray-500 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
La demanda de mercado es la suma horizontal de todas las demandas individuales.
|
||||
Calcula la cantidad total demandada sumando las cantidades de todos los consumidores.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
{/* Tarjetas de consumidores */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">Demandas Individuales</h3>
|
||||
{consumidores.map((consumidor, index) => (
|
||||
<motion.div
|
||||
key={consumidor.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="flex items-center justify-between p-4 bg-blue-50 rounded-lg border-2 border-blue-100"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold">
|
||||
{consumidor.nombre[0]}
|
||||
</div>
|
||||
<span className="font-medium text-gray-800">{consumidor.nombre}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-sm text-gray-500">Demanda:</span>
|
||||
<span className="ml-2 font-bold text-blue-600">{consumidor.cantidad} unidades</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Visualización de la suma */}
|
||||
<div className="flex flex-col items-center justify-center p-6 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-700 mb-4">Suma de Demandas</h3>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 mb-4">
|
||||
{consumidores.map((consumidor, index) => (
|
||||
<React.Fragment key={consumidor.id}>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: index * 0.2 }}
|
||||
className="px-3 py-2 bg-blue-100 text-blue-700 rounded-lg font-bold"
|
||||
>
|
||||
{consumidor.cantidad}
|
||||
</motion.div>
|
||||
{index < consumidores.length - 1 && (
|
||||
<Plus className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mb-4 rotate-90 md:rotate-0" />
|
||||
<div className="w-full max-w-xs">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Demanda Total del Mercado:
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={respuestaUsuario}
|
||||
onChange={(e) => setRespuestaUsuario(e.target.value)}
|
||||
disabled={mostrarResultado}
|
||||
placeholder="¿Cuánto suma?"
|
||||
className={`flex-1 px-4 py-3 border-2 rounded-lg text-center text-lg font-bold focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all ${
|
||||
mostrarResultado
|
||||
? parseInt(respuestaUsuario) === demandaTotal
|
||||
? 'border-green-500 bg-green-50 text-green-700'
|
||||
: 'border-red-500 bg-red-50 text-red-700'
|
||||
: 'border-gray-300 focus:border-blue-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-gray-600 font-medium">unidades</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarResultado && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="mb-6 overflow-hidden"
|
||||
>
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
parseInt(respuestaUsuario) === demandaTotal
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
{parseInt(respuestaUsuario) === demandaTotal ? (
|
||||
<Check className="w-6 h-6 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="w-6 h-6 text-red-600 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${
|
||||
parseInt(respuestaUsuario) === demandaTotal ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{parseInt(respuestaUsuario) === demandaTotal
|
||||
? '¡Correcto! Has calculado correctamente la demanda de mercado'
|
||||
: 'Respuesta incorrecta'}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-gray-700">
|
||||
{parseInt(respuestaUsuario) === demandaTotal
|
||||
? `La demanda total es ${demandaTotal} unidades (${consumidores.map(c => c.cantidad).join(' + ')}).`
|
||||
: `La respuesta correcta es ${demandaTotal} unidades. Sumaste ${respuestaUsuario}.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-gray-200">
|
||||
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||
<span className="font-bold text-gray-800">Puntuación: {score}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{!mostrarResultado ? (
|
||||
<button
|
||||
onClick={validarRespuesta}
|
||||
disabled={respuestaUsuario === ''}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
Validar Respuesta
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{!completado && (
|
||||
<button
|
||||
onClick={handleSiguienteIntento}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
Intentar de Nuevo
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleFinalizar}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
Finalizar Ejercicio
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{intentos > 0 && (
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
Intentos realizados: {intentos}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemandaIndividualVsMercado;
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowRightLeft, MoveHorizontal, DollarSign, Check, X, Trophy, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface DesplazamientoVsMovimientoProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
situacion: string;
|
||||
tipo: 'movimiento' | 'desplazamiento';
|
||||
explicacion: string;
|
||||
pista: string;
|
||||
}
|
||||
|
||||
const escenarios: Escenario[] = [
|
||||
{
|
||||
id: 1,
|
||||
situacion: "El precio de las manzanas sube de $2 a $4 por kilo. ¿Qué ocurre con la cantidad demandada de manzanas?",
|
||||
tipo: 'movimiento',
|
||||
explicacion: "Cambio en el precio del propio bien = MOVIMIENTO a lo largo de la curva. La cantidad demandada disminuye.",
|
||||
pista: "¿Cambió el precio del propio bien o un factor externo?"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
situacion: "Los ingresos de los consumidores aumentan. ¿Qué ocurre con la demanda de restaurantes?",
|
||||
tipo: 'desplazamiento',
|
||||
explicacion: "Cambio en ingresos = DESPLAZAMIENTO de la curva. La demanda aumenta (la curva se mueve a la derecha).",
|
||||
pista: "El ingreso es un factor externo que desplaza toda la curva."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
situacion: "Una heladería baja sus precios en verano. ¿Qué ocurre con la cantidad demandada de helados?",
|
||||
tipo: 'movimiento',
|
||||
explicacion: "Cambio en el precio del bien = MOVIMIENTO a lo largo de la curva. La cantidad demandada aumenta.",
|
||||
pista: "La heladería cambió sus precios, no un factor externo."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
situacion: "Se espera que el precio de los autos suba el próximo mes. ¿Qué ocurre con la demanda de autos hoy?",
|
||||
tipo: 'desplazamiento',
|
||||
explicacion: "Expectativas futuras = DESPLAZAMIENTO de la curva. La gente compra antes, aumentando la demanda actual.",
|
||||
pista: "Las expectativas son un factor que desplaza la curva, no el precio actual."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
situacion: "Una campaña publicitaria exitosa promueve el consumo de aguacates. ¿Qué ocurre con la demanda?",
|
||||
tipo: 'desplazamiento',
|
||||
explicacion: "Cambio en gustos/preferencias = DESPLAZAMIENTO de la curva. La demanda aumenta.",
|
||||
pista: "La publicidad afecta los gustos, desplazando la curva."
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
situacion: "El precio de las entradas al cine baja un 50%. ¿Qué ocurre con la cantidad demandada de entradas?",
|
||||
tipo: 'movimiento',
|
||||
explicacion: "Cambio en el precio del propio bien = MOVIMIENTO a lo largo de la curva. Más personas van al cine.",
|
||||
pista: "¿El precio del bien mismo cambió? Entonces es un movimiento."
|
||||
}
|
||||
];
|
||||
|
||||
export const DesplazamientoVsMovimiento: React.FC<DesplazamientoVsMovimientoProps> = ({
|
||||
ejercicioId: _ejercicioId,
|
||||
onComplete
|
||||
}) => {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<'movimiento' | 'desplazamiento' | null>(null);
|
||||
const [mostrarFeedback, setMostrarFeedback] = useState(false);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [mostrarPista, setMostrarPista] = useState(false);
|
||||
const [usoPistas, setUsoPistas] = useState(0);
|
||||
|
||||
const escenario = escenarios[escenarioActual];
|
||||
const esCorrecta = respuestaSeleccionada === escenario.tipo;
|
||||
|
||||
const handleSeleccionar = (tipo: 'movimiento' | 'desplazamiento') => {
|
||||
if (mostrarFeedback) return;
|
||||
setRespuestaSeleccionada(tipo);
|
||||
};
|
||||
|
||||
const handleValidar = () => {
|
||||
if (respuestaSeleccionada === null) return;
|
||||
|
||||
setMostrarFeedback(true);
|
||||
if (esCorrecta) {
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (escenarioActual < escenarios.length - 1) {
|
||||
setEscenarioActual(prev => prev + 1);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarFeedback(false);
|
||||
setMostrarPista(false);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
// Calcular puntuación
|
||||
let puntuacion = Math.round((respuestasCorrectas + (esCorrecta ? 1 : 0)) / escenarios.length * 100);
|
||||
// Penalización por uso de pistas
|
||||
puntuacion -= usoPistas * 10;
|
||||
puntuacion = Math.max(puntuacion, 0);
|
||||
|
||||
setTimeout(() => {
|
||||
if (onComplete) {
|
||||
onComplete(puntuacion);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMostrarPista = () => {
|
||||
setMostrarPista(true);
|
||||
setUsoPistas(prev => prev + 1);
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setEscenarioActual(0);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarFeedback(false);
|
||||
setRespuestasCorrectas(0);
|
||||
setCompletado(false);
|
||||
setMostrarPista(false);
|
||||
setUsoPistas(0);
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
const puntuacionFinal = Math.max(0, Math.round((respuestasCorrectas / escenarios.length) * 100) - usoPistas * 10);
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto p-6 bg-white rounded-xl shadow-lg text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-4"
|
||||
>
|
||||
<Trophy className="w-10 h-10 text-green-600" />
|
||||
</motion.div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">¡Ejercicio Completado!</h2>
|
||||
<p className="text-gray-600 mb-2">
|
||||
Has identificado correctamente {respuestasCorrectas} de {escenarios.length} situaciones
|
||||
</p>
|
||||
{usoPistas > 0 && (
|
||||
<p className="text-sm text-yellow-600 mb-4">Pistas utilizadas: {usoPistas} (-{usoPistas * 10} pts)</p>
|
||||
)}
|
||||
<div className="text-4xl font-bold text-blue-600 mb-2">{puntuacionFinal}/100</div>
|
||||
<p className="text-sm text-gray-500">Puntuación final</p>
|
||||
<button
|
||||
onClick={reiniciar}
|
||||
className="mt-6 px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Intentar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-800">Desplazamiento vs. Movimiento</h2>
|
||||
<span className="text-sm text-gray-500">Situación {escenarioActual + 1} de {escenarios.length}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-blue-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((escenarioActual + 1) / escenarios.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* Panel izquierdo: Situación */}
|
||||
<div className="p-5 bg-blue-50 rounded-xl border-2 border-blue-100">
|
||||
<h3 className="font-semibold text-blue-800 mb-3 flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5" />
|
||||
Situación
|
||||
</h3>
|
||||
<p className="text-gray-800 leading-relaxed">{escenario.situacion}</p>
|
||||
|
||||
{!mostrarPista && !mostrarFeedback && (
|
||||
<button
|
||||
onClick={handleMostrarPista}
|
||||
className="mt-4 text-sm text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
¿Necesitas una pista? (-10 pts)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{mostrarPista && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg"
|
||||
>
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Pista:</strong> {escenario.pista}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel derecho: Opciones */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">¿Qué tipo de cambio ocurre?</h3>
|
||||
|
||||
<motion.button
|
||||
onClick={() => handleSeleccionar('movimiento')}
|
||||
disabled={mostrarFeedback}
|
||||
whileHover={!mostrarFeedback ? { scale: 1.02 } : {}}
|
||||
whileTap={!mostrarFeedback ? { scale: 0.98 } : {}}
|
||||
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
respuestaSeleccionada === 'movimiento'
|
||||
? mostrarFeedback
|
||||
? escenario.tipo === 'movimiento'
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-red-500 bg-red-50'
|
||||
: 'border-blue-500 bg-blue-50'
|
||||
: mostrarFeedback && escenario.tipo === 'movimiento'
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-gray-200 hover:border-blue-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
mostrarFeedback && escenario.tipo === 'movimiento'
|
||||
? 'bg-green-100 text-green-600'
|
||||
: respuestaSeleccionada === 'movimiento' && mostrarFeedback
|
||||
? 'bg-red-100 text-red-600'
|
||||
: 'bg-blue-100 text-blue-600'
|
||||
}`}>
|
||||
<ArrowRightLeft className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-800">Movimiento a lo largo de la curva</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Cambio en la cantidad demandada debido a un cambio en el precio del propio bien
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => handleSeleccionar('desplazamiento')}
|
||||
disabled={mostrarFeedback}
|
||||
whileHover={!mostrarFeedback ? { scale: 1.02 } : {}}
|
||||
whileTap={!mostrarFeedback ? { scale: 0.98 } : {}}
|
||||
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
respuestaSeleccionada === 'desplazamiento'
|
||||
? mostrarFeedback
|
||||
? escenario.tipo === 'desplazamiento'
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-red-500 bg-red-50'
|
||||
: 'border-blue-500 bg-blue-50'
|
||||
: mostrarFeedback && escenario.tipo === 'desplazamiento'
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-gray-200 hover:border-blue-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
mostrarFeedback && escenario.tipo === 'desplazamiento'
|
||||
? 'bg-green-100 text-green-600'
|
||||
: respuestaSeleccionada === 'desplazamiento' && mostrarFeedback
|
||||
? 'bg-red-100 text-red-600'
|
||||
: 'bg-purple-100 text-purple-600'
|
||||
}`}>
|
||||
<MoveHorizontal className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-800">Desplazamiento de la curva</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Cambio en la demanda debido a factores externos (ingresos, gustos, expectativas, etc.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarFeedback && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="mb-6 overflow-hidden"
|
||||
>
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
esCorrecta ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{esCorrecta ? (
|
||||
<Check className="w-6 h-6 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="w-6 h-6 text-red-600 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${esCorrecta ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-gray-700">{escenario.explicacion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{!mostrarFeedback ? (
|
||||
<button
|
||||
onClick={handleValidar}
|
||||
disabled={respuestaSeleccionada === null}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Validar Respuesta
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSiguiente}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{escenarioActual < escenarios.length - 1 ? 'Siguiente Situación' : 'Finalizar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesplazamientoVsMovimiento;
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface EscenarioElasticidad {
|
||||
id: number;
|
||||
producto: string;
|
||||
descripcion: string;
|
||||
precioInicial: number;
|
||||
precioFinal: number;
|
||||
cantidadInicial: number;
|
||||
cantidadFinal: number;
|
||||
categoriaCorrecta: 'elastica' | 'inelastica' | 'unitaria';
|
||||
explicacion: string;
|
||||
}
|
||||
|
||||
const escenarios: EscenarioElasticidad[] = [
|
||||
{
|
||||
id: 1,
|
||||
producto: 'Medicamentos esenciales',
|
||||
descripcion: 'El precio de antibióticos aumenta un 20% debido a escasez.',
|
||||
precioInicial: 100,
|
||||
precioFinal: 120,
|
||||
cantidadInicial: 10000,
|
||||
cantidadFinal: 9500,
|
||||
categoriaCorrecta: 'inelastica',
|
||||
explicacion: 'Los medicamentos esenciales tienen demanda inelástica porque son necesarios para la salud y no tienen sustitutos cercanos. La cantidad demandada disminuye muy poco (5%) a pesar del gran aumento de precio (20%).'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
producto: 'Boletos de cine de lujo',
|
||||
descripcion: 'Los cines VIP aumentan sus precios un 15%.',
|
||||
precioInicial: 200,
|
||||
precioFinal: 230,
|
||||
cantidadInicial: 5000,
|
||||
cantidadFinal: 3000,
|
||||
categoriaCorrecta: 'elastica',
|
||||
explicacion: 'El entretenimiento de lujo es elástico porque es un bien discrecional con muchos sustitutos (streaming, cines regulares, otras actividades). La cantidad demandada cae drásticamente (40%) ante un aumento moderado de precio.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
producto: 'Gasolina',
|
||||
descripcion: 'El precio de la gasolina sube un 10% por impuestos.',
|
||||
precioInicial: 50,
|
||||
precioFinal: 55,
|
||||
cantidadInicial: 100000,
|
||||
cantidadFinal: 95000,
|
||||
categoriaCorrecta: 'inelastica',
|
||||
explicacion: 'La gasolina tiene demanda inelástica a corto plazo porque es necesaria para el transporte y muchos no pueden cambiar sus hábitos inmediatamente. La cantidad solo baja 5% pese al aumento de 10%.'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
producto: 'Marca específica de cereal',
|
||||
descripcion: 'Una marca de cereal aumenta su precio un 8% mientras las competidoras mantienen precios.',
|
||||
precioInicial: 50,
|
||||
precioFinal: 54,
|
||||
cantidadInicial: 8000,
|
||||
cantidadFinal: 4000,
|
||||
categoriaCorrecta: 'elastica',
|
||||
explicacion: 'Una marca específica de cereal tiene demanda muy elástica porque hay muchos sustitutos perfectos (otras marcas). Los consumidores cambian fácilmente de marca cuando sube el precio.'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
producto: 'Sal marina gourmet',
|
||||
descripcion: 'El precio de sal marina artesanal baja un 25% en promoción.',
|
||||
precioInicial: 40,
|
||||
precioFinal: 30,
|
||||
cantidadInicial: 2000,
|
||||
cantidadFinal: 2100,
|
||||
categoriaCorrecta: 'inelastica',
|
||||
explicacion: 'La sal es un bien básico con demanda muy inelástica. Aunque baje el precio, la cantidad demandada no aumenta mucho porque la gente solo consume la cantidad que necesita.'
|
||||
}
|
||||
];
|
||||
|
||||
export const ElasticidadElasticaInelastica: React.FC = () => {
|
||||
const [escenarioActual, setEscenarioActual] = useState<number>(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<string | null>(null);
|
||||
const [resultado, setResultado] = useState<{
|
||||
correcto: boolean;
|
||||
mensaje: string;
|
||||
mostrarExplicacion: boolean;
|
||||
} | null>(null);
|
||||
const [puntuacion, setPuntuacion] = useState<number>(0);
|
||||
const [ejerciciosCompletados, setEjerciciosCompletados] = useState<number>(0);
|
||||
|
||||
const escenario = escenarios[escenarioActual];
|
||||
|
||||
const calcularElasticidad = (e: EscenarioElasticidad): number => {
|
||||
const cambioCantidad = e.cantidadFinal - e.cantidadInicial;
|
||||
const cambioPrecio = e.precioFinal - e.precioInicial;
|
||||
const cantidadPromedio = (e.cantidadInicial + e.cantidadFinal) / 2;
|
||||
const precioPromedio = (e.precioInicial + e.precioFinal) / 2;
|
||||
|
||||
if (cantidadPromedio === 0 || precioPromedio === 0) return 0;
|
||||
|
||||
return Math.abs((cambioCantidad / cantidadPromedio) / (cambioPrecio / precioPromedio));
|
||||
};
|
||||
|
||||
const verificarRespuesta = (categoria: string) => {
|
||||
if (resultado) return;
|
||||
|
||||
setRespuestaSeleccionada(categoria);
|
||||
const correcto = categoria === escenario.categoriaCorrecta;
|
||||
|
||||
if (correcto) {
|
||||
setPuntuacion(prev => prev + 1);
|
||||
}
|
||||
|
||||
setResultado({
|
||||
correcto,
|
||||
mensaje: correcto
|
||||
? '¡Correcto! Has identificado la elasticidad correctamente.'
|
||||
: 'Incorrecto. Revisa el valor calculado de la elasticidad.',
|
||||
mostrarExplicacion: true
|
||||
});
|
||||
|
||||
setEjerciciosCompletados(prev => prev + 1);
|
||||
};
|
||||
|
||||
const siguienteEjercicio = () => {
|
||||
const siguiente = (escenarioActual + 1) % escenarios.length;
|
||||
setEscenarioActual(siguiente);
|
||||
setRespuestaSeleccionada(null);
|
||||
setResultado(null);
|
||||
};
|
||||
|
||||
const reiniciarEjercicios = () => {
|
||||
setEscenarioActual(0);
|
||||
setRespuestaSeleccionada(null);
|
||||
setResultado(null);
|
||||
setPuntuacion(0);
|
||||
setEjerciciosCompletados(0);
|
||||
};
|
||||
|
||||
const elasticidadCalculada = calcularElasticidad(escenario);
|
||||
const cambioPrecioPorcentaje = ((escenario.precioFinal - escenario.precioInicial) / ((escenario.precioInicial + escenario.precioFinal) / 2) * 100);
|
||||
const cambioCantidadPorcentaje = ((escenario.cantidadFinal - escenario.cantidadInicial) / ((escenario.cantidadInicial + escenario.cantidadFinal) / 2) * 100);
|
||||
|
||||
const getCategoriaColor = (cat: string) => {
|
||||
switch (cat) {
|
||||
case 'elastica': return 'bg-green-100 border-green-300 text-green-800';
|
||||
case 'inelastica': return 'bg-amber-100 border-amber-300 text-amber-800';
|
||||
case 'unitaria': return 'bg-blue-100 border-blue-300 text-blue-800';
|
||||
default: return 'bg-gray-100 border-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 bg-white rounded-lg shadow-md">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-blue-800">Clasificación de Elasticidad</h2>
|
||||
<p className="text-gray-600">Analiza cada escenario y clasifica la elasticidad de la demanda.</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="bg-blue-50 px-4 py-2 rounded-lg">
|
||||
<p className="text-sm text-gray-600">Puntuación</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{puntuacion}/{ejerciciosCompletados}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 p-6 rounded-xl border border-indigo-200 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-indigo-500 rounded-xl flex items-center justify-center text-white text-xl font-bold flex-shrink-0">
|
||||
{escenario.id}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">{escenario.producto}</h3>
|
||||
<p className="text-gray-600 mb-4">{escenario.descripcion}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<p className="text-sm text-gray-500 mb-1">Precio Inicial</p>
|
||||
<p className="text-xl font-bold text-blue-600">${escenario.precioInicial}</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<p className="text-sm text-gray-500 mb-1">Precio Final</p>
|
||||
<p className="text-xl font-bold text-blue-600">${escenario.precioFinal}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Cambio: {cambioPrecioPorcentaje > 0 ? '+' : ''}{cambioPrecioPorcentaje.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<p className="text-sm text-gray-500 mb-1">Cantidad Inicial</p>
|
||||
<p className="text-xl font-bold text-green-600">{escenario.cantidadInicial.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<p className="text-sm text-gray-500 mb-1">Cantidad Final</p>
|
||||
<p className="text-xl font-bold text-green-600">{escenario.cantidadFinal.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Cambio: {cambioCantidadPorcentaje > 0 ? '+' : ''}{cambioCantidadPorcentaje.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 p-4 rounded-lg border border-amber-200">
|
||||
<p className="text-sm font-medium text-amber-800 mb-2">Datos calculados para ti:</p>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">Elasticidad calculada:</span>
|
||||
<span className="ml-2 font-bold text-indigo-600 text-lg">{elasticidadCalculada.toFixed(2)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Ratio %Q / %P:</span>
|
||||
<span className="ml-2 font-bold text-indigo-600">
|
||||
{Math.abs(cambioCantidadPorcentaje / cambioPrecioPorcentaje).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="font-bold text-gray-800 mb-4 text-lg">¿Cómo clasificarías la elasticidad de la demanda?</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => verificarRespuesta('elastica')}
|
||||
disabled={!!resultado}
|
||||
className={`p-5 rounded-xl border-2 text-left transition-all ${
|
||||
respuestaSeleccionada === 'elastica'
|
||||
? escenario.categoriaCorrecta === 'elastica'
|
||||
? 'bg-green-100 border-green-500 ring-2 ring-green-300'
|
||||
: 'bg-red-100 border-red-500 ring-2 ring-red-300'
|
||||
: 'bg-white border-gray-200 hover:border-green-300 hover:bg-green-50'
|
||||
} ${!!resultado && escenario.categoriaCorrecta !== 'elastica' && respuestaSeleccionada !== 'elastica' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-lg">Elástica</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">E<sub>d</sub> > 1</p>
|
||||
<p className="text-xs text-gray-500 mt-2">La cantidad cambia más que el precio</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => verificarRespuesta('unitaria')}
|
||||
disabled={!!resultado}
|
||||
className={`p-5 rounded-xl border-2 text-left transition-all ${
|
||||
respuestaSeleccionada === 'unitaria'
|
||||
? escenario.categoriaCorrecta === 'unitaria'
|
||||
? 'bg-green-100 border-green-500 ring-2 ring-green-300'
|
||||
: 'bg-red-100 border-red-500 ring-2 ring-red-300'
|
||||
: 'bg-white border-gray-200 hover:border-blue-300 hover:bg-blue-50'
|
||||
} ${!!resultado && escenario.categoriaCorrecta !== 'unitaria' && respuestaSeleccionada !== 'unitaria' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-lg">Unitaria</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">E<sub>d</sub> = 1</p>
|
||||
<p className="text-xs text-gray-500 mt-2">La cantidad cambia igual que el precio</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => verificarRespuesta('inelastica')}
|
||||
disabled={!!resultado}
|
||||
className={`p-5 rounded-xl border-2 text-left transition-all ${
|
||||
respuestaSeleccionada === 'inelastica'
|
||||
? escenario.categoriaCorrecta === 'inelastica'
|
||||
? 'bg-green-100 border-green-500 ring-2 ring-green-300'
|
||||
: 'bg-red-100 border-red-500 ring-2 ring-red-300'
|
||||
: 'bg-white border-gray-200 hover:border-amber-300 hover:bg-amber-50'
|
||||
} ${!!resultado && escenario.categoriaCorrecta !== 'inelastica' && respuestaSeleccionada !== 'inelastica' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-lg">Inelástica</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">E<sub>d</sub> < 1</p>
|
||||
<p className="text-xs text-gray-500 mt-2">La cantidad cambia menos que el precio</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resultado && (
|
||||
<div className={`p-6 rounded-xl border-2 mb-6 ${resultado.correcto ? 'bg-green-50 border-green-300' : 'bg-red-50 border-red-300'}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-full ${resultado.correcto ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
{resultado.correcto ? (
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-bold text-lg ${resultado.correcto ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{resultado.correcto ? '¡Respuesta Correcta!' : 'Respuesta Incorrecta'}
|
||||
</h4>
|
||||
<p className={`mt-1 ${resultado.correcto ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{resultado.mensaje}
|
||||
</p>
|
||||
|
||||
<div className={`mt-4 p-4 rounded-lg border ${getCategoriaColor(escenario.categoriaCorrecta)}`}>
|
||||
<p className="font-bold mb-2">
|
||||
Respuesta correcta: {escenario.categoriaCorrecta === 'elastica' ? 'Elástica' : escenario.categoriaCorrecta === 'inelastica' ? 'Inelástica' : 'Unitaria'} (E<sub>d</sub> = {elasticidadCalculada.toFixed(2)})
|
||||
</p>
|
||||
<p className="text-sm">{escenario.explicacion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultado && (
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={siguienteEjercicio}
|
||||
className="flex-1 bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
Siguiente Ejercicio
|
||||
</button>
|
||||
<button
|
||||
onClick={reiniciarEjercicios}
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white px-6 py-3 rounded-lg font-semibold transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Reiniciar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 bg-gray-50 p-5 rounded-xl border border-gray-200">
|
||||
<h4 className="font-bold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Guía de Clasificación
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border-l-4 border-green-500">
|
||||
<p className="font-bold text-green-700 mb-2">Elástica (E<sub>d</sub> > 1)</p>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Lujos y bienes discrecionales</li>
|
||||
<li>• Muchos sustitutos disponibles</li>
|
||||
<li>• Consumo puede posponerse</li>
|
||||
<li>• Representa % grande del ingreso</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border-l-4 border-blue-500">
|
||||
<p className="font-bold text-blue-700 mb-2">Unitaria (E<sub>d</sub> = 1)</p>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Cambio proporcional exacto</li>
|
||||
<li>• Caso teórico ideal</li>
|
||||
<li>• Ingreso total constante</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border-l-4 border-amber-500">
|
||||
<p className="font-bold text-amber-700 mb-2">Inelástica (E<sub>d</sub> < 1)</p>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Necesidades básicas</li>
|
||||
<li>• Pocos o ningún sustituto</li>
|
||||
<li>• Consumo indispensable</li>
|
||||
<li>• Representa % pequeño del ingreso</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElasticidadElasticaInelastica;
|
||||
@@ -0,0 +1,437 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface ProductoEscenario {
|
||||
id: number;
|
||||
nombre: string;
|
||||
elasticidad: number;
|
||||
precioInicial: number;
|
||||
cantidadInicial: number;
|
||||
precioActual: number;
|
||||
cantidadActual: number;
|
||||
descripcion: string;
|
||||
}
|
||||
|
||||
const generarEscenario = (): ProductoEscenario => {
|
||||
const elasticidades = [0.3, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0];
|
||||
const elasticidad = elasticidades[Math.floor(Math.random() * elasticidades.length)];
|
||||
|
||||
const nombresProductos = [
|
||||
'Medicamentos esenciales',
|
||||
'Gasolina',
|
||||
'Pan de caja',
|
||||
'Leche',
|
||||
'Cereal de marca',
|
||||
'Entradas de cine',
|
||||
'Restaurantes de lujo',
|
||||
'Viajes internacionales',
|
||||
'Yates',
|
||||
'Diamantes'
|
||||
];
|
||||
|
||||
const nombre = nombresProductos[Math.floor(Math.random() * nombresProductos.length)];
|
||||
const precioInicial = Math.round((Math.random() * 200 + 20) * 100) / 100;
|
||||
const cantidadInicial = Math.round(Math.random() * 5000 + 500);
|
||||
|
||||
const cambioPrecioPorcentaje = (Math.random() > 0.5 ? 1 : -1) * (Math.random() * 30 + 10);
|
||||
const cambioCantidadPorcentaje = -elasticidad * cambioPrecioPorcentaje;
|
||||
|
||||
const precioActual = Math.round(precioInicial * (1 + cambioPrecioPorcentaje / 100) * 100) / 100;
|
||||
const cantidadActual = Math.round(cantidadInicial * (1 + cambioCantidadPorcentaje / 100));
|
||||
|
||||
const descripciones: Record<string, string> = {
|
||||
'Medicamentos esenciales': 'Bien de necesidad sin sustitutos. La demanda es extremadamente inelástica.',
|
||||
'Gasolina': 'Necesidad a corto plazo con pocos sustitutos inmediatos.',
|
||||
'Pan de caja': 'Bien básico con algunos sustitutos (pan artesanal, tortillas).',
|
||||
'Leche': 'Necesidad básica aunque existen sustitutos (leche de almendra, soya).',
|
||||
'Cereal de marca': 'Bien con muchos sustitutos de otras marcas.',
|
||||
'Entradas de cine': 'Entretenimiento discrecional con alternativas (streaming).',
|
||||
'Restaurantes de lujo': 'Bien de lujo altamente discrecional.',
|
||||
'Viajes internacionales': 'Lujo con muchas alternativas de entretenimiento.',
|
||||
'Yates': 'Bien de super lujo, demanda muy elástica.',
|
||||
'Diamantes': 'Bien de lujo con demanda altamente sensible al precio.'
|
||||
};
|
||||
|
||||
return {
|
||||
id: Date.now(),
|
||||
nombre,
|
||||
elasticidad,
|
||||
precioInicial: Math.max(1, precioInicial),
|
||||
cantidadInicial: Math.max(10, cantidadInicial),
|
||||
precioActual: Math.max(1, precioActual),
|
||||
cantidadActual: Math.max(10, cantidadActual),
|
||||
descripcion: descripciones[nombre] || 'Producto con características estándar.'
|
||||
};
|
||||
};
|
||||
|
||||
export const ElasticidadIngresoTotal: React.FC = () => {
|
||||
const [escenario, setEscenario] = useState<ProductoEscenario>(generarEscenario());
|
||||
const [decision, setDecision] = useState<'subir' | 'bajar' | null>(null);
|
||||
const [resultado, setResultado] = useState<{
|
||||
correcto: boolean;
|
||||
mensaje: string;
|
||||
ingresoInicial: number;
|
||||
ingresoNuevo: number;
|
||||
mostrarAnalisis: boolean;
|
||||
} | null>(null);
|
||||
const [puntuacion, setPuntuacion] = useState<number>(0);
|
||||
const [intentos, setIntentos] = useState<number>(0);
|
||||
|
||||
const ingresoInicial = escenario.precioInicial * escenario.cantidadInicial;
|
||||
const ingresoActual = escenario.precioActual * escenario.cantidadActual;
|
||||
const cambioPrecio = ((escenario.precioActual - escenario.precioInicial) / escenario.precioInicial) * 100;
|
||||
const elasticidadCalculada = Math.abs(
|
||||
((escenario.cantidadActual - escenario.cantidadInicial) / ((escenario.cantidadInicial + escenario.cantidadActual) / 2)) /
|
||||
((escenario.precioActual - escenario.precioInicial) / ((escenario.precioInicial + escenario.precioActual) / 2))
|
||||
);
|
||||
|
||||
const verificarDecision = (dec: 'subir' | 'bajar') => {
|
||||
if (resultado) return;
|
||||
|
||||
setDecision(dec);
|
||||
setIntentos(prev => prev + 1);
|
||||
|
||||
const demandaElastica = elasticidadCalculada > 1;
|
||||
const precioSubio = cambioPrecio > 0;
|
||||
|
||||
let correcto = false;
|
||||
let mensaje = '';
|
||||
|
||||
if (demandaElastica) {
|
||||
// Demanda elástica: subir precio reduce ingreso, bajar precio aumenta ingreso
|
||||
if (dec === 'subir') {
|
||||
correcto = false;
|
||||
mensaje = 'Incorrecto. Con demanda elástica, subir el precio reduce el ingreso total porque la cantidad cae proporcionalmente más.';
|
||||
} else {
|
||||
correcto = true;
|
||||
mensaje = '¡Correcto! Con demanda elástica, bajar el precio aumenta el ingreso total porque la cantidad vendida aumenta proporcionalmente más.';
|
||||
setPuntuacion(prev => prev + 1);
|
||||
}
|
||||
} else if (elasticidadCalculada < 1) {
|
||||
// Demanda inelástica: subir precio aumenta ingreso, bajar precio reduce ingreso
|
||||
if (dec === 'subir') {
|
||||
correcto = true;
|
||||
mensaje = '¡Correcto! Con demanda inelástica, subir el precio aumenta el ingreso total porque la cantidad cae proporcionalmente menos.';
|
||||
setPuntuacion(prev => prev + 1);
|
||||
} else {
|
||||
correcto = false;
|
||||
mensaje = 'Incorrecto. Con demanda inelástica, bajar el precio reduce el ingreso total porque la cantidad no aumenta lo suficiente para compensar.';
|
||||
}
|
||||
} else {
|
||||
// Demanda unitaria
|
||||
correcto = true;
|
||||
mensaje = 'La demanda es unitaria, por lo que cualquier cambio de precio mantendrá el ingreso constante. Ambas opciones son igualmente válidas.';
|
||||
setPuntuacion(prev => prev + 1);
|
||||
}
|
||||
|
||||
setResultado({
|
||||
correcto,
|
||||
mensaje,
|
||||
ingresoInicial,
|
||||
ingresoNuevo: ingresoActual,
|
||||
mostrarAnalisis: true
|
||||
});
|
||||
};
|
||||
|
||||
const generarNuevoEscenario = () => {
|
||||
setEscenario(generarEscenario());
|
||||
setDecision(null);
|
||||
setResultado(null);
|
||||
};
|
||||
|
||||
const formatearDinero = (cantidad: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
minimumFractionDigits: 2
|
||||
}).format(cantidad);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 bg-white rounded-lg shadow-md">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-blue-800">Elasticidad e Ingreso Total</h2>
|
||||
<p className="text-gray-600">Maximiza el ingreso total tomando la decisión correcta sobre precios.</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="bg-blue-50 px-4 py-2 rounded-lg">
|
||||
<p className="text-sm text-gray-600">Puntuación</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{puntuacion}/{intentos}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 p-6 rounded-xl border border-indigo-200 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-indigo-500 rounded-xl flex items-center justify-center text-white flex-shrink-0">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-1">{escenario.nombre}</h3>
|
||||
<p className="text-gray-600 mb-4">{escenario.descripcion}</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-white p-3 rounded-lg shadow-sm text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Precio Inicial</p>
|
||||
<p className="text-lg font-bold text-blue-600">{formatearDinero(escenario.precioInicial)}</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg shadow-sm text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Cantidad Inicial</p>
|
||||
<p className="text-lg font-bold text-blue-600">{escenario.cantidadInicial.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg shadow-sm text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Precio Actual</p>
|
||||
<p className={`text-lg font-bold ${cambioPrecio > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{formatearDinero(escenario.precioActual)}
|
||||
</p>
|
||||
<p className={`text-xs ${cambioPrecio > 0 ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{cambioPrecio > 0 ? '+' : ''}{cambioPrecio.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg shadow-sm text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Cantidad Actual</p>
|
||||
<p className={`text-lg font-bold ${escenario.cantidadActual > escenario.cantidadInicial ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{escenario.cantidadActual.toLocaleString()}
|
||||
</p>
|
||||
<p className={`text-xs ${escenario.cantidadActual > escenario.cantidadInicial ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{((escenario.cantidadActual - escenario.cantidadInicial) / escenario.cantidadInicial * 100) > 0 ? '+' : ''}
|
||||
{((escenario.cantidadActual - escenario.cantidadInicial) / escenario.cantidadInicial * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 p-5 rounded-xl border border-green-200">
|
||||
<h4 className="font-bold text-green-800 mb-2 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Ingreso Inicial
|
||||
</h4>
|
||||
<p className="text-3xl font-bold text-green-700">{formatearDinero(ingresoInicial)}</p>
|
||||
<p className="text-sm text-green-600 mt-1">
|
||||
{escenario.precioInicial} × {escenario.cantidadInicial.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-5 rounded-xl border border-blue-200">
|
||||
<h4 className="font-bold text-blue-800 mb-2 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Ingreso Actual
|
||||
</h4>
|
||||
<p className="text-3xl font-bold text-blue-700">{formatearDinero(ingresoActual)}</p>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
{escenario.precioActual} × {escenario.cantidadActual.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`bg-gradient-to-br p-5 rounded-xl border ${
|
||||
ingresoActual > ingresoInicial
|
||||
? 'from-green-50 to-emerald-50 border-green-200'
|
||||
: 'from-red-50 to-pink-50 border-red-200'
|
||||
}`}>
|
||||
<h4 className={`font-bold mb-2 flex items-center gap-2 ${
|
||||
ingresoActual > ingresoInicial ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
Cambio en Ingreso
|
||||
</h4>
|
||||
<p className={`text-3xl font-bold ${ingresoActual > ingresoInicial ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{ingresoActual > ingresoInicial ? '+' : ''}
|
||||
{formatearDinero(ingresoActual - ingresoInicial)}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${ingresoActual > ingresoInicial ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{((ingresoActual - ingresoInicial) / ingresoInicial * 100) > 0 ? '+' : ''}
|
||||
{((ingresoActual - ingresoInicial) / ingresoInicial * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 p-5 rounded-xl border border-amber-200 mb-6">
|
||||
<h4 className="font-bold text-amber-800 mb-3 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Tu Decisión
|
||||
</h4>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Basándote en los datos anteriores, ¿qué decisión de precio maximizaría el ingreso total?
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => verificarDecision('subir')}
|
||||
disabled={!!resultado}
|
||||
className={`p-5 rounded-xl border-2 text-left transition-all ${
|
||||
decision === 'subir'
|
||||
? elasticidadCalculada < 1
|
||||
? 'bg-green-100 border-green-500 ring-2 ring-green-300'
|
||||
: 'bg-red-100 border-red-500 ring-2 ring-red-300'
|
||||
: 'bg-white border-gray-200 hover:border-red-300 hover:bg-red-50'
|
||||
} ${!!resultado && decision !== 'subir' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-lg">Subir Precio</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Aumentar el precio actual</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => verificarDecision('bajar')}
|
||||
disabled={!!resultado}
|
||||
className={`p-5 rounded-xl border-2 text-left transition-all ${
|
||||
decision === 'bajar'
|
||||
? elasticidadCalculada > 1
|
||||
? 'bg-green-100 border-green-500 ring-2 ring-green-300'
|
||||
: 'bg-red-100 border-red-500 ring-2 ring-red-300'
|
||||
: 'bg-white border-gray-200 hover:border-green-300 hover:bg-green-50'
|
||||
} ${!!resultado && decision !== 'bajar' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-lg">Bajar Precio</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Reducir el precio actual</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resultado && (
|
||||
<div className={`p-6 rounded-xl border-2 mb-6 ${resultado.correcto ? 'bg-green-50 border-green-300' : 'bg-red-50 border-red-300'}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-full ${resultado.correcto ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
{resultado.correcto ? (
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-bold text-lg ${resultado.correcto ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{resultado.correcto ? '¡Decisión Correcta!' : 'Decisión Incorrecta'}
|
||||
</h4>
|
||||
<p className={`mt-1 ${resultado.correcto ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{resultado.mensaje}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<span className="text-gray-600">Elasticidad calculada:</span>
|
||||
<span className="ml-2 font-bold text-indigo-600 text-lg">{elasticidadCalculada.toFixed(2)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Clasificación:</span>
|
||||
<span className={`ml-2 font-bold ${
|
||||
elasticidadCalculada > 1 ? 'text-green-600' :
|
||||
elasticidadCalculada < 1 ? 'text-amber-600' : 'text-blue-600'
|
||||
}`}>
|
||||
{elasticidadCalculada > 1 ? 'Elástica' : elasticidadCalculada < 1 ? 'Inelástica' : 'Unitaria'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded-lg">
|
||||
<p className="font-medium text-gray-800 mb-2">Análisis del Ingreso Total:</p>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Ingreso inicial: {formatearDinero(resultado.ingresoInicial)}</li>
|
||||
<li>• Ingreso después del cambio: {formatearDinero(resultado.ingresoNuevo)}</li>
|
||||
<li>• Diferencia: {formatearDinero(resultado.ingresoNuevo - resultado.ingresoInicial)}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultado && (
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={generarNuevoEscenario}
|
||||
className="flex-1 bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Nuevo Escenario
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 bg-gradient-to-r from-indigo-50 to-blue-50 p-5 rounded-xl border border-indigo-200">
|
||||
<h4 className="font-bold text-indigo-800 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Regla para Maximizar Ingreso Total
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border border-green-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-bold">E<sub>d</sub> > 1</span>
|
||||
<span className="font-bold text-gray-800">Demanda Elástica</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">Para maximizar ingreso:</p>
|
||||
<p className="text-green-700 font-semibold flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
BAJAR el precio
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
La cantidad aumenta más que proporcionalmente al precio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-amber-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-sm font-bold">E<sub>d</sub> < 1</span>
|
||||
<span className="font-bold text-gray-800">Demanda Inelástica</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">Para maximizar ingreso:</p>
|
||||
<p className="text-amber-700 font-semibold flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
SUBIR el precio
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
La cantidad cae menos que proporcionalmente al precio.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 bg-blue-100 p-3 rounded-lg border border-blue-200 text-center">
|
||||
<p className="text-blue-800 font-medium">
|
||||
<span className="font-bold">E<sub>d</sub> = 1 (Unitaria):</span> El ingreso total ya está maximizado. Cualquier cambio de precio mantendrá el ingreso constante.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElasticidadIngresoTotal;
|
||||
426
frontend/src/components/exercises/modulo2/EquilibrioFinder.tsx
Normal file
426
frontend/src/components/exercises/modulo2/EquilibrioFinder.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Calculator, Check, X, Trophy, RotateCcw, ArrowRight, Lightbulb, Target } from 'lucide-react';
|
||||
|
||||
interface EquilibrioFinderProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Problema {
|
||||
id: number;
|
||||
demanda: { a: number; b: number };
|
||||
oferta: { c: number; d: number };
|
||||
producto: string;
|
||||
dificultad: 'facil' | 'medio' | 'dificil';
|
||||
}
|
||||
|
||||
const problemas: Problema[] = [
|
||||
{
|
||||
id: 1,
|
||||
demanda: { a: 100, b: -2 },
|
||||
oferta: { c: 10, d: 3 },
|
||||
producto: 'Manzanas',
|
||||
dificultad: 'facil'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
demanda: { a: 80, b: -1.5 },
|
||||
oferta: { c: 20, d: 2 },
|
||||
producto: 'Camisetas',
|
||||
dificultad: 'facil'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
demanda: { a: 120, b: -0.8 },
|
||||
oferta: { c: 30, d: 1.2 },
|
||||
producto: 'Entradas de cine',
|
||||
dificultad: 'medio'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
demanda: { a: 200, b: -4 },
|
||||
oferta: { c: 50, d: 2.5 },
|
||||
producto: 'Bicicletas',
|
||||
dificultad: 'medio'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
demanda: { a: 150, b: -1.2 },
|
||||
oferta: { c: 25, d: 0.8 },
|
||||
producto: 'Consultas médicas',
|
||||
dificultad: 'dificil'
|
||||
}
|
||||
];
|
||||
|
||||
export const EquilibrioFinder: React.FC<EquilibrioFinderProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [problemaActual, setProblemaActual] = useState(0);
|
||||
const [respuestaPrecio, setRespuestaPrecio] = useState('');
|
||||
const [respuestaCantidad, setRespuestaCantidad] = useState('');
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [esCorrecto, setEsCorrecto] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [mostrarAyuda, setMostrarAyuda] = useState(false);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [_startTime] = useState(Date.now());
|
||||
|
||||
const problema = problemas[problemaActual];
|
||||
|
||||
const calcularEquilibrio = (problema: Problema) => {
|
||||
const { a, b } = problema.demanda;
|
||||
const { c, d } = problema.oferta;
|
||||
const Q = (c - a) / (b - d);
|
||||
const P = a + b * Q;
|
||||
return { Q: Math.round(Q * 10) / 10, P: Math.round(P * 10) / 10 };
|
||||
};
|
||||
|
||||
const equilibrio = calcularEquilibrio(problema);
|
||||
|
||||
const handleVerificar = () => {
|
||||
const precioIngresado = parseFloat(respuestaPrecio);
|
||||
const cantidadIngresada = parseFloat(respuestaCantidad);
|
||||
|
||||
if (isNaN(precioIngresado) || isNaN(cantidadIngresada)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const margenError = 0.5;
|
||||
const precioCorrecto = Math.abs(precioIngresado - equilibrio.P) <= margenError;
|
||||
const cantidadCorrecta = Math.abs(cantidadIngresada - equilibrio.Q) <= margenError;
|
||||
|
||||
const correcto = precioCorrecto && cantidadCorrecta;
|
||||
setEsCorrecto(correcto);
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (correcto) {
|
||||
setScore(prev => prev + Math.round(100 / problemas.length));
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (problemaActual < problemas.length - 1) {
|
||||
setProblemaActual(prev => prev + 1);
|
||||
setRespuestaPrecio('');
|
||||
setRespuestaCantidad('');
|
||||
setMostrarResultado(false);
|
||||
setMostrarAyuda(false);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setProblemaActual(0);
|
||||
setRespuestaPrecio('');
|
||||
setRespuestaCantidad('');
|
||||
setMostrarResultado(false);
|
||||
setScore(0);
|
||||
setRespuestasCorrectas(0);
|
||||
setMostrarAyuda(false);
|
||||
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 generarPuntosCurva = (tipo: 'demanda' | 'oferta') => {
|
||||
const puntos = [];
|
||||
for (let Q = 0; Q <= 50; Q += 5) {
|
||||
if (tipo === 'demanda') {
|
||||
const P = problema.demanda.a + problema.demanda.b * Q;
|
||||
if (P >= 0) puntos.push({ Q, P });
|
||||
} else {
|
||||
const P = problema.oferta.c + problema.oferta.d * Q;
|
||||
if (P >= 0) puntos.push({ Q, P });
|
||||
}
|
||||
}
|
||||
return puntos;
|
||||
};
|
||||
|
||||
const scaleX = (Q: number) => 50 + (Q / 50) * 300;
|
||||
const scaleY = (P: number) => 250 - (P / 150) * 200;
|
||||
|
||||
const puntosDemanda = generarPuntosCurva('demanda');
|
||||
const puntosOferta = generarPuntosCurva('oferta');
|
||||
|
||||
const demandaPath = puntosDemanda.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.P)}`
|
||||
).join(' ');
|
||||
|
||||
const ofertaPath = puntosOferta.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.P)}`
|
||||
).join(' ');
|
||||
|
||||
if (completado) {
|
||||
const porcentaje = Math.round((respuestasCorrectas / problemas.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 encontrado los puntos de equilibrio</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 {problemas.length} problemas resueltos correctamente
|
||||
</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-5xl 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">
|
||||
<Target className="w-8 h-8 text-purple-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Buscador de Equilibrio</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getDificultadColor(problema.dificultad)}`}>
|
||||
{problema.dificultad.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{problemaActual + 1} de {problemas.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: `${((problemaActual + 1) / problemas.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Calcula el precio y cantidad de equilibrio donde Qd = Qo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-4 text-center">Gráfico de Mercado: {problema.producto}</h3>
|
||||
|
||||
<svg width="400" height="280" className="mx-auto">
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line x1={50 + i * 60} y1="30" x2={50 + i * 60} y2="250" stroke="#e5e7eb" strokeWidth="1" />
|
||||
<line x1="50" y1={30 + i * 44} x2="350" y2={30 + i * 44} stroke="#e5e7eb" strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1="50" y1="250" x2="350" y2="250" stroke="#374151" strokeWidth="2" />
|
||||
<line x1="50" y1="30" x2="50" y2="250" stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Labels */}
|
||||
<text x="200" y="275" textAnchor="middle" className="text-sm fill-gray-600">Cantidad (Q)</text>
|
||||
<text x="20" y="140" textAnchor="middle" transform="rotate(-90, 20, 140)" className="text-sm fill-gray-600">Precio (P)</text>
|
||||
|
||||
{/* Curva de Demanda */}
|
||||
{demandaPath && (
|
||||
<g>
|
||||
<path d={demandaPath} fill="none" stroke="#3b82f6" strokeWidth="3" />
|
||||
<text x="330" y={scaleY(20)} className="text-sm fill-blue-600 font-medium">D</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Curva de Oferta */}
|
||||
{ofertaPath && (
|
||||
<g>
|
||||
<path d={ofertaPath} fill="none" stroke="#22c55e" strokeWidth="3" />
|
||||
<text x="330" y={scaleY(130)} className="text-sm fill-green-600 font-medium">S</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Punto de equilibrio (mostrar si ya respondió correctamente) */}
|
||||
{mostrarResultado && esCorrecto && (
|
||||
<motion.g
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
>
|
||||
<circle
|
||||
cx={scaleX(equilibrio.Q)}
|
||||
cy={scaleY(equilibrio.P)}
|
||||
r="8"
|
||||
fill="#8b5cf6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={scaleX(equilibrio.Q) + 12} y={scaleY(equilibrio.P)} className="text-xs fill-purple-600 font-bold">
|
||||
E
|
||||
</text>
|
||||
<line x1="50" y1={scaleY(equilibrio.P)} x2={scaleX(equilibrio.Q)} y2={scaleY(equilibrio.P)} stroke="#8b5cf6" strokeWidth="1" strokeDasharray="3,3" />
|
||||
<line x1={scaleX(equilibrio.Q)} y1={scaleY(equilibrio.P)} x2={scaleX(equilibrio.Q)} y2="250" stroke="#8b5cf6" strokeWidth="1" strokeDasharray="3,3" />
|
||||
</motion.g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<div className="mt-4 p-3 bg-white rounded-lg">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Ecuaciones:</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-600 font-medium">Qd =</span>
|
||||
<span className="text-gray-700">{problema.demanda.a} {problema.demanda.b > 0 ? '+' : ''}{problema.demanda.b}P</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-600 font-medium">Qo =</span>
|
||||
<span className="text-gray-700">{problema.oferta.c > 0 ? '' : '-'}{problema.oferta.c} {problema.oferta.d > 0 ? '+' : ''}{problema.oferta.d}P</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<Calculator className="w-5 h-5 text-purple-600" />
|
||||
Encuentra el Equilibrio
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Precio de Equilibrio (P*)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={respuestaPrecio}
|
||||
onChange={(e) => setRespuestaPrecio(e.target.value)}
|
||||
disabled={mostrarResultado}
|
||||
placeholder="Ej: 45.5"
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-lg focus:border-purple-500 focus:outline-none disabled:bg-gray-100"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Cantidad de Equilibrio (Q*)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={respuestaCantidad}
|
||||
onChange={(e) => setRespuestaCantidad(e.target.value)}
|
||||
disabled={mostrarResultado}
|
||||
placeholder="Ej: 25.3"
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-lg focus:border-purple-500 focus:outline-none disabled:bg-gray-100"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setMostrarAyuda(!mostrarAyuda)}
|
||||
className="mt-4 flex items-center gap-2 text-purple-600 hover:text-purple-700 text-sm"
|
||||
>
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
{mostrarAyuda ? 'Ocultar ayuda' : 'Mostrar ayuda'}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarAyuda && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg"
|
||||
>
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Tip:</strong> En equilibrio, Qd = Qo. Iguala las dos ecuaciones y despeja P.
|
||||
Luego sustituye P en cualquier ecuación para encontrar Q.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarResultado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-4 rounded-lg border ${esCorrecto ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{esCorrecto ? (
|
||||
<Check className="w-6 h-6 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="w-6 h-6 text-red-600 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${esCorrecto ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecto ? '¡Correcto!' : 'Incorrecto'}
|
||||
</p>
|
||||
{!esCorrecto && (
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
<p>La respuesta correcta es:</p>
|
||||
<p className="font-medium">P* = ${equilibrio.P}</p>
|
||||
<p className="font-medium">Q* = {equilibrio.Q} unidades</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!mostrarResultado ? (
|
||||
<button
|
||||
onClick={handleVerificar}
|
||||
disabled={!respuestaPrecio || !respuestaCantidad}
|
||||
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"
|
||||
>
|
||||
{problemaActual < problemas.length - 1 ? (
|
||||
<>
|
||||
Siguiente
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
) : (
|
||||
'Finalizar'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EquilibrioFinder;
|
||||
543
frontend/src/components/exercises/modulo2/EquilibrioGrafico.tsx
Normal file
543
frontend/src/components/exercises/modulo2/EquilibrioGrafico.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { LineChart, Check, X, RotateCcw, Trophy, Info, MousePointer2 } from 'lucide-react';
|
||||
|
||||
interface EquilibrioGraficoProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Punto {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const GRID_SIZE = 350;
|
||||
const PADDING = 50;
|
||||
const MAX_PRECIO = 100;
|
||||
const MAX_CANTIDAD = 100;
|
||||
|
||||
export const EquilibrioGrafico: React.FC<EquilibrioGraficoProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [demandaPoints, setDemandaPoints] = useState<Punto[]>([]);
|
||||
const [ofertaPoints, setOfertaPoints] = useState<Punto[]>([]);
|
||||
const [modoActivo, setModoActivo] = useState<'demanda' | 'oferta'>('demanda');
|
||||
const [equilibrioEncontrado, setEquilibrioEncontrado] = useState(false);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [mensaje, setMensaje] = useState('');
|
||||
const [showTutorial, setShowTutorial] = useState(true);
|
||||
const [_startTime] = useState(Date.now());
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
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 (equilibrioEncontrado) 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 newPoint: Punto = { x: cartesian.x, y: cartesian.y };
|
||||
|
||||
if (modoActivo === 'demanda') {
|
||||
if (demandaPoints.length >= 2) {
|
||||
setDemandaPoints([newPoint]);
|
||||
} else {
|
||||
setDemandaPoints(prev => [...prev, newPoint]);
|
||||
}
|
||||
} else {
|
||||
if (ofertaPoints.length >= 2) {
|
||||
setOfertaPoints([newPoint]);
|
||||
} else {
|
||||
setOfertaPoints(prev => [...prev, newPoint]);
|
||||
}
|
||||
}
|
||||
setMensaje('');
|
||||
};
|
||||
|
||||
const calcularInterseccion = () => {
|
||||
if (demandaPoints.length < 2 || ofertaPoints.length < 2) return null;
|
||||
|
||||
const d1 = demandaPoints[0];
|
||||
const d2 = demandaPoints[1];
|
||||
const s1 = ofertaPoints[0];
|
||||
const s2 = ofertaPoints[1];
|
||||
|
||||
const m1 = (d2.y - d1.y) / (d2.x - d1.x);
|
||||
const m2 = (s2.y - s1.y) / (s2.x - s1.x);
|
||||
|
||||
if (Math.abs(m1 - m2) < 0.01) return null;
|
||||
|
||||
const b1 = d1.y - m1 * d1.x;
|
||||
const b2 = s1.y - m2 * s1.x;
|
||||
|
||||
const x = (b2 - b1) / (m1 - m2);
|
||||
const y = m1 * x + b1;
|
||||
|
||||
return { x: Math.round(x), y: Math.round(y) };
|
||||
};
|
||||
|
||||
const validarEquilibrio = () => {
|
||||
if (demandaPoints.length < 2) {
|
||||
setMensaje('Necesitas 2 puntos para trazar la curva de demanda');
|
||||
return;
|
||||
}
|
||||
if (ofertaPoints.length < 2) {
|
||||
setMensaje('Necesitas 2 puntos para trazar la curva de oferta');
|
||||
return;
|
||||
}
|
||||
|
||||
const d1 = demandaPoints[0];
|
||||
const d2 = demandaPoints[1];
|
||||
const s1 = ofertaPoints[0];
|
||||
const s2 = ofertaPoints[1];
|
||||
|
||||
const pendienteDemanda = (d2.y - d1.y) / (d2.x - d1.x);
|
||||
const pendienteOferta = (s2.y - s1.y) / (s2.x - s1.x);
|
||||
|
||||
if (pendienteDemanda >= 0) {
|
||||
setMensaje('La demanda debe tener pendiente negativa (bajar de izquierda a derecha)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendienteOferta <= 0) {
|
||||
setMensaje('La oferta debe tener pendiente positiva (subir de izquierda a derecha)');
|
||||
return;
|
||||
}
|
||||
|
||||
const interseccion = calcularInterseccion();
|
||||
if (!interseccion) {
|
||||
setMensaje('Las curvas no se intersectan dentro del rango válido');
|
||||
return;
|
||||
}
|
||||
|
||||
setEquilibrioEncontrado(true);
|
||||
setMostrarResultado(true);
|
||||
setScore(100);
|
||||
setMensaje('');
|
||||
|
||||
setTimeout(() => {
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setDemandaPoints([]);
|
||||
setOfertaPoints([]);
|
||||
setModoActivo('demanda');
|
||||
setEquilibrioEncontrado(false);
|
||||
setMostrarResultado(false);
|
||||
setScore(0);
|
||||
setMensaje('');
|
||||
setShowTutorial(true);
|
||||
};
|
||||
|
||||
const renderLineaCurva = (puntos: Punto[], color: string, esPunteada: boolean = false) => {
|
||||
if (puntos.length < 2) return null;
|
||||
|
||||
const sorted = [...puntos].sort((a, b) => a.x - b.x);
|
||||
const start = sorted[0];
|
||||
const end = sorted[sorted.length - 1];
|
||||
|
||||
const startSvg = cartesianToSvg(start.x, start.y);
|
||||
const endSvg = cartesianToSvg(end.x, end.y);
|
||||
|
||||
const m = (end.y - start.y) / (end.x - start.x);
|
||||
const b = start.y - m * start.x;
|
||||
|
||||
const yAtX0 = b;
|
||||
const yAtXMax = m * MAX_CANTIDAD + b;
|
||||
|
||||
const p0 = cartesianToSvg(0, Math.max(0, Math.min(MAX_PRECIO, yAtX0)));
|
||||
const pMax = cartesianToSvg(MAX_CANTIDAD, Math.max(0, Math.min(MAX_PRECIO, yAtXMax)));
|
||||
|
||||
return (
|
||||
<line
|
||||
x1={p0.x}
|
||||
y1={p0.y}
|
||||
x2={pMax.x}
|
||||
y2={pMax.y}
|
||||
stroke={color}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={esPunteada ? "5,5" : "0"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const interseccion = calcularInterseccion();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl 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-purple-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Gráfico de Equilibrio</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">Encuentra la intersección</span>
|
||||
<button
|
||||
onClick={reiniciar}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Traza las curvas de demanda y oferta para encontrar el punto de equilibrio donde se cruzan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showTutorial && (
|
||||
<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 jugar:</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• Selecciona el modo (Demanda u Oferta) con los botones</li>
|
||||
<li>• Haz clic en el gráfico para colocar 2 puntos de cada curva</li>
|
||||
<li>• La demanda debe tener pendiente negativa (baja)</li>
|
||||
<li>• La oferta debe tener pendiente positiva (sube)</li>
|
||||
<li>• Presiona "Validar Equilibrio" cuando estén ambas curvas</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowTutorial(false)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={GRID_SIZE + 2 * PADDING}
|
||||
height={GRID_SIZE + 2 * PADDING}
|
||||
className="w-full border-2 border-gray-300 rounded-lg bg-white cursor-crosshair"
|
||||
onClick={handleSvgClick}
|
||||
>
|
||||
{/* 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 + 30} textAnchor="middle" className="text-sm fill-gray-600">
|
||||
Cantidad (Q)
|
||||
</text>
|
||||
<text x={20} y={PADDING + GRID_SIZE / 2} textAnchor="middle" transform={`rotate(-90, 20, ${PADDING + GRID_SIZE / 2})`} className="text-sm fill-gray-600">
|
||||
Precio (P)
|
||||
</text>
|
||||
|
||||
{/* Curvas */}
|
||||
{renderLineaCurva(demandaPoints, '#3b82f6')}
|
||||
{renderLineaCurva(ofertaPoints, '#22c55e')}
|
||||
|
||||
{/* Labels de curvas */}
|
||||
{demandaPoints.length >= 2 && (
|
||||
<text x={PADDING + GRID_SIZE - 20} y={PADDING + 30} className="text-sm fill-blue-600 font-bold">D</text>
|
||||
)}
|
||||
{ofertaPoints.length >= 2 && (
|
||||
<text x={PADDING + GRID_SIZE - 20} y={PADDING + GRID_SIZE - 20} className="text-sm fill-green-600 font-bold">S</text>
|
||||
)}
|
||||
|
||||
{/* Puntos Demanda */}
|
||||
{demandaPoints.map((punto, index) => {
|
||||
const svg = cartesianToSvg(punto.x, punto.y);
|
||||
return (
|
||||
<motion.g
|
||||
key={`demanda-${index}`}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
>
|
||||
<circle
|
||||
cx={svg.x}
|
||||
cy={svg.y}
|
||||
r="8"
|
||||
fill="#3b82f6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={svg.x} y={svg.y - 12} textAnchor="middle" className="text-xs fill-blue-600 font-medium">
|
||||
D{index + 1}
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Puntos Oferta */}
|
||||
{ofertaPoints.map((punto, index) => {
|
||||
const svg = cartesianToSvg(punto.x, punto.y);
|
||||
return (
|
||||
<motion.g
|
||||
key={`oferta-${index}`}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
>
|
||||
<circle
|
||||
cx={svg.x}
|
||||
cy={svg.y}
|
||||
r="8"
|
||||
fill="#22c55e"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={svg.x} y={svg.y - 12} textAnchor="middle" className="text-xs fill-green-600 font-medium">
|
||||
S{index + 1}
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Punto de Equilibrio */}
|
||||
{equilibrioEncontrado && interseccion && (
|
||||
<motion.g
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, delay: 0.3 }}
|
||||
>
|
||||
<circle
|
||||
cx={cartesianToSvg(interseccion.x, interseccion.y).x}
|
||||
cy={cartesianToSvg(interseccion.x, interseccion.y).y}
|
||||
r="12"
|
||||
fill="#8b5cf6"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<text
|
||||
x={cartesianToSvg(interseccion.x, interseccion.y).x + 15}
|
||||
y={cartesianToSvg(interseccion.x, interseccion.y).y - 10}
|
||||
className="text-sm fill-purple-600 font-bold"
|
||||
>
|
||||
E ({interseccion.x}, {interseccion.y})
|
||||
</text>
|
||||
|
||||
{/* Líneas guía */}
|
||||
<line
|
||||
x1={PADDING}
|
||||
y1={cartesianToSvg(interseccion.x, interseccion.y).y}
|
||||
x2={cartesianToSvg(interseccion.x, interseccion.y).x}
|
||||
y2={cartesianToSvg(interseccion.x, interseccion.y).y}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4,4"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<line
|
||||
x1={cartesianToSvg(interseccion.x, interseccion.y).x}
|
||||
y1={cartesianToSvg(interseccion.x, interseccion.y).y}
|
||||
x2={cartesianToSvg(interseccion.x, interseccion.y).x}
|
||||
y2={PADDING + GRID_SIZE}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4,4"
|
||||
opacity="0.5"
|
||||
/>
|
||||
</motion.g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 p-3 bg-gray-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setModoActivo('demanda')}
|
||||
disabled={equilibrioEncontrado}
|
||||
className={`flex-1 py-3 px-3 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
|
||||
modoActivo === 'demanda'
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-3 h-3 bg-blue-400 rounded-full" />
|
||||
Demanda
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModoActivo('oferta')}
|
||||
disabled={equilibrioEncontrado}
|
||||
className={`flex-1 py-3 px-3 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
|
||||
modoActivo === 'oferta'
|
||||
? 'bg-green-600 text-white shadow-lg'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-3 h-3 bg-green-400 rounded-full" />
|
||||
Oferta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<MousePointer2 className="w-4 h-4" />
|
||||
Estado
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Puntos Demanda:</span>
|
||||
<span className={`text-sm font-medium ${demandaPoints.length >= 2 ? 'text-blue-600' : 'text-gray-400'}`}>
|
||||
{demandaPoints.length}/2
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-blue-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(demandaPoints.length / 2) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<span className="text-sm text-gray-600">Puntos Oferta:</span>
|
||||
<span className={`text-sm font-medium ${ofertaPoints.length >= 2 ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
{ofertaPoints.length}/2
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-green-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(ofertaPoints.length / 2) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{interseccion && !equilibrioEncontrado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-3 bg-purple-50 border border-purple-200 rounded-lg"
|
||||
>
|
||||
<p className="text-sm text-purple-800">
|
||||
<strong>Intersección detectada:</strong>
|
||||
</p>
|
||||
<p className="text-sm text-purple-700">
|
||||
Q* = {interseccion.x}, P* = {interseccion.y}
|
||||
</p>
|
||||
</motion.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>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={validarEquilibrio}
|
||||
disabled={equilibrioEncontrado}
|
||||
className="w-full 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 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
Validar Equilibrio
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarResultado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="p-4 bg-green-50 border border-green-200 rounded-lg text-center"
|
||||
>
|
||||
<Trophy className="w-12 h-12 text-green-500 mx-auto mb-2" />
|
||||
<p className="font-bold text-green-800 text-lg">¡Equilibrio Encontrado!</p>
|
||||
{interseccion && (
|
||||
<div className="mt-2 text-green-700">
|
||||
<p>Precio de equilibrio: <strong>${interseccion.y}</strong></p>
|
||||
<p>Cantidad de equilibrio: <strong>{interseccion.x} unidades</strong></p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Recuerda:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
|
||||
<li>• Demanda: pendiente negativa ↘️</li>
|
||||
<li>• Oferta: pendiente positiva ↗️</li>
|
||||
<li>• El equilibrio es donde se cruzan</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EquilibrioGrafico;
|
||||
@@ -0,0 +1,454 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, TrendingDown, ArrowRight, CheckCircle2, XCircle, Trophy, RotateCcw, BookOpen } from 'lucide-react';
|
||||
|
||||
interface ExcesoDemandaEscasezProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
producto: string;
|
||||
precioEquilibrio: number;
|
||||
cantidadEquilibrio: number;
|
||||
precioControl: number;
|
||||
demanda: { a: number; b: number };
|
||||
oferta: { c: number; d: number };
|
||||
contexto: string;
|
||||
dificultad: 'facil' | 'medio' | 'dificil';
|
||||
}
|
||||
|
||||
const escenarios: Escenario[] = [
|
||||
{
|
||||
id: 1,
|
||||
producto: 'Pan',
|
||||
precioEquilibrio: 50,
|
||||
cantidadEquilibrio: 100,
|
||||
precioControl: 30,
|
||||
demanda: { a: 150, b: -1 },
|
||||
oferta: { c: 0, d: 2 },
|
||||
contexto: 'El gobierno fija un precio máximo de $30 para el pan para proteger a los consumidores.',
|
||||
dificultad: 'facil'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
producto: 'Gasolina',
|
||||
precioEquilibrio: 80,
|
||||
cantidadEquilibrio: 60,
|
||||
precioControl: 50,
|
||||
demanda: { a: 140, b: -1 },
|
||||
oferta: { c: 20, d: 0.75 },
|
||||
contexto: 'Se impone un precio máximo de $50 por galón ante el alza de precios internacionales.',
|
||||
dificultad: 'medio'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
producto: 'Vivienda',
|
||||
precioEquilibrio: 1200,
|
||||
cantidadEquilibrio: 500,
|
||||
precioControl: 800,
|
||||
demanda: { a: 2200, b: -2 },
|
||||
oferta: { c: 200, d: 2 },
|
||||
contexto: 'Control de rentas fija el precio máximo en $800 para hacer la vivienda accesible.',
|
||||
dificultad: 'dificil'
|
||||
}
|
||||
];
|
||||
|
||||
export const ExcesoDemandaEscasez: React.FC<ExcesoDemandaEscasezProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [respuestaExceso, setRespuestaExceso] = useState('');
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [esCorrecto, setEsCorrecto] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [_startTime] = useState(Date.now());
|
||||
|
||||
const escenario = escenarios[escenarioActual];
|
||||
|
||||
const calcularCantidades = (precio: number, escenario: Escenario) => {
|
||||
const Qd = escenario.demanda.a + escenario.demanda.b * precio;
|
||||
const Qo = escenario.oferta.c + escenario.oferta.d * precio;
|
||||
return { Qd: Math.max(0, Qd), Qo: Math.max(0, Qo) };
|
||||
};
|
||||
|
||||
const equilibrio = calcularCantidades(escenario.precioEquilibrio, escenario);
|
||||
const conControl = calcularCantidades(escenario.precioControl, escenario);
|
||||
const excesoDemandaReal = conControl.Qd - conControl.Qo;
|
||||
|
||||
const handleVerificar = () => {
|
||||
const respuesta = parseFloat(respuestaExceso);
|
||||
if (isNaN(respuesta)) return;
|
||||
|
||||
const margenError = escenario.precioEquilibrio * 0.1;
|
||||
const correcto = Math.abs(respuesta - excesoDemandaReal) <= margenError;
|
||||
|
||||
setEsCorrecto(correcto);
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (correcto) {
|
||||
setScore(prev => prev + Math.round(100 / escenarios.length));
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (escenarioActual < escenarios.length - 1) {
|
||||
setEscenarioActual(prev => prev + 1);
|
||||
setRespuestaExceso('');
|
||||
setMostrarResultado(false);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setEscenarioActual(0);
|
||||
setRespuestaExceso('');
|
||||
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 generarPuntosCurva = (tipo: 'demanda' | 'oferta', escenario: Escenario) => {
|
||||
const puntos = [];
|
||||
const maxP = Math.max(escenario.precioEquilibrio * 1.5, escenario.precioControl * 1.2);
|
||||
for (let P = 0; P <= maxP; P += maxP / 20) {
|
||||
if (tipo === 'demanda') {
|
||||
const Q = escenario.demanda.a + escenario.demanda.b * P;
|
||||
if (Q >= 0) puntos.push({ Q, P });
|
||||
} else {
|
||||
const Q = escenario.oferta.c + escenario.oferta.d * P;
|
||||
if (Q >= 0) puntos.push({ Q, P });
|
||||
}
|
||||
}
|
||||
return puntos;
|
||||
};
|
||||
|
||||
const maxQ = Math.max(equilibrio.Qd, conControl.Qd) * 1.3;
|
||||
const maxP = escenario.precioEquilibrio * 1.5;
|
||||
|
||||
const scaleX = (Q: number) => 50 + (Q / maxQ) * 350;
|
||||
const scaleY = (P: number) => 300 - (P / maxP) * 250;
|
||||
|
||||
const puntosDemanda = generarPuntosCurva('demanda', escenario);
|
||||
const puntosOferta = generarPuntosCurva('oferta', escenario);
|
||||
|
||||
const demandaPath = puntosDemanda.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.P)}`
|
||||
).join(' ');
|
||||
|
||||
const ofertaPath = puntosOferta.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.P)}`
|
||||
).join(' ');
|
||||
|
||||
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 analizado escenarios de escasez</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-6 mb-6">
|
||||
<div className="text-5xl font-bold text-red-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-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
Intentar de nuevo
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl 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">
|
||||
<AlertTriangle className="w-8 h-8 text-red-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Exceso de Demanda (Escasez)</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-red-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((escenarioActual + 1) / escenarios.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Analiza qué sucede cuando el precio está por debajo del equilibrio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-2 text-center">Mercado de {escenario.producto}</h3>
|
||||
|
||||
<svg width="450" height="320" className="mx-auto">
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line x1={50 + i * 70} y1="30" x2={50 + i * 70} y2="300" stroke="#e5e7eb" strokeWidth="1" />
|
||||
<line x1="50" y1={30 + i * 54} x2="400" y2={30 + i * 54} stroke="#e5e7eb" strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1="50" y1="300" x2="400" y2="300" stroke="#374151" strokeWidth="2" />
|
||||
<line x1="50" y1="30" x2="50" y2="300" stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Labels */}
|
||||
<text x="225" y="320" textAnchor="middle" className="text-sm fill-gray-600">Cantidad (Q)</text>
|
||||
<text x="25" y="165" textAnchor="middle" transform="rotate(-90, 25, 165)" className="text-sm fill-gray-600">Precio (P)</text>
|
||||
|
||||
{/* Curva de Demanda */}
|
||||
<path d={demandaPath} fill="none" stroke="#3b82f6" strokeWidth="3" />
|
||||
<text x="380" y={scaleY(20)} className="text-sm fill-blue-600 font-medium">D</text>
|
||||
|
||||
{/* Curva de Oferta */}
|
||||
<path d={ofertaPath} fill="none" stroke="#22c55e" strokeWidth="3" />
|
||||
<text x="380" y={scaleY(maxP * 0.9)} className="text-sm fill-green-600 font-medium">S</text>
|
||||
|
||||
{/* Punto de equilibrio */}
|
||||
<circle
|
||||
cx={scaleX(equilibrio.Qd)}
|
||||
cy={scaleY(escenario.precioEquilibrio)}
|
||||
r="6"
|
||||
fill="#8b5cf6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={scaleX(equilibrio.Qd) + 10} y={scaleY(escenario.precioEquilibrio)} className="text-xs fill-purple-600">
|
||||
E
|
||||
</text>
|
||||
|
||||
{/* Línea de precio de control */}
|
||||
<line
|
||||
x1="50"
|
||||
y1={scaleY(escenario.precioControl)}
|
||||
x2="400"
|
||||
y2={scaleY(escenario.precioControl)}
|
||||
stroke="#ef4444"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<text x="410" y={scaleY(escenario.precioControl)} className="text-xs fill-red-500 font-medium">
|
||||
Pmax=${escenario.precioControl}
|
||||
</text>
|
||||
|
||||
{/* Zona de exceso de demanda */}
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: mostrarResultado && esCorrecto ? 1 : 0.3 }}
|
||||
>
|
||||
<rect
|
||||
x={scaleX(conControl.Qo)}
|
||||
y={scaleY(escenario.precioControl) - 5}
|
||||
width={scaleX(conControl.Qd) - scaleX(conControl.Qo)}
|
||||
height="10"
|
||||
fill="#fef3c7"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
opacity="0.7"
|
||||
/>
|
||||
<text
|
||||
x={scaleX((conControl.Qd + conControl.Qo) / 2)}
|
||||
y={scaleY(escenario.precioControl) - 15}
|
||||
textAnchor="middle"
|
||||
className="text-xs fill-amber-700 font-medium"
|
||||
>
|
||||
Escasez
|
||||
</text>
|
||||
</motion.g>
|
||||
|
||||
{/* Puntos de cantidad */}
|
||||
<circle
|
||||
cx={scaleX(conControl.Qd)}
|
||||
cy={scaleY(escenario.precioControl)}
|
||||
r="5"
|
||||
fill="#3b82f6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={scaleX(conControl.Qd)} y={scaleY(escenario.precioControl) + 20} textAnchor="middle" className="text-xs fill-blue-600">
|
||||
Qd
|
||||
</text>
|
||||
|
||||
<circle
|
||||
cx={scaleX(conControl.Qo)}
|
||||
cy={scaleY(escenario.precioControl)}
|
||||
r="5"
|
||||
fill="#22c55e"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={scaleX(conControl.Qo)} y={scaleY(escenario.precioControl) + 20} textAnchor="middle" className="text-xs fill-green-600">
|
||||
Qo
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="bg-white p-2 rounded border">
|
||||
<span className="text-purple-600 font-medium">Equilibrio:</span>
|
||||
<p className="text-gray-700">P=${escenario.precioEquilibrio}, Q={Math.round(equilibrio.Qd)}</p>
|
||||
</div>
|
||||
<div className="bg-red-50 p-2 rounded border border-red-200">
|
||||
<span className="text-red-600 font-medium">Con Pmax:</span>
|
||||
<p className="text-gray-700">Qd={Math.round(conControl.Qd)}, Qo={Math.round(conControl.Qo)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-red-50 to-orange-50 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<BookOpen className="w-6 h-6 text-red-600 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">Escenario</h3>
|
||||
<p className="text-gray-700">{escenario.contexto}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-2 border-red-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<TrendingDown className="w-5 h-5 text-red-600" />
|
||||
Calcula el Exceso de Demanda
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
¿Cuántas unidades de escasez se generan?
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={respuestaExceso}
|
||||
onChange={(e) => setRespuestaExceso(e.target.value)}
|
||||
disabled={mostrarResultado}
|
||||
placeholder="Ingresa el valor numérico"
|
||||
className="w-full px-4 py-3 border-2 border-red-200 rounded-lg focus:border-red-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Fórmula: Exceso de Demanda = Qd - Qo (al precio de control)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarResultado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-4 rounded-lg border ${esCorrecto ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{esCorrecto ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${esCorrecto ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecto ? '¡Correcto!' : 'Incorrecto'}
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
<p>Al precio de ${escenario.precioControl}:</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
<li>• Cantidad demandada: <strong>{Math.round(conControl.Qd)} unidades</strong></li>
|
||||
<li>• Cantidad ofrecida: <strong>{Math.round(conControl.Qo)} unidades</strong></li>
|
||||
<li>• Exceso de demanda: <strong>{Math.round(excesoDemandaReal)} unidades</strong></li>
|
||||
</ul>
|
||||
{!esCorrecto && (
|
||||
<p className="mt-2 text-red-700">
|
||||
La respuesta correcta es: <strong>{Math.round(excesoDemandaReal)} unidades</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!mostrarResultado ? (
|
||||
<button
|
||||
onClick={handleVerificar}
|
||||
disabled={!respuestaExceso}
|
||||
className="flex-1 py-3 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-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 className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Consecuencias del exceso de demanda:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-amber-700 mt-1 space-y-1">
|
||||
<li>• Largas filas y esperas</li>
|
||||
<li>• Racionamiento del producto</li>
|
||||
<li>• Mercados negros</li>
|
||||
<li>• Pérdida de peso muerto</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExcesoDemandaEscasez;
|
||||
@@ -0,0 +1,454 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Package, TrendingUp, ArrowRight, CheckCircle2, XCircle, Trophy, RotateCcw, BookOpen } from 'lucide-react';
|
||||
|
||||
interface ExcesoOfertaSuperavitProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
producto: string;
|
||||
precioEquilibrio: number;
|
||||
cantidadEquilibrio: number;
|
||||
precioMinimo: number;
|
||||
demanda: { a: number; b: number };
|
||||
oferta: { c: number; d: number };
|
||||
contexto: string;
|
||||
dificultad: 'facil' | 'medio' | 'dificil';
|
||||
}
|
||||
|
||||
const escenarios: Escenario[] = [
|
||||
{
|
||||
id: 1,
|
||||
producto: 'Leche',
|
||||
precioEquilibrio: 40,
|
||||
cantidadEquilibrio: 80,
|
||||
precioMinimo: 60,
|
||||
demanda: { a: 120, b: -1 },
|
||||
oferta: { c: 0, d: 2 },
|
||||
contexto: 'El gobierno establece un precio mínimo de $60 para proteger a los productores lecheros.',
|
||||
dificultad: 'facil'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
producto: 'Trigo',
|
||||
precioEquilibrio: 100,
|
||||
cantidadEquilibrio: 200,
|
||||
precioMinimo: 140,
|
||||
demanda: { a: 300, b: -1 },
|
||||
oferta: { c: -100, d: 2 },
|
||||
contexto: 'Se fija un precio de sustento de $140 para garantizar ingresos a los agricultores.',
|
||||
dificultad: 'medio'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
producto: 'Trabajo no calificado',
|
||||
precioEquilibrio: 50,
|
||||
cantidadEquilibrio: 1000,
|
||||
precioMinimo: 80,
|
||||
demanda: { a: 150, b: -0.1 },
|
||||
oferta: { c: 0, d: 20 },
|
||||
contexto: 'El salario mínimo se fija en $80, por encima del salario de equilibrio del mercado.',
|
||||
dificultad: 'dificil'
|
||||
}
|
||||
];
|
||||
|
||||
export const ExcesoOfertaSuperavit: React.FC<ExcesoOfertaSuperavitProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [respuestaExceso, setRespuestaExceso] = useState('');
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [esCorrecto, setEsCorrecto] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [_startTime] = useState(Date.now());
|
||||
|
||||
const escenario = escenarios[escenarioActual];
|
||||
|
||||
const calcularCantidades = (precio: number, escenario: Escenario) => {
|
||||
const Qd = escenario.demanda.a + escenario.demanda.b * precio;
|
||||
const Qo = escenario.oferta.c + escenario.oferta.d * precio;
|
||||
return { Qd: Math.max(0, Qd), Qo: Math.max(0, Qo) };
|
||||
};
|
||||
|
||||
const equilibrio = calcularCantidades(escenario.precioEquilibrio, escenario);
|
||||
const conMinimo = calcularCantidades(escenario.precioMinimo, escenario);
|
||||
const excesoOfertaReal = conMinimo.Qo - conMinimo.Qd;
|
||||
|
||||
const handleVerificar = () => {
|
||||
const respuesta = parseFloat(respuestaExceso);
|
||||
if (isNaN(respuesta)) return;
|
||||
|
||||
const margenError = escenario.precioEquilibrio * 0.15;
|
||||
const correcto = Math.abs(respuesta - excesoOfertaReal) <= margenError;
|
||||
|
||||
setEsCorrecto(correcto);
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (correcto) {
|
||||
setScore(prev => prev + Math.round(100 / escenarios.length));
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (escenarioActual < escenarios.length - 1) {
|
||||
setEscenarioActual(prev => prev + 1);
|
||||
setRespuestaExceso('');
|
||||
setMostrarResultado(false);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setEscenarioActual(0);
|
||||
setRespuestaExceso('');
|
||||
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 generarPuntosCurva = (tipo: 'demanda' | 'oferta', escenario: Escenario) => {
|
||||
const puntos = [];
|
||||
const maxP = Math.max(escenario.precioMinimo * 1.2, escenario.precioEquilibrio * 1.5);
|
||||
for (let P = 0; P <= maxP; P += maxP / 20) {
|
||||
if (tipo === 'demanda') {
|
||||
const Q = escenario.demanda.a + escenario.demanda.b * P;
|
||||
if (Q >= 0) puntos.push({ Q, P });
|
||||
} else {
|
||||
const Q = escenario.oferta.c + escenario.oferta.d * P;
|
||||
if (Q >= 0) puntos.push({ Q, P });
|
||||
}
|
||||
}
|
||||
return puntos;
|
||||
};
|
||||
|
||||
const maxQ = Math.max(conMinimo.Qo, equilibrio.Qd) * 1.3;
|
||||
const maxP = Math.max(escenario.precioMinimo, escenario.precioEquilibrio) * 1.3;
|
||||
|
||||
const scaleX = (Q: number) => 50 + (Q / maxQ) * 350;
|
||||
const scaleY = (P: number) => 300 - (P / maxP) * 250;
|
||||
|
||||
const puntosDemanda = generarPuntosCurva('demanda', escenario);
|
||||
const puntosOferta = generarPuntosCurva('oferta', escenario);
|
||||
|
||||
const demandaPath = puntosDemanda.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.P)}`
|
||||
).join(' ');
|
||||
|
||||
const ofertaPath = puntosOferta.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.P)}`
|
||||
).join(' ');
|
||||
|
||||
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 analizado escenarios de superávit</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-6 mb-6">
|
||||
<div className="text-5xl font-bold text-amber-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-amber-600 text-white rounded-lg font-medium hover:bg-amber-700 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
Intentar de nuevo
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl 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">
|
||||
<Package className="w-8 h-8 text-amber-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Exceso de Oferta (Superávit)</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-amber-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((escenarioActual + 1) / escenarios.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Analiza qué sucede cuando el precio está por encima del equilibrio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-2 text-center">Mercado de {escenario.producto}</h3>
|
||||
|
||||
<svg width="450" height="320" className="mx-auto">
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line x1={50 + i * 70} y1="30" x2={50 + i * 70} y2="300" stroke="#e5e7eb" strokeWidth="1" />
|
||||
<line x1="50" y1={30 + i * 54} x2="400" y2={30 + i * 54} stroke="#e5e7eb" strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1="50" y1="300" x2="400" y2="300" stroke="#374151" strokeWidth="2" />
|
||||
<line x1="50" y1="30" x2="50" y2="300" stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Labels */}
|
||||
<text x="225" y="320" textAnchor="middle" className="text-sm fill-gray-600">Cantidad (Q)</text>
|
||||
<text x="25" y="165" textAnchor="middle" transform="rotate(-90, 25, 165)" className="text-sm fill-gray-600">Precio (P)</text>
|
||||
|
||||
{/* Curva de Demanda */}
|
||||
<path d={demandaPath} fill="none" stroke="#3b82f6" strokeWidth="3" />
|
||||
<text x="380" y={scaleY(20)} className="text-sm fill-blue-600 font-medium">D</text>
|
||||
|
||||
{/* Curva de Oferta */}
|
||||
<path d={ofertaPath} fill="none" stroke="#22c55e" strokeWidth="3" />
|
||||
<text x="380" y={scaleY(maxP * 0.9)} className="text-sm fill-green-600 font-medium">S</text>
|
||||
|
||||
{/* Punto de equilibrio */}
|
||||
<circle
|
||||
cx={scaleX(equilibrio.Qd)}
|
||||
cy={scaleY(escenario.precioEquilibrio)}
|
||||
r="6"
|
||||
fill="#8b5cf6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={scaleX(equilibrio.Qd) + 10} y={scaleY(escenario.precioEquilibrio)} className="text-xs fill-purple-600">
|
||||
E
|
||||
</text>
|
||||
|
||||
{/* Línea de precio mínimo */}
|
||||
<line
|
||||
x1="50"
|
||||
y1={scaleY(escenario.precioMinimo)}
|
||||
x2="400"
|
||||
y2={scaleY(escenario.precioMinimo)}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<text x="410" y={scaleY(escenario.precioMinimo)} className="text-xs fill-amber-500 font-medium">
|
||||
Pmin=${escenario.precioMinimo}
|
||||
</text>
|
||||
|
||||
{/* Zona de exceso de oferta */}
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: mostrarResultado && esCorrecto ? 1 : 0.3 }}
|
||||
>
|
||||
<rect
|
||||
x={scaleX(conMinimo.Qd)}
|
||||
y={scaleY(escenario.precioMinimo) - 5}
|
||||
width={scaleX(conMinimo.Qo) - scaleX(conMinimo.Qd)}
|
||||
height="10"
|
||||
fill="#fef3c7"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
opacity="0.7"
|
||||
/>
|
||||
<text
|
||||
x={scaleX((conMinimo.Qd + conMinimo.Qo) / 2)}
|
||||
y={scaleY(escenario.precioMinimo) - 15}
|
||||
textAnchor="middle"
|
||||
className="text-xs fill-amber-700 font-medium"
|
||||
>
|
||||
Superávit
|
||||
</text>
|
||||
</motion.g>
|
||||
|
||||
{/* Puntos de cantidad */}
|
||||
<circle
|
||||
cx={scaleX(conMinimo.Qd)}
|
||||
cy={scaleY(escenario.precioMinimo)}
|
||||
r="5"
|
||||
fill="#3b82f6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={scaleX(conMinimo.Qd)} y={scaleY(escenario.precioMinimo) + 20} textAnchor="middle" className="text-xs fill-blue-600">
|
||||
Qd
|
||||
</text>
|
||||
|
||||
<circle
|
||||
cx={scaleX(conMinimo.Qo)}
|
||||
cy={scaleY(escenario.precioMinimo)}
|
||||
r="5"
|
||||
fill="#22c55e"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={scaleX(conMinimo.Qo)} y={scaleY(escenario.precioMinimo) + 20} textAnchor="middle" className="text-xs fill-green-600">
|
||||
Qo
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="bg-white p-2 rounded border">
|
||||
<span className="text-purple-600 font-medium">Equilibrio:</span>
|
||||
<p className="text-gray-700">P=${escenario.precioEquilibrio}, Q={Math.round(equilibrio.Qd)}</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 p-2 rounded border border-amber-200">
|
||||
<span className="text-amber-600 font-medium">Con Pmin:</span>
|
||||
<p className="text-gray-700">Qd={Math.round(conMinimo.Qd)}, Qo={Math.round(conMinimo.Qo)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-amber-50 to-yellow-50 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<BookOpen className="w-6 h-6 text-amber-600 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">Escenario</h3>
|
||||
<p className="text-gray-700">{escenario.contexto}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-2 border-amber-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-amber-600" />
|
||||
Calcula el Exceso de Oferta
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
¿Cuántas unidades de superávit se generan?
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={respuestaExceso}
|
||||
onChange={(e) => setRespuestaExceso(e.target.value)}
|
||||
disabled={mostrarResultado}
|
||||
placeholder="Ingresa el valor numérico"
|
||||
className="w-full px-4 py-3 border-2 border-amber-200 rounded-lg focus:border-amber-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Fórmula: Exceso de Oferta = Qo - Qd (al precio mínimo)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarResultado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-4 rounded-lg border ${esCorrecto ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{esCorrecto ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${esCorrecto ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecto ? '¡Correcto!' : 'Incorrecto'}
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
<p>Al precio de ${escenario.precioMinimo}:</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
<li>• Cantidad ofrecida: <strong>{Math.round(conMinimo.Qo)} unidades</strong></li>
|
||||
<li>• Cantidad demandada: <strong>{Math.round(conMinimo.Qd)} unidades</strong></li>
|
||||
<li>• Exceso de oferta: <strong>{Math.round(excesoOfertaReal)} unidades</strong></li>
|
||||
</ul>
|
||||
{!esCorrecto && (
|
||||
<p className="mt-2 text-red-700">
|
||||
La respuesta correcta es: <strong>{Math.round(excesoOfertaReal)} unidades</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!mostrarResultado ? (
|
||||
<button
|
||||
onClick={handleVerificar}
|
||||
disabled={!respuestaExceso}
|
||||
className="flex-1 py-3 px-4 bg-amber-600 text-white rounded-lg font-medium hover:bg-amber-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 className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Consecuencias del exceso de oferta:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
|
||||
<li>• Acumulación de inventarios</li>
|
||||
<li>• Presión para bajar precios</li>
|
||||
<li>• Necesidad de compras gubernamentales</li>
|
||||
<li>• Desperdicio de recursos</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExcesoOfertaSuperavit;
|
||||
@@ -0,0 +1,339 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { TrendingUp, TrendingDown, Users, DollarSign, Heart, Briefcase, Check, X, Trophy, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface FactoresDesplazanDemandaProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Factor {
|
||||
id: string;
|
||||
nombre: string;
|
||||
icono: React.ReactNode;
|
||||
descripcion: string;
|
||||
efecto: 'aumenta' | 'disminuye';
|
||||
explicacion: string;
|
||||
ejemplo: string;
|
||||
}
|
||||
|
||||
const factores: Factor[] = [
|
||||
{
|
||||
id: 'ingreso',
|
||||
nombre: 'Ingreso de los consumidores',
|
||||
icono: <DollarSign className="w-6 h-6" />,
|
||||
descripcion: 'Cuando los ingresos cambian, la demanda de bienes normales se desplaza',
|
||||
efecto: 'aumenta',
|
||||
explicacion: 'Si los ingresos suben, los consumidores pueden comprar más de casi todo (bienes normales).',
|
||||
ejemplo: 'Un aumento de sueldo permite comprar más ropa, comer fuera más seguido, etc.'
|
||||
},
|
||||
{
|
||||
id: 'precios_relacionados',
|
||||
nombre: 'Precios de bienes relacionados',
|
||||
icono: <TrendingUp className="w-6 h-6" />,
|
||||
descripcion: 'El precio de sustitutos y complementos afecta la demanda',
|
||||
efecto: 'aumenta',
|
||||
explicacion: 'Si el precio del café sube, la demanda de té (sustituto) aumenta.',
|
||||
ejemplo: 'Si la gasolina sube, más personas quieren autos eléctricos (sustitutos del consumo de gasolina).'
|
||||
},
|
||||
{
|
||||
id: 'gustos',
|
||||
nombre: 'Gustos y preferencias',
|
||||
icono: <Heart className="w-6 h-6" />,
|
||||
descripcion: 'Las modas, publicidad y cambios culturales desplazan la demanda',
|
||||
efecto: 'aumenta',
|
||||
explicacion: 'Si un producto se vuelve popular, más personas lo quieren independientemente del precio.',
|
||||
ejemplo: 'La moda de los celulares inteligentes desplazó la demanda de cámaras fotográficas tradicionales.'
|
||||
},
|
||||
{
|
||||
id: 'expectativas',
|
||||
nombre: 'Expectativas de precios futuros',
|
||||
icono: <TrendingDown className="w-6 h-6" />,
|
||||
descripcion: 'Lo que esperamos que pase con los precios afecta la demanda actual',
|
||||
efecto: 'aumenta',
|
||||
explicacion: 'Si esperamos que suban los precios, compramos más ahora (demanda aumenta).',
|
||||
ejemplo: 'Antes de un aumento de impuestos a autos, la gente compra vehículos anticipadamente.'
|
||||
},
|
||||
{
|
||||
id: 'poblacion',
|
||||
nombre: 'Número de compradores',
|
||||
icono: <Users className="w-6 h-6" />,
|
||||
descripcion: 'Más consumidores en el mercado aumentan la demanda total',
|
||||
efecto: 'aumenta',
|
||||
explicacion: 'El crecimiento poblacional o la apertura de nuevos mercados aumenta la demanda.',
|
||||
ejemplo: 'Una nueva colonia residencial aumenta la demanda de supermercados cercanos.'
|
||||
},
|
||||
{
|
||||
id: 'demografia',
|
||||
nombre: 'Cambios demográficos',
|
||||
icono: <Briefcase className="w-6 h-6" />,
|
||||
descripcion: 'La edad, género, ocupación y educación de la población afectan la demanda',
|
||||
efecto: 'aumenta',
|
||||
explicacion: 'Diferentes grupos demográficos tienen diferentes necesidades y preferencias.',
|
||||
ejemplo: 'El envejecimiento poblacional aumenta la demanda de servicios de salud y centros de jubilados.'
|
||||
}
|
||||
];
|
||||
|
||||
interface PreguntaFactor {
|
||||
factorId: string;
|
||||
pregunta: string;
|
||||
respuestaCorrecta: boolean;
|
||||
escenario: string;
|
||||
}
|
||||
|
||||
const preguntas: PreguntaFactor[] = [
|
||||
{
|
||||
factorId: 'ingreso',
|
||||
pregunta: '¿Qué sucede con la demanda de bienes normales si los ingresos de los consumidores aumentan?',
|
||||
respuestaCorrecta: true,
|
||||
escenario: 'La economía está creciendo y los salarios suben un 10%'
|
||||
},
|
||||
{
|
||||
factorId: 'precios_relacionados',
|
||||
pregunta: 'Si el precio de la mantequilla sube mucho, ¿qué pasa con la demanda de margarina?',
|
||||
respuestaCorrecta: true,
|
||||
escenario: 'La mantequilla cuesta $10 y la margarina $5'
|
||||
},
|
||||
{
|
||||
factorId: 'gustos',
|
||||
pregunta: 'Un influencer popular recomienda un nuevo celular. ¿Qué sucede con su demanda?',
|
||||
respuestaCorrecta: true,
|
||||
escenario: 'El video del influencer tiene 10 millones de views'
|
||||
},
|
||||
{
|
||||
factorId: 'expectativas',
|
||||
pregunta: 'Se anuncia que el precio de la gasolina subirá mañana. ¿Qué pasa hoy con la demanda?',
|
||||
respuestaCorrecta: true,
|
||||
escenario: 'Todos los noticieros anuncian el aumento de precios'
|
||||
},
|
||||
{
|
||||
factorId: 'poblacion',
|
||||
pregunta: 'Una nueva fábrica trae 5,000 trabajadores a una ciudad pequeña. ¿Qué pasa con la demanda de vivienda?',
|
||||
respuestaCorrecta: true,
|
||||
escenario: 'La población de la ciudad se duplica en un año'
|
||||
},
|
||||
{
|
||||
factorId: 'demografia',
|
||||
pregunta: 'En un país, el 30% de la población tiene más de 60 años. ¿Qué servicios verán aumentada demanda?',
|
||||
respuestaCorrecta: true,
|
||||
escenario: 'La población está envejeciendo rápidamente'
|
||||
}
|
||||
];
|
||||
|
||||
export const FactoresDesplazanDemanda: React.FC<FactoresDesplazanDemandaProps> = ({
|
||||
ejercicioId: _ejercicioId,
|
||||
onComplete
|
||||
}) => {
|
||||
const [modo, setModo] = useState<'aprender' | 'practicar'>('aprender');
|
||||
const [preguntaActual, setPreguntaActual] = useState(0);
|
||||
const [respuestas, setRespuestas] = useState<boolean[]>([]);
|
||||
const [mostrarFeedback, setMostrarFeedback] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const handleRespuesta = (respuesta: boolean) => {
|
||||
const esCorrecta = respuesta === preguntas[preguntaActual].respuestaCorrecta;
|
||||
setRespuestas(prev => [...prev, esCorrecta]);
|
||||
setMostrarFeedback(true);
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (preguntaActual < preguntas.length - 1) {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setMostrarFeedback(false);
|
||||
} else {
|
||||
const correctas = respuestas.filter(r => r).length + (mostrarFeedback && respuestas.length === preguntaActual ? 1 : 0);
|
||||
const puntuacion = Math.round((correctas / preguntas.length) * 100);
|
||||
setScore(puntuacion);
|
||||
setCompletado(true);
|
||||
setTimeout(() => {
|
||||
if (onComplete) {
|
||||
onComplete(puntuacion);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setModo('aprender');
|
||||
setPreguntaActual(0);
|
||||
setRespuestas([]);
|
||||
setMostrarFeedback(false);
|
||||
setScore(0);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
const pregunta = preguntas[preguntaActual];
|
||||
const factorActual = factores.find(f => f.id === pregunta.factorId);
|
||||
const ultimaRespuestaCorrecta = respuestas[respuestas.length - 1];
|
||||
|
||||
if (completado) {
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto p-6 bg-white rounded-xl shadow-lg text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-4"
|
||||
>
|
||||
<Trophy className="w-10 h-10 text-green-600" />
|
||||
</motion.div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">¡Ejercicio Completado!</h2>
|
||||
<p className="text-gray-600 mb-4">Has identificado correctamente {respuestas.filter(r => r).length} de {preguntas.length} factores</p>
|
||||
<div className="text-4xl font-bold text-blue-600 mb-2">{score}/100</div>
|
||||
<p className="text-sm text-gray-500">Puntuación final</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (modo === 'aprender') {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">Factores que Desplazan la Curva de Demanda</h2>
|
||||
<p className="text-gray-600">
|
||||
Estos 6 factores hacen que la curva de demanda se desplace (aumente o disminuya),
|
||||
a diferencia de un movimiento a lo largo de la curva que solo el precio puede causar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{factores.map((factor, index) => (
|
||||
<motion.div
|
||||
key={factor.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="p-4 bg-blue-50 rounded-lg border-2 border-blue-100 hover:border-blue-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-600 text-white rounded-lg">
|
||||
{factor.icono}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-800 mb-1">{factor.nombre}</h3>
|
||||
<p className="text-sm text-gray-600 mb-2">{factor.descripcion}</p>
|
||||
<div className="text-xs bg-white p-2 rounded border border-blue-200">
|
||||
<span className="font-medium text-blue-700">Ejemplo:</span> {factor.ejemplo}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={() => setModo('practicar')}
|
||||
className="px-8 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
Practicar con Ejercicios
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-800">Identifica el Factor</h2>
|
||||
<span className="text-sm text-gray-500">Pregunta {preguntaActual + 1} de {preguntas.length}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-blue-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((preguntaActual + 1) / preguntas.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{factorActual && (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-600 text-white rounded-lg">
|
||||
{factorActual.icono}
|
||||
</div>
|
||||
<span className="font-medium text-blue-800">Factor: {factorActual.nombre}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="p-4 bg-gray-100 rounded-lg mb-4">
|
||||
<span className="text-sm font-medium text-gray-600">Escenario:</span>
|
||||
<p className="text-gray-800 mt-1">{pregunta.escenario}</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">{pregunta.pregunta}</h3>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarFeedback && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="mb-6 overflow-hidden"
|
||||
>
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
ultimaRespuestaCorrecta ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{ultimaRespuestaCorrecta ? (
|
||||
<Check className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${ultimaRespuestaCorrecta ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{ultimaRespuestaCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</p>
|
||||
{factorActual && (
|
||||
<p className="text-sm mt-1 text-gray-700">
|
||||
{factorActual.explicacion}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{!mostrarFeedback ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleRespuesta(true)}
|
||||
className="flex-1 py-3 px-4 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors"
|
||||
>
|
||||
La demanda AUMENTA (se desplaza a la derecha)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRespuesta(false)}
|
||||
className="flex-1 py-3 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
La demanda DISMINUYE (se desplaza a la izquierda)
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSiguiente}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{preguntaActual < preguntas.length - 1 ? 'Siguiente Pregunta' : 'Ver Resultados'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setModo('aprender')}
|
||||
className="mt-4 text-sm text-gray-500 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
← Volver a repasar los factores
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FactoresDesplazanDemanda;
|
||||
@@ -0,0 +1,440 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowRightLeft, CheckCircle2, XCircle, Trophy, RotateCcw, Factory, DollarSign, Users, Zap, Truck, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface FactoresDesplazanOfertaProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
type Direccion = 'izquierda' | 'derecha' | 'ninguna';
|
||||
type Categoria = 'tecnologia' | 'insumos' | 'competidores' | 'expectativas' | 'impuestos';
|
||||
|
||||
interface Factor {
|
||||
id: number;
|
||||
nombre: string;
|
||||
icono: React.ReactNode;
|
||||
descripcion: string;
|
||||
efecto: Direccion;
|
||||
explicacion: string;
|
||||
categoria: Categoria;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const factores: Factor[] = [
|
||||
{
|
||||
id: 1,
|
||||
nombre: 'Tecnología',
|
||||
icono: <Zap className="w-6 h-6" />,
|
||||
descripcion: 'Nueva maquinaria reduce el tiempo de producción a la mitad',
|
||||
efecto: 'derecha',
|
||||
explicacion: 'La tecnología mejora la productividad, permitiendo producir más al mismo costo. La oferta aumenta (se desplaza a la derecha).',
|
||||
categoria: 'tecnologia',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nombre: 'Costo de Insumos',
|
||||
icono: <DollarSign className="w-6 h-6" />,
|
||||
descripcion: 'El precio del petróleo (materia prima) sube un 50%',
|
||||
efecto: 'izquierda',
|
||||
explicacion: 'Al subir los costos de producción, es menos rentable fabricar. La oferta disminuye (se desplaza a la izquierda).',
|
||||
categoria: 'insumos',
|
||||
color: 'red'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
nombre: 'Número de Vendedores',
|
||||
icono: <Users className="w-6 h-6" />,
|
||||
descripcion: 'Muchas nuevas empresas entran al mercado',
|
||||
efecto: 'derecha',
|
||||
explicacion: 'Más vendedores en el mercado significa más producción total. La oferta aumenta (se desplaza a la derecha).',
|
||||
categoria: 'competidores',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
nombre: 'Expectativas',
|
||||
icono: <AlertTriangle className="w-6 h-6" />,
|
||||
descripcion: 'Los productores esperan que el precio suba el próximo mes',
|
||||
efecto: 'izquierda',
|
||||
explicacion: 'Si esperan precios más altos mañana, retienen producción hoy. La oferta actual disminuye (se desplaza a la izquierda).',
|
||||
categoria: 'expectativas',
|
||||
color: 'orange'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
nombre: 'Impuestos',
|
||||
icono: <Factory className="w-6 h-6" />,
|
||||
descripcion: 'El gobierno elimina un impuesto a la producción',
|
||||
efecto: 'derecha',
|
||||
explicacion: 'Sin el impuesto, los costos de producción bajan. La oferta aumenta (se desplaza a la derecha).',
|
||||
categoria: 'impuestos',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
nombre: 'Subsidios',
|
||||
icono: <Truck className="w-6 h-6" />,
|
||||
descripcion: 'El gobierno cancela un subsidio a los agricultores',
|
||||
efecto: 'izquierda',
|
||||
explicacion: 'Sin el subsidio, los costos de producción suben. La oferta disminuye (se desplaza a la izquierda).',
|
||||
categoria: 'impuestos',
|
||||
color: 'red'
|
||||
}
|
||||
];
|
||||
|
||||
export const FactoresDesplazanOferta: React.FC<FactoresDesplazanOfertaProps> = ({
|
||||
onComplete,
|
||||
ejercicioId: _ejercicioId
|
||||
}) => {
|
||||
const [factorActual, setFactorActual] = useState(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<Direccion | null>(null);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const factor = factores[factorActual];
|
||||
|
||||
const handleSeleccionar = (direccion: Direccion) => {
|
||||
if (mostrarResultado) return;
|
||||
setRespuestaSeleccionada(direccion);
|
||||
};
|
||||
|
||||
const handleVerificar = () => {
|
||||
if (!respuestaSeleccionada) return;
|
||||
|
||||
const esCorrecta = respuestaSeleccionada === factor.efecto;
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (esCorrecta) {
|
||||
setScore(prev => prev + Math.round(100 / factores.length));
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (factorActual < factores.length - 1) {
|
||||
setFactorActual(prev => prev + 1);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setFactorActual(0);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
setScore(0);
|
||||
setRespuestasCorrectas(0);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
const renderGrafico = () => {
|
||||
const isRight = factor.efecto === 'derecha';
|
||||
|
||||
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 S1 */}
|
||||
<line x1="80" y1="180" x2="200" y2="80" stroke="#22c55e" strokeWidth="3" />
|
||||
<text x="210" y="75" className="text-sm fill-green-600 font-medium">S₁</text>
|
||||
|
||||
{/* Curva desplazada S2 */}
|
||||
<motion.g
|
||||
initial={{ opacity: 0, x: isRight ? 30 : -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<line
|
||||
x1={isRight ? 110 : 50}
|
||||
y1="180"
|
||||
x2={isRight ? 230 : 170}
|
||||
y2="80"
|
||||
stroke="#22c55e"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<text x={isRight ? 240 : 180} y="75" className="text-sm fill-green-600 font-medium">S₂</text>
|
||||
</motion.g>
|
||||
|
||||
{/* Flecha de dirección */}
|
||||
{mostrarResultado && (
|
||||
<motion.path
|
||||
d={isRight ? 'M 260 130 L 280 130' : 'M 280 130 L 260 130'}
|
||||
stroke="#22c55e"
|
||||
strokeWidth="3"
|
||||
markerEnd="url(#arrowhead)"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Defs para flecha */}
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#22c55e" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Labels */}
|
||||
<text x="150" y="235" textAnchor="middle" className="text-xs fill-gray-600">Cantidad</text>
|
||||
<text x="25" y="120" textAnchor="middle" transform="rotate(-90, 25, 120)" className="text-xs fill-gray-600">Precio</text>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
const porcentaje = Math.round((respuestasCorrectas / factores.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 los factores que desplazan la oferta</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 {factores.length} respuestas correctas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{factores.filter(f => f.efecto === 'derecha').length}
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">Aumentan oferta →</p>
|
||||
</div>
|
||||
<div className="p-4 bg-red-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{factores.filter(f => f.efecto === 'izquierda').length}
|
||||
</p>
|
||||
<p className="text-sm text-red-700">Disminuyen oferta ←</p>
|
||||
</div>
|
||||
</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">
|
||||
<ArrowRightLeft className="w-8 h-8 text-green-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Factores que Desplazan la Oferta</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
{factorActual + 1} de {factores.length}
|
||||
</span>
|
||||
<div className="w-32 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-green-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((factorActual + 1) / factores.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Identifica en qué dirección se desplaza la curva de oferta ante cada situación.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<motion.div
|
||||
className={`p-6 rounded-lg mb-4 bg-${factor.color}-50 border border-${factor.color}-200`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
key={factor.id}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`p-3 rounded-full bg-${factor.color}-100 text-${factor.color}-600`}>
|
||||
{factor.icono}
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-800 text-lg">{factor.nombre}</h3>
|
||||
</div>
|
||||
<p className="text-gray-700">{factor.descripcion}</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-gray-700">¿Qué ocurre con la oferta?</p>
|
||||
|
||||
{(['izquierda', 'derecha'] as Direccion[]).map((direccion) => {
|
||||
const isSelected = respuestaSeleccionada === direccion;
|
||||
const isCorrect = mostrarResultado && direccion === factor.efecto;
|
||||
const isWrong = mostrarResultado && isSelected && direccion !== factor.efecto;
|
||||
|
||||
let buttonClass = 'w-full p-4 rounded-lg border-2 transition-all flex items-center gap-3 ';
|
||||
|
||||
if (isCorrect) {
|
||||
buttonClass += 'border-green-500 bg-green-50';
|
||||
} else if (isWrong) {
|
||||
buttonClass += 'border-red-500 bg-red-50';
|
||||
} else if (isSelected) {
|
||||
buttonClass += 'border-green-500 bg-green-50';
|
||||
} else {
|
||||
buttonClass += 'border-gray-200 hover:border-gray-300 hover:bg-gray-50';
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={direccion}
|
||||
onClick={() => handleSeleccionar(direccion)}
|
||||
disabled={mostrarResultado}
|
||||
whileHover={!mostrarResultado ? { scale: 1.02 } : {}}
|
||||
whileTap={!mostrarResultado ? { scale: 0.98 } : {}}
|
||||
className={buttonClass}
|
||||
>
|
||||
<span className={`text-2xl ${
|
||||
direccion === 'izquierda' ? 'transform rotate-180' : ''
|
||||
}`}>
|
||||
→
|
||||
</span>
|
||||
<span className={`flex-1 font-semibold ${
|
||||
isCorrect ? 'text-green-700' :
|
||||
isWrong ? 'text-red-700' :
|
||||
isSelected ? 'text-green-700' : 'text-gray-700'
|
||||
}`}>
|
||||
{direccion === 'derecha' ? 'Aumenta (derecha)' : 'Disminuye (izquierda)'}
|
||||
</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 === factor.efecto
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{respuestaSeleccionada === factor.efecto ? (
|
||||
<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 === factor.efecto ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{respuestaSeleccionada === factor.efecto ? '¡Correcto!' : 'Incorrecto'}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-gray-700">{factor.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-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Verificar respuesta
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSiguiente}
|
||||
className="flex-1 py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{factorActual < factores.length - 1 ? 'Siguiente' : '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 Desplazamiento</h3>
|
||||
{renderGrafico()}
|
||||
|
||||
<div className="mt-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-0.5 bg-green-600"></div>
|
||||
<span className="text-gray-600">S₁: Oferta original</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-0.5 border-t-2 border-dashed border-green-600"></div>
|
||||
<span className="text-gray-600">S₂: Nueva oferta</span>
|
||||
</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>Recuerda:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
|
||||
<li>• <strong>→ Derecha:</strong> Oferta aumenta</li>
|
||||
<li>• <strong>← Izquierda:</strong> Oferta disminuye</li>
|
||||
<li>• El precio del bien NO desplaza la curva</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicadores de progreso */}
|
||||
<div className="mt-6 flex justify-center items-center gap-1">
|
||||
{factores.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
index === factorActual
|
||||
? 'bg-green-600'
|
||||
: index < factorActual
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FactoresDesplazanOferta;
|
||||
@@ -0,0 +1,448 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface PreguntaFactor {
|
||||
id: number;
|
||||
pregunta: string;
|
||||
opciones: string[];
|
||||
respuestaCorrecta: number;
|
||||
explicacion: string;
|
||||
categoria: 'sustitutos' | 'necesidad' | 'porcion' | 'tiempo' | 'definicion';
|
||||
}
|
||||
|
||||
const preguntas: PreguntaFactor[] = [
|
||||
{
|
||||
id: 1,
|
||||
pregunta: "¿Qué sucede con la elasticidad de la demanda cuando aumenta la disponibilidad de bienes sustitutos?",
|
||||
opciones: [
|
||||
"La elasticidad disminuye (se vuelve más inelástica)",
|
||||
"La elasticidad aumenta (se vuelve más elástica)",
|
||||
"La elasticidad no se ve afectada",
|
||||
"La elasticidad se vuelve unitaria"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "Cuanto más sustitutos disponibles tenga un bien, más elástica será su demanda. Los consumidores pueden cambiar fácilmente a alternativas cuando el precio sube, haciendo que la cantidad demandada responda más al cambio de precio.",
|
||||
categoria: 'sustitutos'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pregunta: "¿Cuál de los siguientes bienes probablemente tenga la demanda más inelástica?",
|
||||
opciones: [
|
||||
"Un yate de lujo",
|
||||
"Agua embotellada en un día normal",
|
||||
"Entradas para un concierto de una banda específica",
|
||||
"Una marca particular de cereal"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "El agua es una necesidad básica sin sustitutos cercanos en la mayoría de situaciones. La demanda de necesidades es inelástica porque los consumidores la necesitan independientemente del precio, dentro de rangos razonables.",
|
||||
categoria: 'necesidad'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pregunta: "Si el precio de la sal aumenta un 50%, ¿por qué la cantidad demandada probablemente no cambie significativamente?",
|
||||
opciones: [
|
||||
"Porque la sal es un lujo que todos quieren",
|
||||
"Porque representa una pequeña porción del presupuesto y es una necesidad",
|
||||
"Porque hay muchos sustitutos para la sal",
|
||||
"Porque la ley prohíbe cambiar el consumo de sal"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "La sal representa una porción muy pequeña del ingreso de los consumidores y es una necesidad básica. Incluso si el precio sube mucho, el impacto económico es mínimo y no hay sustitutos directos para su función en la alimentación.",
|
||||
categoria: 'porcion'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pregunta: "¿Por qué la demanda de gasolina es más elástica a largo plazo que a corto plazo?",
|
||||
opciones: [
|
||||
"Porque la gasolina es más barata a largo plazo",
|
||||
"Porque los consumidores pueden ajustar su comportamiento (comprar autos eficientes, mudarse, etc.)",
|
||||
"Porque hay más estaciones de gasolina a largo plazo",
|
||||
"Porque el gobierno regula los precios a largo plazo"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "A corto plazo, los consumidores están 'atrapados' con sus vehículos y rutas actuales. A largo plazo, pueden hacer cambios significativos como comprar autos más eficientes, usar transporte público, mudarse más cerca del trabajo, etc., haciendo la demanda más sensible al precio.",
|
||||
categoria: 'tiempo'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pregunta: "¿Qué relación existe entre el lujo/necesidad y la elasticidad de la demanda?",
|
||||
opciones: [
|
||||
"Los lujos tienen demanda inelástica; las necesidades tienen demanda elástica",
|
||||
"Los lujos tienen demanda elástica; las necesidades tienen demanda inelástica",
|
||||
"Ambos tienen la misma elasticidad",
|
||||
"La elasticidad depende únicamente del precio, no del tipo de bien"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "Los bienes de lujo tienen demanda elástica porque son discrecionales - los consumidores pueden reducir su consumo o eliminarlo si el precio sube. Las necesidades tienen demanda inelástica porque se requieren independientemente del precio.",
|
||||
categoria: 'definicion'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
pregunta: "Un bien representa el 30% del presupuesto mensual de una familia. ¿Qué podemos esperar sobre su elasticidad?",
|
||||
opciones: [
|
||||
"Será inelástica porque representa una porción grande del presupuesto",
|
||||
"Será elástica porque los cambios de precio tendrán impacto significativo",
|
||||
"La elasticidad solo depende de si es necesidad o lujo",
|
||||
"No se puede determinar sin más información"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "Cuando un bien representa una porción significativa del presupuesto, los consumidores son más sensibles a los cambios de precio. Un aumento de precio significaría un impacto sustancial en sus finanzas, por lo que buscarán alternativas o reducirán consumo, haciendo la demanda más elástica.",
|
||||
categoria: 'porcion'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
pregunta: "¿Cuál factor NO es determinante de la elasticidad precio de la demanda?",
|
||||
opciones: [
|
||||
"Disponibilidad de sustitutos",
|
||||
"Naturaleza del bien (necesidad vs lujo)",
|
||||
"Porción del ingreso que representa",
|
||||
"El color del empaque del producto"
|
||||
],
|
||||
respuestaCorrecta: 3,
|
||||
explicacion: "El color del empaque puede afectar las preferencias pero no determina la elasticidad precio de la demanda. Los factores clave son: disponibilidad de sustitutos, naturaleza del bien (necesidad/lujo), porción del ingreso, y horizonte temporal (corto vs largo plazo).",
|
||||
categoria: 'definicion'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
pregunta: "¿Por qué la demanda de medicamentos específicos para enfermedades crónicas es extremadamente inelástica?",
|
||||
opciones: [
|
||||
"Porque son muy baratos",
|
||||
"Porque no tienen sustitutos y son necesarios para la salud",
|
||||
"Porque hay muchas marcas competidoras",
|
||||
"Porque representan una pequeña porción del ingreso"
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: "Los medicamentos para enfermedades crónicas combinan dos factores de inelasticidad: son necesidades absolutas (sin ellos la salud se deteriora) y frecuentemente no tienen sustitutos terapéuticos equivalentes. Los pacientes deben comprarlos independientemente del precio.",
|
||||
categoria: 'sustitutos'
|
||||
}
|
||||
];
|
||||
|
||||
export const FactoresElasticidad: React.FC = () => {
|
||||
const [preguntaActual, setPreguntaActual] = useState<number>(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<number | null>(null);
|
||||
const [resultado, setResultado] = useState<{
|
||||
correcto: boolean;
|
||||
mostrarResultado: boolean;
|
||||
} | null>(null);
|
||||
const [puntuacion, setPuntuacion] = useState<number>(0);
|
||||
const [respondidas, setRespondidas] = useState<number>(0);
|
||||
const [mostrarResumen, setMostrarResumen] = useState<boolean>(false);
|
||||
|
||||
const pregunta = preguntas[preguntaActual];
|
||||
|
||||
const verificarRespuesta = (indice: number) => {
|
||||
if (resultado?.mostrarResultado) return;
|
||||
|
||||
setRespuestaSeleccionada(indice);
|
||||
const correcto = indice === pregunta.respuestaCorrecta;
|
||||
|
||||
if (correcto) {
|
||||
setPuntuacion(prev => prev + 1);
|
||||
}
|
||||
|
||||
setResultado({
|
||||
correcto,
|
||||
mostrarResultado: true
|
||||
});
|
||||
|
||||
setRespondidas(prev => prev + 1);
|
||||
};
|
||||
|
||||
const siguientePregunta = () => {
|
||||
if (preguntaActual < preguntas.length - 1) {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setRespuestaSeleccionada(null);
|
||||
setResultado(null);
|
||||
} else {
|
||||
setMostrarResumen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const reiniciarQuiz = () => {
|
||||
setPreguntaActual(0);
|
||||
setRespuestaSeleccionada(null);
|
||||
setResultado(null);
|
||||
setPuntuacion(0);
|
||||
setRespondidas(0);
|
||||
setMostrarResumen(false);
|
||||
};
|
||||
|
||||
const getCategoriaIcon = (categoria: string) => {
|
||||
switch (categoria) {
|
||||
case 'sustitutos':
|
||||
return (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
);
|
||||
case 'necesidad':
|
||||
return (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
);
|
||||
case 'porcion':
|
||||
return (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
case 'tiempo':
|
||||
return (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoriaNombre = (categoria: string) => {
|
||||
switch (categoria) {
|
||||
case 'sustitutos': return 'Sustitutos';
|
||||
case 'necesidad': return 'Lujo vs Necesidad';
|
||||
case 'porcion': return 'Porción del Ingreso';
|
||||
case 'tiempo': return 'Horizonte Temporal';
|
||||
default: return 'Definiciones';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoriaColor = (categoria: string) => {
|
||||
switch (categoria) {
|
||||
case 'sustitutos': return 'bg-purple-100 text-purple-700 border-purple-200';
|
||||
case 'necesidad': return 'bg-pink-100 text-pink-700 border-pink-200';
|
||||
case 'porcion': return 'bg-green-100 text-green-700 border-green-200';
|
||||
case 'tiempo': return 'bg-blue-100 text-blue-700 border-blue-200';
|
||||
default: return 'bg-gray-100 text-gray-700 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
if (mostrarResumen) {
|
||||
const porcentaje = Math.round((puntuacion / preguntas.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 bg-white rounded-lg shadow-md">
|
||||
<div className="text-center py-8">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full mx-auto mb-6 flex items-center justify-center">
|
||||
<span className="text-4xl font-bold text-white">{porcentaje}%</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">¡Quiz Completado!</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Has respondido {puntuacion} de {preguntas.length} preguntas correctamente
|
||||
</p>
|
||||
|
||||
<div className="max-w-md mx-auto bg-gray-50 p-6 rounded-xl border border-gray-200 mb-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-gray-600">Respuestas correctas:</span>
|
||||
<span className="font-bold text-green-600">{puntuacion}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-gray-600">Respuestas incorrectas:</span>
|
||||
<span className="font-bold text-red-600">{preguntas.length - puntuacion}</span>
|
||||
</div>
|
||||
<div className="h-4 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-500 to-green-600 transition-all duration-500"
|
||||
style={{ width: `${porcentaje}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={reiniciarQuiz}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white px-8 py-3 rounded-lg font-semibold transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Intentar de Nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 bg-white rounded-lg shadow-md">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-blue-800">Factores de la Elasticidad</h2>
|
||||
<p className="text-gray-600">Identifica cómo diferentes factores afectan la elasticidad de la demanda.</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="bg-blue-50 px-4 py-2 rounded-lg">
|
||||
<p className="text-sm text-gray-600">Pregunta {preguntaActual + 1} de {preguntas.length}</p>
|
||||
<p className="text-xl font-bold text-blue-700">{puntuacion}/{respondidas}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 transition-all duration-300"
|
||||
style={{ width: `${((preguntaActual + 1) / preguntas.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-gray-50 to-indigo-50 p-6 rounded-xl border border-indigo-100 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-xl ${getCategoriaColor(pregunta.categoria)}`}>
|
||||
{getCategoriaIcon(pregunta.categoria)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className={`inline-block px-3 py-1 rounded-full text-xs font-semibold mb-3 ${getCategoriaColor(pregunta.categoria)}`}>
|
||||
{getCategoriaNombre(pregunta.categoria)}
|
||||
</span>
|
||||
<h3 className="text-xl font-bold text-gray-800">{pregunta.pregunta}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{pregunta.opciones.map((opcion, indice) => (
|
||||
<button
|
||||
key={indice}
|
||||
onClick={() => verificarRespuesta(indice)}
|
||||
disabled={resultado?.mostrarResultado}
|
||||
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
respuestaSeleccionada === indice
|
||||
? indice === pregunta.respuestaCorrecta
|
||||
? 'bg-green-100 border-green-500 ring-2 ring-green-200'
|
||||
: 'bg-red-100 border-red-500 ring-2 ring-red-200'
|
||||
: resultado?.mostrarResultado && indice === pregunta.respuestaCorrecta
|
||||
? 'bg-green-100 border-green-500'
|
||||
: 'bg-white border-gray-200 hover:border-indigo-300 hover:bg-indigo-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${
|
||||
respuestaSeleccionada === indice
|
||||
? indice === pregunta.respuestaCorrecta
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
: resultado?.mostrarResultado && indice === pregunta.respuestaCorrecta
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{String.fromCharCode(65 + indice)}
|
||||
</span>
|
||||
<span className="flex-1">{opcion}</span>
|
||||
{respuestaSeleccionada === indice && indice === pregunta.respuestaCorrecta && (
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
{respuestaSeleccionada === indice && indice !== pregunta.respuestaCorrecta && (
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{resultado?.mostrarResultado && (
|
||||
<div className={`p-6 rounded-xl border-2 mb-6 ${resultado.correcto ? 'bg-green-50 border-green-300' : 'bg-red-50 border-red-300'}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-full ${resultado.correcto ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
{resultado.correcto ? (
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-bold text-lg ${resultado.correcto ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{resultado.correcto ? '¡Respuesta Correcta!' : 'Respuesta Incorrecta'}
|
||||
</h4>
|
||||
|
||||
<div className="mt-4 bg-white p-4 rounded-lg border border-gray-200">
|
||||
<p className="font-medium text-gray-800 mb-2">Explicación:</p>
|
||||
<p className="text-gray-600">{pregunta.explicacion}</p>
|
||||
</div>
|
||||
|
||||
{!resultado.correcto && (
|
||||
<p className="mt-3 text-green-700 font-medium">
|
||||
La respuesta correcta es: {pregunta.opciones[pregunta.respuestaCorrecta]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultado?.mostrarResultado && (
|
||||
<button
|
||||
onClick={siguientePregunta}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-4 rounded-lg font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{preguntaActual < preguntas.length - 1 ? 'Siguiente Pregunta' : 'Ver Resultados'}
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="mt-8 bg-gradient-to-r from-indigo-50 to-purple-50 p-5 rounded-xl border border-indigo-200">
|
||||
<h4 className="font-bold text-indigo-800 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
Factores Clave de la Elasticidad
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-white p-3 rounded-lg border border-purple-200">
|
||||
<div className="text-purple-600 mb-1">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-800">Sustitutos</p>
|
||||
<p className="text-xs text-gray-500">Más sustitutos = más elástica</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg border border-pink-200">
|
||||
<div className="text-pink-600 mb-1">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-800">Necesidad</p>
|
||||
<p className="text-xs text-gray-500">Necesidades = inelástica</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg border border-green-200">
|
||||
<div className="text-green-600 mb-1">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-800">Porción</p>
|
||||
<p className="text-xs text-gray-500">% mayor del ingreso = más elástica</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg border border-blue-200">
|
||||
<div className="text-blue-600 mb-1">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-800">Tiempo</p>
|
||||
<p className="text-xs text-gray-500">Largo plazo = más elástica</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FactoresElasticidad;
|
||||
467
frontend/src/components/exercises/modulo2/IdentificarShocks.tsx
Normal file
467
frontend/src/components/exercises/modulo2/IdentificarShocks.tsx
Normal 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;
|
||||
244
frontend/src/components/exercises/modulo2/LeyDemandaQuiz.tsx
Normal file
244
frontend/src/components/exercises/modulo2/LeyDemandaQuiz.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { HelpCircle, Check, X, ArrowRight, Trophy } from 'lucide-react';
|
||||
|
||||
interface LeyDemandaQuizProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Pregunta {
|
||||
id: number;
|
||||
pregunta: string;
|
||||
opciones: string[];
|
||||
correcta: number;
|
||||
explicacion: string;
|
||||
}
|
||||
|
||||
const preguntas: Pregunta[] = [
|
||||
{
|
||||
id: 1,
|
||||
pregunta: "¿Qué sucede con la cantidad demandada cuando el precio de un bien aumenta?",
|
||||
opciones: [
|
||||
"Aumenta proporcionalmente",
|
||||
"Disminuye (ley de la demanda)",
|
||||
"Se mantiene constante",
|
||||
"Depende del tipo de bien"
|
||||
],
|
||||
correcta: 1,
|
||||
explicacion: "Según la Ley de la Demanda, existe una relación inversa entre precio y cantidad demandada: cuando el precio sube, la cantidad demandada baja."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pregunta: "¿Cuál es la forma típica de la curva de demanda?",
|
||||
opciones: [
|
||||
"Línea horizontal",
|
||||
"Línea vertical",
|
||||
"Pendiente descendente (de izquierda a derecha)",
|
||||
"Pendiente ascendente"
|
||||
],
|
||||
correcta: 2,
|
||||
explicacion: "La curva de demanda tiene pendiente descendente porque a precios más bajos, los consumidores están dispuestos a comprar más cantidad."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pregunta: "Si el precio del helado baja de $5 a $3, ¿qué esperamos que ocurra?",
|
||||
opciones: [
|
||||
"La gente comprará menos helado",
|
||||
"No cambiará la cantidad demandada",
|
||||
"La gente comprará más helado",
|
||||
"Solo los ricos comprarán helado"
|
||||
],
|
||||
correcta: 2,
|
||||
explicacion: "Una disminución en el precio genera un aumento en la cantidad demandada (movimiento a lo largo de la curva)."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pregunta: "¿Qué representa el eje vertical (Y) en un gráfico de demanda?",
|
||||
opciones: [
|
||||
"Cantidad demandada",
|
||||
"Precio del bien",
|
||||
"Ingreso de los consumidores",
|
||||
"Tiempo"
|
||||
],
|
||||
correcta: 1,
|
||||
explicacion: "En un gráfico de demanda estándar, el eje Y representa el Precio y el eje X representa la Cantidad."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pregunta: "Complete: 'A mayor precio, ______ cantidad demandada'",
|
||||
opciones: [
|
||||
"Mayor",
|
||||
"Menor",
|
||||
"Igual",
|
||||
"No hay relación"
|
||||
],
|
||||
correcta: 1,
|
||||
explicacion: "La ley de la demanda establece que a mayor precio, menor cantidad demandada (relación inversa)."
|
||||
}
|
||||
];
|
||||
|
||||
export const LeyDemandaQuiz: React.FC<LeyDemandaQuizProps> = ({ ejercicioId: _ejercicioId, onComplete }) => {
|
||||
const [preguntaActual, setPreguntaActual] = useState(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<number | null>(null);
|
||||
const [mostrarFeedback, setMostrarFeedback] = useState(false);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const pregunta = preguntas[preguntaActual];
|
||||
const esCorrecta = respuestaSeleccionada === pregunta.correcta;
|
||||
|
||||
const handleSeleccionar = (index: number) => {
|
||||
if (mostrarFeedback) return;
|
||||
setRespuestaSeleccionada(index);
|
||||
};
|
||||
|
||||
const handleValidar = () => {
|
||||
if (respuestaSeleccionada === null) return;
|
||||
|
||||
setMostrarFeedback(true);
|
||||
if (esCorrecta) {
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (preguntaActual < preguntas.length - 1) {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarFeedback(false);
|
||||
} else {
|
||||
const puntuacion = Math.round((respuestasCorrectas + (esCorrecta ? 1 : 0)) / preguntas.length * 100);
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(puntuacion);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const calcularProgreso = () => ((preguntaActual + 1) / preguntas.length) * 100;
|
||||
|
||||
if (completado) {
|
||||
const puntuacionFinal = Math.round(respuestasCorrectas / preguntas.length * 100);
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto p-6 bg-white rounded-xl shadow-lg text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-4"
|
||||
>
|
||||
<Trophy className="w-10 h-10 text-green-600" />
|
||||
</motion.div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">¡Quiz Completado!</h2>
|
||||
<p className="text-gray-600 mb-4">Has respondido {respuestasCorrectas} de {preguntas.length} preguntas correctamente</p>
|
||||
<div className="text-4xl font-bold text-blue-600 mb-2">{puntuacionFinal}/100</div>
|
||||
<p className="text-sm text-gray-500">Puntuación final</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-800">Quiz: Ley de la Demanda</h2>
|
||||
<span className="text-sm text-gray-500">Pregunta {preguntaActual + 1} de {preguntas.length}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-blue-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${calcularProgreso()}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">{pregunta.pregunta}</h3>
|
||||
<div className="space-y-3">
|
||||
{pregunta.opciones.map((opcion, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleSeleccionar(index)}
|
||||
disabled={mostrarFeedback}
|
||||
whileHover={!mostrarFeedback ? { scale: 1.02 } : {}}
|
||||
whileTap={!mostrarFeedback ? { scale: 0.98 } : {}}
|
||||
className={`w-full p-4 rounded-lg border-2 text-left transition-all ${
|
||||
respuestaSeleccionada === index
|
||||
? mostrarFeedback
|
||||
? index === pregunta.correcta
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-red-500 bg-red-50'
|
||||
: 'border-blue-500 bg-blue-50'
|
||||
: mostrarFeedback && index === pregunta.correcta
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{opcion}</span>
|
||||
{mostrarFeedback && index === pregunta.correcta && (
|
||||
<Check className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
{mostrarFeedback && respuestaSeleccionada === index && index !== pregunta.correcta && (
|
||||
<X className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarFeedback && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="mb-6 overflow-hidden"
|
||||
>
|
||||
<div className={`p-4 rounded-lg border ${esCorrecta ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{esCorrecta ? (
|
||||
<Check className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${esCorrecta ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{esCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${esCorrecta ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{pregunta.explicacion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{!mostrarFeedback ? (
|
||||
<button
|
||||
onClick={handleValidar}
|
||||
disabled={respuestaSeleccionada === null}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Validar Respuesta
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSiguiente}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{preguntaActual < preguntas.length - 1 ? 'Siguiente' : 'Finalizar'}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeyDemandaQuiz;
|
||||
340
frontend/src/components/exercises/modulo2/LeyOfertaQuiz.tsx
Normal file
340
frontend/src/components/exercises/modulo2/LeyOfertaQuiz.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { TrendingUp, CheckCircle2, XCircle, Trophy, RotateCcw, BookOpen, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface LeyOfertaQuizProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Pregunta {
|
||||
id: number;
|
||||
pregunta: string;
|
||||
opciones: string[];
|
||||
respuestaCorrecta: number;
|
||||
explicacion: string;
|
||||
}
|
||||
|
||||
const preguntas: Pregunta[] = [
|
||||
{
|
||||
id: 1,
|
||||
pregunta: 'Según la Ley de la Oferta, ¿qué ocurre cuando el precio de un bien aumenta?',
|
||||
opciones: [
|
||||
'Los productores ofrecen menos cantidad',
|
||||
'Los productores ofrecen más cantidad',
|
||||
'La cantidad ofrecida no cambia',
|
||||
'La demanda aumenta'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'La Ley de la Oferta establece que existe una relación directa entre precio y cantidad ofrecida: cuando sube el precio, los productores quieren vender más.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
pregunta: '¿Por qué la curva de oferta tiene pendiente positiva?',
|
||||
opciones: [
|
||||
'Porque a mayor precio, mayor es el costo de producción',
|
||||
'Porque los consumidores compran más cuando bajan los precios',
|
||||
'Porque a mayor precio, más rentable es producir y vender',
|
||||
'Porque el gobierno lo establece así'
|
||||
],
|
||||
respuestaCorrecta: 2,
|
||||
explicacion: 'La pendiente positiva refleja que a precios más altos, la producción es más rentable, incentivando a los productores a ofrecer más cantidad.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
pregunta: 'Un agricultor vende manzanas. Si el precio pasa de $2 a $4 por kg, ¿qué esperamos?',
|
||||
opciones: [
|
||||
'Venderá la misma cantidad de siempre',
|
||||
'Querrá vender menos porque es más caro',
|
||||
'Querrá vender más manzanas al mercado',
|
||||
'Dejará de vender manzanas'
|
||||
],
|
||||
respuestaCorrecta: 2,
|
||||
explicacion: 'Al duplicar el precio, el agricultor tiene más incentivo para llevar más manzanas al mercado, aumentando su oferta.'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
pregunta: '¿Cuál de los siguientes es un movimiento A LO LARGO de la curva de oferta?',
|
||||
opciones: [
|
||||
'Mejora tecnológica que reduce costos',
|
||||
'Aumento del precio del petróleo (insumo)',
|
||||
'Subida del precio del bien, aumentando cantidad ofrecida',
|
||||
'Entrada de nuevos competidores al mercado'
|
||||
],
|
||||
respuestaCorrecta: 2,
|
||||
explicacion: 'El movimiento a lo largo de la curva ocurre solo cuando cambia el precio del propio bien. Los otros factores desplazan toda la curva.'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
pregunta: 'La relación precio-cantidad ofrecida es:',
|
||||
opciones: [
|
||||
'Inversa (negativa)',
|
||||
'Directa (positiva)',
|
||||
'No existe relación',
|
||||
'Depende del tipo de bien'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'La relación es directa o positiva: a mayor precio, mayor cantidad ofrecida. Esto es lo opuesto a la demanda, que tiene relación inversa.'
|
||||
}
|
||||
];
|
||||
|
||||
export const LeyOfertaQuiz: React.FC<LeyOfertaQuizProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [preguntaActual, setPreguntaActual] = useState(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<number | null>(null);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const pregunta = preguntas[preguntaActual];
|
||||
|
||||
const handleSeleccionar = (index: number) => {
|
||||
if (mostrarResultado) return;
|
||||
setRespuestaSeleccionada(index);
|
||||
};
|
||||
|
||||
const handleVerificar = () => {
|
||||
if (respuestaSeleccionada === null) return;
|
||||
|
||||
const esCorrecta = respuestaSeleccionada === pregunta.respuestaCorrecta;
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (esCorrecta) {
|
||||
setScore(prev => prev + Math.round(100 / preguntas.length));
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (preguntaActual < preguntas.length - 1) {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setPreguntaActual(0);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
setScore(0);
|
||||
setRespuestasCorrectas(0);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
const porcentaje = Math.round((respuestasCorrectas / preguntas.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">¡Quiz Completado!</h2>
|
||||
<p className="text-gray-600 mb-6">Has demostrado tu comprensión de la Ley de la Oferta</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 {preguntas.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-3xl 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">
|
||||
<TrendingUp className="w-8 h-8 text-green-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Ley de la Oferta</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
{preguntaActual + 1} de {preguntas.length}
|
||||
</span>
|
||||
<div className="w-32 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-green-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((preguntaActual + 1) / preguntas.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Responde las preguntas sobre la relación entre precio y cantidad ofrecida.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<BookOpen className="w-6 h-6 text-green-600 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">Pregunta {pregunta.id}</h3>
|
||||
<p className="text-gray-700 text-lg">{pregunta.pregunta}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{pregunta.opciones.map((opcion, index) => {
|
||||
const isSelected = respuestaSeleccionada === index;
|
||||
const isCorrect = mostrarResultado && index === pregunta.respuestaCorrecta;
|
||||
const isWrong = mostrarResultado && isSelected && index !== pregunta.respuestaCorrecta;
|
||||
|
||||
let buttonClass = 'w-full p-4 rounded-lg border-2 text-left transition-all flex items-center gap-3 ';
|
||||
|
||||
if (isCorrect) {
|
||||
buttonClass += 'border-green-500 bg-green-50';
|
||||
} else if (isWrong) {
|
||||
buttonClass += 'border-red-500 bg-red-50';
|
||||
} else if (isSelected) {
|
||||
buttonClass += 'border-green-500 bg-green-50';
|
||||
} else {
|
||||
buttonClass += 'border-gray-200 hover:border-gray-300 hover:bg-gray-50';
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleSeleccionar(index)}
|
||||
disabled={mostrarResultado}
|
||||
whileHover={!mostrarResultado ? { scale: 1.01 } : {}}
|
||||
whileTap={!mostrarResultado ? { scale: 0.99 } : {}}
|
||||
className={buttonClass}
|
||||
>
|
||||
<span className={`w-8 h-8 rounded-full flex items-center justify-center font-semibold ${
|
||||
isCorrect ? 'bg-green-500 text-white' :
|
||||
isWrong ? 'bg-red-500 text-white' :
|
||||
isSelected ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-700'
|
||||
}`}>
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
<span className={`flex-1 ${
|
||||
isCorrect ? 'text-green-700 font-semibold' :
|
||||
isWrong ? 'text-red-700' :
|
||||
isSelected ? 'text-green-700' : 'text-gray-700'
|
||||
}`}>
|
||||
{opcion}
|
||||
</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={`mb-6 p-4 rounded-lg ${
|
||||
respuestaSeleccionada === pregunta.respuestaCorrecta
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{respuestaSeleccionada === pregunta.respuestaCorrecta ? (
|
||||
<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 === pregunta.respuestaCorrecta ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{respuestaSeleccionada === pregunta.respuestaCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-gray-700">{pregunta.explicacion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!mostrarResultado ? (
|
||||
<button
|
||||
onClick={handleVerificar}
|
||||
disabled={respuestaSeleccionada === null}
|
||||
className="flex-1 py-3 px-4 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Verificar respuesta
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSiguiente}
|
||||
className="flex-1 py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{preguntaActual < preguntas.length - 1 ? (
|
||||
<>
|
||||
Siguiente
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
) : (
|
||||
'Finalizar'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => setPreguntaActual(Math.max(0, preguntaActual - 1))}
|
||||
disabled={preguntaActual === 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">
|
||||
{preguntas.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
index === preguntaActual
|
||||
? 'bg-green-600'
|
||||
: index < preguntaActual
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setPreguntaActual(Math.min(preguntas.length - 1, preguntaActual + 1))}
|
||||
disabled={preguntaActual === preguntas.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 LeyOfertaQuiz;
|
||||
@@ -0,0 +1,443 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Clock, CheckCircle2, XCircle, Trophy, RotateCcw, TrendingUp, AlertCircle, BookOpen } from 'lucide-react';
|
||||
|
||||
interface OfertaCortoLargoPlazoProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
tipo: 'corto' | 'largo';
|
||||
descripcion: string;
|
||||
tiempo: string;
|
||||
opciones: string[];
|
||||
respuestaCorrecta: number;
|
||||
explicacion: string;
|
||||
}
|
||||
|
||||
const escenarios: Escenario[] = [
|
||||
{
|
||||
id: 1,
|
||||
tipo: 'corto',
|
||||
descripcion: 'El precio del café sube de $5 a $10 por libra. Los agricultores tienen 1 mes para reaccionar.',
|
||||
tiempo: 'Plazo: 1 mes',
|
||||
opciones: [
|
||||
'Pueden plantar más árboles de café y aumentar significativamente la producción',
|
||||
'Solo pueden cosechar más del cultivo existente, aumento limitado de oferta',
|
||||
'La oferta no cambia en absoluto',
|
||||
'Pueden contratar más trabajadores inmediatamente y duplicar la producción'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'En el corto plazo, los agricultores no pueden plantar nuevos árboles (toman 3-4 años en producir). Solo pueden cosechar más del cultivo existente, por lo que el aumento de oferta es limitado.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tipo: 'largo',
|
||||
descripcion: 'El precio del café se mantiene alto a $10 por libra durante 5 años. Los agricultores pueden planificar a futuro.',
|
||||
tiempo: 'Plazo: 5 años',
|
||||
opciones: [
|
||||
'La oferta permanece igual que al inicio',
|
||||
'Solo pueden vender lo que ya tenían almacenado',
|
||||
'Pueden plantar nuevos árboles, expandir fincas y aumentar significativamente la oferta',
|
||||
'El gobierno controla cuánto pueden producir'
|
||||
],
|
||||
respuestaCorrecta: 2,
|
||||
explicacion: 'En el largo plazo, los agricultores pueden hacer todo: plantar nuevos árboles, comprar más tierra, invertir en tecnología. La oferta es mucho más elástica.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
tipo: 'corto',
|
||||
descripcion: 'Una fábrica de autos recibe un pedido urgente. Necesita aumentar la producción esta semana.',
|
||||
tiempo: 'Plazo: 1 semana',
|
||||
opciones: [
|
||||
'Puede construir una nueva planta de producción rápidamente',
|
||||
'Solo puede aumentar turnos existentes y usar inventarios, aumento limitado',
|
||||
'Puede contratar y entrenar a 500 nuevos trabajadores en 2 días',
|
||||
'La producción se duplica automáticamente'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'En el corto plazo, la fábrica no puede construir nuevas instalaciones. Solo puede aumentar turnos, usar inventarios o pedir horas extras. La capacidad de aumentar oferta es limitada.'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
tipo: 'largo',
|
||||
descripcion: 'La demanda de software crece constantemente durante 3 años. Las empresas tecnológicas responden.',
|
||||
tiempo: 'Plazo: 3 años',
|
||||
opciones: [
|
||||
'No pueden hacer nada, la oferta de programadores es fija',
|
||||
'Pueden contratar algunos freelancers temporalmente',
|
||||
'Pueden contratar y formar programadores, expandir oficinas, adaptar toda su capacidad',
|
||||
'El precio sube pero la cantidad ofrecida no cambia'
|
||||
],
|
||||
respuestaCorrecta: 2,
|
||||
explicacion: 'En el largo plazo, las empresas pueden formar nuevos programadores (universidades, bootcamps), abrir oficinas en nuevas ciudades, adaptar completamente su capacidad productiva.'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
tipo: 'corto',
|
||||
descripcion: 'Un huracán destruye refinerías de petróleo. El precio sube. ¿Qué pueden hacer otras refinerías?',
|
||||
tiempo: 'Plazo: Inmediato',
|
||||
opciones: [
|
||||
'Construir nuevas refinerías en un mes',
|
||||
'Operar al máximo de su capacidad existente, aumento muy limitado',
|
||||
'Descubrir petróleo nuevo en semanas',
|
||||
'La oferta de petróleo es infinitamente elástica'
|
||||
],
|
||||
respuestaCorrecta: 1,
|
||||
explicacion: 'Las refinerías existentes ya operan cerca de su capacidad máxima. En el corto plazo no pueden construir nuevas instalaciones (toma años). Solo pueden intentar operar al máximo.'
|
||||
}
|
||||
];
|
||||
|
||||
export const OfertaCortoLargoPlazo: React.FC<OfertaCortoLargoPlazoProps> = ({
|
||||
onComplete,
|
||||
ejercicioId: _ejercicioId
|
||||
}) => {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<number | null>(null);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const escenario = escenarios[escenarioActual];
|
||||
|
||||
const handleSeleccionar = (index: number) => {
|
||||
if (mostrarResultado) return;
|
||||
setRespuestaSeleccionada(index);
|
||||
};
|
||||
|
||||
const handleVerificar = () => {
|
||||
if (respuestaSeleccionada === null) return;
|
||||
|
||||
const esCorrecta = respuestaSeleccionada === escenario.respuestaCorrecta;
|
||||
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);
|
||||
};
|
||||
|
||||
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 comprendido la elasticidad temporal de la oferta</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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-orange-50 rounded-lg border border-orange-200">
|
||||
<Clock className="w-8 h-8 text-orange-600 mx-auto mb-2" />
|
||||
<h3 className="font-semibold text-orange-800">Corto Plazo</h3>
|
||||
<p className="text-sm text-orange-700">Oferta inelástica</p>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<TrendingUp className="w-8 h-8 text-blue-600 mx-auto mb-2" />
|
||||
<h3 className="font-semibold text-blue-800">Largo Plazo</h3>
|
||||
<p className="text-sm text-blue-700">Oferta más elástica</p>
|
||||
</div>
|
||||
</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">
|
||||
<Clock className="w-8 h-8 text-green-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Oferta: Corto vs Largo Plazo</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
escenario.tipo === 'corto'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{escenario.tipo === 'corto' ? 'CORTO PLAZO' : 'LARGO PLAZO'}
|
||||
</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-green-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((escenarioActual + 1) / escenarios.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Determina si el escenario describe el corto plazo (oferta inelástica) o largo plazo (oferta elástica).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-5 gap-6">
|
||||
<div className="md:col-span-3">
|
||||
<motion.div
|
||||
className={`p-6 rounded-lg mb-4 ${
|
||||
escenario.tipo === 'corto'
|
||||
? 'bg-orange-50 border border-orange-200'
|
||||
: 'bg-blue-50 border border-blue-200'
|
||||
}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
key={escenario.id}
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<BookOpen className={`w-6 h-6 flex-shrink-0 mt-0.5 ${
|
||||
escenario.tipo === 'corto' ? 'text-orange-600' : 'text-blue-600'
|
||||
}`} />
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-800 text-lg mb-1">
|
||||
Escenario {escenario.id}
|
||||
</h3>
|
||||
<p className={`inline-block px-2 py-1 rounded text-xs font-medium mb-3 ${
|
||||
escenario.tipo === 'corto'
|
||||
? 'bg-orange-200 text-orange-800'
|
||||
: 'bg-blue-200 text-blue-800'
|
||||
}`}>
|
||||
{escenario.tiempo}
|
||||
</p>
|
||||
<p className="text-gray-700">{escenario.descripcion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{escenario.opciones.map((opcion, index) => {
|
||||
const isSelected = respuestaSeleccionada === index;
|
||||
const isCorrect = mostrarResultado && index === escenario.respuestaCorrecta;
|
||||
const isWrong = mostrarResultado && isSelected && index !== escenario.respuestaCorrecta;
|
||||
|
||||
let buttonClass = 'w-full p-4 rounded-lg border-2 text-left transition-all ';
|
||||
|
||||
if (isCorrect) {
|
||||
buttonClass += 'border-green-500 bg-green-50';
|
||||
} else if (isWrong) {
|
||||
buttonClass += 'border-red-500 bg-red-50';
|
||||
} else if (isSelected) {
|
||||
buttonClass += 'border-green-500 bg-green-50';
|
||||
} else {
|
||||
buttonClass += 'border-gray-200 hover:border-gray-300 hover:bg-gray-50';
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleSeleccionar(index)}
|
||||
disabled={mostrarResultado}
|
||||
whileHover={!mostrarResultado ? { scale: 1.01 } : {}}
|
||||
whileTap={!mostrarResultado ? { scale: 0.99 } : {}}
|
||||
className={buttonClass}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0 ${
|
||||
isCorrect ? 'bg-green-500 text-white' :
|
||||
isWrong ? 'bg-red-500 text-white' :
|
||||
isSelected ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-700'
|
||||
}`}>
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
<span className={`flex-1 ${
|
||||
isCorrect ? 'text-green-700 font-medium' :
|
||||
isWrong ? 'text-red-700' :
|
||||
isSelected ? 'text-green-700' : 'text-gray-700'
|
||||
}`}>
|
||||
{opcion}
|
||||
</span>
|
||||
{isCorrect && <CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />}
|
||||
{isWrong && <XCircle className="w-5 h-5 text-red-600 flex-shrink-0" />}
|
||||
</div>
|
||||
</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.respuestaCorrecta
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{respuestaSeleccionada === escenario.respuestaCorrecta ? (
|
||||
<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.respuestaCorrecta ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{respuestaSeleccionada === escenario.respuestaCorrecta ? '¡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 === null}
|
||||
className="flex-1 py-3 px-4 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Verificar respuesta
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSiguiente}
|
||||
className="flex-1 py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
{escenarioActual < escenarios.length - 1 ? 'Siguiente escenario' : 'Finalizar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
{/* Gráfico de elasticidad */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-4 text-center">Elasticidad de la Oferta</h3>
|
||||
<svg width="250" height="200" className="mx-auto">
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line x1={40 + i * 35} y1="20" x2={40 + i * 35} y2="170" stroke="#e5e7eb" strokeWidth="1" />
|
||||
<line x1="40" y1={20 + i * 30} x2="210" y2={20 + i * 30} stroke="#e5e7eb" strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1="40" y1="170" x2="210" y2="170" stroke="#374151" strokeWidth="2" />
|
||||
<line x1="40" y1="20" x2="40" y2="170" stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Curva corto plazo (más vertical) */}
|
||||
<line x1="70" y1="150" x2="100" y2="50" stroke="#f97316" strokeWidth="3" />
|
||||
<text x="105" y="45" className="text-xs fill-orange-600 font-medium">CP</text>
|
||||
|
||||
{/* Curva largo plazo (más horizontal) */}
|
||||
<line x1="70" y1="140" x2="180" y2="80" stroke="#3b82f6" strokeWidth="3" />
|
||||
<text x="185" y="75" className="text-xs fill-blue-600 font-medium">LP</text>
|
||||
|
||||
{/* Labels */}
|
||||
<text x="125" y="190" textAnchor="middle" className="text-xs fill-gray-600">Cantidad</text>
|
||||
<text x="20" y="95" textAnchor="middle" transform="rotate(-90, 20, 95)" className="text-xs fill-gray-600">Precio</text>
|
||||
</svg>
|
||||
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-0.5 bg-orange-500"></div>
|
||||
<span className="text-gray-600">Corto plazo: inelástica</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-0.5 bg-blue-500"></div>
|
||||
<span className="text-gray-600">Largo plazo: elástica</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info boxes */}
|
||||
<div className="p-4 bg-orange-50 rounded-lg border border-orange-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-5 h-5 text-orange-600" />
|
||||
<h4 className="font-semibold text-orange-800">Corto Plazo</h4>
|
||||
</div>
|
||||
<ul className="text-sm text-orange-700 space-y-1">
|
||||
<li>• Algunos factores son fijos</li>
|
||||
<li>• Difícil cambiar capacidad</li>
|
||||
<li>• Oferta poco sensible a precios</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-5 h-5 text-blue-600" />
|
||||
<h4 className="font-semibold text-blue-800">Largo Plazo</h4>
|
||||
</div>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• Todos los factores son variables</li>
|
||||
<li>• Pueden expandir capacidad</li>
|
||||
<li>• Oferta muy sensible a precios</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicadores de progreso */}
|
||||
<div className="mt-6 flex justify-center items-center gap-1">
|
||||
{escenarios.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
index === escenarioActual
|
||||
? 'bg-green-600'
|
||||
: index < escenarioActual
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OfertaCortoLargoPlazo;
|
||||
412
frontend/src/components/exercises/modulo2/PrecioMaximoTecho.tsx
Normal file
412
frontend/src/components/exercises/modulo2/PrecioMaximoTecho.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowDown, AlertTriangle, Home, Scale, Info, CheckCircle2, XCircle } from 'lucide-react';
|
||||
|
||||
interface PrecioMaximoTechoProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
nombre: string;
|
||||
descripcion: string;
|
||||
pe: number;
|
||||
qe: number;
|
||||
pmax: number;
|
||||
contexto: string;
|
||||
consecuencias: string[];
|
||||
icono: React.ReactNode;
|
||||
}
|
||||
|
||||
const escenarios: Escenario[] = [
|
||||
{
|
||||
id: 1,
|
||||
nombre: "Control de Alquileres",
|
||||
descripcion: "El gobierno establece un precio máximo de $600 para apartamentos cuando el equilibrio está en $800.",
|
||||
pe: 800,
|
||||
qe: 50,
|
||||
pmax: 600,
|
||||
contexto: "Mercado de vivienda en alquiler",
|
||||
consecuencias: [
|
||||
"Escasez de apartamentos disponibles",
|
||||
"Listas de espera cada vez más largas",
|
||||
"Deterioro de la calidad de las viviendas",
|
||||
"Mercado negro de alquileres"
|
||||
],
|
||||
icono: <Home className="w-6 h-6" />
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nombre: "Gasolina Subsidiada",
|
||||
descripcion: "El precio de la gasolina se congela en $3/galón cuando el precio de mercado es $5/galón.",
|
||||
pe: 5,
|
||||
qe: 100,
|
||||
pmax: 3,
|
||||
contexto: "Mercado de combustibles",
|
||||
consecuencias: [
|
||||
"Largas filas en gasolineras",
|
||||
"Desabastecimiento periódico",
|
||||
"Contrabando de combustible",
|
||||
"Inversión insuficiente en refinación"
|
||||
],
|
||||
icono: <AlertTriangle className="w-6 h-6" />
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
nombre: "Medicamentos Esenciales",
|
||||
descripcion: "Precio máximo en medicamentos básicos: $20 cuando cuestan $35 producirlos.",
|
||||
pe: 35,
|
||||
qe: 80,
|
||||
pmax: 20,
|
||||
contexto: "Mercado farmacéutico",
|
||||
consecuencias: [
|
||||
"Desaparición de medicamentos del mercado",
|
||||
"Reducción de la investigación",
|
||||
"Mercado negro de medicinas",
|
||||
"Importación irregular"
|
||||
],
|
||||
icono: <Scale className="w-6 h-6" />
|
||||
}
|
||||
];
|
||||
|
||||
export const PrecioMaximoTecho: React.FC<PrecioMaximoTechoProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [respuestas, setRespuestas] = useState<Record<number, boolean>>({});
|
||||
const [mostrarExplicacion, setMostrarExplicacion] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
|
||||
const escenario = escenarios[escenarioActual];
|
||||
|
||||
// Cálculos para el gráfico
|
||||
const calcularInterseccion = (precio: number) => {
|
||||
const qd = Math.max(0, escenario.qe + (escenario.pe - precio) * 2);
|
||||
const qo = Math.max(0, escenario.qe - (escenario.pe - precio) * 1.5);
|
||||
return { qd, qo };
|
||||
};
|
||||
|
||||
const datosGrafico = useMemo(() => {
|
||||
const { qd, qo } = calcularInterseccion(escenario.pmax);
|
||||
const excesoDemanda = Math.max(0, qd - qo);
|
||||
return {
|
||||
qd,
|
||||
qo,
|
||||
excesoDemanda,
|
||||
cantidadTransada: Math.min(qd, qo)
|
||||
};
|
||||
}, [escenario]);
|
||||
|
||||
const verificarRespuesta = (hayEscasez: boolean) => {
|
||||
const correcto = hayEscasez === true;
|
||||
setRespuestas(prev => ({ ...prev, [escenario.id]: correcto }));
|
||||
setMostrarExplicacion(true);
|
||||
|
||||
if (correcto) {
|
||||
setScore(prev => prev + 33);
|
||||
}
|
||||
|
||||
if (escenarioActual === escenarios.length - 1 && correcto) {
|
||||
setTimeout(() => {
|
||||
onComplete?.(Math.min(100, score + 33));
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const siguienteEscenario = () => {
|
||||
if (escenarioActual < escenarios.length - 1) {
|
||||
setEscenarioActual(prev => prev + 1);
|
||||
setMostrarExplicacion(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Configuración del gráfico SVG
|
||||
const width = 400;
|
||||
const height = 300;
|
||||
const padding = 50;
|
||||
const graphWidth = width - 2 * padding;
|
||||
const graphHeight = height - 2 * padding;
|
||||
|
||||
const maxP = Math.max(escenario.pe, escenario.pmax) * 1.2;
|
||||
const maxQ = escenario.qe * 1.5;
|
||||
|
||||
const scaleX = (q: number) => padding + (q / maxQ) * graphWidth;
|
||||
const scaleY = (p: number) => padding + graphHeight - (p / maxP) * graphHeight;
|
||||
|
||||
// Generar puntos de curvas
|
||||
const puntosDemanda = [];
|
||||
const puntosOferta = [];
|
||||
|
||||
for (let q = 0; q <= maxQ; q += 2) {
|
||||
const pd = escenario.pe + (escenario.pe / escenario.qe) * (escenario.qe - q);
|
||||
const po = escenario.pe * 0.3 + (escenario.pe / escenario.qe) * 0.7 * q;
|
||||
if (pd > 0 && pd <= maxP) puntosDemanda.push({ q, p: pd });
|
||||
if (po > 0 && po <= maxP) puntosOferta.push({ q, p: po });
|
||||
}
|
||||
|
||||
const pathDemanda = puntosDemanda.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.q)} ${scaleY(p.p)}`
|
||||
).join(' ');
|
||||
|
||||
const pathOferta = puntosOferta.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.q)} ${scaleY(p.p)}`
|
||||
).join(' ');
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 rounded-lg">
|
||||
<ArrowDown className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">Precio Máximo (Techo)</h2>
|
||||
<p className="text-gray-600">Analiza los efectos de los controles de precios máximos</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">Ejercicio {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-red-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenido principal */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Panel izquierdo: Escenario y gráfico */}
|
||||
<div className="space-y-4">
|
||||
{/* Tarjeta del escenario */}
|
||||
<motion.div
|
||||
key={escenario.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-4 bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border border-red-200"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm">
|
||||
{escenario.icono}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-800">{escenario.nombre}</h3>
|
||||
<span className="text-sm text-red-600">{escenario.contexto}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700">{escenario.descripcion}</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Gráfico interactivo */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
Análisis Gráfico
|
||||
</h4>
|
||||
<svg width={width} height={height} className="w-full">
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={padding}
|
||||
y1={padding + (i * graphHeight) / 5}
|
||||
x2={padding + graphWidth}
|
||||
y2={padding + (i * graphHeight) / 5}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1={padding + (i * graphWidth) / 5}
|
||||
y1={padding}
|
||||
x2={padding + (i * graphWidth) / 5}
|
||||
y2={padding + graphHeight}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1={padding} y1={padding + graphHeight} x2={padding + graphWidth} y2={padding + graphHeight} stroke="#374151" strokeWidth="2" />
|
||||
<line x1={padding} y1={padding} x2={padding} y2={padding + graphHeight} stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Etiquetas */}
|
||||
<text x={padding + graphWidth / 2} y={height - 10} textAnchor="middle" className="text-xs fill-gray-600">Cantidad</text>
|
||||
<text x={15} y={padding + graphHeight / 2} textAnchor="middle" transform={`rotate(-90, 15, ${padding + graphHeight / 2})`} className="text-xs fill-gray-600">Precio</text>
|
||||
|
||||
{/* Curva de demanda */}
|
||||
<path d={pathDemanda} fill="none" stroke="#3b82f6" strokeWidth="3" />
|
||||
<text x={padding + graphWidth - 30} y={padding + 20} className="text-sm fill-blue-600 font-bold">D</text>
|
||||
|
||||
{/* Curva de oferta */}
|
||||
<path d={pathOferta} fill="none" stroke="#22c55e" strokeWidth="3" />
|
||||
<text x={padding + graphWidth - 30} y={padding + graphHeight - 30} className="text-sm fill-green-600 font-bold">S</text>
|
||||
|
||||
{/* Punto de equilibrio */}
|
||||
<circle cx={scaleX(escenario.qe)} cy={scaleY(escenario.pe)} r="6" fill="#8b5cf6" stroke="white" strokeWidth="2" />
|
||||
<text x={scaleX(escenario.qe) + 10} y={scaleY(escenario.pe) - 10} className="text-xs fill-purple-600 font-bold">E</text>
|
||||
|
||||
{/* Línea de precio máximo */}
|
||||
<line
|
||||
x1={padding}
|
||||
y1={scaleY(escenario.pmax)}
|
||||
x2={padding + graphWidth}
|
||||
y2={scaleY(escenario.pmax)}
|
||||
stroke="#ef4444"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<text x={padding + graphWidth - 40} y={scaleY(escenario.pmax) - 5} className="text-xs fill-red-600 font-bold">Pmax</text>
|
||||
|
||||
{/* Puntos de intersección con Pmax */}
|
||||
<circle cx={scaleX(datosGrafico.qd)} cy={scaleY(escenario.pmax)} r="5" fill="#3b82f6" stroke="white" strokeWidth="2" />
|
||||
<circle cx={scaleX(datosGrafico.qo)} cy={scaleY(escenario.pmax)} r="5" fill="#22c55e" stroke="white" strokeWidth="2" />
|
||||
|
||||
{/* Zona de escasez */}
|
||||
{datosGrafico.excesoDemanda > 0 && (
|
||||
<g>
|
||||
<rect
|
||||
x={scaleX(datosGrafico.qo)}
|
||||
y={scaleY(escenario.pmax) - 20}
|
||||
width={scaleX(datosGrafico.qd) - scaleX(datosGrafico.qo)}
|
||||
height="20"
|
||||
fill="#fef3c7"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<text
|
||||
x={scaleX((datosGrafico.qo + datosGrafico.qd) / 2)}
|
||||
y={scaleY(escenario.pmax) - 25}
|
||||
textAnchor="middle"
|
||||
className="text-xs fill-amber-700 font-bold"
|
||||
>
|
||||
Escasez: {datosGrafico.excesoDemanda.toFixed(1)}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel derecho: Pregunta y consecuencias */}
|
||||
<div className="space-y-4">
|
||||
{/* Pregunta */}
|
||||
<AnimatePresence mode="wait">
|
||||
{!mostrarExplicacion ? (
|
||||
<motion.div
|
||||
key="pregunta"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="p-6 bg-white border-2 border-gray-200 rounded-lg"
|
||||
>
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-4">
|
||||
¿Qué ocurrirá en este mercado con el precio máximo establecido?
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => verificarRespuesta(true)}
|
||||
className="w-full p-4 text-left bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors flex items-center gap-3"
|
||||
>
|
||||
<ArrowDown className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<span className="font-semibold text-red-800">Habrá escasez</span>
|
||||
<p className="text-sm text-red-600">La demanda excederá a la oferta</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => verificarRespuesta(false)}
|
||||
className="w-full p-4 text-left bg-green-50 hover:bg-green-100 border border-green-200 rounded-lg transition-colors flex items-center gap-3"
|
||||
>
|
||||
<Scale className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<span className="font-semibold text-green-800">El mercado se equilibrará</span>
|
||||
<p className="text-sm text-green-600">La cantidad demandada igualará a la ofrecida</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="explicacion"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-6 rounded-lg border-2 ${respuestas[escenario.id] ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{respuestas[escenario.id] ? (
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-8 h-8 text-red-600" />
|
||||
)}
|
||||
<h3 className={`text-lg font-bold ${respuestas[escenario.id] ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{respuestas[escenario.id] ? '¡Correcto!' : 'Incorrecto'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Al fijar un precio máximo <strong>por debajo</strong> del precio de equilibrio (${escenario.pe}),
|
||||
se crea una escasez porque:
|
||||
</p>
|
||||
<ul className="space-y-2 mb-6">
|
||||
<li className="flex items-start gap-2 text-gray-700">
|
||||
<span className="text-blue-600 font-bold">↓</span>
|
||||
<span>Los productores reducen la cantidad ofrecida a {datosGrafico.qo.toFixed(1)} unidades</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-gray-700">
|
||||
<span className="text-green-600 font-bold">↑</span>
|
||||
<span>Los consumidores aumentan la cantidad demandada a {datosGrafico.qd.toFixed(1)} unidades</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-gray-700">
|
||||
<span className="text-amber-600 font-bold">!</span>
|
||||
<span>Resultado: Exceso de demanda de {datosGrafico.excesoDemanda.toFixed(1)} unidades</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{escenarioActual < escenarios.length - 1 ? (
|
||||
<button
|
||||
onClick={siguienteEscenario}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Siguiente Escenario →
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-center p-4 bg-green-100 rounded-lg">
|
||||
<p className="font-semibold text-green-800">¡Ejercicio completado!</p>
|
||||
<p className="text-sm text-green-600">Has analizado todos los escenarios</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Consecuencias */}
|
||||
<div className="p-4 bg-amber-50 rounded-lg border border-amber-200">
|
||||
<h4 className="font-semibold text-amber-800 mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Consecuencias Típicas
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{escenario.consecuencias.map((consecuencia, idx) => (
|
||||
<motion.li
|
||||
key={idx}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className="flex items-start gap-2 text-sm text-amber-900"
|
||||
>
|
||||
<span className="text-amber-600 font-bold mt-0.5">•</span>
|
||||
{consecuencia}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrecioMaximoTecho;
|
||||
432
frontend/src/components/exercises/modulo2/PrecioMinimoPiso.tsx
Normal file
432
frontend/src/components/exercises/modulo2/PrecioMinimoPiso.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowUp, AlertTriangle, Briefcase, Wheat, Info, CheckCircle2, XCircle } from 'lucide-react';
|
||||
|
||||
interface PrecioMinimoPisoProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
nombre: string;
|
||||
descripcion: string;
|
||||
pe: number;
|
||||
qe: number;
|
||||
pmin: number;
|
||||
contexto: string;
|
||||
consecuencias: string[];
|
||||
icono: React.ReactNode;
|
||||
}
|
||||
|
||||
const escenarios: Escenario[] = [
|
||||
{
|
||||
id: 1,
|
||||
nombre: "Salario Mínimo",
|
||||
descripcion: "El salario mínimo se fija en $15/hora cuando el equilibrio del mercado laboral está en $10/hora.",
|
||||
pe: 10,
|
||||
qe: 1000,
|
||||
pmin: 15,
|
||||
contexto: "Mercado laboral",
|
||||
consecuencias: [
|
||||
"Reducción de la demanda de trabajadores",
|
||||
"Aumento del desempleo",
|
||||
"Beneficio para trabajadores que conservan empleo",
|
||||
"Posible mercado laboral informal"
|
||||
],
|
||||
icono: <Briefcase className="w-6 h-6" />
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nombre: "Precio de Soporte Agrícola",
|
||||
descripcion: "El gobierno garantiza $5/bushel de trigo cuando el precio de mercado es $3/bushel.",
|
||||
pe: 3,
|
||||
qe: 500,
|
||||
pmin: 5,
|
||||
contexto: "Mercado agrícola",
|
||||
consecuencias: [
|
||||
"Superávit de producción agrícola",
|
||||
"El gobierno debe comprar el exceso",
|
||||
"Costos fiscales significativos",
|
||||
"Posible despilfarro de recursos"
|
||||
],
|
||||
icono: <Wheat className="w-6 h-6" />
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
nombre: "Tarifa Mínima de Taxis",
|
||||
descripcion: "La tarifa mínima se establece en $25 cuando el precio de equilibrio es $15 por viaje.",
|
||||
pe: 15,
|
||||
qe: 200,
|
||||
pmin: 25,
|
||||
contexto: "Mercado de transporte",
|
||||
consecuencias: [
|
||||
"Menor demanda de servicios de taxi",
|
||||
"Exceso de oferta (taxis vacíos)",
|
||||
"Aparición de competencia informal",
|
||||
"Beneficio para conductores con clientes"
|
||||
],
|
||||
icono: <AlertTriangle className="w-6 h-6" />
|
||||
}
|
||||
];
|
||||
|
||||
export const PrecioMinimoPiso: React.FC<PrecioMinimoPisoProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [respuestas, setRespuestas] = useState<Record<number, boolean>>({});
|
||||
const [mostrarExplicacion, setMostrarExplicacion] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
|
||||
const escenario = escenarios[escenarioActual];
|
||||
|
||||
// Cálculos para el gráfico
|
||||
const calcularInterseccion = (precio: number) => {
|
||||
const qd = Math.max(0, escenario.qe - (precio - escenario.pe) * 1.5);
|
||||
const qo = Math.max(0, escenario.qe + (precio - escenario.pe) * 2);
|
||||
return { qd, qo };
|
||||
};
|
||||
|
||||
const datosGrafico = useMemo(() => {
|
||||
const { qd, qo } = calcularInterseccion(escenario.pmin);
|
||||
const excesoOferta = Math.max(0, qo - qd);
|
||||
return {
|
||||
qd,
|
||||
qo,
|
||||
excesoOferta,
|
||||
cantidadTransada: Math.min(qd, qo)
|
||||
};
|
||||
}, [escenario]);
|
||||
|
||||
const verificarRespuesta = (haySuperavit: boolean) => {
|
||||
const correcto = haySuperavit === true;
|
||||
setRespuestas(prev => ({ ...prev, [escenario.id]: correcto }));
|
||||
setMostrarExplicacion(true);
|
||||
|
||||
if (correcto) {
|
||||
setScore(prev => prev + 33);
|
||||
}
|
||||
|
||||
if (escenarioActual === escenarios.length - 1 && correcto) {
|
||||
setTimeout(() => {
|
||||
onComplete?.(Math.min(100, score + 33));
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const siguienteEscenario = () => {
|
||||
if (escenarioActual < escenarios.length - 1) {
|
||||
setEscenarioActual(prev => prev + 1);
|
||||
setMostrarExplicacion(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Configuración del gráfico SVG
|
||||
const width = 400;
|
||||
const height = 300;
|
||||
const padding = 50;
|
||||
const graphWidth = width - 2 * padding;
|
||||
const graphHeight = height - 2 * padding;
|
||||
|
||||
const maxP = escenario.pmin * 1.2;
|
||||
const maxQ = Math.max(datosGrafico.qo, escenario.qe) * 1.3;
|
||||
|
||||
const scaleX = (q: number) => padding + (q / maxQ) * graphWidth;
|
||||
const scaleY = (p: number) => padding + graphHeight - (p / maxP) * graphHeight;
|
||||
|
||||
// Generar puntos de curvas
|
||||
const puntosDemanda = [];
|
||||
const puntosOferta = [];
|
||||
|
||||
for (let q = 0; q <= maxQ; q += 5) {
|
||||
const pd = escenario.pe + (escenario.pe / escenario.qe) * (escenario.qe - q);
|
||||
const po = escenario.pe * 0.5 + (escenario.pe / escenario.qe) * q;
|
||||
if (pd > 0 && pd <= maxP) puntosDemanda.push({ q, p: pd });
|
||||
if (po > 0 && po <= maxP) puntosOferta.push({ q, p: po });
|
||||
}
|
||||
|
||||
const pathDemanda = puntosDemanda.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.q)} ${scaleY(p.p)}`
|
||||
).join(' ');
|
||||
|
||||
const pathOferta = puntosOferta.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.q)} ${scaleY(p.p)}`
|
||||
).join(' ');
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 rounded-lg">
|
||||
<ArrowUp className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">Precio Mínimo (Piso)</h2>
|
||||
<p className="text-gray-600">Analiza los efectos de los precios mínimos o precios de soporte</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">Ejercicio {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-amber-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenido principal */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Panel izquierdo: Escenario y gráfico */}
|
||||
<div className="space-y-4">
|
||||
{/* Tarjeta del escenario */}
|
||||
<motion.div
|
||||
key={escenario.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-4 bg-gradient-to-br from-amber-50 to-yellow-50 rounded-lg border border-amber-200"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm">
|
||||
{escenario.icono}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-800">{escenario.nombre}</h3>
|
||||
<span className="text-sm text-amber-600">{escenario.contexto}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700">{escenario.descripcion}</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Gráfico interactivo */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
Análisis Gráfico
|
||||
</h4>
|
||||
<svg width={width} height={height} className="w-full">
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={padding}
|
||||
y1={padding + (i * graphHeight) / 5}
|
||||
x2={padding + graphWidth}
|
||||
y2={padding + (i * graphHeight) / 5}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1={padding + (i * graphWidth) / 5}
|
||||
y1={padding}
|
||||
x2={padding + (i * graphWidth) / 5}
|
||||
y2={padding + graphHeight}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1={padding} y1={padding + graphHeight} x2={padding + graphWidth} y2={padding + graphHeight} stroke="#374151" strokeWidth="2" />
|
||||
<line x1={padding} y1={padding} x2={padding} y2={padding + graphHeight} stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Etiquetas */}
|
||||
<text x={padding + graphWidth / 2} y={height - 10} textAnchor="middle" className="text-xs fill-gray-600">Cantidad</text>
|
||||
<text x={15} y={padding + graphHeight / 2} textAnchor="middle" transform={`rotate(-90, 15, ${padding + graphHeight / 2})`} className="text-xs fill-gray-600">Precio</text>
|
||||
|
||||
{/* Curva de demanda */}
|
||||
<path d={pathDemanda} fill="none" stroke="#3b82f6" strokeWidth="3" />
|
||||
<text x={padding + graphWidth - 30} y={padding + 20} className="text-sm fill-blue-600 font-bold">D</text>
|
||||
|
||||
{/* Curva de oferta */}
|
||||
<path d={pathOferta} fill="none" stroke="#22c55e" strokeWidth="3" />
|
||||
<text x={padding + graphWidth - 30} y={padding + graphHeight - 30} className="text-sm fill-green-600 font-bold">S</text>
|
||||
|
||||
{/* Punto de equilibrio */}
|
||||
<circle cx={scaleX(escenario.qe)} cy={scaleY(escenario.pe)} r="6" fill="#8b5cf6" stroke="white" strokeWidth="2" />
|
||||
<text x={scaleX(escenario.qe) + 10} y={scaleY(escenario.pe) + 15} className="text-xs fill-purple-600 font-bold">E</text>
|
||||
|
||||
{/* Línea de precio mínimo */}
|
||||
<line
|
||||
x1={padding}
|
||||
y1={scaleY(escenario.pmin)}
|
||||
x2={padding + graphWidth}
|
||||
y2={scaleY(escenario.pmin)}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<text x={padding + graphWidth - 40} y={scaleY(escenario.pmin) - 5} className="text-xs fill-amber-600 font-bold">Pmin</text>
|
||||
|
||||
{/* Puntos de intersección con Pmin */}
|
||||
<circle cx={scaleX(datosGrafico.qd)} cy={scaleY(escenario.pmin)} r="5" fill="#3b82f6" stroke="white" strokeWidth="2" />
|
||||
<circle cx={scaleX(datosGrafico.qo)} cy={scaleY(escenario.pmin)} r="5" fill="#22c55e" stroke="white" strokeWidth="2" />
|
||||
|
||||
{/* Zona de superávit */}
|
||||
{datosGrafico.excesoOferta > 0 && (
|
||||
<g>
|
||||
<rect
|
||||
x={scaleX(datosGrafico.qd)}
|
||||
y={scaleY(escenario.pmin)}
|
||||
width={scaleX(datosGrafico.qo) - scaleX(datosGrafico.qd)}
|
||||
height="20"
|
||||
fill="#fef3c7"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<text
|
||||
x={scaleX((datosGrafico.qd + datosGrafico.qo) / 2)}
|
||||
y={scaleY(escenario.pmin) + 35}
|
||||
textAnchor="middle"
|
||||
className="text-xs fill-amber-700 font-bold"
|
||||
>
|
||||
Superávit: {datosGrafico.excesoOferta.toFixed(0)}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Flechas indicadoras */}
|
||||
{datosGrafico.excesoOferta > 0 && (
|
||||
<g>
|
||||
<line
|
||||
x1={scaleX(datosGrafico.qd)}
|
||||
y1={scaleY(escenario.pmin) + 30}
|
||||
x2={scaleX(datosGrafico.qo)}
|
||||
y2={scaleY(escenario.pmin) + 30}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#f59e0b" />
|
||||
</marker>
|
||||
</defs>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel derecho: Pregunta y consecuencias */}
|
||||
<div className="space-y-4">
|
||||
{/* Pregunta */}
|
||||
<AnimatePresence mode="wait">
|
||||
{!mostrarExplicacion ? (
|
||||
<motion.div
|
||||
key="pregunta"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="p-6 bg-white border-2 border-gray-200 rounded-lg"
|
||||
>
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-4">
|
||||
¿Qué ocurrirá en este mercado con el precio mínimo establecido?
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => verificarRespuesta(true)}
|
||||
className="w-full p-4 text-left bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-lg transition-colors flex items-center gap-3"
|
||||
>
|
||||
<ArrowUp className="w-5 h-5 text-amber-600" />
|
||||
<div>
|
||||
<span className="font-semibold text-amber-800">Habrá superávit</span>
|
||||
<p className="text-sm text-amber-600">La oferta excederá a la demanda</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => verificarRespuesta(false)}
|
||||
className="w-full p-4 text-left bg-green-50 hover:bg-green-100 border border-green-200 rounded-lg transition-colors flex items-center gap-3"
|
||||
>
|
||||
<AlertTriangle className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<span className="font-semibold text-green-800">El mercado se equilibrará</span>
|
||||
<p className="text-sm text-green-600">La cantidad demandada igualará a la ofrecida</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="explicacion"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-6 rounded-lg border-2 ${respuestas[escenario.id] ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{respuestas[escenario.id] ? (
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-8 h-8 text-red-600" />
|
||||
)}
|
||||
<h3 className={`text-lg font-bold ${respuestas[escenario.id] ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{respuestas[escenario.id] ? '¡Correcto!' : 'Incorrecto'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Al fijar un precio mínimo <strong>por encima</strong> del precio de equilibrio (${escenario.pe}),
|
||||
se crea un superávit porque:
|
||||
</p>
|
||||
<ul className="space-y-2 mb-6">
|
||||
<li className="flex items-start gap-2 text-gray-700">
|
||||
<span className="text-green-600 font-bold">↑</span>
|
||||
<span>Los productores aumentan la cantidad ofrecida a {datosGrafico.qo.toFixed(0)} unidades</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-gray-700">
|
||||
<span className="text-blue-600 font-bold">↓</span>
|
||||
<span>Los consumidores reducen la cantidad demandada a {datosGrafico.qd.toFixed(0)} unidades</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-gray-700">
|
||||
<span className="text-amber-600 font-bold">!</span>
|
||||
<span>Resultado: Exceso de oferta de {datosGrafico.excesoOferta.toFixed(0)} unidades</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{escenarioActual < escenarios.length - 1 ? (
|
||||
<button
|
||||
onClick={siguienteEscenario}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Siguiente Escenario →
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-center p-4 bg-green-100 rounded-lg">
|
||||
<p className="font-semibold text-green-800">¡Ejercicio completado!</p>
|
||||
<p className="text-sm text-green-600">Has analizado todos los escenarios</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Consecuencias */}
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-semibold text-blue-800 mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Consecuencias Típicas
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{escenario.consecuencias.map((consecuencia, idx) => (
|
||||
<motion.li
|
||||
key={idx}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className="flex items-start gap-2 text-sm text-blue-900"
|
||||
>
|
||||
<span className="text-blue-600 font-bold mt-0.5">•</span>
|
||||
{consecuencia}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrecioMinimoPiso;
|
||||
600
frontend/src/components/exercises/modulo2/SimuladorControles.tsx
Normal file
600
frontend/src/components/exercises/modulo2/SimuladorControles.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Play, RotateCcw, TrendingDown, TrendingUp, Scale, AlertTriangle, Calculator, BarChart3 } from 'lucide-react';
|
||||
|
||||
interface SimuladorControlesProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface ResultadoSimulacion {
|
||||
tipo: 'equilibrio' | 'precio-maximo' | 'precio-minimo';
|
||||
precio: number;
|
||||
cantidad: number;
|
||||
excesoDemanda: number;
|
||||
excesoOferta: number;
|
||||
pesoMuerto: number;
|
||||
mensaje: string;
|
||||
}
|
||||
|
||||
interface EscenarioPredefinido {
|
||||
id: string;
|
||||
nombre: string;
|
||||
descripcion: string;
|
||||
tipo: 'maximo' | 'minimo' | 'libre';
|
||||
precio: number;
|
||||
pe: number;
|
||||
qe: number;
|
||||
}
|
||||
|
||||
const escenariosPredefinidos: EscenarioPredefinido[] = [
|
||||
{
|
||||
id: 'libre',
|
||||
nombre: "Mercado Libre",
|
||||
descripcion: "Sin intervención gubernamental",
|
||||
tipo: 'libre',
|
||||
precio: 0,
|
||||
pe: 50,
|
||||
qe: 100
|
||||
},
|
||||
{
|
||||
id: 'rent-control',
|
||||
nombre: "Control de Alquileres",
|
||||
descripcion: "Precio máximo de $35 (equilibrio: $50)",
|
||||
tipo: 'maximo',
|
||||
precio: 35,
|
||||
pe: 50,
|
||||
qe: 100
|
||||
},
|
||||
{
|
||||
id: 'salario-minimo',
|
||||
nombre: "Salario Mínimo",
|
||||
descripcion: "Precio mínimo de $65 (equilibrio: $50)",
|
||||
tipo: 'minimo',
|
||||
precio: 65,
|
||||
pe: 50,
|
||||
qe: 100
|
||||
}
|
||||
];
|
||||
|
||||
export const SimuladorControles: React.FC<SimuladorControlesProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [escenarioActivo, setEscenarioActivo] = useState<string | null>(null);
|
||||
const [precioControl, setPrecioControl] = useState(50);
|
||||
const [tipoControl, setTipoControl] = useState<'maximo' | 'minimo' | null>(null);
|
||||
const [historial, setHistorial] = useState<ResultadoSimulacion[]>([]);
|
||||
const [score, setScore] = useState(0);
|
||||
|
||||
const pe = 50; // Precio de equilibrio
|
||||
const qe = 100; // Cantidad de equilibrio
|
||||
|
||||
// Funciones de demanda y oferta lineales
|
||||
const calcularCantidades = (p: number) => {
|
||||
// Demanda: Qd = 150 - 1*P (pendiente negativa)
|
||||
const qd = Math.max(0, 150 - p);
|
||||
// Oferta: Qo = 0 + 2*P (pendiente positiva)
|
||||
const qo = Math.max(0, 2 * p);
|
||||
return { qd, qo };
|
||||
};
|
||||
|
||||
const resultado = useMemo((): ResultadoSimulacion => {
|
||||
if (!tipoControl) {
|
||||
return {
|
||||
tipo: 'equilibrio',
|
||||
precio: pe,
|
||||
cantidad: qe,
|
||||
excesoDemanda: 0,
|
||||
excesoOferta: 0,
|
||||
pesoMuerto: 0,
|
||||
mensaje: 'Mercado en equilibrio libre'
|
||||
};
|
||||
}
|
||||
|
||||
const { qd, qo } = calcularCantidades(precioControl);
|
||||
const cantidadTransada = Math.min(qd, qo);
|
||||
const excesoDemanda = Math.max(0, qd - qo);
|
||||
const excesoOferta = Math.max(0, qo - qd);
|
||||
|
||||
// Calcular pérdida de peso muerto (triángulo)
|
||||
const base = qe - cantidadTransada;
|
||||
const altura = tipoControl === 'maximo'
|
||||
? pe - precioControl
|
||||
: precioControl - pe;
|
||||
const pesoMuerto = 0.5 * base * altura;
|
||||
|
||||
if (tipoControl === 'maximo' && precioControl < pe) {
|
||||
return {
|
||||
tipo: 'precio-maximo',
|
||||
precio: precioControl,
|
||||
cantidad: cantidadTransada,
|
||||
excesoDemanda,
|
||||
excesoOferta: 0,
|
||||
pesoMuerto: Math.max(0, pesoMuerto),
|
||||
mensaje: `Precio máximo crea escasez de ${excesoDemanda.toFixed(1)} unidades`
|
||||
};
|
||||
}
|
||||
|
||||
if (tipoControl === 'minimo' && precioControl > pe) {
|
||||
return {
|
||||
tipo: 'precio-minimo',
|
||||
precio: precioControl,
|
||||
cantidad: cantidadTransada,
|
||||
excesoDemanda: 0,
|
||||
excesoOferta,
|
||||
pesoMuerto: Math.max(0, pesoMuerto),
|
||||
mensaje: `Precio mínimo crea superávit de ${excesoOferta.toFixed(1)} unidades`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tipo: 'equilibrio',
|
||||
precio: precioControl,
|
||||
cantidad: Math.min(qd, qo),
|
||||
excesoDemanda: 0,
|
||||
excesoOferta: 0,
|
||||
pesoMuerto: 0,
|
||||
mensaje: tipoControl === 'maximo'
|
||||
? 'El precio máximo no es restrictivo (está por encima del equilibrio)'
|
||||
: 'El precio mínimo no es restrictivo (está por debajo del equilibrio)'
|
||||
};
|
||||
}, [precioControl, tipoControl]);
|
||||
|
||||
const aplicarEscenario = (escenario: EscenarioPredefinido) => {
|
||||
setEscenarioActivo(escenario.id);
|
||||
|
||||
if (escenario.tipo === 'libre') {
|
||||
setTipoControl(null);
|
||||
setPrecioControl(escenario.pe);
|
||||
} else {
|
||||
setTipoControl(escenario.tipo);
|
||||
setPrecioControl(escenario.precio);
|
||||
}
|
||||
|
||||
// Agregar al historial
|
||||
const nuevoResultado: ResultadoSimulacion = {
|
||||
tipo: escenario.tipo === 'libre' ? 'equilibrio' : escenario.tipo === 'maximo' ? 'precio-maximo' : 'precio-minimo',
|
||||
precio: escenario.tipo === 'libre' ? escenario.pe : escenario.precio,
|
||||
cantidad: escenario.qe,
|
||||
excesoDemanda: escenario.tipo === 'maximo' ? 30 : 0,
|
||||
excesoOferta: escenario.tipo === 'minimo' ? 30 : 0,
|
||||
pesoMuerto: escenario.tipo === 'libre' ? 0 : 225,
|
||||
mensaje: escenario.nombre
|
||||
};
|
||||
|
||||
setHistorial(prev => [...prev.slice(-2), nuevoResultado]);
|
||||
|
||||
if (score < 100) {
|
||||
setScore(prev => Math.min(100, prev + 25));
|
||||
}
|
||||
|
||||
if (historial.length >= 2) {
|
||||
setTimeout(() => {
|
||||
onComplete?.(100);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setEscenarioActivo(null);
|
||||
setTipoControl(null);
|
||||
setPrecioControl(50);
|
||||
setHistorial([]);
|
||||
setScore(0);
|
||||
};
|
||||
|
||||
// Configuración del gráfico SVG
|
||||
const width = 450;
|
||||
const height = 350;
|
||||
const padding = 50;
|
||||
const graphWidth = width - 2 * padding;
|
||||
const graphHeight = height - 2 * padding;
|
||||
|
||||
const maxP = 80;
|
||||
const maxQ = 150;
|
||||
|
||||
const scaleX = (q: number) => padding + (q / maxQ) * graphWidth;
|
||||
const scaleY = (p: number) => padding + graphHeight - (p / maxP) * graphHeight;
|
||||
|
||||
// Puntos para curvas
|
||||
const puntosDemanda = [];
|
||||
const puntosOferta = [];
|
||||
|
||||
for (let p = 0; p <= maxP; p += 2) {
|
||||
const { qd, qo } = calcularCantidades(p);
|
||||
puntosDemanda.push({ p, q: qd });
|
||||
puntosOferta.push({ p, q: qo });
|
||||
}
|
||||
|
||||
const pathDemanda = puntosDemanda
|
||||
.filter(p => p.q >= 0 && p.q <= maxQ)
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(p.q)} ${scaleY(p.p)}`)
|
||||
.join(' ');
|
||||
|
||||
const pathOferta = puntosOferta
|
||||
.filter(p => p.q >= 0 && p.q <= maxQ)
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(p.q)} ${scaleY(p.p)}`)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<Calculator className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">Simulador de Controles</h2>
|
||||
<p className="text-gray-600">Experimenta con diferentes controles de precio y observa las consecuencias</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<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: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Panel izquierdo: Escenarios */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-gray-700 flex items-center gap-2">
|
||||
<Play className="w-4 h-4" />
|
||||
Escenarios Predefinidos
|
||||
</h3>
|
||||
|
||||
{escenariosPredefinidos.map((escenario) => (
|
||||
<button
|
||||
key={escenario.id}
|
||||
onClick={() => aplicarEscenario(escenario)}
|
||||
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
|
||||
escenarioActivo === escenario.id
|
||||
? 'bg-purple-50 border-purple-400 shadow-md'
|
||||
: 'bg-white border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-gray-800">{escenario.nombre}</span>
|
||||
{escenarioActivo === escenario.id && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="w-3 h-3 bg-purple-500 rounded-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{escenario.descripcion}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
escenario.tipo === 'maximo' ? 'bg-red-100 text-red-700' :
|
||||
escenario.tipo === 'minimo' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{escenario.tipo === 'maximo' ? 'Precio Máximo' :
|
||||
escenario.tipo === 'minimo' ? 'Precio Mínimo' :
|
||||
'Libre'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Control manual */}
|
||||
<div className="p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<h4 className="font-semibold text-gray-700 mb-3">Control Manual</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setTipoControl('maximo')}
|
||||
className={`flex-1 py-2 px-3 rounded text-sm font-medium transition-colors ${
|
||||
tipoControl === 'maximo'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-white border border-gray-300 text-gray-700 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
Precio Máx
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTipoControl('minimo')}
|
||||
className={`flex-1 py-2 px-3 rounded text-sm font-medium transition-colors ${
|
||||
tipoControl === 'minimo'
|
||||
? 'bg-amber-600 text-white'
|
||||
: 'bg-white border border-gray-300 text-gray-700 hover:bg-amber-50'
|
||||
}`}
|
||||
>
|
||||
Precio Mín
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTipoControl(null)}
|
||||
className={`flex-1 py-2 px-3 rounded text-sm font-medium transition-colors ${
|
||||
tipoControl === null
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-white border border-gray-300 text-gray-700 hover:bg-green-50'
|
||||
}`}
|
||||
>
|
||||
Libre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tipoControl && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Precio: ${precioControl}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="75"
|
||||
value={precioControl}
|
||||
onChange={(e) => setPrecioControl(Number(e.target.value))}
|
||||
className={`w-full ${
|
||||
tipoControl === 'maximo' ? 'accent-red-500' : 'accent-amber-500'
|
||||
}`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>$10</span>
|
||||
<span className="text-gray-400">Equilibrio: $50</span>
|
||||
<span>$75</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel central: Gráfico */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Gráfico de Mercado
|
||||
</h3>
|
||||
|
||||
<svg width={width} height={height} className="w-full">
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={padding}
|
||||
y1={padding + (i * graphHeight) / 5}
|
||||
x2={padding + graphWidth}
|
||||
y2={padding + (i * graphHeight) / 5}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1={padding + (i * graphWidth) / 5}
|
||||
y1={padding}
|
||||
x2={padding + (i * graphWidth) / 5}
|
||||
y2={padding + graphHeight}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1={padding} y1={padding + graphHeight} x2={padding + graphWidth} y2={padding + graphHeight} stroke="#374151" strokeWidth="2" />
|
||||
<line x1={padding} y1={padding} x2={padding} y2={padding + graphHeight} stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Etiquetas */}
|
||||
<text x={padding + graphWidth / 2} y={height - 10} textAnchor="middle" className="text-xs fill-gray-600">Cantidad</text>
|
||||
<text x={15} y={padding + graphHeight / 2} textAnchor="middle" transform={`rotate(-90, 15, ${padding + graphHeight / 2})`} className="text-xs fill-gray-600">Precio ($)</text>
|
||||
|
||||
{/* Curvas */}
|
||||
<path d={pathDemanda} fill="none" stroke="#3b82f6" strokeWidth="3" />
|
||||
<text x={padding + 10} y={padding + 15} className="text-sm fill-blue-600 font-bold">D</text>
|
||||
|
||||
<path d={pathOferta} fill="none" stroke="#22c55e" strokeWidth="3" />
|
||||
<text x={padding + graphWidth - 30} y={padding + graphHeight - 10} className="text-sm fill-green-600 font-bold">S</text>
|
||||
|
||||
{/* Punto de equilibrio */}
|
||||
<circle cx={scaleX(qe)} cy={scaleY(pe)} r="6" fill="#8b5cf6" stroke="white" strokeWidth="2" />
|
||||
<text x={scaleX(qe) + 10} y={scaleY(pe) - 10} className="text-xs fill-purple-600 font-bold">E ($50)</text>
|
||||
|
||||
{/* Línea de control */}
|
||||
<AnimatePresence>
|
||||
{tipoControl && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<line
|
||||
x1={padding}
|
||||
y1={scaleY(precioControl)}
|
||||
x2={padding + graphWidth}
|
||||
y2={scaleY(precioControl)}
|
||||
stroke={tipoControl === 'maximo' ? '#ef4444' : '#f59e0b'}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<text
|
||||
x={padding + 5}
|
||||
y={scaleY(precioControl) - 5}
|
||||
className={`text-xs font-bold ${tipoControl === 'maximo' ? 'fill-red-600' : 'fill-amber-600'}`}
|
||||
>
|
||||
{tipoControl === 'maximo' ? 'Pmax' : 'Pmin'}: ${precioControl}
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Indicadores de desequilibrio */}
|
||||
<AnimatePresence>
|
||||
{resultado.excesoDemanda > 0 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<rect
|
||||
x={scaleX(resultado.cantidad)}
|
||||
y={scaleY(precioControl) - 25}
|
||||
width={scaleX(resultado.excesoDemanda) - padding}
|
||||
height="20"
|
||||
fill="#fef3c7"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<text
|
||||
x={scaleX(resultado.cantidad + resultado.excesoDemanda / 2)}
|
||||
y={scaleY(precioControl) - 30}
|
||||
textAnchor="middle"
|
||||
className="text-xs fill-red-700 font-bold"
|
||||
>
|
||||
Escasez
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{resultado.excesoOferta > 0 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<rect
|
||||
x={scaleX(resultado.cantidad)}
|
||||
y={scaleY(precioControl)}
|
||||
width={scaleX(resultado.excesoOferta) - padding}
|
||||
height="20"
|
||||
fill="#dbeafe"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<text
|
||||
x={scaleX(resultado.cantidad + resultado.excesoOferta / 2)}
|
||||
y={scaleY(precioControl) + 35}
|
||||
textAnchor="middle"
|
||||
className="text-xs fill-amber-700 font-bold"
|
||||
>
|
||||
Superávit
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Panel derecho: Resultados */}
|
||||
<div className="space-y-4">
|
||||
{/* Resultado actual */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={resultado.tipo}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`p-4 rounded-lg border-2 ${
|
||||
resultado.tipo === 'equilibrio' ? 'bg-green-50 border-green-200' :
|
||||
resultado.tipo === 'precio-maximo' ? 'bg-red-50 border-red-200' :
|
||||
'bg-amber-50 border-amber-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{resultado.tipo === 'equilibrio' ? (
|
||||
<Scale className="w-5 h-5 text-green-600" />
|
||||
) : resultado.tipo === 'precio-maximo' ? (
|
||||
<TrendingDown className="w-5 h-5 text-red-600" />
|
||||
) : (
|
||||
<TrendingUp className="w-5 h-5 text-amber-600" />
|
||||
)}
|
||||
<span className={`font-bold ${
|
||||
resultado.tipo === 'equilibrio' ? 'text-green-800' :
|
||||
resultado.tipo === 'precio-maximo' ? 'text-red-800' :
|
||||
'text-amber-800'
|
||||
}`}>
|
||||
{resultado.mensaje}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="bg-white rounded p-2">
|
||||
<span className="text-gray-500">Precio:</span>
|
||||
<p className="font-bold text-gray-800">${resultado.precio.toFixed(0)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded p-2">
|
||||
<span className="text-gray-500">Cantidad:</span>
|
||||
<p className="font-bold text-gray-800">{resultado.cantidad.toFixed(0)} un</p>
|
||||
</div>
|
||||
|
||||
{resultado.excesoDemanda > 0 && (
|
||||
<div className="bg-red-100 rounded p-2 col-span-2">
|
||||
<span className="text-red-600">Exceso de demanda:</span>
|
||||
<p className="font-bold text-red-800">{resultado.excesoDemanda.toFixed(1)} unidades</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultado.excesoOferta > 0 && (
|
||||
<div className="bg-amber-100 rounded p-2 col-span-2">
|
||||
<span className="text-amber-600">Exceso de oferta:</span>
|
||||
<p className="font-bold text-amber-800">{resultado.excesoOferta.toFixed(1)} unidades</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultado.pesoMuerto > 0 && (
|
||||
<div className="bg-gray-100 rounded p-2 col-span-2">
|
||||
<span className="text-gray-600 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Pérdida de peso muerto:
|
||||
</span>
|
||||
<p className="font-bold text-gray-800">${resultado.pesoMuerto.toFixed(0)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Historial */}
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-semibold text-gray-700 mb-3">Historial</h4>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{historial.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">Selecciona un escenario para comenzar</p>
|
||||
) : (
|
||||
historial.map((h, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={`p-2 rounded text-sm ${
|
||||
h.tipo === 'equilibrio' ? 'bg-green-100 text-green-800' :
|
||||
h.tipo === 'precio-maximo' ? 'bg-red-100 text-red-800' :
|
||||
'bg-amber-100 text-amber-800'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{h.mensaje}</div>
|
||||
<div className="text-xs opacity-75">
|
||||
P: ${h.precio} | Q: {h.cantidad}
|
||||
{h.excesoDemanda > 0 && ` | Esc: ${h.excesoDemanda.toFixed(0)}`}
|
||||
{h.excesoOferta > 0 && ` | Sup: ${h.excesoOferta.toFixed(0)}`}
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instrucciones */}
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-semibold text-blue-800 mb-2">💡 Consejos</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• Prueba los 3 escenarios predefinidos</li>
|
||||
<li>• Observa cómo cambian las cantidades</li>
|
||||
<li>• Identifica escasez vs superávit</li>
|
||||
<li>• La pérdida de peso muerto es ineficiencia</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimuladorControles;
|
||||
454
frontend/src/components/exercises/modulo2/SimuladorPrecios.tsx
Normal file
454
frontend/src/components/exercises/modulo2/SimuladorPrecios.tsx
Normal 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;
|
||||
262
frontend/src/components/exercises/modulo2/TablaDemanda.tsx
Normal file
262
frontend/src/components/exercises/modulo2/TablaDemanda.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Table, Check, X, Trophy, RotateCcw, Calculator } from 'lucide-react';
|
||||
|
||||
interface TablaDemandaProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface FilaDemanda {
|
||||
precio: number;
|
||||
cantidadCorrecta: number;
|
||||
cantidadUsuario: string;
|
||||
}
|
||||
|
||||
const datosDemanda: FilaDemanda[] = [
|
||||
{ precio: 10, cantidadCorrecta: 100, cantidadUsuario: '' },
|
||||
{ precio: 20, cantidadCorrecta: 80, cantidadUsuario: '' },
|
||||
{ precio: 30, cantidadCorrecta: 60, cantidadUsuario: '' },
|
||||
{ precio: 40, cantidadCorrecta: 40, cantidadUsuario: '' },
|
||||
{ precio: 50, cantidadCorrecta: 20, cantidadUsuario: '' },
|
||||
];
|
||||
|
||||
export const TablaDemanda: React.FC<TablaDemandaProps> = ({
|
||||
ejercicioId: _ejercicioId,
|
||||
onComplete
|
||||
}) => {
|
||||
const [filas, setFilas] = useState<FilaDemanda[]>(datosDemanda);
|
||||
const [mostrarResultados, setMostrarResultados] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [intentos, setIntentos] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const handleCantidadChange = (index: number, valor: string) => {
|
||||
if (mostrarResultados) return;
|
||||
const nuevasFilas = [...filas];
|
||||
nuevasFilas[index].cantidadUsuario = valor;
|
||||
setFilas(nuevasFilas);
|
||||
};
|
||||
|
||||
const validarRespuestas = () => {
|
||||
setIntentos(prev => prev + 1);
|
||||
|
||||
// Verificar que todos los campos estén llenos
|
||||
const camposVacios = filas.some(fila => fila.cantidadUsuario === '');
|
||||
if (camposVacios) {
|
||||
alert('Por favor completa todas las cantidades antes de validar');
|
||||
return;
|
||||
}
|
||||
|
||||
setMostrarResultados(true);
|
||||
|
||||
// Calcular puntuación
|
||||
let correctas = 0;
|
||||
filas.forEach(fila => {
|
||||
if (parseInt(fila.cantidadUsuario) === fila.cantidadCorrecta) {
|
||||
correctas++;
|
||||
}
|
||||
});
|
||||
|
||||
let puntuacion = Math.round((correctas / filas.length) * 100);
|
||||
// Penalización por intentos
|
||||
if (intentos >= 1) puntuacion -= 10;
|
||||
if (intentos >= 2) puntuacion -= 10;
|
||||
puntuacion = Math.max(puntuacion, 20);
|
||||
|
||||
setScore(puntuacion);
|
||||
|
||||
if (correctas === filas.length) {
|
||||
setCompletado(true);
|
||||
setTimeout(() => {
|
||||
if (onComplete) {
|
||||
onComplete(puntuacion);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setFilas(datosDemanda.map(f => ({ ...f, cantidadUsuario: '' })));
|
||||
setMostrarResultados(false);
|
||||
setScore(0);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
const handleFinalizar = () => {
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
};
|
||||
|
||||
const tablaCompletada = filas.every(fila => fila.cantidadUsuario !== '');
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl 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">
|
||||
<Table className="w-8 h-8 text-blue-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Tabla de Demanda</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={reiniciar}
|
||||
className="p-2 text-gray-500 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Completa la tabla de demanda siguiendo la ley de la demanda: a mayor precio, menor cantidad demandada.
|
||||
<br />
|
||||
<span className="text-sm text-blue-600">
|
||||
<strong>Pista:</strong> Por cada $10 que sube el precio, la cantidad baja 20 unidades.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-blue-50">
|
||||
<th className="p-4 text-left font-semibold text-gray-700 border-b-2 border-blue-200">
|
||||
Precio ($)
|
||||
</th>
|
||||
<th className="p-4 text-left font-semibold text-gray-700 border-b-2 border-blue-200">
|
||||
Cantidad Demandada
|
||||
</th>
|
||||
{mostrarResultados && (
|
||||
<th className="p-4 text-center font-semibold text-gray-700 border-b-2 border-blue-200 w-24">
|
||||
Resultado
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filas.map((fila, index) => (
|
||||
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="p-4 border-b border-gray-200 font-medium">
|
||||
${fila.precio}
|
||||
</td>
|
||||
<td className="p-4 border-b border-gray-200">
|
||||
<input
|
||||
type="number"
|
||||
value={fila.cantidadUsuario}
|
||||
onChange={(e) => handleCantidadChange(index, e.target.value)}
|
||||
disabled={mostrarResultados}
|
||||
placeholder="¿Cuántas unidades?"
|
||||
className={`w-full max-w-xs px-4 py-2 border-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all ${
|
||||
mostrarResultados
|
||||
? parseInt(fila.cantidadUsuario) === fila.cantidadCorrecta
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-red-500 bg-red-50'
|
||||
: 'border-gray-300 focus:border-blue-500'
|
||||
}`}
|
||||
/>
|
||||
</td>
|
||||
{mostrarResultados && (
|
||||
<td className="p-4 border-b border-gray-200 text-center">
|
||||
{parseInt(fila.cantidadUsuario) === fila.cantidadCorrecta ? (
|
||||
<Check className="w-6 h-6 text-green-500 mx-auto" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<X className="w-6 h-6 text-red-500" />
|
||||
<span className="text-xs text-green-600 font-medium">
|
||||
{fila.cantidadCorrecta}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarResultados && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="mb-6 overflow-hidden"
|
||||
>
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
filas.every(f => parseInt(f.cantidadUsuario) === f.cantidadCorrecta)
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-yellow-50 border-yellow-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{filas.every(f => parseInt(f.cantidadUsuario) === f.cantidadCorrecta) ? (
|
||||
<Trophy className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<Calculator className="w-6 h-6 text-yellow-600" />
|
||||
)}
|
||||
<span className={`font-semibold ${
|
||||
filas.every(f => parseInt(f.cantidadUsuario) === f.cantidadCorrecta)
|
||||
? 'text-green-800'
|
||||
: 'text-yellow-800'
|
||||
}`}>
|
||||
{filas.every(f => parseInt(f.cantidadUsuario) === f.cantidadCorrecta)
|
||||
? '¡Perfecto! Todas las respuestas son correctas'
|
||||
: 'Algunas respuestas necesitan revisión'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
Puntuación: <span className="font-bold text-blue-600">{score}/100</span>
|
||||
</p>
|
||||
{!filas.every(f => parseInt(f.cantidadUsuario) === f.cantidadCorrecta) && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Las cantidades correctas se muestran en verde debajo de las respuestas incorrectas.
|
||||
Observa el patrón: la cantidad disminuye 20 unidades por cada aumento de $10 en el precio.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{!mostrarResultados ? (
|
||||
<button
|
||||
onClick={validarRespuestas}
|
||||
disabled={!tablaCompletada}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
Validar Respuestas
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{!completado && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMostrarResultados(false);
|
||||
}}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
Intentar de Nuevo
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleFinalizar}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
Finalizar Ejercicio
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{intentos > 0 && (
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
Intentos realizados: {intentos}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TablaDemanda;
|
||||
289
frontend/src/components/exercises/modulo2/TablaOferta.tsx
Normal file
289
frontend/src/components/exercises/modulo2/TablaOferta.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Table, Check, X, RotateCcw, Trophy, Calculator, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface TablaOfertaProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface FilaTabla {
|
||||
precio: number;
|
||||
cantidad: number | null;
|
||||
respuestaCorrecta: number;
|
||||
}
|
||||
|
||||
const datosIniciales: FilaTabla[] = [
|
||||
{ precio: 10, cantidad: null, respuestaCorrecta: 20 },
|
||||
{ precio: 20, cantidad: null, respuestaCorrecta: 40 },
|
||||
{ precio: 30, cantidad: null, respuestaCorrecta: 60 },
|
||||
{ precio: 40, cantidad: null, respuestaCorrecta: 80 },
|
||||
{ precio: 50, cantidad: null, respuestaCorrecta: 100 },
|
||||
];
|
||||
|
||||
export const TablaOferta: React.FC<TablaOfertaProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [filas, setFilas] = useState<FilaTabla[]>(datosIniciales);
|
||||
const [respuestasUsuario, setRespuestasUsuario] = useState<{[key: number]: string}>({});
|
||||
const [mostrarResultados, setMostrarResultados] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [intentos, setIntentos] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const handleInputChange = (precio: number, valor: string) => {
|
||||
if (mostrarResultados) return;
|
||||
setRespuestasUsuario(prev => ({
|
||||
...prev,
|
||||
[precio]: valor
|
||||
}));
|
||||
};
|
||||
|
||||
const verificarRespuestas = () => {
|
||||
let correctas = 0;
|
||||
const nuevasFilas = filas.map(fila => {
|
||||
const respuestaUsuario = parseInt(respuestasUsuario[fila.precio] || '0');
|
||||
const esCorrecta = respuestaUsuario === fila.respuestaCorrecta;
|
||||
if (esCorrecta) correctas++;
|
||||
return {
|
||||
...fila,
|
||||
cantidad: respuestaUsuario
|
||||
};
|
||||
});
|
||||
|
||||
setFilas(nuevasFilas);
|
||||
setMostrarResultados(true);
|
||||
setIntentos(prev => prev + 1);
|
||||
|
||||
// Calcular puntuación
|
||||
const porcentajeCorrectas = correctas / filas.length;
|
||||
const bonusIntentos = intentos === 0 ? 20 : intentos === 1 ? 10 : 0;
|
||||
const puntajeFinal = Math.round((porcentajeCorrectas * 80) + bonusIntentos);
|
||||
|
||||
setScore(puntajeFinal);
|
||||
|
||||
if (correctas === filas.length) {
|
||||
setCompletado(true);
|
||||
setTimeout(() => {
|
||||
if (onComplete) {
|
||||
onComplete(puntajeFinal);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setFilas(datosIniciales);
|
||||
setRespuestasUsuario({});
|
||||
setMostrarResultados(false);
|
||||
setScore(0);
|
||||
setIntentos(0);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
const todasRespondidas = filas.every(fila => respuestasUsuario[fila.precio] !== undefined && respuestasUsuario[fila.precio] !== '');
|
||||
|
||||
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">
|
||||
<Table className="w-8 h-8 text-green-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Completar Tabla de Oferta</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{completado && (
|
||||
<span className="text-2xl font-bold text-green-600">{score} pts</span>
|
||||
)}
|
||||
<button
|
||||
onClick={reiniciar}
|
||||
className="p-2 text-gray-500 hover:text-green-600 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Completa la tabla de oferta siguiendo la relación directa entre precio y cantidad.
|
||||
Cuando el precio se duplica, la cantidad ofrecida también se duplica.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Calculator className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-800 mb-1">Datos del problema:</h3>
|
||||
<p className="text-green-700 text-sm">
|
||||
Un productor de camisetas está dispuesto a vender 20 unidades a $10 cada una.
|
||||
La función de oferta es lineal: <strong>Q = 2 × P</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border-2 border-gray-300 p-4 text-left font-semibold text-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Precio ($)</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="border-2 border-gray-300 p-4 text-left font-semibold text-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Cantidad Ofrecida (unidades)</span>
|
||||
<HelpCircle className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
</th>
|
||||
<th className="border-2 border-gray-300 p-4 text-center font-semibold text-gray-700 w-24">
|
||||
Estado
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filas.map((fila, index) => {
|
||||
const respuestaUsuario = respuestasUsuario[fila.precio] || '';
|
||||
const esCorrecta = mostrarResultados && parseInt(respuestaUsuario) === fila.respuestaCorrecta;
|
||||
const esIncorrecta = mostrarResultados && parseInt(respuestaUsuario) !== fila.respuestaCorrecta;
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
key={fila.precio}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className={`
|
||||
${esCorrecta ? 'bg-green-50' : ''}
|
||||
${esIncorrecta ? 'bg-red-50' : ''}
|
||||
`}
|
||||
>
|
||||
<td className="border-2 border-gray-300 p-4">
|
||||
<span className="text-xl font-semibold text-gray-800">
|
||||
${fila.precio}
|
||||
</span>
|
||||
</td>
|
||||
<td className="border-2 border-gray-300 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={respuestaUsuario}
|
||||
onChange={(e) => handleInputChange(fila.precio, e.target.value)}
|
||||
disabled={mostrarResultados}
|
||||
placeholder="¿Cuántas unidades?"
|
||||
className={`
|
||||
w-full max-w-xs px-4 py-2 border-2 rounded-lg text-lg font-medium
|
||||
focus:outline-none focus:ring-2 focus:ring-green-500
|
||||
${esCorrecta ? 'border-green-500 bg-green-50 text-green-700' : ''}
|
||||
${esIncorrecta ? 'border-red-500 bg-red-50 text-red-700' : ''}
|
||||
${!mostrarResultados ? 'border-gray-300 hover:border-green-400' : ''}
|
||||
`}
|
||||
/>
|
||||
{mostrarResultados && esIncorrecta && (
|
||||
<span className="text-sm text-red-600">
|
||||
Correcto: {fila.respuestaCorrecta}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-2 border-gray-300 p-4 text-center">
|
||||
<AnimatePresence>
|
||||
{mostrarResultados && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
>
|
||||
{esCorrecta ? (
|
||||
<Check className="w-6 h-6 text-green-600 mx-auto" />
|
||||
) : (
|
||||
<X className="w-6 h-6 text-red-600 mx-auto" />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Visualización de la relación */}
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<h3 className="font-semibold text-blue-800 mb-3">Patrón a seguir:</h3>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-blue-700">
|
||||
<span className="font-semibold">P = $10</span>
|
||||
<span>→</span>
|
||||
<span>Q = 20</span>
|
||||
</div>
|
||||
<span className="text-blue-400">|</span>
|
||||
<div className="flex items-center gap-2 text-blue-700">
|
||||
<span className="font-semibold">P = $20</span>
|
||||
<span>→</span>
|
||||
<span>Q = 40</span>
|
||||
</div>
|
||||
<span className="text-blue-400">|</span>
|
||||
<div className="flex items-center gap-2 text-blue-700">
|
||||
<span className="font-semibold">P = $30</span>
|
||||
<span>→</span>
|
||||
<span>Q = 60</span>
|
||||
</div>
|
||||
<span className="text-blue-400">|</span>
|
||||
<span className="text-blue-600 italic">¿Sigues el patrón?</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{mostrarResultados && (
|
||||
<span>
|
||||
Correctas: <span className="font-semibold text-green-600">
|
||||
{filas.filter(f => f.cantidad === f.respuestaCorrecta).length}
|
||||
</span> de {filas.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!completado ? (
|
||||
<button
|
||||
onClick={verificarRespuestas}
|
||||
disabled={!todasRespondidas}
|
||||
className="w-full sm:w-auto px-8 py-3 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
{mostrarResultados ? 'Intentar de nuevo' : 'Verificar respuestas'}
|
||||
</button>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex items-center gap-3 px-6 py-3 bg-green-50 border border-green-200 rounded-lg"
|
||||
>
|
||||
<Trophy className="w-6 h-6 text-green-600" />
|
||||
<div>
|
||||
<p className="font-semibold text-green-700">¡Completado!</p>
|
||||
<p className="text-sm text-green-600">Puntuación: {score}/100</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mostrarResultados && !completado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg"
|
||||
>
|
||||
<p className="text-yellow-800">
|
||||
<strong>Consejo:</strong> La cantidad ofrecida siempre es el doble del precio.
|
||||
Por ejemplo: si P = $40, entonces Q = 2 × 40 = 80 unidades.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TablaOferta;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user