Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411f235f75 | ||
|
|
ce7cb27290 | ||
|
|
07929b6c60 | ||
|
|
a3360ec6e4 | ||
|
|
25614704dc | ||
|
|
fa8e0c58ce |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"fingerprint": "c6be2155b5d458c41adc0623b75fe4fad880b5f6"
|
||||
"fingerprint": "ee41cd16aee142978eedd00dcf1854a07a534d9e"
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ Last updated: 2026-07-20
|
||||
## Sources scanned
|
||||
|
||||
- /home/ren/.config/opencode/skills
|
||||
- /home/ren/.claude/skills
|
||||
- /home/ren/.codex/skills
|
||||
- /home/ren/.qwen/skills
|
||||
|
||||
## Contract
|
||||
|
||||
@@ -21,60 +19,16 @@ Last updated: 2026-07-20
|
||||
|
||||
| Skill | Trigger / description | Scope | Path |
|
||||
| --- | --- | --- | --- |
|
||||
| `api-design` | REST API design patterns including resource naming, status codes, pagination, filtering, error responses, versioning, and rate limiting for production APIs. | user | `/home/ren/.claude/skills/api-design/SKILL.md` |
|
||||
| `backend-patterns` | Backend architecture patterns, API design, database optimization, and server-side best practices for Node.js, Express, and Next.js API routes. | user | `/home/ren/.claude/skills/backend-patterns/SKILL.md` |
|
||||
| `branch-pr` | Create Gentle AI pull requests with issue-first checks. Trigger: creating, opening, or preparing PRs for review. | user | `/home/ren/.config/opencode/skills/branch-pr/SKILL.md` |
|
||||
| `chained-pr` | Trigger: PRs over 400 lines, stacked PRs, review slices. Split oversized changes into chained PRs that protect review focus. | user | `/home/ren/.config/opencode/skills/chained-pr/SKILL.md` |
|
||||
| `clickhouse-io` | ClickHouse database patterns, query optimization, analytics, and data engineering best practices for high-performance analytical workloads. | user | `/home/ren/.claude/skills/clickhouse-io/SKILL.md` |
|
||||
| `coding-standards` | Universal coding standards, best practices, and patterns for TypeScript, JavaScript, React, and Node.js development. | user | `/home/ren/.claude/skills/coding-standards/SKILL.md` |
|
||||
| `cognitive-doc-design` | Design docs that reduce cognitive load. Trigger: writing guides, READMEs, RFCs, onboarding, architecture, or review-facing docs. | user | `/home/ren/.config/opencode/skills/cognitive-doc-design/SKILL.md` |
|
||||
| `comment-writer` | Write warm, direct collaboration comments. Trigger: PR feedback, issue replies, reviews, Slack messages, or GitHub comments. | user | `/home/ren/.config/opencode/skills/comment-writer/SKILL.md` |
|
||||
| `configure-ecc` | Interactive installer for Everything Claude Code — guides users through selecting and installing skills and rules to user-level or project-level directories, verifies paths, and optionally optimizes installed files. | user | `/home/ren/.claude/skills/configure-ecc/SKILL.md` |
|
||||
| `constructor` | Build, rebuild, flatten, clean, and verify structures in the Minecraft world managed by Crafty Controller on this VPS. Use when requests involve placing/removing blocks, terraforming areas, teleporting players, recreating murals/flags from images, validating coordinates or block states, and keeping ren.md updated after world changes. | user | `/home/ren/.codex/skills/constructor/SKILL.md` |
|
||||
| `continuous-learning` | Automatically extract reusable patterns from Claude Code sessions and save them as learned skills for future use. | user | `/home/ren/.claude/skills/continuous-learning/SKILL.md` |
|
||||
| `continuous-learning-v2` | Instinct-based learning system that observes sessions via hooks, creates atomic instincts with confidence scoring, and evolves them into skills/commands/agents. | user | `/home/ren/.claude/skills/continuous-learning-v2/SKILL.md` |
|
||||
| `cpp-testing` | Use only when writing/updating/fixing C++ tests, configuring GoogleTest/CTest, diagnosing failing or flaky tests, or adding coverage/sanitizers. | user | `/home/ren/.claude/skills/cpp-testing/SKILL.md` |
|
||||
| `database-migrations` | Database migration best practices for schema changes, data migrations, rollbacks, and zero-downtime deployments across PostgreSQL, MySQL, and common ORMs (Prisma, Drizzle, Django, TypeORM, golang-migrate). | user | `/home/ren/.claude/skills/database-migrations/SKILL.md` |
|
||||
| `deployment-patterns` | Deployment workflows, CI/CD pipeline patterns, Docker containerization, health checks, rollback strategies, and production readiness checklists for web applications. | user | `/home/ren/.claude/skills/deployment-patterns/SKILL.md` |
|
||||
| `django-patterns` | Django architecture patterns, REST API design with DRF, ORM best practices, caching, signals, middleware, and production-grade Django apps. | user | `/home/ren/.claude/skills/django-patterns/SKILL.md` |
|
||||
| `django-security` | Django security best practices, authentication, authorization, CSRF protection, SQL injection prevention, XSS prevention, and secure deployment configurations. | user | `/home/ren/.claude/skills/django-security/SKILL.md` |
|
||||
| `django-tdd` | Django testing strategies with pytest-django, TDD methodology, factory_boy, mocking, coverage, and testing Django REST Framework APIs. | user | `/home/ren/.claude/skills/django-tdd/SKILL.md` |
|
||||
| `django-verification` | Verification loop for Django projects: migrations, linting, tests with coverage, security scans, and deployment readiness checks before release or PR. | user | `/home/ren/.claude/skills/django-verification/SKILL.md` |
|
||||
| `docker-patterns` | Docker and Docker Compose patterns for local development, container security, networking, volume strategies, and multi-service orchestration. | user | `/home/ren/.claude/skills/docker-patterns/SKILL.md` |
|
||||
| `e2e-testing` | Playwright E2E testing patterns, Page Object Model, configuration, CI/CD integration, artifact management, and flaky test strategies. | user | `/home/ren/.claude/skills/e2e-testing/SKILL.md` |
|
||||
| `eval-harness` | Formal evaluation framework for Claude Code sessions implementing eval-driven development (EDD) principles | user | `/home/ren/.claude/skills/eval-harness/SKILL.md` |
|
||||
| `frontend-patterns` | Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices. | user | `/home/ren/.claude/skills/frontend-patterns/SKILL.md` |
|
||||
| `gentle-ai-chained-pr` | Split large changes into chained or stacked pull requests that protect reviewer focus and stay within Gentle AI's 400-line cognitive review budget. Trigger: when a PR would exceed 400 changed lines, when planning chained PRs, stacked PRs, or reviewable slices. | user | `/home/ren/.claude/skills/chained-pr/SKILL.md` |
|
||||
| `go-testing` | Trigger: Go tests, go test coverage, Bubbletea teatest, golden files. Apply focused Go testing patterns. | user | `/home/ren/.config/opencode/skills/go-testing/SKILL.md` |
|
||||
| `golang-patterns` | Idiomatic Go patterns, best practices, and conventions for building robust, efficient, and maintainable Go applications. | user | `/home/ren/.claude/skills/golang-patterns/SKILL.md` |
|
||||
| `golang-testing` | Go testing patterns including table-driven tests, subtests, benchmarks, fuzzing, and test coverage. Follows TDD methodology with idiomatic Go practices. | user | `/home/ren/.claude/skills/golang-testing/SKILL.md` |
|
||||
| `issue-creation` | Create Gentle AI issues with issue-first checks. Trigger: creating GitHub issues, bug reports, or feature requests. | user | `/home/ren/.config/opencode/skills/issue-creation/SKILL.md` |
|
||||
| `iterative-retrieval` | Pattern for progressively refining context retrieval to solve the subagent context problem | user | `/home/ren/.claude/skills/iterative-retrieval/SKILL.md` |
|
||||
| `java-coding-standards` | Java coding standards for Spring Boot services: naming, immutability, Optional usage, streams, exceptions, generics, and project layout. | user | `/home/ren/.claude/skills/java-coding-standards/SKILL.md` |
|
||||
| `jpa-patterns` | JPA/Hibernate patterns for entity design, relationships, query optimization, transactions, auditing, indexing, pagination, and pooling in Spring Boot. | user | `/home/ren/.claude/skills/jpa-patterns/SKILL.md` |
|
||||
| `judgment-day` | Trigger: judgment day, dual review, adversarial review, juzgar. Run blind dual review, fix confirmed issues, then re-judge. | user | `/home/ren/.config/opencode/skills/judgment-day/SKILL.md` |
|
||||
| `kilo-force-multiplier` | Use Kilo (OpenCode's AI agent) as a force multiplier for development work. Covers subagent architecture, custom commands, skills system, MCP integration, multi-model routing, context management, and the SDD workflow. Trigger: when setting up Kilo, creating custom commands, building skills, configuring MCP servers, or optimizing AI-assisted development workflows. | user | `/home/ren/.claude/skills/kilo-force-multiplier/SKILL.md` |
|
||||
| `minebuilder` | — | user | `/home/ren/.claude/skills/minebuilder/SKILL.md` |
|
||||
| `minecraft-construction` | Minecraft large-scale construction via Crafty API. Parallel section processing, proper terrain flattening, chunk management, and multi-agent coordination for efficient building. | user | `/home/ren/.claude/skills/minecraft-construction/SKILL.md` |
|
||||
| `nutrient-document-processing` | Process, convert, OCR, extract, redact, sign, and fill documents using the Nutrient DWS API. Works with PDFs, DOCX, XLSX, PPTX, HTML, and images. | user | `/home/ren/.claude/skills/nutrient-document-processing/SKILL.md` |
|
||||
| `postgres-patterns` | PostgreSQL database patterns for query optimization, schema design, indexing, and security. Based on Supabase best practices. | user | `/home/ren/.claude/skills/postgres-patterns/SKILL.md` |
|
||||
| `project-guidelines-example` | Example project-specific skill template based on a real production application. | user | `/home/ren/.claude/skills/project-guidelines-example/SKILL.md` |
|
||||
| `python-patterns` | Pythonic idioms, PEP 8 standards, type hints, and best practices for building robust, efficient, and maintainable Python applications. | user | `/home/ren/.claude/skills/python-patterns/SKILL.md` |
|
||||
| `python-testing` | Python testing strategies using pytest, TDD methodology, fixtures, mocking, parametrization, and coverage requirements. | user | `/home/ren/.claude/skills/python-testing/SKILL.md` |
|
||||
| `security-review` | Use this skill when adding authentication, handling user input, working with secrets, creating API endpoints, or implementing payment/sensitive features. Provides comprehensive security checklist and patterns. | user | `/home/ren/.claude/skills/security-review/SKILL.md` |
|
||||
| `security-scan` | Scan your Claude Code configuration (.claude/ directory) for security vulnerabilities, misconfigurations, and injection risks using AgentShield. Checks CLAUDE.md, settings.json, MCP servers, hooks, and agent definitions. | user | `/home/ren/.claude/skills/security-scan/SKILL.md` |
|
||||
| `skill-creator` | Trigger: new skills, agent instructions, documenting AI usage patterns. Create LLM-first skills with valid frontmatter. | user | `/home/ren/.config/opencode/skills/skill-creator/SKILL.md` |
|
||||
| `skill-improver` | Trigger: improve skills, audit skills, refactor skills, skill quality. Audit and upgrade existing LLM-first skills. | user | `/home/ren/.config/opencode/skills/skill-improver/SKILL.md` |
|
||||
| `skill-installer` | Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). | user | `/home/ren/.codex/skills/.system/skill-installer/SKILL.md` |
|
||||
| `springboot-patterns` | Spring Boot architecture patterns, REST API design, layered services, data access, caching, async processing, and logging. Use for Java Spring Boot backend work. | user | `/home/ren/.claude/skills/springboot-patterns/SKILL.md` |
|
||||
| `springboot-security` | Spring Security best practices for authn/authz, validation, CSRF, secrets, headers, rate limiting, and dependency security in Java Spring Boot services. | user | `/home/ren/.claude/skills/springboot-security/SKILL.md` |
|
||||
| `springboot-tdd` | Test-driven development for Spring Boot using JUnit 5, Mockito, MockMvc, Testcontainers, and JaCoCo. Use when adding features, fixing bugs, or refactoring. | user | `/home/ren/.claude/skills/springboot-tdd/SKILL.md` |
|
||||
| `springboot-verification` | Verification loop for Spring Boot projects: build, static analysis, tests with coverage, security scans, and diff review before release or PR. | user | `/home/ren/.claude/skills/springboot-verification/SKILL.md` |
|
||||
| `strategic-compact` | Suggests manual context compaction at logical intervals to preserve context through task phases rather than arbitrary auto-compaction. | user | `/home/ren/.claude/skills/strategic-compact/SKILL.md` |
|
||||
| `tdd-workflow` | Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests. | user | `/home/ren/.claude/skills/tdd-workflow/SKILL.md` |
|
||||
| `verification-loop` | A comprehensive verification system for Claude Code sessions. | user | `/home/ren/.claude/skills/verification-loop/SKILL.md` |
|
||||
| `work-unit-commits` | Plan commits as reviewable work units. Trigger: implementation, commit splitting, chained PRs, or keeping tests and docs with code. | user | `/home/ren/.config/opencode/skills/work-unit-commits/SKILL.md` |
|
||||
| `zai-vision` | Analyze images via Z.AI GLM-4.6V vision model. Use when the user shares an image file path, base64 image data, image URL, or asks to describe/analyze/OCR/read visual content. Gives vision capabilities to text-only models. | user | `/home/ren/.claude/skills/zai-vision/SKILL.md` |
|
||||
|
||||
## Loading protocol
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
e2e
|
||||
.playwright
|
||||
playwright-report
|
||||
test-results
|
||||
*.md
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
@@ -0,0 +1,10 @@
|
||||
# EduEasy configuration
|
||||
|
||||
# Database
|
||||
DATABASE_URL="postgresql://edueasy:edueasy_pass@localhost:5432/edueasy?schema=public"
|
||||
|
||||
# Auth (change in production)
|
||||
NEXTAUTH_SECRET="change-me-in-production"
|
||||
|
||||
# Environment
|
||||
NODE_ENV="development"
|
||||
@@ -2,6 +2,9 @@ node_modules/
|
||||
.next/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
.env.local
|
||||
.env*.local
|
||||
.cache/
|
||||
|
||||
+11
-4
@@ -1,11 +1,13 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:22-alpine AS deps
|
||||
RUN apk add --no-cache espeak-ng
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build
|
||||
FROM node:22-alpine AS builder
|
||||
RUN apk add --no-cache espeak-ng
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
@@ -15,18 +17,23 @@ RUN npm run build
|
||||
|
||||
# Stage 3: Production
|
||||
FROM node:22-alpine AS runner
|
||||
RUN apk add --no-cache espeak-ng
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN addgroup --system --gid 1001 nextjs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN adduser nextjs nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/prisma ./prisma
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/node_modules/prisma ./node_modules/prisma
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# EduEasy — Instalación y Uso
|
||||
|
||||
## Instalación rápida con Docker
|
||||
|
||||
```bash
|
||||
# 1. Clonar el repositorio
|
||||
git clone https://gitea.cbcren.online/renato97/edueasy.git
|
||||
cd edueasy
|
||||
|
||||
# 2. Levantar todo (app + base de datos)
|
||||
docker compose up -d
|
||||
|
||||
# 3. Verificar que está corriendo
|
||||
curl http://localhost:3000
|
||||
```
|
||||
|
||||
La app queda disponible en `http://localhost:3000`.
|
||||
La base de datos PostgreSQL en `http://localhost:5432`.
|
||||
|
||||
### Variables de entorno
|
||||
|
||||
| Variable | Default | Descripción |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `postgresql://edueasy:edueasy_pass@db:5432/edueasy` | URL de PostgreSQL |
|
||||
| `NEXTAUTH_SECRET` | `change-me-in-production` | Secreto para sesiones (CAMBIAR en producción) |
|
||||
| `NODE_ENV` | `production` | Entorno de ejecución |
|
||||
|
||||
### Detener
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Detener y borrar datos
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Instalación manual (sin Docker)
|
||||
|
||||
### Requisitos
|
||||
|
||||
- Node.js 22+
|
||||
- PostgreSQL 16+
|
||||
- espeak-ng (para TTS)
|
||||
|
||||
```bash
|
||||
# 1. Instalar dependencias
|
||||
npm install
|
||||
|
||||
# 2. Configurar base de datos
|
||||
createdb edueasy
|
||||
echo 'DATABASE_URL="postgresql://edueasy:edueasy_pass@localhost:5432/edueasy?schema=public"' > .env.local
|
||||
|
||||
# 3. Aplicar schema
|
||||
npx prisma db push
|
||||
npx prisma generate
|
||||
|
||||
# 4. Desarrollo
|
||||
npm run dev
|
||||
|
||||
# 5. Producción
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Perfiles de aprendizaje
|
||||
|
||||
| Perfil | Edad | Materias | Métodos europeos |
|
||||
|--------|------|----------|------------------|
|
||||
| **Isabella** | 4 años | Lectura (9 etapas), Números (8 etapas) | Borel-Maisonny (gesto-fonema), Montessori sensorial |
|
||||
| **Francesca** | 6 años | Matemáticas (10 etapas), Lecturas (9 etapas), Ortografía (2 etapas) | Montessori operacional, Decroly (centros de interés) |
|
||||
| **Sebastián** | 8 años | Matemáticas (11 etapas), Lectura (6 etapas), Historia (5 etapas), Geografía (3 etapas), Banderas (2 etapas) | Montessori avanzado, Decroly, Freinet (expresión) |
|
||||
|
||||
### Cómo navegar
|
||||
|
||||
1. Página principal (`/`) → muestra los 3 perfiles
|
||||
2. Seleccionar un perfil → va a `/francesca`, `/sebastian`, o `/child` (Isabella)
|
||||
3. Cada perfil muestra sus actividades
|
||||
4. Al tocar una actividad, empieza la sesión de ejercicios con TTS
|
||||
|
||||
---
|
||||
|
||||
## APIs de debug (solo desarrollo)
|
||||
|
||||
> **Estas APIs solo funcionan en `NODE_ENV=development`**. En producción devuelven 403.
|
||||
|
||||
### Resetear progreso de un child
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/debug/reset \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"childId":"..."}'
|
||||
```
|
||||
|
||||
### Ver estado completo de un child
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/debug/state?childId=...
|
||||
```
|
||||
|
||||
### Sembrar ejercicios de prueba
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/debug/seed \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"childId":"...","profile":"FRANCESCA","topic":"matematicas","stages":[1,2],"markAllCorrect":true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# TypeScript
|
||||
npx tsc --noEmit
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# E2E tests (Playwright)
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estructura del proyecto
|
||||
|
||||
```
|
||||
curriculum/ # Datos de ejercicios (generados programáticamente)
|
||||
lectura/ # Isabella - lectura (9 etapas)
|
||||
numeros/ # Isabella - matemática (8 etapas)
|
||||
francesca/ # Francesca - matemática (10 etapas)
|
||||
francesca-lectura/ # Francesca - comprensión lectora (9 etapas)
|
||||
francesca-ortografia/ # Francesca - ortografía (2 etapas)
|
||||
sebastian/ # Sebastián - matemática (11 etapas)
|
||||
sebastian-lectura/ # Sebastián - lectura y expresión (6 etapas)
|
||||
sebastian-historia/ # Sebastián - historia argentina (5 etapas)
|
||||
sebastian-geografia/ # Sebastián - geografía (3 etapas)
|
||||
sebastian-banderas/ # Sebastián - banderas provinciales (2 etapas)
|
||||
|
||||
lib/curriculum/engine.ts # Motor: carga etapas, calcula próximo ejercicio
|
||||
app/api/ # APIs REST
|
||||
components/ # Componentes React reutilizables
|
||||
e2e/ # Tests E2E (Playwright)
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# EduEasy
|
||||
|
||||
Plataforma de aprendizaje para niños con métodos pedagógicos europeos (Montessori, Borel-Maisonny, Decroly, Freinet).
|
||||
|
||||
## Perfiles
|
||||
|
||||
| Perfil | Edad | Materias |
|
||||
|--------|------|----------|
|
||||
| Isabella | 4 años | Lectura fonética + conteo 1-20 |
|
||||
| Francesca | 6 años | Operaciones multidígito + comprensión lectora |
|
||||
| Sebastián | 8 años | Fracciones/decimales + historia/geografía argentina |
|
||||
|
||||
## Instalación rápida
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
App disponible en `http://localhost:3000`.
|
||||
|
||||
## Instalación manual
|
||||
|
||||
Ver [INSTALL.md](./INSTALL.md).
|
||||
|
||||
## Documentación
|
||||
|
||||
- [INSTALL.md](./INSTALL.md) — Guía de instalación
|
||||
- [fase2.md](./fase2.md) — Plan de expansión multi-perfil
|
||||
- [fase3.md](./fase3.md) — Bug fixes y hardening
|
||||
- [fase4.md](./fase4.md) — Overhaul, Docker, métodos europeos
|
||||
- [fase5.md](./fase5.md) — Seguridad e infraestructura
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const parent = await prisma.parent.findUnique({
|
||||
where: { id: session.user.id },
|
||||
include: {
|
||||
family: {
|
||||
include: { children: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!parent || !parent.family) {
|
||||
return NextResponse.json({ error: "Parent or family not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(parent.family.children)
|
||||
} catch (error) {
|
||||
console.error("[children GET] error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { name, birthdate, profileNotes } = await request.json()
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "name required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parent = await prisma.parent.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true },
|
||||
})
|
||||
|
||||
if (!parent || !parent.familyId) {
|
||||
return NextResponse.json({ error: "Parent has no family" }, { status: 400 })
|
||||
}
|
||||
|
||||
const child = await prisma.child.create({
|
||||
data: {
|
||||
name,
|
||||
birthdate: birthdate ? new Date(birthdate) : undefined,
|
||||
profileNotes,
|
||||
familyId: parent.familyId,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(child, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("[children POST] error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { buildFsrsCard, gradeCard, skillCodeFromGrade } from "@/lib/fsrs"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { childId, exerciseId, skillCode, correct, promptLevel, responseMs, errorType, stage } = await request.json()
|
||||
|
||||
if (!childId || !exerciseId || !skillCode) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Auto-create session log if none exists (so SkillAttempts are always stored)
|
||||
let sessionLog = await prisma.sessionLog.findFirst({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
})
|
||||
|
||||
if (!sessionLog) {
|
||||
sessionLog = await prisma.sessionLog.create({
|
||||
data: { childId, startedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.skillAttempt.create({
|
||||
data: {
|
||||
sessionLogId: sessionLog.id,
|
||||
skillCode,
|
||||
promptLevel: promptLevel ?? 0,
|
||||
correct,
|
||||
responseMs: responseMs ?? null,
|
||||
errorType: errorType ?? null,
|
||||
exerciseId,
|
||||
stage: stage ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
// Update/create FSRS card so due-date sorting works in the engine
|
||||
const existingCard = await prisma.fsrsCard.findFirst({
|
||||
where: { childId, skillCode },
|
||||
})
|
||||
|
||||
const grade = skillCodeFromGrade(correct, responseMs ?? 5000)
|
||||
const baseCard = buildFsrsCard({
|
||||
stability: existingCard?.stability,
|
||||
difficulty: existingCard?.difficulty,
|
||||
reps: existingCard?.reps,
|
||||
lapses: existingCard?.lapses,
|
||||
due: existingCard?.dueAt,
|
||||
})
|
||||
|
||||
const { card: updatedCard } = gradeCard(baseCard, grade)
|
||||
|
||||
if (existingCard) {
|
||||
await prisma.fsrsCard.update({
|
||||
where: { id: existingCard.id },
|
||||
data: {
|
||||
dueAt: updatedCard.due,
|
||||
stability: updatedCard.stability,
|
||||
difficulty: updatedCard.difficulty,
|
||||
reps: updatedCard.reps,
|
||||
lapses: updatedCard.lapses,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.fsrsCard.create({
|
||||
data: {
|
||||
childId,
|
||||
skillCode,
|
||||
dueAt: updatedCard.due,
|
||||
stability: updatedCard.stability,
|
||||
difficulty: updatedCard.difficulty,
|
||||
reps: updatedCard.reps,
|
||||
lapses: updatedCard.lapses,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Curriculum grade error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { getNextExercise } from "@/lib/curriculum/engine"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
const topic = request.nextUrl.searchParams.get("topic") || "lectura"
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const child = await prisma.child.findUnique({
|
||||
where: { id: childId },
|
||||
select: { profile: true },
|
||||
})
|
||||
|
||||
if (!child) {
|
||||
return NextResponse.json({ error: "child not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const profile = (request.nextUrl.searchParams.get("profile") || child.profile || "ISABELLA").toUpperCase()
|
||||
const exercise = await getNextExercise(childId, topic, profile)
|
||||
return NextResponse.json(exercise || { id: null, done: true })
|
||||
} catch (error) {
|
||||
console.error("[curriculum/next] error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { initCurriculum, getLecturaStages, getNumerosStages } from "@/curriculum/index"
|
||||
import { getCurrentStage, getNextExercise } from "@/lib/curriculum/engine"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
const topic = (request.nextUrl.searchParams.get("topic") || "lectura") as "lectura" | "numeros"
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
await initCurriculum()
|
||||
|
||||
const stages = topic === "lectura" ? getLecturaStages() : getNumerosStages()
|
||||
const stageKeys = Object.keys(stages).map(Number).sort((a, b) => a - b)
|
||||
|
||||
const progress = prisma.curriculumProgress.findMany({
|
||||
where: { childId, topic },
|
||||
orderBy: { stage: "asc" },
|
||||
})
|
||||
|
||||
const allAttempts = prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
exerciseId: { not: null },
|
||||
stage: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { exerciseId: true, correct: true },
|
||||
})
|
||||
|
||||
const [progressRows, attempts] = await Promise.all([progress, allAttempts])
|
||||
|
||||
const mastered = new Set(
|
||||
attempts.filter((a) => a.correct).map((a) => a.exerciseId),
|
||||
)
|
||||
|
||||
const stagesInfo = stageKeys.map((sk) => {
|
||||
const s = stages[sk]
|
||||
const total = s?.exercises?.length || 0
|
||||
const completed = s?.exercises?.filter((e) => mastered.has(e.id)).length || 0
|
||||
const prog = progressRows.find((p) => p.stage === sk)
|
||||
return {
|
||||
stage: sk,
|
||||
name: s?.name || `Etapa ${sk}`,
|
||||
total,
|
||||
completed,
|
||||
unlocked: !!prog,
|
||||
completedAt: prog?.completedAt || null,
|
||||
percent: total > 0 ? Math.round((completed / total) * 100) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
const errorTypes = await prisma.skillAttempt.groupBy({
|
||||
by: ["errorType"],
|
||||
where: {
|
||||
errorType: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
_count: { errorType: true },
|
||||
})
|
||||
|
||||
const errorPatterns = errorTypes.map((e) => ({
|
||||
errorType: e.errorType,
|
||||
count: e._count.errorType,
|
||||
}))
|
||||
|
||||
const activeProgress = progressRows.find((p) => !p.completedAt)
|
||||
const currentStage = activeProgress?.stage || 1
|
||||
|
||||
return NextResponse.json({
|
||||
currentStage,
|
||||
stages: stagesInfo,
|
||||
errorPatterns,
|
||||
hasActiveSession: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Progress route error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const [totalSessions, todaySessions, milestones, fsrsCards, lastSession, weeklySessions, milestoneRecords, curriculumProgress, errorPatterns, allAttempts] =
|
||||
await Promise.all([
|
||||
prisma.sessionLog.count({ where: { childId } }),
|
||||
prisma.sessionLog.count({
|
||||
where: { childId, startedAt: { gte: today } },
|
||||
}),
|
||||
prisma.milestone.count({ where: { childId } }),
|
||||
prisma.fsrsCard.findMany({ where: { childId } }),
|
||||
prisma.sessionLog.findFirst({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
include: { skillAttempts: true },
|
||||
}),
|
||||
prisma.sessionLog.findMany({
|
||||
where: { childId, startedAt: { gte: new Date(Date.now() - 7 * 86400000) } },
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: { startedAt: true, durationSec: true },
|
||||
}),
|
||||
prisma.milestone.findMany({
|
||||
where: { childId },
|
||||
orderBy: { reachedAt: "desc" },
|
||||
select: { skillCode: true, reachedAt: true },
|
||||
}),
|
||||
prisma.curriculumProgress.findMany({
|
||||
where: { childId },
|
||||
orderBy: { stage: "asc" },
|
||||
}),
|
||||
prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
errorType: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { errorType: true },
|
||||
}),
|
||||
prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
correct: false,
|
||||
exerciseId: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { exerciseId: true, errorType: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const todayMinutes = todaySessions > 0
|
||||
? await prisma.sessionLog.aggregate({
|
||||
where: { childId, startedAt: { gte: today } },
|
||||
_sum: { durationSec: true },
|
||||
}).then((r) => Math.round((r._sum.durationSec ?? 0) / 60))
|
||||
: 0
|
||||
|
||||
const totalSkills = fsrsCards.length
|
||||
const masteredSkills = fsrsCards.filter((c) => c.reps >= 5 && c.stability > 30).length
|
||||
const masteryPercent = totalSkills > 0 ? Math.round((masteredSkills / totalSkills) * 100) : 0
|
||||
|
||||
const weeklyData = weeklySessions.map((s) => ({
|
||||
date: s.startedAt.toISOString().slice(0, 10),
|
||||
minutes: Math.round((s.durationSec ?? 0) / 60),
|
||||
}))
|
||||
|
||||
const skillBreakdown = fsrsCards.map((c) => ({
|
||||
skillCode: c.skillCode,
|
||||
stability: c.stability,
|
||||
difficulty: c.difficulty,
|
||||
reps: c.reps,
|
||||
due: c.dueAt.toISOString(),
|
||||
}))
|
||||
|
||||
const streak = await calculateStreak(childId)
|
||||
|
||||
const totalErrors = allAttempts.length
|
||||
const errorTypeCount = errorPatterns.reduce<Record<string, number>>((acc, e) => {
|
||||
const key = e.errorType || "sin-clasificar"
|
||||
acc[key] = (acc[key] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const stageProgress = curriculumProgress.map((cp) => ({
|
||||
topic: cp.topic,
|
||||
stage: cp.stage,
|
||||
completedAt: cp.completedAt?.toISOString() || null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
totalSessions,
|
||||
todaySessions,
|
||||
todayMinutes,
|
||||
masteryPercent,
|
||||
masteredSkills,
|
||||
totalSkills,
|
||||
milestones,
|
||||
streak,
|
||||
weeklyData,
|
||||
skillBreakdown,
|
||||
milestoneData: milestoneRecords,
|
||||
stageProgress,
|
||||
errorTypeCount,
|
||||
totalErrors,
|
||||
lastSession: lastSession
|
||||
? {
|
||||
startedAt: lastSession.startedAt.toISOString(),
|
||||
durationSec: lastSession.durationSec,
|
||||
skillsPracticed: lastSession.skillsPracticed,
|
||||
correctCount: lastSession.skillAttempts.filter((a) => a.correct).length,
|
||||
totalAttempts: lastSession.skillAttempts.length,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[dashboard/summary] error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function calculateStreak(childId: string): Promise<number> {
|
||||
const logs = await prisma.sessionLog.findMany({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
select: { startedAt: true },
|
||||
})
|
||||
|
||||
if (logs.length === 0) return 0
|
||||
|
||||
const days = [
|
||||
...new Set(logs.map((l) => l.startedAt.toISOString().slice(0, 10))),
|
||||
].sort()
|
||||
.reverse()
|
||||
|
||||
let streak = 1
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
if (days[0] !== today && days[0] !== getYesterday()) return 0
|
||||
|
||||
for (let i = 1; i < days.length; i++) {
|
||||
const prev = new Date(days[i - 1])
|
||||
const curr = new Date(days[i])
|
||||
const diff = (prev.getTime() - curr.getTime()) / 86400000
|
||||
if (Math.round(diff) === 1) {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
function getYesterday(): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return NextResponse.json({ error: "Not available in production" }, { status: 403 })
|
||||
}
|
||||
try {
|
||||
const { childId } = await request.json()
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Delete in dependency order
|
||||
await prisma.skillAttempt.deleteMany({
|
||||
where: { sessionLog: { childId } },
|
||||
})
|
||||
await prisma.sessionLog.deleteMany({ where: { childId } })
|
||||
await prisma.fsrsCard.deleteMany({ where: { childId } })
|
||||
await prisma.curriculumProgress.deleteMany({ where: { childId } })
|
||||
await prisma.milestone.deleteMany({ where: { childId } })
|
||||
|
||||
return NextResponse.json({ success: true, message: `Reset state for child ${childId}` })
|
||||
} catch (error) {
|
||||
console.error("Debug reset error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { getStagesForProfile } from "@/lib/curriculum/engine"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return NextResponse.json({ error: "Not available in production" }, { status: 403 })
|
||||
}
|
||||
try {
|
||||
const { childId, profile = "ISABELLA", topic = "lectura", stages: stageIds, markAllCorrect = false } = await request.json()
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const child = await prisma.child.findUnique({
|
||||
where: { id: childId },
|
||||
select: { id: true, name: true, profile: true },
|
||||
})
|
||||
|
||||
if (!child) {
|
||||
return NextResponse.json({ error: "Child not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const resolvedProfile = (profile || child.profile).toUpperCase()
|
||||
|
||||
// Get stages for this topic
|
||||
const stages = await getStagesForProfile(resolvedProfile, topic)
|
||||
|
||||
// Filter to specific stages if requested
|
||||
const stageEntries = stageIds
|
||||
? Object.entries(stages).filter(([id]) => stageIds.includes(Number(id)))
|
||||
: Object.entries(stages)
|
||||
|
||||
let seeded = 0
|
||||
|
||||
for (const [stageId, stage] of stageEntries) {
|
||||
// Ensure curriculum progress exists
|
||||
const exists = await prisma.curriculumProgress.findUnique({
|
||||
where: { childId_topic_stage: { childId, topic, stage: Number(stageId) } },
|
||||
})
|
||||
|
||||
if (!exists) {
|
||||
await prisma.curriculumProgress.create({
|
||||
data: { childId, topic, stage: Number(stageId) },
|
||||
})
|
||||
}
|
||||
|
||||
// Create a session log
|
||||
const sessionLog = await prisma.sessionLog.create({
|
||||
data: { childId, startedAt: new Date() },
|
||||
})
|
||||
|
||||
// Create skill attempts for each exercise in the stage
|
||||
for (const ex of stage.exercises) {
|
||||
await prisma.skillAttempt.create({
|
||||
data: {
|
||||
sessionLogId: sessionLog.id,
|
||||
skillCode: ex.skillCode,
|
||||
promptLevel: 0,
|
||||
correct: markAllCorrect,
|
||||
responseMs: 2000,
|
||||
errorType: markAllCorrect ? null : (ex.errorType || "discriminacion-auditiva"),
|
||||
exerciseId: ex.id,
|
||||
stage: ex.stage,
|
||||
},
|
||||
})
|
||||
|
||||
// Create FSRS card
|
||||
await prisma.fsrsCard.create({
|
||||
data: {
|
||||
childId,
|
||||
skillCode: ex.skillCode,
|
||||
dueAt: new Date(Date.now() + 86400000),
|
||||
stability: markAllCorrect ? 3.0 : 0.5,
|
||||
difficulty: markAllCorrect ? 4.0 : 7.0,
|
||||
reps: 1,
|
||||
lapses: markAllCorrect ? 0 : 1,
|
||||
},
|
||||
})
|
||||
|
||||
seeded++
|
||||
}
|
||||
|
||||
// Mark stage as completed if markAllCorrect
|
||||
if (markAllCorrect) {
|
||||
await prisma.curriculumProgress.updateMany({
|
||||
where: { childId, topic, stage: Number(stageId) },
|
||||
data: { completedAt: new Date() },
|
||||
})
|
||||
|
||||
// Advance to next stage
|
||||
const nextStage = Number(stageId) + 1
|
||||
const nextExists = await prisma.curriculumProgress.findUnique({
|
||||
where: { childId_topic_stage: { childId, topic, stage: nextStage } },
|
||||
})
|
||||
if (!nextExists) {
|
||||
await prisma.curriculumProgress.create({
|
||||
data: { childId, topic, stage: nextStage },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
child: { id: child.id, name: child.name, profile: resolvedProfile },
|
||||
topic,
|
||||
stagesSeeded: stageEntries.length,
|
||||
exercisesSeeded: seeded,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Debug seed error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { getStagesForProfile } from "@/lib/curriculum/engine"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return NextResponse.json({ error: "Not available in production" }, { status: 403 })
|
||||
}
|
||||
try {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
const profile = request.nextUrl.searchParams.get("profile") || "ISABELLA"
|
||||
const topic = request.nextUrl.searchParams.get("topic") || "lectura"
|
||||
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const child = await prisma.child.findUnique({
|
||||
where: { id: childId },
|
||||
select: { id: true, name: true, profile: true },
|
||||
})
|
||||
|
||||
const progress = await prisma.curriculumProgress.findMany({
|
||||
where: { childId },
|
||||
orderBy: [{ topic: "asc" }, { stage: "asc" }],
|
||||
})
|
||||
|
||||
const skillAttempts = await prisma.skillAttempt.findMany({
|
||||
where: { sessionLog: { childId } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
})
|
||||
|
||||
const fsrsCards = await prisma.fsrsCard.findMany({
|
||||
where: { childId },
|
||||
orderBy: { dueAt: "asc" },
|
||||
})
|
||||
|
||||
const sessionLogs = await prisma.sessionLog.findMany({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 5,
|
||||
})
|
||||
|
||||
// Count mastered exercises for this topic
|
||||
const mastered = new Set(
|
||||
skillAttempts
|
||||
.filter((a) => a.exerciseId && a.exerciseId.startsWith(`${topic[0]}${topic[1]}`) && a.correct)
|
||||
.map((a) => a.exerciseId),
|
||||
)
|
||||
|
||||
const stages = await getStagesForProfile(profile, topic)
|
||||
const totalExercises = Object.values(stages).reduce((sum, s) => sum + s.exercises.length, 0)
|
||||
|
||||
return NextResponse.json({
|
||||
child,
|
||||
progress,
|
||||
skillAttempts: skillAttempts.slice(0, 10),
|
||||
fsrsCards: fsrsCards.slice(0, 10),
|
||||
sessionLogs,
|
||||
stats: {
|
||||
totalExercisesInTopic: totalExercises,
|
||||
masteredInTopic: mastered.size,
|
||||
progressPercent: totalExercises > 0 ? Math.round((mastered.size / totalExercises) * 100) : 0,
|
||||
currentStage: progress.find((p) => !p.completedAt)?.stage || null,
|
||||
currentTopic: progress.find((p) => !p.completedAt)?.topic || null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Debug state error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { buildFsrsCard, gradeCard, skillCodeFromGrade } from "@/lib/fsrs"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { childId, skillCode, correct, responseMs } = await request.json()
|
||||
if (!childId || !skillCode) {
|
||||
return NextResponse.json({ error: "childId and skillCode required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const existingCard = await prisma.fsrsCard.findFirst({
|
||||
where: { childId, skillCode },
|
||||
})
|
||||
|
||||
const grade = skillCodeFromGrade(correct, responseMs ?? 5000)
|
||||
const baseCard = buildFsrsCard({
|
||||
stability: existingCard?.stability,
|
||||
difficulty: existingCard?.difficulty,
|
||||
reps: existingCard?.reps,
|
||||
lapses: existingCard?.lapses,
|
||||
due: existingCard?.dueAt,
|
||||
})
|
||||
|
||||
const { card: updatedCard } = gradeCard(baseCard, grade)
|
||||
|
||||
if (existingCard) {
|
||||
await prisma.fsrsCard.update({
|
||||
where: { id: existingCard.id },
|
||||
data: {
|
||||
dueAt: updatedCard.due,
|
||||
stability: updatedCard.stability,
|
||||
difficulty: updatedCard.difficulty,
|
||||
reps: updatedCard.reps,
|
||||
lapses: updatedCard.lapses,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.fsrsCard.create({
|
||||
data: {
|
||||
childId,
|
||||
skillCode,
|
||||
dueAt: updatedCard.due,
|
||||
stability: updatedCard.stability,
|
||||
difficulty: updatedCard.difficulty,
|
||||
reps: updatedCard.reps,
|
||||
lapses: updatedCard.lapses,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("FSRS grade error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { buildFsrsCard, getRetrievability } from "@/lib/fsrs"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const dueCard = await prisma.fsrsCard.findFirst({
|
||||
where: {
|
||||
childId,
|
||||
dueAt: { lte: new Date() },
|
||||
},
|
||||
orderBy: { dueAt: "asc" },
|
||||
})
|
||||
|
||||
if (dueCard) {
|
||||
const card = buildFsrsCard({
|
||||
stability: dueCard.stability,
|
||||
difficulty: dueCard.difficulty,
|
||||
reps: dueCard.reps,
|
||||
lapses: dueCard.lapses,
|
||||
due: dueCard.dueAt,
|
||||
})
|
||||
return NextResponse.json({
|
||||
...dueCard,
|
||||
cardId: dueCard.id,
|
||||
retrievability: getRetrievability(card),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ skillCode: null })
|
||||
} catch (error) {
|
||||
console.error("[fsrs/next] error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
timestamp: Date.now(),
|
||||
database: "connected",
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ status: "error", timestamp: Date.now(), database: "disconnected" },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
const milestones = await prisma.milestone.findMany({
|
||||
where: { childId },
|
||||
orderBy: { reachedAt: "desc" },
|
||||
})
|
||||
return NextResponse.json(milestones)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { childId, skillCode } = await request.json()
|
||||
if (!childId || !skillCode) {
|
||||
return NextResponse.json({ error: "childId and skillCode required" }, { status: 400 })
|
||||
}
|
||||
const existing = await prisma.milestone.findUnique({
|
||||
where: { childId_skillCode: { childId, skillCode } },
|
||||
})
|
||||
if (existing) {
|
||||
return NextResponse.json(existing)
|
||||
}
|
||||
const milestone = await prisma.milestone.create({
|
||||
data: { childId, skillCode },
|
||||
})
|
||||
return NextResponse.json(milestone, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Milestone error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
+70
-21
@@ -4,18 +4,58 @@ import { randomUUID } from "crypto"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { deviceFingerprint, pairingCode: incomingCode } = await request.json()
|
||||
const { deviceFingerprint, pairingCode, childId, action } = await request.json()
|
||||
|
||||
if (incomingCode) {
|
||||
// Step 2: Parent's device confirms pairing
|
||||
const device = await prisma.device.findFirst({
|
||||
where: { deviceFingerprint: incomingCode },
|
||||
if (action === "generate") {
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
const code = randomUUID().slice(0, 8).toUpperCase()
|
||||
await prisma.pendingPairing.create({
|
||||
data: {
|
||||
code,
|
||||
childId,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ pairingCode: code })
|
||||
}
|
||||
|
||||
if (pairingCode) {
|
||||
const pending = await prisma.pendingPairing.findUnique({
|
||||
where: { code: pairingCode },
|
||||
})
|
||||
if (!pending || pending.expiresAt < new Date()) {
|
||||
return NextResponse.json({ error: "Código inválido o expirado" }, { status: 404 })
|
||||
}
|
||||
|
||||
const targetChildId = childId || pending.childId
|
||||
if (!targetChildId) {
|
||||
return NextResponse.json({ error: "Sin niño asociado" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (childId && !pending.childId) {
|
||||
await prisma.pendingPairing.update({
|
||||
where: { id: pending.id },
|
||||
data: { childId },
|
||||
})
|
||||
}
|
||||
|
||||
const fp = deviceFingerprint || pending.deviceFingerprint
|
||||
if (!fp) {
|
||||
return NextResponse.json({ error: "Se requiere deviceFingerprint" }, { status: 400 })
|
||||
}
|
||||
|
||||
const device = await prisma.device.create({
|
||||
data: {
|
||||
childId: targetChildId,
|
||||
deviceFingerprint: fp,
|
||||
role: "child",
|
||||
},
|
||||
include: { child: true },
|
||||
})
|
||||
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: "Código inválido" }, { status: 404 })
|
||||
}
|
||||
await prisma.pendingPairing.delete({ where: { id: pending.id } })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -24,24 +64,33 @@ export async function POST(request: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Step 1: Child device requests pairing
|
||||
const existingDevice = await prisma.device.findFirst({
|
||||
where: { deviceFingerprint },
|
||||
})
|
||||
if (deviceFingerprint) {
|
||||
const existingDevice = await prisma.device.findFirst({
|
||||
where: { deviceFingerprint },
|
||||
})
|
||||
if (existingDevice) {
|
||||
return NextResponse.json({
|
||||
paired: true,
|
||||
childId: existingDevice.childId,
|
||||
role: existingDevice.role,
|
||||
})
|
||||
}
|
||||
|
||||
if (existingDevice) {
|
||||
const code = randomUUID().slice(0, 8).toUpperCase()
|
||||
await prisma.pendingPairing.create({
|
||||
data: {
|
||||
code,
|
||||
deviceFingerprint,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({
|
||||
paired: true,
|
||||
childId: existingDevice.childId,
|
||||
paired: false,
|
||||
pairingCode: code,
|
||||
})
|
||||
}
|
||||
|
||||
const pairingCode = randomUUID().slice(0, 8)
|
||||
|
||||
return NextResponse.json({
|
||||
paired: false,
|
||||
pairingCode,
|
||||
})
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error("Pairing error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { execFileSync } from "child_process"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { createHash } from "crypto"
|
||||
import { readFile, mkdir } from "fs/promises"
|
||||
import { join } from "path"
|
||||
|
||||
const CACHE_DIR = join(process.cwd(), "public", "tts-cache")
|
||||
|
||||
async function ensureCacheDir() {
|
||||
if (!existsSync(CACHE_DIR)) {
|
||||
await mkdir(CACHE_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function getCachePath(text: string): string {
|
||||
const hash = createHash("md5").update(text).digest("hex")
|
||||
return join(CACHE_DIR, `${hash}.wav`)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const text = request.nextUrl.searchParams.get("text")
|
||||
if (!text || text.length > 200) {
|
||||
return NextResponse.json({ error: "text param required (max 200 chars)" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Sanitize: only allow alphanumeric, spaces, and common punctuation
|
||||
if (!/^[\w\sáéíóúñüÁÉÍÓÚÑÜ.,;:!¿?¡()\-]+$/.test(text)) {
|
||||
return NextResponse.json({ error: "Invalid text characters" }, { status: 400 })
|
||||
}
|
||||
|
||||
await ensureCacheDir()
|
||||
const cachePath = getCachePath(text)
|
||||
|
||||
if (!existsSync(cachePath)) {
|
||||
try {
|
||||
execFileSync("espeak-ng", [
|
||||
"-v", "es-mx",
|
||||
"-s", "140",
|
||||
"-p", "60",
|
||||
"-w", cachePath,
|
||||
text,
|
||||
], { timeout: 10000 })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "TTS generation failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const audioBuffer = await readFile(cachePath)
|
||||
return new NextResponse(audioBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
"Content-Length": audioBuffer.length.toString(),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import GatoMascota from "@/components/child/gato-mascota"
|
||||
import AudioUnlock from "@/components/child/audio-unlock"
|
||||
|
||||
export default function ChildLayout({
|
||||
|
||||
+7
-197
@@ -1,202 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import VocalCard from "@/components/child/vocal-card"
|
||||
import TrazoLetra from "@/components/child/trazo-letra"
|
||||
import TemporizadorVisual from "@/components/child/temporizador-visual"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { vocales } from "@/lib/pedagogia/borel-maisonny"
|
||||
import { getInitialState, evaluateResponse, isMastered } from "@/lib/pedagogia/errorless"
|
||||
|
||||
type Step = "gesto" | "escuchar" | "trazar" | "elegir" | "completo"
|
||||
import ExerciseSession from "@/components/child/exercise-session"
|
||||
|
||||
export default function LecturaSesion() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [step, setStep] = useState<Step>("gesto")
|
||||
const [promptState, setPromptState] = useState(getInitialState())
|
||||
const [sessionComplete, setSessionComplete] = useState(false)
|
||||
const [running, setRunning] = useState(true)
|
||||
const [score, setScore] = useState(0)
|
||||
|
||||
const vocal = vocales[currentIndex]
|
||||
|
||||
const handleGestoComplete = useCallback(() => {
|
||||
speak(`Escuchá. Esta es la letra ${vocal.letra}. ${vocal.palabra}. ${vocal.fonema} ${vocal.fonema} ${vocal.fonema}`)
|
||||
setStep("escuchar")
|
||||
}, [vocal])
|
||||
|
||||
const handleEscucharComplete = useCallback(() => {
|
||||
speak(`Ahora trazala con tu dedo`)
|
||||
setStep("trazar")
|
||||
}, [])
|
||||
|
||||
const handleTrazoComplete = useCallback(() => {
|
||||
setScore((s) => s + 1)
|
||||
const newState = evaluateResponse(promptState, true)
|
||||
setPromptState(newState)
|
||||
|
||||
if (isMastered(newState.level)) {
|
||||
speak(`¡Muy bien! Pasemos a la siguiente`)
|
||||
if (currentIndex < vocales.length - 1) {
|
||||
setCurrentIndex((i) => i + 1)
|
||||
setStep("gesto")
|
||||
setPromptState(getInitialState())
|
||||
} else {
|
||||
setSessionComplete(true)
|
||||
setRunning(false)
|
||||
}
|
||||
} else {
|
||||
setStep("elegir")
|
||||
}
|
||||
}, [promptState, currentIndex])
|
||||
|
||||
const handleChoice = useCallback(
|
||||
(correct: boolean) => {
|
||||
const newState = evaluateResponse(promptState, correct)
|
||||
setPromptState(newState)
|
||||
|
||||
if (correct) {
|
||||
setScore((s) => s + 1)
|
||||
}
|
||||
|
||||
if (isMastered(newState.level)) {
|
||||
if (currentIndex < vocales.length - 1) {
|
||||
setCurrentIndex((i) => i + 1)
|
||||
setStep("gesto")
|
||||
setPromptState(getInitialState())
|
||||
} else {
|
||||
setSessionComplete(true)
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[promptState, currentIndex],
|
||||
)
|
||||
|
||||
if (sessionComplete) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="celebrate" message="¡Terminaste todas las vocales!" size="lg" />
|
||||
<p className="text-lg font-display">Puntuación: {score}/{vocales.length * 2}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentIndex(0)
|
||||
setStep("gesto")
|
||||
setPromptState(getInitialState())
|
||||
setSessionComplete(false)
|
||||
setRunning(true)
|
||||
setScore(0)
|
||||
}}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Jugar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
|
||||
<TemporizadorVisual
|
||||
durationMinutes={10}
|
||||
onComplete={() => {
|
||||
setRunning(false)
|
||||
setSessionComplete(true)
|
||||
}}
|
||||
running={running}
|
||||
/>
|
||||
|
||||
<GatoMascota
|
||||
mood={step === "gesto" ? "thinking" : step === "completo" ? "celebrate" : "coach"}
|
||||
message={
|
||||
step === "gesto"
|
||||
? `Hacé así: ${vocal.gesto}`
|
||||
: step === "escuchar"
|
||||
? "Escuchá bien"
|
||||
: step === "trazar"
|
||||
? "Traza con tu dedo"
|
||||
: ""
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${vocal.letra}-${step}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{step === "gesto" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-8xl">{vocal.imageEmoji}</span>
|
||||
<p className="text-lg text-foreground/60 italic max-w-xs text-center">
|
||||
{vocal.gesto}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleGestoComplete}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
¡Lo hice!
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "escuchar" && (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<VocalCard
|
||||
letra={vocal.letra}
|
||||
fonema={vocal.fonema}
|
||||
palabra={vocal.palabra}
|
||||
imageEmoji={vocal.imageEmoji}
|
||||
color={vocal.color}
|
||||
onClick={() => {
|
||||
speak(`${vocal.fonema}... de ${vocal.palabra}`)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleEscucharComplete}
|
||||
className="bg-secondary text-secondary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "trazar" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<TrazoLetra
|
||||
letra={vocal.letra}
|
||||
fonema={vocal.fonema}
|
||||
color={vocal.color}
|
||||
onComplete={handleTrazoComplete}
|
||||
promptLevel={promptState.level}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "elegir" && (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<p className="text-lg font-display">¿Cuál es la {vocal.letra.toUpperCase()}?</p>
|
||||
<div className="flex flex-wrap gap-4 justify-center">
|
||||
{[...vocales].sort(() => Math.random() - 0.5).slice(0, 3).map((v) => (
|
||||
<button
|
||||
key={v.letra}
|
||||
onClick={() => handleChoice(v.letra === vocal.letra)}
|
||||
className={`touch-target-lg rounded-2xl text-2xl font-display font-bold px-8 py-6 shadow-md active:scale-95 transition-transform`}
|
||||
style={{ backgroundColor: v.color }}
|
||||
>
|
||||
{v.letra.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<ExerciseSession
|
||||
topic="lectura"
|
||||
profile="ISABELLA"
|
||||
loadingMessage="Preparando ejercicios..."
|
||||
completeMessage="¡Terminaste todos los ejercicios por ahora!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+59
-26
@@ -1,45 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
|
||||
const achievements = [
|
||||
{ id: "a", label: "Letra A", emoji: "✈️", completed: true },
|
||||
{ id: "e", label: "Letra E", emoji: "🐘", completed: false },
|
||||
{ id: "i", label: "Letra I", emoji: "🏔️", completed: false },
|
||||
{ id: "o", label: "Letra O", emoji: "🐻", completed: false },
|
||||
{ id: "u", label: "Letra U", emoji: "🍇", completed: false },
|
||||
{ id: "1", label: "Número 1", emoji: "1️⃣", completed: false },
|
||||
{ id: "2", label: "Número 2", emoji: "2️⃣", completed: false },
|
||||
const ALL_SKILLS = [
|
||||
{ id: "vocal-a", label: "Letra A", emoji: "✈️" },
|
||||
{ id: "vocal-e", label: "Letra E", emoji: "🐘" },
|
||||
{ id: "vocal-i", label: "Letra I", emoji: "🏔️" },
|
||||
{ id: "vocal-o", label: "Letra O", emoji: "🐻" },
|
||||
{ id: "vocal-u", label: "Letra U", emoji: "🍇" },
|
||||
{ id: "numero-1", label: "Número 1", emoji: "1️⃣" },
|
||||
{ id: "numero-2", label: "Número 2", emoji: "2️⃣" },
|
||||
{ id: "numero-3", label: "Número 3", emoji: "3️⃣" },
|
||||
{ id: "numero-4", label: "Número 4", emoji: "4️⃣" },
|
||||
{ id: "numero-5", label: "Número 5", emoji: "5️⃣" },
|
||||
]
|
||||
|
||||
const CHILD_ID_KEY = "edueasy_child_id"
|
||||
|
||||
export default function LogrosPage() {
|
||||
const completedCount = achievements.filter((a) => a.completed).length
|
||||
const [milestones, setMilestones] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const childId = localStorage.getItem(CHILD_ID_KEY)
|
||||
if (!childId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
fetch(`/api/milestones?childId=${childId}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setMilestones(data.map((m: any) => m.skillCode))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const completedCount = milestones.length
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center p-6 gap-6">
|
||||
<GatoMascota mood="happy" message="Tus logros" size="sm" />
|
||||
|
||||
<p className="text-lg font-display">
|
||||
{completedCount} de {achievements.length} completados
|
||||
{completedCount} de {ALL_SKILLS.length} completados
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3 justify-center max-w-sm">
|
||||
{achievements.map((a, i) => (
|
||||
<motion.div
|
||||
key={a.id}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className={`rounded-2xl p-4 flex flex-col items-center gap-1 w-24 ${
|
||||
a.completed ? "bg-primary/20 border-2 border-primary" : "bg-muted opacity-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-3xl">{a.emoji}</span>
|
||||
<span className="text-xs text-center font-display font-medium">{a.label}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">Cargando...</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3 justify-center max-w-sm">
|
||||
{ALL_SKILLS.map((skill, i) => {
|
||||
const completed = milestones.includes(skill.id)
|
||||
return (
|
||||
<motion.div
|
||||
key={skill.id}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className={`rounded-2xl p-4 flex flex-col items-center gap-1 w-24 ${
|
||||
completed ? "bg-primary/20 border-2 border-primary" : "bg-muted opacity-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-3xl">{skill.emoji}</span>
|
||||
<span className="text-xs text-center font-display font-medium">{skill.label}</span>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-213
@@ -1,217 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import { CuisenaireRod, rodColors } from "@/components/child/cuisenaire-rod"
|
||||
import TemporizadorVisual from "@/components/child/temporizador-visual"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { getRods } from "@/lib/pedagogia/cuisenaire"
|
||||
import { createCPAState, advanceStage } from "@/lib/pedagogia/cpa-progression"
|
||||
import { generateCPAItems, speakCPAInstruction } from "@/components/child/cpa-exercise"
|
||||
|
||||
type CPAStage = "concrete" | "pictoric" | "abstract"
|
||||
|
||||
export default function NumerosPage() {
|
||||
const [currentValue, setCurrentValue] = useState(1)
|
||||
const [stage, setStage] = useState<CPAStage>("concrete")
|
||||
const [running, setRunning] = useState(true)
|
||||
const [sessionComplete, setSessionComplete] = useState(false)
|
||||
const [score, setScore] = useState(0)
|
||||
const [tappedItems, setTappedItems] = useState<number[]>([])
|
||||
|
||||
const rods = getRods(10)
|
||||
const cpaItems = generateCPAItems(currentValue, stage)
|
||||
|
||||
const startValue = useCallback(
|
||||
(value: number) => {
|
||||
setCurrentValue(value)
|
||||
const newStage = value <= 3 ? "concrete" : value <= 6 ? "pictoric" : "abstract"
|
||||
setStage(newStage)
|
||||
setTappedItems([])
|
||||
speakCPAInstruction(value, newStage)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleItemTap = useCallback(
|
||||
(id: number) => {
|
||||
setTappedItems((prev) => {
|
||||
const next = [...prev, id]
|
||||
if (next.length === currentValue) {
|
||||
setScore((s) => s + 1)
|
||||
if (currentValue >= 5) {
|
||||
setSessionComplete(true)
|
||||
setRunning(false)
|
||||
} else {
|
||||
const nextValue = currentValue + 1
|
||||
setCurrentValue(nextValue)
|
||||
const newStage: CPAStage = nextValue <= 3 ? "concrete" : nextValue <= 6 ? "pictoric" : "abstract"
|
||||
setStage(newStage)
|
||||
setTappedItems([])
|
||||
setTimeout(() => speakCPAInstruction(nextValue, newStage), 500)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[currentValue],
|
||||
)
|
||||
|
||||
if (sessionComplete) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="celebrate" message="¡Muy bien con los números!" size="lg" />
|
||||
<p className="text-lg font-display">Puntuación: {score}/5</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentValue(1)
|
||||
setStage("concrete")
|
||||
setScore(0)
|
||||
setTappedItems([])
|
||||
setSessionComplete(false)
|
||||
}}
|
||||
className="bg-accent text-accent-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Jugar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import ExerciseSession from "@/components/child/exercise-session"
|
||||
|
||||
export default function NumerosSesion() {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
|
||||
<TemporizadorVisual
|
||||
durationMinutes={10}
|
||||
onComplete={() => {
|
||||
setRunning(false)
|
||||
setSessionComplete(true)
|
||||
}}
|
||||
running={running}
|
||||
/>
|
||||
|
||||
<GatoMascota
|
||||
mood="coach"
|
||||
message={
|
||||
stage === "concrete"
|
||||
? `Tocá ${currentValue} cosas`
|
||||
: stage === "pictoric"
|
||||
? "¿Cuántos hay?"
|
||||
: "Señalá el número"
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${currentValue}-${stage}`}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
{/* Level indicator */}
|
||||
<div className="flex gap-3 items-center">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => startValue(n)}
|
||||
className={`w-12 h-12 rounded-full text-lg font-display font-bold ${
|
||||
n <= currentValue ? "bg-primary text-white" : "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CPA area */}
|
||||
<div className="w-full max-w-sm min-h-[200px] flex flex-wrap items-center justify-center gap-4 p-4 bg-white rounded-3xl shadow-md">
|
||||
{stage === "concrete" &&
|
||||
cpaItems.map((item) => (
|
||||
<motion.button
|
||||
key={item.id}
|
||||
onClick={() => handleItemTap(item.id)}
|
||||
className="text-4xl touch-target active:scale-110 transition-transform"
|
||||
whileTap={{ scale: 1.2 }}
|
||||
animate={
|
||||
tappedItems.includes(item.id)
|
||||
? { scale: 0.8, opacity: 0.5 }
|
||||
: { scale: 1, opacity: 1 }
|
||||
}
|
||||
>
|
||||
{item.emoji}
|
||||
</motion.button>
|
||||
))}
|
||||
|
||||
{stage === "pictoric" && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{cpaItems.map((item, i) => (
|
||||
<motion.span
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="text-3xl"
|
||||
>
|
||||
{item.emoji}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-4">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => {
|
||||
if (n === currentValue) {
|
||||
handleItemTap(n)
|
||||
}
|
||||
}}
|
||||
className="w-14 h-14 rounded-2xl bg-muted text-2xl font-display font-bold active:scale-90 transition-transform"
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === "abstract" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-6xl font-display font-bold">{currentValue}</span>
|
||||
<p className="text-muted-foreground">Tocá el número que ves arriba</p>
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => {
|
||||
if (n === currentValue) {
|
||||
handleItemTap(n)
|
||||
}
|
||||
}}
|
||||
className="w-16 h-16 rounded-2xl bg-muted text-2xl font-display font-bold active:scale-90 transition-transform"
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cuisenaire reference */}
|
||||
<div className="flex flex-wrap justify-center gap-1 mt-2">
|
||||
{rods.slice(0, currentValue).map((rod) => (
|
||||
<CuisenaireRod
|
||||
key={rod.value}
|
||||
value={rod.value}
|
||||
color={rod.color}
|
||||
length={rod.lengthPx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<ExerciseSession
|
||||
topic="numeros"
|
||||
profile="ISABELLA"
|
||||
loadingMessage="Preparando números..."
|
||||
completeMessage="¡Terminaste los números por ahora!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+13
-2
@@ -7,14 +7,24 @@ const activities = [
|
||||
{ id: "lectura", label: "Letras", icon: "Aa", color: "bg-primary" },
|
||||
{ id: "numeros", label: "Números", icon: "123", color: "bg-accent" },
|
||||
{ id: "logros", label: "Mis logros", icon: "⭐", color: "bg-warning" },
|
||||
{ id: "pairing", label: "iPad nuevo", icon: "🔗", color: "bg-secondary" },
|
||||
]
|
||||
|
||||
export default function ChildHome() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-8">
|
||||
<GatoMascota mood="happy" message="¡Hola! ¿Qué querés aprender hoy?" />
|
||||
<div className="flex-1 flex flex-col p-4">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="self-start flex items-center gap-2 bg-surface text-foreground border border-border rounded-xl px-4 py-2 text-base font-display font-semibold shadow-sm active:scale-95 transition-transform mb-4"
|
||||
>
|
||||
<span className="text-xl">←</span>
|
||||
<span>Cambiar perfil</span>
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-8">
|
||||
<GatoMascota mood="happy" message="¡Hola! ¿Qué querés aprender hoy?" />
|
||||
|
||||
<div className="flex flex-col gap-4 w-full max-w-md">
|
||||
{activities.map((a) => (
|
||||
@@ -28,6 +38,7 @@ export default function ChildHome() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import { speak } from "@/lib/speech"
|
||||
|
||||
const CHILD_ID_KEY = "edueasy_child_id"
|
||||
|
||||
export default function ChildPairingPage() {
|
||||
const [code, setCode] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [paired, setPaired] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handlePair = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const deviceFingerprint = `child-${navigator.userAgent.slice(0, 64)}`
|
||||
const res = await fetch("/api/pairing", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
pairingCode: code.toUpperCase(),
|
||||
deviceFingerprint,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(CHILD_ID_KEY, data.childId)
|
||||
setPaired(true)
|
||||
speak("¡Emparejado! Ahora podemos aprender juntos")
|
||||
}
|
||||
|
||||
if (paired) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="celebrate" message="iPad emparejado" size="lg" />
|
||||
<button
|
||||
onClick={() => router.push("/child")}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Ir a jugar
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
|
||||
<GatoMascota mood="coach" message="Poné el código que te dió mamá" size="md" />
|
||||
|
||||
<form onSubmit={handlePair} className="flex flex-col items-center gap-4 w-full max-w-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="Código"
|
||||
maxLength={8}
|
||||
className="w-full rounded-2xl border-2 border-primary bg-white px-6 py-4 text-center text-3xl tracking-[0.5em] uppercase font-display font-bold focus:outline-none focus:ring-4 focus:ring-primary/30"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length < 8}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Emparejando..." : "¡Listo!"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/child")}
|
||||
className="text-muted-foreground underline text-sm"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import ExerciseSession from "@/components/child/exercise-session"
|
||||
|
||||
export default function FrancescaLecturas() {
|
||||
return (
|
||||
<ExerciseSession
|
||||
topic="lecturas"
|
||||
profile="FRANCESCA"
|
||||
loadingMessage="Preparando lecturas..."
|
||||
completeMessage="¡Terminaste las lecturas por ahora!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import ExerciseSession from "@/components/child/exercise-session"
|
||||
|
||||
export default function FrancescaMate() {
|
||||
return (
|
||||
<ExerciseSession
|
||||
topic="matematicas"
|
||||
profile="FRANCESCA"
|
||||
loadingMessage="Preparando matemáticas..."
|
||||
completeMessage="¡Terminaste las matemáticas por ahora!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import ExerciseSession from "@/components/child/exercise-session"
|
||||
|
||||
export default function FrancescaOrtografia() {
|
||||
return (
|
||||
<ExerciseSession
|
||||
topic="ortografia"
|
||||
profile="FRANCESCA"
|
||||
loadingMessage="Preparando ortografía..."
|
||||
completeMessage="¡Terminaste ortografía por ahora!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import AudioUnlock from "@/components/child/audio-unlock"
|
||||
|
||||
const FRANCESCA_KEY = "edueasy_child_francesca"
|
||||
|
||||
const activities = [
|
||||
{ id: "matematicas", label: "Matemáticas", icon: "🔢", description: "Sumas, restas, tablas y divisiones" },
|
||||
{ id: "lecturas", label: "Lecturas cortas", icon: "📖", description: "Leé textos sobre distintos temas" },
|
||||
{ id: "ortografia", label: "Ortografía", icon: "✏️", description: "Mayúsculas y reglas" },
|
||||
{ id: "logros", label: "Mis logros", icon: "⭐", color: "bg-warning" },
|
||||
{ id: "pairing", label: "iPad nuevo", icon: "🔗", color: "bg-secondary" },
|
||||
]
|
||||
|
||||
export default function FrancescaHome() {
|
||||
const router = useRouter()
|
||||
const [audioReady, setAudioReady] = useState(false)
|
||||
const [paired, setPaired] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setPaired(!!localStorage.getItem(FRANCESCA_KEY))
|
||||
}, [])
|
||||
|
||||
const handleActivity = (id: string) => {
|
||||
if (id === "logros") {
|
||||
router.push("/child/logros?profile=francesca")
|
||||
} else if (id === "pairing") {
|
||||
router.push("/child/pairing?profile=francesca")
|
||||
} else {
|
||||
router.push(`/francesca/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioReady) {
|
||||
return <AudioUnlock onUnlock={() => setAudioReady(true)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-4">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="self-start flex items-center gap-2 bg-surface text-foreground border border-border rounded-xl px-4 py-2 text-base font-display font-semibold shadow-sm active:scale-95 transition-transform mb-4"
|
||||
>
|
||||
<span className="text-xl">←</span>
|
||||
<span>Cambiar perfil</span>
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-8">
|
||||
<GatoMascota mood="happy" message="¡Hola Francesca! ¿Qué querés practicar hoy?" />
|
||||
|
||||
{!paired ? (
|
||||
<div className="text-center max-w-xs space-y-4">
|
||||
<p className="text-foreground/60">Todavía no estás conectada. Pedile a mamá que te empareje.</p>
|
||||
<button
|
||||
onClick={() => router.push("/child/pairing?profile=francesca")}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Ir a emparejar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full max-w-md">
|
||||
{activities.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => handleActivity(a.id)}
|
||||
className={`${a.color || "bg-accent"} text-primary-foreground touch-target-lg rounded-2xl flex items-center gap-4 px-6 py-5 text-xl font-display font-semibold shadow-md active:scale-95 transition-transform`}
|
||||
>
|
||||
<span className="text-2xl">{a.icon}</span>
|
||||
<div className="text-left">
|
||||
<div>{a.label}</div>
|
||||
{a.description && (
|
||||
<div className="text-sm opacity-80">{a.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
-4
@@ -29,11 +29,10 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="es" className={inter.variable}>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<link rel="apple-touch-icon" href="/icon.png" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
</head>
|
||||
<body className="dvh-screen overflow-x-hidden">{children}</body>
|
||||
<body className="min-h-dvh overflow-x-hidden">{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
+77
-3
@@ -3,6 +3,33 @@
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
const PROFILES = [
|
||||
{
|
||||
id: "isabella",
|
||||
label: "Isabella",
|
||||
edad: "4 años",
|
||||
emoji: "👧",
|
||||
color: "from-pink-400 to-pink-500",
|
||||
desc: "Letras, números y primeras palabras",
|
||||
},
|
||||
{
|
||||
id: "francesca",
|
||||
label: "Francesca",
|
||||
edad: "6 años",
|
||||
emoji: "👦",
|
||||
color: "from-blue-400 to-blue-500",
|
||||
desc: "Matemáticas, lectura y ortografía",
|
||||
},
|
||||
{
|
||||
id: "sebastian",
|
||||
label: "Sebastián",
|
||||
edad: "8 años",
|
||||
emoji: "🧒",
|
||||
color: "from-green-400 to-green-500",
|
||||
desc: "Matemáticas avanzadas, historia y geografía",
|
||||
},
|
||||
]
|
||||
|
||||
export default function RootPage() {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -10,10 +37,57 @@ export default function RootPage() {
|
||||
const host = window.location.hostname
|
||||
if (host.includes("simulacro")) {
|
||||
router.replace("/parent")
|
||||
} else {
|
||||
router.replace("/child")
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return null
|
||||
const handleSelect = (id: string) => {
|
||||
if (id === "isabella") {
|
||||
router.push("/child")
|
||||
} else {
|
||||
router.push(`/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background flex flex-col items-center justify-center p-6 gap-6 relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-primary via-accent to-secondary opacity-30" />
|
||||
|
||||
{/* Logo area */}
|
||||
<div className="text-center space-y-1 mb-4">
|
||||
<div className="text-5xl">📚</div>
|
||||
<h1 className="text-3xl font-display font-bold text-foreground">
|
||||
EduEasy
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">¿Quién va a aprender hoy?</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full max-w-sm">
|
||||
{PROFILES.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleSelect(p.id)}
|
||||
className={`bg-gradient-to-r ${p.color} text-white touch-target-lg rounded-2xl flex items-center gap-4 px-6 py-6 text-left shadow-lg active:scale-95 transition-all hover:shadow-xl font-display`}
|
||||
>
|
||||
<span className="text-5xl shrink-0">{p.emoji}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xl font-bold">{p.label}</div>
|
||||
<div className="text-sm opacity-80">{p.edad}</div>
|
||||
<div className="text-xs opacity-70 mt-1">{p.desc}</div>
|
||||
</div>
|
||||
<span className="text-2xl opacity-60">→</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-4 text-center">
|
||||
<button
|
||||
onClick={() => router.push("/parent")}
|
||||
className="text-sm text-muted-foreground underline underline-offset-2"
|
||||
>
|
||||
Mamá / Papá
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { getAuth } from "@/lib/auth-client"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
@@ -16,14 +16,7 @@ export default function LoginPage() {
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const auth = getAuth()
|
||||
if (!auth) {
|
||||
setError("Error de inicialización")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { error: err } = await auth.signIn.email({
|
||||
const { error: err } = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
@@ -73,6 +66,13 @@ export default function LoginPage() {
|
||||
>
|
||||
{loading ? "Entrando..." : "Iniciar sesión"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
¿No tenés cuenta?{" "}
|
||||
<a href="/parent/auth/register" className="text-primary underline">
|
||||
Crear cuenta
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,37 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { getAuth } from "@/lib/auth-client"
|
||||
import { useState, useEffect } from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
|
||||
interface ChildSummary {
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
|
||||
export default function PairingPage() {
|
||||
const [code, setCode] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [paired, setPaired] = useState(false)
|
||||
const auth = getAuth()
|
||||
const { data: session } = auth?.useSession() ?? { data: null }
|
||||
const [children, setChildren] = useState<ChildSummary[]>([])
|
||||
const [selectedChildId, setSelectedChildId] = useState("")
|
||||
const { data: session } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/children")
|
||||
.then((r) => r.json())
|
||||
.then((list) => {
|
||||
if (Array.isArray(list)) {
|
||||
setChildren(list)
|
||||
if (list.length > 0) setSelectedChildId(list[0].id)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handlePairing = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const res = await fetch("/api/pairing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ pairingCode: code, deviceFingerprint: navigator.userAgent }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
if (!selectedChildId) {
|
||||
setError("Seleccioná un niño")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPaired(true)
|
||||
try {
|
||||
const res = await fetch("/api/pairing", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
pairingCode: code.toUpperCase(),
|
||||
childId: selectedChildId,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPaired(true)
|
||||
} catch {
|
||||
setError("Error de conexión")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -68,6 +100,26 @@ export default function PairingPage() {
|
||||
<p className="text-muted-foreground text-center">
|
||||
Ingresá el código de 8 caracteres que aparece en el iPad
|
||||
</p>
|
||||
|
||||
{children.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{children.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedChildId(c.id)}
|
||||
className={`rounded-xl px-4 py-2 font-display text-sm transition-colors ${
|
||||
c.id === selectedChildId
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{c.name || "Sin nombre"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
@@ -75,7 +127,7 @@ export default function PairingPage() {
|
||||
placeholder="Código de 8 letras"
|
||||
maxLength={8}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-center text-2xl tracking-widest uppercase focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-destructive text-sm text-center">{error}</p>}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const router = useRouter()
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const { error: err } = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name: name || "",
|
||||
})
|
||||
|
||||
if (err) {
|
||||
setError(err.message || "Error al registrarse")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/parent")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center p-6">
|
||||
<form onSubmit={handleRegister} className="w-full max-w-sm flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-display font-bold text-foreground text-center">
|
||||
Crear cuenta · Mamá
|
||||
</h1>
|
||||
|
||||
{error && <p className="text-destructive text-sm text-center">{error}</p>}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tu nombre"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Contraseña (mín. 8 caracteres)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary text-primary-foreground touch-target rounded-xl text-lg font-display font-semibold disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Creando cuenta..." : "Crear cuenta"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
¿Ya tenés cuenta?{" "}
|
||||
<a href="/parent/auth/login" className="text-primary underline">
|
||||
Iniciá sesión
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { getAuth } from "@/lib/auth-client"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function ConfigPage() {
|
||||
const auth = getAuth()
|
||||
const { data: session } = auth?.useSession() ?? { data: null }
|
||||
const { data: session } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
|
||||
+271
-53
@@ -1,75 +1,293 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { getAuth } from "@/lib/auth-client"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { MetricCard } from "@/components/parent/metric-card"
|
||||
import { WeeklyActivityChart } from "@/components/parent/weekly-activity-chart"
|
||||
import { SkillMasteryChart } from "@/components/parent/skill-mastery-chart"
|
||||
import { MilestonesList } from "@/components/parent/milestones-list"
|
||||
import ErrorPatternsChart from "@/components/parent/error-patterns-chart"
|
||||
|
||||
interface ChildSummary {
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
totalSessions: number
|
||||
todaySessions: number
|
||||
todayMinutes: number
|
||||
masteryPercent: number
|
||||
masteredSkills: number
|
||||
totalSkills: number
|
||||
milestones: number
|
||||
milestoneData: { skillCode: string; reachedAt: string }[]
|
||||
streak: number
|
||||
weeklyData: { date: string; minutes: number }[]
|
||||
skillBreakdown: { skillCode: string; stability: number; reps: number }[]
|
||||
stageProgress: { topic: string; stage: number; completedAt: string | null }[]
|
||||
errorTypeCount: Record<string, number>
|
||||
totalErrors: number
|
||||
lastSession: {
|
||||
startedAt: string
|
||||
durationSec: number
|
||||
skillsPracticed: string
|
||||
correctCount: number
|
||||
totalAttempts: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export default function ParentDashboard() {
|
||||
const auth = getAuth()
|
||||
const { data: session } = auth?.useSession() ?? { data: null }
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
const [children, setChildren] = useState<ChildSummary[]>([])
|
||||
const [selectedChildId, setSelectedChildId] = useState<string>("")
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newChildName, setNewChildName] = useState("")
|
||||
const [pairingCode, setPairingCode] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (session === null) {
|
||||
fetch("/api/children")
|
||||
.then((r) => r.json())
|
||||
.then((list) => {
|
||||
if (Array.isArray(list)) {
|
||||
setChildren(list)
|
||||
if (list.length > 0) setSelectedChildId(list[0].id)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedChildId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
fetch(`/api/dashboard/summary?childId=${selectedChildId}`)
|
||||
.then((r) => r.json())
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [selectedChildId])
|
||||
|
||||
const handleCreateChild = async () => {
|
||||
if (!newChildName.trim()) return
|
||||
setCreating(true)
|
||||
const res = await fetch("/api/children", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newChildName.trim() }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const child = await res.json()
|
||||
setChildren((prev) => [...prev, child])
|
||||
setSelectedChildId(child.id)
|
||||
setNewChildName("")
|
||||
}
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const handleGeneratePairingCode = async () => {
|
||||
if (!selectedChildId) return
|
||||
const res = await fetch("/api/pairing", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "generate", childId: selectedChildId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.pairingCode) {
|
||||
setPairingCode(data.pairingCode)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.push("/parent/auth/login")
|
||||
}
|
||||
}, [session, router])
|
||||
}, [session, isPending, router])
|
||||
|
||||
if (!session) return null
|
||||
if (isPending || !session) {
|
||||
return (
|
||||
<div className="p-4 text-center text-muted-foreground">Cargando...</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedChild = children.find((c) => c.id === selectedChildId)
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh p-6">
|
||||
<header className="flex items-center justify-between mb-8">
|
||||
<div className="p-4 sm:p-6 max-w-2xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-bold">EduEasy</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<h2 className="text-2xl font-display font-bold">Dashboard</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bienvenida, {session.user?.name || "Mamá"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => auth?.signOut()}
|
||||
className="text-sm text-muted-foreground underline"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 max-w-lg">
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm col-span-2">
|
||||
<p className="text-sm text-muted-foreground">Racha actual</p>
|
||||
<p className="text-3xl font-display font-bold text-primary">— días</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">Hoy</p>
|
||||
<p className="text-2xl font-display font-bold">— min</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">Logros</p>
|
||||
<p className="text-2xl font-display font-bold">— %</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm col-span-2">
|
||||
<p className="text-sm text-muted-foreground mb-2">Última sesión</p>
|
||||
<p className="text-muted-foreground italic">No hay sesiones todavía</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-6 max-w-lg">
|
||||
<button
|
||||
onClick={() => router.push("/parent/auth/pairing")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl font-display font-semibold"
|
||||
>
|
||||
Emparejar iPad
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push("/parent/configuracion")}
|
||||
className="bg-muted text-foreground touch-target rounded-xl font-display"
|
||||
>
|
||||
Configuración
|
||||
</button>
|
||||
</div>
|
||||
{children.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-border">
|
||||
<h3 className="text-lg font-display font-semibold mb-4">Agregar un niño</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newChildName}
|
||||
onChange={(e) => setNewChildName(e.target.value)}
|
||||
placeholder="Nombre de la nena"
|
||||
className="flex-1 rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateChild}
|
||||
disabled={creating || !newChildName.trim()}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 font-display font-semibold disabled:opacity-50"
|
||||
>
|
||||
{creating ? "..." : "Agregar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{children.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setSelectedChildId(c.id)}
|
||||
className={`rounded-xl px-4 py-2 font-display font-medium text-sm transition-colors ${
|
||||
c.id === selectedChildId
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{c.name || "Sin nombre"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Cargando...</div>
|
||||
) : data ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<MetricCard title="Racha" value={`${data.streak} días`} variant="primary" />
|
||||
<MetricCard
|
||||
title="Hoy"
|
||||
value={`${data.todayMinutes} min`}
|
||||
subtitle={data.todaySessions > 0 ? `${data.todaySessions} sesiones` : undefined}
|
||||
variant="success"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Maestría"
|
||||
value={`${data.masteryPercent}%`}
|
||||
subtitle={`${data.masteredSkills}/${data.totalSkills} skills`}
|
||||
variant="accent"
|
||||
/>
|
||||
<MetricCard title="Logros" value={data.milestones} variant="default" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">
|
||||
Actividad semanal
|
||||
</h3>
|
||||
<WeeklyActivityChart data={data.weeklyData} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">
|
||||
Maestría por skill
|
||||
</h3>
|
||||
<SkillMasteryChart data={data.skillBreakdown} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">Logros</h3>
|
||||
<MilestonesList data={data.milestoneData} />
|
||||
</div>
|
||||
|
||||
{data.stageProgress.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">Progreso curricular</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{data.stageProgress.map((sp) => (
|
||||
<div key={`${sp.topic}-${sp.stage}`} className="flex justify-between items-center">
|
||||
<span className="font-medium">
|
||||
{sp.topic === "lectura" ? "Lectura" : "Números"} — Etapa {sp.stage}
|
||||
</span>
|
||||
<span className={sp.completedAt ? "text-green-600" : "text-yellow-600"}>
|
||||
{sp.completedAt ? "✅ Completada" : "🔵 En curso"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.totalErrors > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">Patrones de error</h3>
|
||||
<ErrorPatternsChart data={data.errorTypeCount} total={data.totalErrors} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.lastSession && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-2">Última sesión</h3>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{new Date(data.lastSession.startedAt).toLocaleDateString("es-AR")}</p>
|
||||
<p>
|
||||
{Math.round((data.lastSession.durationSec ?? 0) / 60)} min ·{" "}
|
||||
{data.lastSession.correctCount}/{data.lastSession.totalAttempts} correctas
|
||||
</p>
|
||||
<p className="text-xs mt-1">Skills: {data.lastSession.skillsPracticed || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 mt-2">
|
||||
<button
|
||||
onClick={handleGeneratePairingCode}
|
||||
className="bg-secondary text-secondary-foreground touch-target rounded-xl px-6 font-display font-semibold"
|
||||
>
|
||||
Emparejar nuevo iPad
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push("/parent/auth/pairing")}
|
||||
className="bg-muted text-foreground touch-target rounded-xl font-display"
|
||||
>
|
||||
Ingresar código
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Seleccioná un niño para ver su progreso
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pairingCode && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-xl max-w-sm w-full text-center space-y-4">
|
||||
<h3 className="text-lg font-display font-semibold">Código de emparejamiento</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ingresá este código en el iPad de la nena
|
||||
</p>
|
||||
<p className="text-3xl tracking-[0.3em] font-bold font-display text-primary select-all">
|
||||
{pairingCode}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setPairingCode("")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 py-3 font-display font-semibold"
|
||||
>
|
||||
Listo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+82
-7
@@ -1,11 +1,86 @@
|
||||
export default function ParentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const navItems = [
|
||||
{ href: "/parent/dashboard", label: "Dashboard", emoji: "📊" },
|
||||
{ href: "/parent/configuracion", label: "Configuración", emoji: "⚙️" },
|
||||
]
|
||||
|
||||
export default function ParentLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const { data: session } = authClient.useSession()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
if (
|
||||
pathname.startsWith("/parent/auth")
|
||||
) {
|
||||
return <div className="min-h-dvh bg-background">{children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
{children}
|
||||
<div className="min-h-dvh bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="text-foreground touch-target rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-lg font-display font-bold">EduEasy</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground hidden sm:block">
|
||||
{session?.user?.name || "Mamá"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => authClient.signOut()}
|
||||
className="text-sm text-muted-foreground underline"
|
||||
>
|
||||
Salir
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-20"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<nav
|
||||
className="fixed left-0 top-0 bottom-0 w-64 bg-white shadow-lg p-4 pt-16"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl px-4 py-3 font-display font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span>{item.emoji}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-3
@@ -2,11 +2,10 @@
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { getAuth } from "@/lib/auth-client"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function ParentPage() {
|
||||
const auth = getAuth()
|
||||
const { data: session, isPending } = auth?.useSession() ?? { data: null, isPending: false }
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import ExerciseSession from "@/components/child/exercise-session"
|
||||
|
||||
export default function SebastianBanderas() {
|
||||
return (
|
||||
<ExerciseSession
|
||||
topic="banderas"
|
||||
profile="SEBASTIAN"
|
||||
loadingMessage="Preparando banderas..."
|
||||
completeMessage="¡Terminaste las banderas por hoy!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import ExerciseSession from "@/components/child/exercise-session"
|
||||
|
||||
export default function SebastianGeografia() {
|
||||
return (
|
||||
<ExerciseSession
|
||||
topic="geografia"
|
||||
profile="SEBASTIAN"
|
||||
loadingMessage="Preparando geografía..."
|
||||
completeMessage="¡Terminaste geografía por ahora!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import ExerciseSession from "@/components/child/exercise-session"
|
||||
|
||||
export default function SebastianHistoria() {
|
||||
return (
|
||||
<ExerciseSession
|
||||
topic="historia"
|
||||
profile="SEBASTIAN"
|
||||
loadingMessage="Preparando historia argentina..."
|
||||
completeMessage="¡Terminaste historia por ahora!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import ExerciseSession from "@/components/child/exercise-session"
|
||||
|
||||
export default function SebastianLectura() {
|
||||
return (
|
||||
<ExerciseSession
|
||||
topic="lectura"
|
||||
profile="SEBASTIAN"
|
||||
loadingMessage="Preparando lectura..."
|
||||
completeMessage="¡Terminaste las lecturas por ahora!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import ExerciseSession from "@/components/child/exercise-session"
|
||||
|
||||
export default function SebastianMatematicas() {
|
||||
return (
|
||||
<ExerciseSession
|
||||
topic="matematicas"
|
||||
profile="SEBASTIAN"
|
||||
loadingMessage="Preparando matemáticas..."
|
||||
completeMessage="¡Terminaste las matemáticas por ahora!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import AudioUnlock from "@/components/child/audio-unlock"
|
||||
|
||||
const SEBASTIAN_KEY = "edueasy_child_sebastian"
|
||||
|
||||
const activities = [
|
||||
{ id: "matematicas", label: "Matemáticas", icon: "➕", description: "Sumas, restas, multiplicación, división, fracciones" },
|
||||
{ id: "lectura", label: "Lectura", icon: "📖", description: "Textos informativos y comprensión" },
|
||||
{ id: "historia", label: "Historia argentina", icon: "🇦🇷", description: "Personajes, fechas y eventos" },
|
||||
{ id: "geografia", label: "Geografía", icon: "🗺️", description: "Provincias, ríos y regiones" },
|
||||
{ id: "banderas", label: "Banderas", icon: "🚩", description: "Provincias y símbolos" },
|
||||
{ id: "logros", label: "Mis logros", icon: "⭐", color: "bg-warning" },
|
||||
{ id: "pairing", label: "iPad nuevo", icon: "🔗", color: "bg-secondary" },
|
||||
]
|
||||
|
||||
export default function SebastianHome() {
|
||||
const router = useRouter()
|
||||
const [audioReady, setAudioReady] = useState(false)
|
||||
const [paired, setPaired] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setPaired(!!localStorage.getItem(SEBASTIAN_KEY))
|
||||
}, [])
|
||||
|
||||
const handleActivity = (id: string) => {
|
||||
if (id === "logros") {
|
||||
router.push("/child/logros?profile=sebastian")
|
||||
} else if (id === "pairing") {
|
||||
router.push("/child/pairing?profile=sebastian")
|
||||
} else {
|
||||
router.push(`/sebastian/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioReady) {
|
||||
return <AudioUnlock onUnlock={() => setAudioReady(true)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-4">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="self-start flex items-center gap-2 bg-surface text-foreground border border-border rounded-xl px-4 py-2 text-base font-display font-semibold shadow-sm active:scale-95 transition-transform mb-4"
|
||||
>
|
||||
<span className="text-xl">←</span>
|
||||
<span>Cambiar perfil</span>
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-8">
|
||||
<GatoMascota mood="happy" message="¡Hola Sebastián! ¿Qué querés explorar hoy?" />
|
||||
|
||||
{!paired ? (
|
||||
<div className="text-center max-w-xs space-y-4">
|
||||
<p className="text-foreground/60">Todavía no estás conectado. Pedile a mamá que te empareje.</p>
|
||||
<button
|
||||
onClick={() => router.push("/child/pairing?profile=sebastian")}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Ir a emparejar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full max-w-md">
|
||||
{activities.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => handleActivity(a.id)}
|
||||
className={`${a.color || "bg-accent"} text-primary-foreground touch-target-lg rounded-2xl flex items-center gap-4 px-6 py-5 text-xl font-display font-semibold shadow-md active:scale-95 transition-transform`}
|
||||
>
|
||||
<span className="text-2xl">{a.icon}</span>
|
||||
<div className="text-left">
|
||||
<div>{a.label}</div>
|
||||
{a.description && (
|
||||
<div className="text-sm opacity-80">{a.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export interface CuisenaireRodProps {
|
||||
value: number
|
||||
color: string
|
||||
length: number // in vw or px
|
||||
length: number
|
||||
draggable?: boolean
|
||||
onDrop?: (value: number) => void
|
||||
}
|
||||
|
||||
const rodColors: Record<number, string> = {
|
||||
1: "#FFFFFF", // blanco
|
||||
2: "#E74C3C", // rojo
|
||||
3: "#2ECC71", // verde claro
|
||||
4: "#E91E63", // rosa
|
||||
5: "#F1C40F", // amarillo
|
||||
6: "#1ABC9C", // verde oscuro
|
||||
7: "#D35400", // naranja
|
||||
8: "#3498DB", // azul
|
||||
9: "#9B59B6", // púrpura
|
||||
10: "#FF5733", // naranja
|
||||
1: "#FFFFFF",
|
||||
2: "#E74C3C",
|
||||
3: "#2ECC71",
|
||||
4: "#E91E63",
|
||||
5: "#F1C40F",
|
||||
6: "#1ABC9C",
|
||||
7: "#2C3E50",
|
||||
8: "#3498DB",
|
||||
9: "#9B59B6",
|
||||
10: "#E67E22",
|
||||
}
|
||||
|
||||
const rodLengths: Record<number, number> = {
|
||||
1: 20,
|
||||
2: 40,
|
||||
3: 60,
|
||||
4: 80,
|
||||
5: 100,
|
||||
6: 120,
|
||||
7: 140,
|
||||
8: 160,
|
||||
9: 180,
|
||||
10: 200,
|
||||
1: 20, 2: 40, 3: 60, 4: 80, 5: 100,
|
||||
6: 120, 7: 140, 8: 160, 9: 180, 10: 200,
|
||||
}
|
||||
|
||||
export { rodColors, rodLengths }
|
||||
|
||||
export function CuisenaireRod({ value, color, length, draggable = false }: CuisenaireRodProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="rounded-lg border border-border/50 shadow-sm touch-target cursor-grab active:cursor-grabbing"
|
||||
className={`rounded-lg border border-border/50 shadow-sm touch-target select-none ${
|
||||
draggable ? "cursor-grab" : ""
|
||||
} ${isDragging ? "cursor-grabbing z-10" : ""}`}
|
||||
style={{
|
||||
width: `${length}px`,
|
||||
minWidth: `${length}px`,
|
||||
height: "40px",
|
||||
backgroundColor: color,
|
||||
borderColor: value === 1 ? "#ddd" : undefined,
|
||||
}}
|
||||
whileTap={{ scale: 1.05 }}
|
||||
whileTap={draggable ? { scale: 1.08 } : undefined}
|
||||
drag={draggable}
|
||||
dragElastic={0.2}
|
||||
dragConstraints={{ left: -200, right: 200, top: -200, bottom: 200 }}
|
||||
dragElastic={0.1}
|
||||
dragMomentum={false}
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onDragEnd={() => setIsDragging(false)}
|
||||
dragConstraints={{ left: -400, right: 400, top: -300, bottom: 300 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import NoChildError from "@/components/child/no-child-error"
|
||||
import TemporizadorVisual from "@/components/child/temporizador-visual"
|
||||
import ExerciseDispatcher from "@/components/exercises/exercise-dispatcher"
|
||||
import { speak, stopSpeaking } from "@/lib/speech"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
const CHILD_ID_KEY = "edueasy_child_id"
|
||||
|
||||
type PageState = "loading" | "ready" | "complete" | "error"
|
||||
|
||||
interface Props {
|
||||
topic: string
|
||||
loadingMessage: string
|
||||
completeMessage: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
function profileHome(profile?: string) {
|
||||
switch (profile?.toUpperCase()) {
|
||||
case "FRANCESCA": return "/francesca"
|
||||
case "SEBASTIAN": return "/sebastian"
|
||||
default: return "/child"
|
||||
}
|
||||
}
|
||||
|
||||
export default function ExerciseSession({ topic, loadingMessage, completeMessage, profile }: Props) {
|
||||
const router = useRouter()
|
||||
const [pageState, setPageState] = useState<PageState>("loading")
|
||||
const [exercise, setExercise] = useState<Exercise | null>(null)
|
||||
const [running, setRunning] = useState(true)
|
||||
const [score, setScore] = useState(0)
|
||||
const [totalAttempts, setTotalAttempts] = useState(0)
|
||||
const childIdRef = useRef<string>("")
|
||||
const exerciseStartedAt = useRef<number>(Date.now())
|
||||
const startedAtRef = useRef(new Date().toISOString())
|
||||
|
||||
const backHref = profileHome(profile)
|
||||
|
||||
const BackBar = () => (
|
||||
<button
|
||||
onClick={() => {
|
||||
stopSpeaking()
|
||||
router.push(backHref)
|
||||
}}
|
||||
className="self-start flex items-center gap-2 bg-surface text-foreground border border-border rounded-xl px-4 py-2 text-base font-display font-semibold shadow-sm active:scale-95 transition-transform"
|
||||
>
|
||||
<span className="text-xl">←</span>
|
||||
<span>Volver</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
const getChildId = useCallback(() => {
|
||||
const id = localStorage.getItem(CHILD_ID_KEY) || ""
|
||||
childIdRef.current = id
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const fetchNext = useCallback(async () => {
|
||||
const childId = getChildId()
|
||||
if (!childId) {
|
||||
setPageState("error")
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const qs = new URLSearchParams({ childId, topic })
|
||||
if (profile) qs.set("profile", profile)
|
||||
const res = await fetch(`/api/curriculum/next?${qs}`)
|
||||
const data = await res.json()
|
||||
if (data.done || !data.id) {
|
||||
setPageState("complete")
|
||||
return null
|
||||
}
|
||||
setExercise(data)
|
||||
exerciseStartedAt.current = Date.now()
|
||||
setPageState("ready")
|
||||
return data
|
||||
} catch {
|
||||
setPageState("error")
|
||||
return null
|
||||
}
|
||||
}, [getChildId, topic, profile])
|
||||
|
||||
useEffect(() => {
|
||||
fetchNext()
|
||||
}, [fetchNext])
|
||||
|
||||
const gradeAttempt = useCallback(async (ex: Exercise, correct: boolean) => {
|
||||
const childId = childIdRef.current
|
||||
if (!childId || !ex) return
|
||||
|
||||
setTotalAttempts((t) => t + 1)
|
||||
if (correct) setScore((s) => s + 1)
|
||||
|
||||
const errorType = !correct
|
||||
? ex.errorType || "discriminacion-auditiva"
|
||||
: null
|
||||
|
||||
try {
|
||||
const responseMs = Date.now() - exerciseStartedAt.current
|
||||
await fetch("/api/curriculum/grade", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
childId,
|
||||
exerciseId: ex.id,
|
||||
skillCode: ex.skillCode,
|
||||
correct,
|
||||
promptLevel: 0,
|
||||
responseMs,
|
||||
errorType,
|
||||
stage: ex.stage,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleComplete = useCallback(
|
||||
async (correct: boolean) => {
|
||||
if (!exercise) return
|
||||
await gradeAttempt(exercise, correct)
|
||||
stopSpeaking()
|
||||
if (correct) {
|
||||
await speak("¡Muy bien!")
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
} else {
|
||||
await speak("¡Seguí intentando!")
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
}
|
||||
fetchNext()
|
||||
},
|
||||
[exercise, gradeAttempt, fetchNext],
|
||||
)
|
||||
|
||||
const resetSession = useCallback(() => {
|
||||
setScore(0)
|
||||
setTotalAttempts(0)
|
||||
setRunning(true)
|
||||
startedAtRef.current = new Date().toISOString()
|
||||
fetchNext()
|
||||
}, [fetchNext])
|
||||
|
||||
if (pageState === "loading") {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-4">
|
||||
<BackBar />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<GatoMascota mood="coach" message={loadingMessage} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageState === "error") {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-4">
|
||||
<BackBar />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<NoChildError onRetry={resetSession} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageState === "complete") {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-4">
|
||||
<BackBar />
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-6">
|
||||
<GatoMascota mood="celebrate" message={completeMessage} size="lg" />
|
||||
<p className="text-lg font-display">
|
||||
Aciertos: {score}/{totalAttempts}
|
||||
</p>
|
||||
<button
|
||||
onClick={resetSession}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Jugar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-4 gap-4">
|
||||
<BackBar />
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-6">
|
||||
<TemporizadorVisual
|
||||
durationMinutes={10}
|
||||
onComplete={() => {
|
||||
setRunning(false)
|
||||
setPageState("complete")
|
||||
}}
|
||||
running={running}
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={exercise?.id || "empty"}
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
{exercise && (
|
||||
<ExerciseDispatcher exercise={exercise} onComplete={handleComplete} />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "./gato-mascota"
|
||||
|
||||
const CHILD_ID_KEY = "edueasy_child_id"
|
||||
|
||||
interface Props {
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export default function NoChildError({ onRetry }: Props) {
|
||||
const router = useRouter()
|
||||
const hasChildId = typeof window !== "undefined" && localStorage.getItem(CHILD_ID_KEY)
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="coach" message={hasChildId ? "Ups, hubo un problema" : "Todavía no estoy conectado"} size="lg" />
|
||||
<div className="text-center max-w-xs">
|
||||
{hasChildId ? (
|
||||
<p className="text-foreground/60">
|
||||
No pude conectar con el servidor. Revisá la conexión.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-foreground/60">Pedile a mamá que te empareje desde su celular.</p>
|
||||
<p className="text-foreground/40 text-sm mt-1">Mamá va a Dashboard → Emparejar nuevo iPad</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{hasChildId && onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push("/child/pairing")}
|
||||
className={`touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold ${
|
||||
hasChildId
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "bg-primary text-primary-foreground"
|
||||
}`}
|
||||
>
|
||||
{hasChildId ? "Emparejar otro iPad" : "Ir a emparejar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export default function TemporizadorVisual({
|
||||
const [remaining, setRemaining] = useState(durationMinutes * 60)
|
||||
const totalSec = durationMinutes * 60
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
|
||||
useEffect(() => {
|
||||
if (running) {
|
||||
@@ -24,7 +26,7 @@ export default function TemporizadorVisual({
|
||||
setRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(intervalRef.current!)
|
||||
onComplete()
|
||||
onCompleteRef.current()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
@@ -37,7 +39,7 @@ export default function TemporizadorVisual({
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [running, onComplete])
|
||||
}, [running])
|
||||
|
||||
const fraction = remaining / totalSec
|
||||
const degrees = fraction * 360
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { getStroke } from "perfect-freehand"
|
||||
import { speak } from "@/lib/speech"
|
||||
|
||||
interface TrazoLetraProps {
|
||||
letra: string
|
||||
fonema: string
|
||||
color?: string
|
||||
onComplete?: () => void
|
||||
promptLevel?: number
|
||||
@@ -14,7 +12,6 @@ interface TrazoLetraProps {
|
||||
|
||||
export default function TrazoLetra({
|
||||
letra,
|
||||
fonema,
|
||||
color = "#8CB8A0",
|
||||
onComplete,
|
||||
promptLevel = 0,
|
||||
@@ -106,10 +103,68 @@ export default function TrazoLetra({
|
||||
ctx.fill()
|
||||
|
||||
if (promptLevel < 3) {
|
||||
// draw direction arrow if early prompt level
|
||||
drawDirectionArrow(ctx, letra)
|
||||
}
|
||||
}
|
||||
|
||||
const drawDirectionArrow = (ctx: CanvasRenderingContext2D, l: string) => {
|
||||
const w = ctx.canvas.width
|
||||
const h = ctx.canvas.height
|
||||
ctx.save()
|
||||
ctx.globalAlpha = 0.3
|
||||
ctx.strokeStyle = "#8CB8A0"
|
||||
ctx.lineWidth = 2
|
||||
ctx.fillStyle = "#8CB8A0"
|
||||
|
||||
const arrowPaths: Record<string, { sx: number; sy: number; ex: number; ey: number }> = {
|
||||
a: { sx: w * 0.3, sy: h * 0.7, ex: w * 0.7, ey: h * 0.3 },
|
||||
e: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
i: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
|
||||
o: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.6, ey: h * 0.6 },
|
||||
u: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
|
||||
// Consonants: generic top-to-bottom, left-to-right arrow
|
||||
b: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
c: { sx: w * 0.6, sy: h * 0.3, ex: w * 0.4, ey: h * 0.7 },
|
||||
d: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
f: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
|
||||
g: { sx: w * 0.6, sy: h * 0.3, ex: w * 0.4, ey: h * 0.7 },
|
||||
h: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
|
||||
j: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
|
||||
k: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
l: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
|
||||
m: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
|
||||
n: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
|
||||
p: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
|
||||
q: { sx: w * 0.6, sy: h * 0.3, ex: w * 0.4, ey: h * 0.7 },
|
||||
r: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
s: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
t: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
|
||||
v: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
w: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
|
||||
x: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
y: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
z: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
|
||||
}
|
||||
|
||||
const path = arrowPaths[l.toLowerCase()]
|
||||
if (path) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(path.sx, path.sy)
|
||||
ctx.lineTo(path.ex, path.ey)
|
||||
ctx.stroke()
|
||||
|
||||
const angle = Math.atan2(path.ey - path.sy, path.ex - path.sx)
|
||||
const sz = 6
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(path.ex, path.ey)
|
||||
ctx.lineTo(path.ex - sz * Math.cos(angle - Math.PI / 6), path.ey - sz * Math.sin(angle - Math.PI / 6))
|
||||
ctx.lineTo(path.ex - sz * Math.cos(angle + Math.PI / 6), path.ey - sz * Math.sin(angle + Math.PI / 6))
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioConciencia({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.text && (
|
||||
<p className="text-2xl font-display font-bold text-center">{content.text}</p>
|
||||
)}
|
||||
|
||||
{content.emoji && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-6xl"
|
||||
>
|
||||
{content.emoji}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-md">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioEleccion({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.text && (
|
||||
<p className="text-2xl font-display font-bold text-center">{content.text}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.12 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-6 text-3xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioLectura({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.text && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="bg-card border rounded-2xl p-6 max-w-md text-center"
|
||||
>
|
||||
<p className="text-xl font-display leading-relaxed">{content.text}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{content.imageEmoji && (
|
||||
<div className="text-6xl">{content.imageEmoji}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.12 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-5 text-lg font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioPalabra({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (opt.id === "escuchar") {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
return
|
||||
}
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.imageEmoji && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-6xl"
|
||||
>
|
||||
{content.imageEmoji}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-md">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className={`touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform ${
|
||||
opt.id === "escuchar"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-white"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, Reorder } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioSecuencia({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const sequence = content.sequence || []
|
||||
|
||||
const [items, setItems] = useState(() =>
|
||||
[...sequence].sort(() => Math.random() - 0.5),
|
||||
)
|
||||
|
||||
const handleReorder = (newOrder: string[]) => {
|
||||
setItems(newOrder)
|
||||
if (newOrder.every((item, i) => item === sequence[i])) {
|
||||
if (content.audioText) speak(content.audioText)
|
||||
onComplete(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={items}
|
||||
onReorder={handleReorder}
|
||||
className="flex flex-col gap-3 max-w-xs w-full"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Reorder.Item
|
||||
key={item}
|
||||
value={item}
|
||||
className="touch-target-lg rounded-2xl px-6 py-4 text-xl font-display font-bold bg-white shadow-md cursor-grab active:cursor-grabbing text-center"
|
||||
>
|
||||
{item}
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
|
||||
<p className="text-sm text-foreground/40">Arrastrá para ordenar</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioSensibilizacion({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const hasOptions = content.options && content.options.length > 0
|
||||
|
||||
const handleClick = async (option: { label: string; correct: boolean }) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(option.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-6xl text-center"
|
||||
>
|
||||
{content.emoji && <span>{content.emoji}</span>}
|
||||
</motion.div>
|
||||
|
||||
{hasOptions && (
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
|
||||
{content.options!.map((opt, i) => (
|
||||
<motion.button
|
||||
key={opt.label}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
onClick={() => handleClick(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasOptions && (
|
||||
<button
|
||||
onClick={() => onComplete(true)}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
¡Lo hice!
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import TrazoLetra from "@/components/child/trazo-letra"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioTrazo({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const charToTrace = content.text && content.text.length === 1
|
||||
? content.text.toUpperCase()
|
||||
: null
|
||||
|
||||
const handleAudio = async () => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{charToTrace ? (
|
||||
<>
|
||||
<TrazoLetra
|
||||
letra={charToTrace}
|
||||
color={content.color || '#8CB8A0'}
|
||||
onComplete={() => onComplete(true)}
|
||||
promptLevel={0}
|
||||
/>
|
||||
{content.audioText && (
|
||||
<button
|
||||
onClick={handleAudio}
|
||||
className="text-sm text-primary underline"
|
||||
>
|
||||
Escuchar instrucción
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{content.emoji && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-6xl"
|
||||
>
|
||||
{content.emoji}
|
||||
</motion.div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onComplete(true)}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
¡Lo hice!
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import type { Exercise, ExerciseType } from "@/curriculum/types"
|
||||
|
||||
const EjercicioSensibilizacion = dynamic(() => import("./ejercicio-sensibilizacion"), { ssr: false })
|
||||
const EjercicioConciencia = dynamic(() => import("./ejercicio-conciencia"), { ssr: false })
|
||||
const EjercicioEleccion = dynamic(() => import("./ejercicio-eleccion"), { ssr: false })
|
||||
const EjercicioSecuencia = dynamic(() => import("./ejercicio-secuencia"), { ssr: false })
|
||||
const EjercicioPalabra = dynamic(() => import("./ejercicio-palabra"), { ssr: false })
|
||||
const EjercicioLectura = dynamic(() => import("./ejercicio-lectura"), { ssr: false })
|
||||
const EjercicioTrazo = dynamic(() => import("./ejercicio-trazo"), { ssr: false })
|
||||
|
||||
const componentMap: Record<ExerciseType, React.ComponentType<{ exercise: Exercise; onComplete: (correct: boolean) => void }>> = {
|
||||
sensibilizacion: EjercicioSensibilizacion,
|
||||
conciencia: EjercicioConciencia,
|
||||
trazo: EjercicioTrazo,
|
||||
eleccion: EjercicioEleccion,
|
||||
emparejar: EjercicioConciencia,
|
||||
secuencia: EjercicioSecuencia,
|
||||
palabra: EjercicioPalabra,
|
||||
problema: EjercicioLectura,
|
||||
lectura: EjercicioLectura,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function ExerciseDispatcher({ exercise, onComplete }: Props) {
|
||||
const Component = componentMap[exercise.type] || EjercicioSensibilizacion
|
||||
return <Component exercise={exercise} onComplete={onComplete} />
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
const errorLabels: Record<string, string> = {
|
||||
"discriminacion-auditiva": "Discriminación auditiva",
|
||||
"confusion-letra": "Confusión de letra",
|
||||
"silaba-dificil": "Sílaba difícil",
|
||||
"omision-letra": "Omisión de letra",
|
||||
"inversion-letra": "Inversión de letra",
|
||||
}
|
||||
|
||||
const errorColors: Record<string, string> = {
|
||||
"discriminacion-auditiva": "bg-blue-200 text-blue-800",
|
||||
"confusion-letra": "bg-red-200 text-red-800",
|
||||
"silaba-dificil": "bg-yellow-200 text-yellow-800",
|
||||
"omision-letra": "bg-purple-200 text-purple-800",
|
||||
"inversion-letra": "bg-orange-200 text-orange-800",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: Record<string, number>
|
||||
total: number
|
||||
}
|
||||
|
||||
export default function ErrorPatternsChart({ data, total }: Props) {
|
||||
const entries = Object.entries(data)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Sin errores registrados. ¡Bien ahí!
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([key, count]) => {
|
||||
const pct = Math.round((count / total) * 100)
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="font-medium">{errorLabels[key] || key}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{count} ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
key === "discriminacion-auditiva"
|
||||
? "bg-blue-400"
|
||||
: key === "confusion-letra"
|
||||
? "bg-red-400"
|
||||
: key === "silaba-dificil"
|
||||
? "bg-yellow-400"
|
||||
: key === "omision-letra"
|
||||
? "bg-purple-400"
|
||||
: "bg-orange-400"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
variant?: "default" | "primary" | "success" | "accent"
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variants = {
|
||||
default: "bg-white",
|
||||
primary: "bg-primary/10 border-primary/20",
|
||||
success: "bg-success/10 border-success/20",
|
||||
accent: "bg-accent/10 border-accent/20",
|
||||
}
|
||||
|
||||
export function MetricCard({ title, value, subtitle, variant = "default", className }: MetricCardProps) {
|
||||
return (
|
||||
<div className={cn("rounded-2xl p-4 shadow-sm border border-border", variants[variant], className)}>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-3xl font-display font-bold text-foreground mt-1">{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
interface MilestoneData {
|
||||
skillCode: string
|
||||
reachedAt: string
|
||||
}
|
||||
|
||||
const skillLabels: Record<string, string> = {
|
||||
"vocal-a": "Letra A",
|
||||
"vocal-e": "Letra E",
|
||||
"vocal-i": "Letra I",
|
||||
"vocal-o": "Letra O",
|
||||
"vocal-u": "Letra U",
|
||||
"numero-1": "Número 1",
|
||||
"numero-2": "Número 2",
|
||||
"numero-3": "Número 3",
|
||||
"numero-4": "Número 4",
|
||||
"numero-5": "Número 5",
|
||||
}
|
||||
|
||||
export function MilestonesList({ data }: { data: MilestoneData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-4">
|
||||
Todavía no hay logros
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.map((m) => (
|
||||
<div
|
||||
key={m.skillCode}
|
||||
className="bg-primary/10 border border-primary/20 rounded-xl px-3 py-2 text-sm font-display font-medium"
|
||||
>
|
||||
{skillLabels[m.skillCode] || m.skillCode}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
ResponsiveContainer,
|
||||
} from "recharts"
|
||||
|
||||
interface SkillData {
|
||||
skillCode: string
|
||||
stability: number
|
||||
reps: number
|
||||
}
|
||||
|
||||
export function SkillMasteryChart({ data }: { data: SkillData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
Sin skills todavía
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = data
|
||||
.filter((s) => s.reps > 0)
|
||||
.slice(0, 8)
|
||||
.map((s) => ({
|
||||
skill: s.skillCode.replace("vocal-", "").replace("numero-", "num "),
|
||||
mastery: Math.min(Math.round((s.stability / 100) * 100), 100),
|
||||
}))
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
Sin skills todavía
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<PolarGrid stroke="#E8E0D8" />
|
||||
<PolarAngleAxis dataKey="skill" tick={{ fontSize: 11, fill: "#6B7280" }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} />
|
||||
<Radar
|
||||
name="Maestría"
|
||||
dataKey="mastery"
|
||||
stroke="#8CB8A0"
|
||||
fill="#8CB8A0"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts"
|
||||
|
||||
interface WeeklyData {
|
||||
date: string
|
||||
minutes: number
|
||||
}
|
||||
|
||||
export function WeeklyActivityChart({ data }: { data: WeeklyData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
No hay actividad esta semana
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const daysOfWeek = ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"]
|
||||
const chartData = data.map((d) => {
|
||||
const day = new Date(d.date).getDay()
|
||||
return {
|
||||
name: daysOfWeek[day],
|
||||
minutos: d.minutes,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E8E0D8" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12, fill: "#6B7280" }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: "#6B7280" }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 12, border: "1px solid #E8E0D8" }}
|
||||
formatter={(value: number) => [`${value} min`, "Actividad"]}
|
||||
/>
|
||||
<Bar dataKey="minutos" fill="#8CB8A0" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const lecturas = [
|
||||
{
|
||||
text: 'Ana tiene un gato. El gato es negro. Juegan juntas en el jardín.',
|
||||
options: ['El gato es negro', 'El gato es blanco', 'Tiene un perro'],
|
||||
correcta: 'El gato es negro',
|
||||
emoji: '🐱',
|
||||
},
|
||||
{
|
||||
text: 'La frutilla es roja. La banana es amarilla. La uva es morada.',
|
||||
options: ['La frutilla es roja', 'La banana es roja', 'La uva es verde'],
|
||||
correcta: 'La frutilla es roja',
|
||||
emoji: '🍓',
|
||||
},
|
||||
{
|
||||
text: 'El coche es rojo. La moto es azul. La bici es verde.',
|
||||
options: ['El coche es rojo', 'La moto es roja', 'La bici es roja'],
|
||||
correcta: 'El coche es rojo',
|
||||
emoji: '🚗',
|
||||
},
|
||||
{
|
||||
text: 'La nena lee un cuento. Papá le ayuda. Es un cuento de princesas.',
|
||||
options: ['Lee un cuento', 'Ve la tele', 'Juega a la pelota'],
|
||||
correcta: 'Lee un cuento',
|
||||
emoji: '📚',
|
||||
},
|
||||
{
|
||||
text: 'En la plaza hay un columpio. Los niños se divierten. Es un día soleado.',
|
||||
options: ['Hay un columpio', 'Hay un tobogán', 'No hay nada'],
|
||||
correcta: 'Hay un columpio',
|
||||
emoji: '🎡',
|
||||
},
|
||||
{
|
||||
text: 'La vaca dice mu. La oveja dice bé. El caballo dice ihí.',
|
||||
options: ['La vaca dice mu', 'La oveja dice mu', 'El caballo dice bé'],
|
||||
correcta: 'La vaca dice mu',
|
||||
emoji: '🐄',
|
||||
},
|
||||
{
|
||||
text: 'El helado es de chocolate. La galleta es de vainilla. La gaseosa es de cola.',
|
||||
options: ['El helado es de chocolate', 'La galleta es de chocolate', 'La gaseosa es de limón'],
|
||||
correcta: 'El helado es de chocolate',
|
||||
emoji: '🍦',
|
||||
},
|
||||
{
|
||||
text: 'La luna está en el cielo. El sol se escondió. Las estrellas brillan.',
|
||||
options: ['La luna está en el cielo', 'El sol está en el cielo', 'Las estrellas duermen'],
|
||||
correcta: 'La luna está en el cielo',
|
||||
emoji: '🌙',
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
for (let i = 0; i < lecturas.length; i++) {
|
||||
const l = lecturas[i]
|
||||
exercises.push({
|
||||
id: `fra-lect1-${i}`,
|
||||
stage: 1,
|
||||
skillCode: `lectura-fra-${i}`,
|
||||
type: 'lectura',
|
||||
prerequisites: i === 0 ? [] : [`fra-lect1-${i - 1}`],
|
||||
content: {
|
||||
instruction: `Leé este texto`,
|
||||
text: l.text,
|
||||
audioText: l.text,
|
||||
emoji: l.emoji,
|
||||
options: l.options.map((o) => ({
|
||||
label: o,
|
||||
correct: o === l.correcta,
|
||||
id: o.toLowerCase().slice(0, 12),
|
||||
})),
|
||||
},
|
||||
functionalContext: 'Leer textos cortos con comprensión.. Centro de interés Decroly.',
|
||||
errorType: 'omision-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 1,
|
||||
name: 'Lecturas nivel 1',
|
||||
description: 'Leer textos muy cortos y responder preguntas simples.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const lecturas = [
|
||||
{
|
||||
text: 'El día de la primavera, todos los chicos van al parque. Llevan merienda, juegan y cantan. Los árboles tienen flores.',
|
||||
options: ['Van al parque', 'Se quedan en casa', 'Ven la tele'],
|
||||
correcta: 'Van al parque',
|
||||
emoji: '🌸',
|
||||
},
|
||||
{
|
||||
text: 'El Río de la Plata es muy ancho. El obelisco está en Buenos Aires. La Casa Rosada es de color rosa.',
|
||||
options: ['El obelisco está en Buenos Aires', 'El obelisco está en Córdoba', 'La Casa Rosada es blanca'],
|
||||
correcta: 'El obelisco está en Buenos Aires',
|
||||
emoji: '🏙️',
|
||||
},
|
||||
{
|
||||
text: 'En el zoológico hay leones, elefantes y jirafas. Los leones duermen mucho. Las jirafas comen hojas.',
|
||||
options: ['Los leones duermen mucho', 'Los leones comen hojas', 'Las jirafas duermen mucho'],
|
||||
correcta: 'Los leones duermen mucho',
|
||||
emoji: '🦁',
|
||||
},
|
||||
{
|
||||
text: 'El cumpleaños de Clara es en octubre. Cumple 8 años. Invita a todos sus compañeros de clase.',
|
||||
options: ['Cumple 8 años', 'Cumple 5 años', 'No invita a nadie'],
|
||||
correcta: 'Cumple 8 años',
|
||||
emoji: '🎂',
|
||||
},
|
||||
{
|
||||
text: 'La biblioteca tiene muchos libros. Los chicos eligen uno y se sientan a leer. Es un lugar muy silencioso.',
|
||||
options: ['Es muy silencioso', 'Es muy ruidoso', 'No hay libros'],
|
||||
correcta: 'Es muy silencioso',
|
||||
emoji: '📚',
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
for (let i = 0; i < lecturas.length; i++) {
|
||||
const l = lecturas[i]
|
||||
exercises.push({
|
||||
id: `fra-lect2-${i}`,
|
||||
stage: 2,
|
||||
skillCode: `lectura-fra2-${i}`,
|
||||
type: 'lectura',
|
||||
prerequisites: i === 0 ? ['fra-lect1-0'] : [`fra-lect2-${i - 1}`],
|
||||
content: {
|
||||
instruction: `Leé y respondé`,
|
||||
text: l.text,
|
||||
audioText: l.text,
|
||||
emoji: l.emoji,
|
||||
options: l.options.map((o) => ({
|
||||
label: o,
|
||||
correct: o === l.correcta,
|
||||
id: o.toLowerCase().slice(0, 12),
|
||||
})),
|
||||
},
|
||||
functionalContext: 'Leer textos algo más largos.. Centro de interés Decroly.',
|
||||
errorType: 'omision-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 2,
|
||||
name: 'Lecturas nivel 2',
|
||||
description: 'Leer textos un poco más largos y responder preguntas.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const lecturas = [
|
||||
{
|
||||
text: "La familia de Ana tiene 4 personas. Mamá, papá, Ana y su hermano Tomás. Viven en una casa con jardín. Ana juega con su perro en el jardín.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuántas personas tiene la familia de Ana?", op: ["4", "3", "5"], correcta: "4" },
|
||||
{ q: "¿Cómo se llama el hermano de Ana?", op: ["Tomás", "Juan", "Pedro"], correcta: "Tomás" },
|
||||
{ q: "¿Con quién juega Ana en el jardín?", op: ["Con su perro", "Con su hermano", "Con su mamá"], correcta: "Con su perro" },
|
||||
],
|
||||
emoji: "👨👩👧👦",
|
||||
},
|
||||
{
|
||||
text: "El abuelo de Lucía vive en el campo. Tiene muchas gallinas y un caballo. Lucía va a visitarlo los fines de semana. Le gusta darle de comer a las gallinas.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuándo visita Lucía a su abuelo?", op: ["Los fines de semana", "Los lunes", "Todos los días"], correcta: "Los fines de semana" },
|
||||
{ q: "¿Qué animales tiene el abuelo?", op: ["Gallinas y un caballo", "Vacas y ovejas", "Perros y gatos"], correcta: "Gallinas y un caballo" },
|
||||
{ q: "¿Qué le gusta hacer a Lucía?", op: ["Darle de comer a las gallinas", "Pasear al caballo", "Cocinar"], correcta: "Darle de comer a las gallinas" },
|
||||
],
|
||||
emoji: "🐴",
|
||||
},
|
||||
{
|
||||
text: "La mamá de Pedro cocina empanadas. Pedro ayuda a ponerles el repollo. Papá pone la mesa. Todos preparan la cena juntos.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué cocina la mamá de Pedro?", op: ["Empanadas", "Pasta", "Ensalada"], correcta: "Empanadas" },
|
||||
{ q: "¿Con qué ayuda Pedro?", op: ["Con el repollo", "Con la mesa", "Con los platos"], correcta: "Con el repollo" },
|
||||
{ q: "¿Quién pone la mesa?", op: ["Papá", "Pedro", "Mamá"], correcta: "Papá" },
|
||||
],
|
||||
emoji: "🥟",
|
||||
},
|
||||
{
|
||||
text: "Sofía tiene dos hermanos: Mateo y Juan. Mateo tiene 8 años y Juan tiene 5. Sofía tiene 6. Juntos juegan a la pelota en el patio.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuántos años tiene Mateo?", op: ["8", "5", "6"], correcta: "8" },
|
||||
{ q: "¿Cuántos hermanos tiene Sofía?", op: ["Dos", "Uno", "Tres"], correcta: "Dos" },
|
||||
{ q: "¿Dónde juegan?", op: ["En el patio", "En la calle", "En la plaza"], correcta: "En el patio" },
|
||||
],
|
||||
emoji: "⚽",
|
||||
},
|
||||
{
|
||||
text: "La familia de Martín sale de paseo. Van al río. Mamá prepara sandwiches. Papá pesca. Los niños juegan en el agua.",
|
||||
preguntas: [
|
||||
{ q: "¿Adónde van de paseo?", op: ["Al río", "Al parque", "A la plaza"], correcta: "Al río" },
|
||||
{ q: "¿Qué prepara mamá?", op: ["Sandwiches", "Galletitas", "Fruta"], correcta: "Sandwiches" },
|
||||
{ q: "¿Qué hace papá?", op: ["Pesca", "Nada", "Duerme"], correcta: "Pesca" },
|
||||
],
|
||||
emoji: "🏞️",
|
||||
},
|
||||
{
|
||||
text: "Los tíos de Valentina viven en Mar del Plata. En verano Valentina va a la playa con ellos. Hace castillos de arena y junta caracoles.",
|
||||
preguntas: [
|
||||
{ q: "¿Dónde viven los tíos de Valentina?", op: ["Mar del Plata", "Buenos Aires", "Córdoba"], correcta: "Mar del Plata" },
|
||||
{ q: "¿Qué hace Valentina en la playa?", op: ["Castillos de arena", "Anda en bicicleta", "Juega en la nieve"], correcta: "Castillos de arena" },
|
||||
{ q: "¿Qué junta Valentina?", op: ["Caracoles", "Flores", "Piedras"], correcta: "Caracoles" },
|
||||
],
|
||||
emoji: "🏖️",
|
||||
},
|
||||
{
|
||||
text: "La abuela de Tomás teje bufandas. Le regaló una bufanda roja a Tomás. Él la usa todos los días porque hace frío.",
|
||||
preguntas: [
|
||||
{ q: "¿Por qué usa Tomás la bufanda todos los días?", op: ["Porque hace frío", "Porque es roja", "Porque su abuela la tejió"], correcta: "Porque hace frío" },
|
||||
{ q: "¿Qué teje la abuela de Tomás?", op: ["Bufandas", "Medias", "Pullóveres"], correcta: "Bufandas" },
|
||||
{ q: "¿De qué color es la bufanda?", op: ["Roja", "Azul", "Verde"], correcta: "Roja" },
|
||||
],
|
||||
emoji: "🧣",
|
||||
},
|
||||
{
|
||||
text: "La familia de Julia tiene una mascota. Es un perro llamado Lulo. Lulo es marrón y muy juguetón. Julia lo pasea todas las tardes.",
|
||||
preguntas: [
|
||||
{ q: "¿Cómo se llama el perro de Julia?", op: ["Lulo", "Julia", "Marrón"], correcta: "Lulo" },
|
||||
{ q: "¿De qué color es Lulo?", op: ["Marrón", "Negro", "Blanco"], correcta: "Marrón" },
|
||||
{ q: "¿Cuándo lo pasea Julia?", op: ["Todas las tardes", "Los fines de semana", "A la mañana"], correcta: "Todas las tardes" },
|
||||
],
|
||||
emoji: "🐕",
|
||||
},
|
||||
{
|
||||
text: "Los primos de Nico vienen a cenar. Son 6 primos más Nico. La mamá prepara pasta para todos. Ponen la mesa grande.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuántos niños comen en total?", op: ["7", "6", "8"], correcta: "7" },
|
||||
{ q: "¿Qué prepara la mamá?", op: ["Pasta", "Milanesas", "Pizza"], correcta: "Pasta" },
|
||||
{ q: "¿Qué mesa ponen?", op: ["La grande", "La chica", "La de la cocina"], correcta: "La grande" },
|
||||
],
|
||||
emoji: "🍝",
|
||||
},
|
||||
{
|
||||
text: "El papá de Lara le lee un cuento cada noche. El cuento favorito de Lara es sobre un dragón amigable. Lara se duerme escuchando.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuál es el cuento favorito de Lara?", op: ["Sobre un dragón amigable", "Sobre una princesa", "Sobre un caballo"], correcta: "Sobre un dragón amigable" },
|
||||
{ q: "¿Cuándo le lee papá?", op: ["Cada noche", "Por la mañana", "Los domingos"], correcta: "Cada noche" },
|
||||
{ q: "¿Qué hace Lara mientras escucha?", op: ["Se duerme", "Juega", "Come"], correcta: "Se duerme" },
|
||||
],
|
||||
emoji: "🐉",
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
let idx = 0
|
||||
for (const l of lecturas) {
|
||||
for (const p of l.preguntas) {
|
||||
exercises.push({
|
||||
id: `fra3-familia-${idx}`,
|
||||
stage: 3,
|
||||
skillCode: `lectura-familia-${idx}`,
|
||||
type: "lectura",
|
||||
prerequisites: idx === 0 ? [] : [`fra3-familia-${idx - 1}`],
|
||||
content: {
|
||||
instruction: p.q,
|
||||
text: l.text,
|
||||
audioText: l.text,
|
||||
emoji: l.emoji,
|
||||
options: p.op.map((o) => ({
|
||||
label: o,
|
||||
correct: o === p.correcta,
|
||||
id: o.toLowerCase().slice(0, 12),
|
||||
})),
|
||||
},
|
||||
functionalContext: "Centro de interés Decroly: la familia. Comprensión lectora de textos sobre el núcleo familiar.",
|
||||
errorType: "omision-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 3,
|
||||
name: 'Centro de interés: "La familia"',
|
||||
description: "Textos cortos sobre la familia con preguntas de comprensión (método Decroly).",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const lecturas = [
|
||||
{
|
||||
text: "Los perros son mamíferos. Tienen cuatro patas y pelo. Les gusta jugar y salir a pasear. Son mascotas muy cariñosas.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuántas patas tienen los perros?", op: ["Cuatro", "Dos", "Seis"], correcta: "Cuatro" },
|
||||
{ q: "¿Qué les gusta hacer a los perros?", op: ["Jugar y pasear", "Nadar", "Volar"], correcta: "Jugar y pasear" },
|
||||
{ q: "¿Cómo son los perros como mascotas?", op: ["Cariñosos", "Peligrosos", "Tímidos"], correcta: "Cariñosos" },
|
||||
],
|
||||
emoji: "🐕",
|
||||
},
|
||||
{
|
||||
text: "Los gatos son ágiles y sigilosos. Tienen ojos que ven en la oscuridad. Les gusta cazar ratones y dormir al sol.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué tienen los gatos en sus ojos?", op: ["Ven en la oscuridad", "Son azules", "Son grandes"], correcta: "Ven en la oscuridad" },
|
||||
{ q: "¿Qué les gusta cazar?", op: ["Ratones", "Pájaros", "Peces"], correcta: "Ratones" },
|
||||
{ q: "¿Dónde les gusta dormir?", op: ["Al sol", "En el agua", "En los árboles"], correcta: "Al sol" },
|
||||
],
|
||||
emoji: "🐱",
|
||||
},
|
||||
{
|
||||
text: "Las aves tienen alas y plumas. La mayoría puede volar. Ponen huevos y construyen nidos en los árboles.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué tienen las aves para volar?", op: ["Alas y plumas", "Aletas", "Patas largas"], correcta: "Alas y plumas" },
|
||||
{ q: "¿Qué ponen las aves?", op: ["Huevos", "Leche", "Flores"], correcta: "Huevos" },
|
||||
{ q: "¿Dónde construyen sus nidos?", op: ["En los árboles", "En el agua", "En las cuevas"], correcta: "En los árboles" },
|
||||
],
|
||||
emoji: "🐦",
|
||||
},
|
||||
{
|
||||
text: "Los peces viven en el agua. Tienen escamas y aletas para nadar. Respiran por branquias. Hay peces de muchos colores.",
|
||||
preguntas: [
|
||||
{ q: "¿Cómo respiran los peces?", op: ["Por branquias", "Por pulmones", "Por la piel"], correcta: "Por branquias" },
|
||||
{ q: "¿Qué tienen los peces para nadar?", op: ["Aletas", "Patas", "Alas"], correcta: "Aletas" },
|
||||
{ q: "¿Dónde viven los peces?", op: ["En el agua", "En la tierra", "En el aire"], correcta: "En el agua" },
|
||||
],
|
||||
emoji: "🐟",
|
||||
},
|
||||
{
|
||||
text: "Las mariposas son insectos. Tienen seis patas y dos alas de colores. Salen de una oruga después de hacer un capullo.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuántas patas tienen las mariposas?", op: ["Seis", "Cuatro", "Ocho"], correcta: "Seis" },
|
||||
{ q: "¿De qué sale la mariposa?", op: ["De una oruga", "De un huevo", "Del agua"], correcta: "De una oruga" },
|
||||
{ q: "¿Qué hacen antes de ser mariposa?", op: ["Un capullo", "Un nido", "Un agujero"], correcta: "Un capullo" },
|
||||
],
|
||||
emoji: "🦋",
|
||||
},
|
||||
{
|
||||
text: "Los elefantes son los animales más grandes de la tierra. Tienen trompa larga y orejas grandes. Pueden pesar hasta 6.000 kilos.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuál es el animal más grande de la tierra?", op: ["El elefante", "La jirafa", "El oso"], correcta: "El elefante" },
|
||||
{ q: "¿Cuánto puede pesar un elefante?", op: ["6.000 kilos", "1.000 kilos", "10.000 kilos"], correcta: "6.000 kilos" },
|
||||
{ q: "¿Qué tienen los elefantes en la cabeza?", op: ["Orejas grandes", "Cuernos", "Melena"], correcta: "Orejas grandes" },
|
||||
],
|
||||
emoji: "🐘",
|
||||
},
|
||||
{
|
||||
text: "Los delfines son mamíferos marinos. Son muy inteligentes y juguetones. Saltan fuera del agua y se comunican con silbidos.",
|
||||
preguntas: [
|
||||
{ q: "¿Cómo se comunican los delfines?", op: ["Con silbidos", "Con ladridos", "Con rugidos"], correcta: "Con silbidos" },
|
||||
{ q: "¿Qué hacen los delfines fuera del agua?", op: ["Saltan", "Vuelan", "Caminan"], correcta: "Saltan" },
|
||||
{ q: "¿Cómo son los delfines?", op: ["Inteligentes y juguetones", "Perezosos y dormilones", "Agresivos y solitarios"], correcta: "Inteligentes y juguetones" },
|
||||
],
|
||||
emoji: "🐬",
|
||||
},
|
||||
{
|
||||
text: "Las abejas hacen miel. Viven en colmenas y trabajan en equipo. Vuelan de flor en flor recogiendo néctar.",
|
||||
preguntas: [
|
||||
{ q: "¿Dónde viven las abejas?", op: ["En colmenas", "En nidos", "En cuevas"], correcta: "En colmenas" },
|
||||
{ q: "¿Qué hacen las abejas?", op: ["Miel", "Leche", "Pan"], correcta: "Miel" },
|
||||
{ q: "¿Qué recogen de las flores?", op: ["Néctar", "Polvo", "Agua"], correcta: "Néctar" },
|
||||
],
|
||||
emoji: "🐝",
|
||||
},
|
||||
{
|
||||
text: "Los leones son felinos salvajes. Viven en África. El macho tiene una melena grande. Son conocidos como los reyes de la selva.",
|
||||
preguntas: [
|
||||
{ q: "¿Dónde viven los leones?", op: ["En África", "En Asia", "En Europa"], correcta: "En África" },
|
||||
{ q: "¿Qué tiene el león macho?", op: ["Una melena grande", "Cuernos", "Rayas"], correcta: "Una melena grande" },
|
||||
{ q: "¿Cómo son conocidos los leones?", op: ["Reyes de la selva", "Reyes del mar", "Reyes del cielo"], correcta: "Reyes de la selva" },
|
||||
],
|
||||
emoji: "🦁",
|
||||
},
|
||||
{
|
||||
text: "Los pingüinos viven en el Polo Sur. No vuelan pero nadan muy bien. Tienen plumaje negro y blanco. Cuidan sus huevos en el hielo.",
|
||||
preguntas: [
|
||||
{ q: "¿Dónde viven los pingüinos?", op: ["En el Polo Sur", "En el Polo Norte", "En la selva"], correcta: "En el Polo Sur" },
|
||||
{ q: "¿Qué hacen muy bien los pingüinos?", op: ["Nadan", "Vuelan", "Corren"], correcta: "Nadan" },
|
||||
{ q: "¿De qué color son los pingüinos?", op: ["Negro y blanco", "Marrón y blanco", "Gris y blanco"], correcta: "Negro y blanco" },
|
||||
],
|
||||
emoji: "🐧",
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
let idx = 0
|
||||
for (const l of lecturas) {
|
||||
for (const p of l.preguntas) {
|
||||
exercises.push({
|
||||
id: `fra4-animales-${idx}`,
|
||||
stage: 4,
|
||||
skillCode: `lectura-animales-${idx}`,
|
||||
type: "lectura",
|
||||
prerequisites: idx === 0 ? [`fra3-familia-29`] : [`fra4-animales-${idx - 1}`],
|
||||
content: {
|
||||
instruction: p.q,
|
||||
text: l.text,
|
||||
audioText: l.text,
|
||||
emoji: l.emoji,
|
||||
options: p.op.map((o) => ({
|
||||
label: o,
|
||||
correct: o === p.correcta,
|
||||
id: o.toLowerCase().slice(0, 12),
|
||||
})),
|
||||
},
|
||||
functionalContext: "Centro de interés Decroly: los animales. Clasificación y características de mamíferos, aves, peces e insectos.",
|
||||
errorType: "omision-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 4,
|
||||
name: 'Centro de interés: "Los animales"',
|
||||
description: "Textos sobre animales con preguntas de comprensión (método Decroly).",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const lecturas = [
|
||||
{
|
||||
text: "En primavera los árboles se llenan de flores. Los pájaros hacen nidos. El clima es templado y los días son más largos.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué pasa con los árboles en primavera?", op: ["Se llenan de flores", "Pierden las hojas", "Se cubren de nieve"], correcta: "Se llenan de flores" },
|
||||
{ q: "¿Qué animales hacen nidos en primavera?", op: ["Los pájaros", "Los gatos", "Los perros"], correcta: "Los pájaros" },
|
||||
{ q: "¿Cómo es el clima en primavera?", op: ["Templado", "Muy frío", "Muy caluroso"], correcta: "Templado" },
|
||||
],
|
||||
emoji: "🌸",
|
||||
},
|
||||
{
|
||||
text: "En verano hace mucho calor. Los niños van a la playa o a la pileta. Comen helado para refrescarse. Los días son muy largos.",
|
||||
preguntas: [
|
||||
{ q: "¿Cómo es el clima en verano?", op: ["Hace mucho calor", "Hace frío", "Está templado"], correcta: "Hace mucho calor" },
|
||||
{ q: "¿Qué comen los niños para refrescarse?", op: ["Helado", "Sopa", "Pan"], correcta: "Helado" },
|
||||
{ q: "¿Adónde van los niños en verano?", op: ["A la playa o pileta", "A la escuela", "Al parque de nieve"], correcta: "A la playa o pileta" },
|
||||
],
|
||||
emoji: "☀️",
|
||||
},
|
||||
{
|
||||
text: "En otoño las hojas de los árboles se ponen amarillas y marrones. El viento las hace caer. Hace más fresco y empieza el colegio.",
|
||||
preguntas: [
|
||||
{ q: "¿De qué color se ponen las hojas en otoño?", op: ["Amarillas y marrones", "Verdes y azules", "Rojas y naranjas"], correcta: "Amarillas y marrones" },
|
||||
{ q: "¿Qué hace caer las hojas?", op: ["El viento", "La lluvia", "El sol"], correcta: "El viento" },
|
||||
{ q: "¿Qué empieza en otoño?", op: ["El colegio", "Las vacaciones", "La navidad"], correcta: "El colegio" },
|
||||
],
|
||||
emoji: "🍂",
|
||||
},
|
||||
{
|
||||
text: "En invierno hace mucho frío y a veces nieva. Los niños usan abrigo, bufanda y guantes. Beben chocolate caliente.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué usan los niños en invierno?", op: ["Abrigo, bufanda y guantes", "Camiseta y short", "Traje de baño"], correcta: "Abrigo, bufanda y guantes" },
|
||||
{ q: "¿Qué beben los niños?", op: ["Chocolate caliente", "Jugo frío", "Agua con hielo"], correcta: "Chocolate caliente" },
|
||||
{ q: "¿Qué pasa a veces en invierno?", op: ["Nieva", "Hay sol fuerte", "Huele a flores"], correcta: "Nieva" },
|
||||
],
|
||||
emoji: "❄️",
|
||||
},
|
||||
{
|
||||
text: "La lluvia es importante para las plantas. Cuando llueve, las flores crecen. Los animales beben agua de los charcos.",
|
||||
preguntas: [
|
||||
{ q: "¿Por qué es importante la lluvia?", op: ["Para las plantas", "Para jugar", "Para dormir"], correcta: "Para las plantas" },
|
||||
{ q: "¿Qué pasa con las flores cuando llueve?", op: ["Crecen", "Se marchitan", "Se cierran"], correcta: "Crecen" },
|
||||
{ q: "¿Qué hacen los animales con los charcos?", op: ["Beben agua", "Se bañan", "Saltan"], correcta: "Beben agua" },
|
||||
],
|
||||
emoji: "🌧️",
|
||||
},
|
||||
{
|
||||
text: "Cuando el viento sopla fuerte, los árboles se mueven. Las hojas vuelan por el aire. A veces el viento tira cosas al piso.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué pasa con los árboles cuando hay viento?", op: ["Se mueven", "Se caen", "Crecen más rápido"], correcta: "Se mueven" },
|
||||
{ q: "¿Qué vuelan por el aire?", op: ["Las hojas", "Las piedras", "Los autos"], correcta: "Las hojas" },
|
||||
{ q: "¿Qué puede tirar el viento?", op: ["Cosas al piso", "La pintura", "Las puertas"], correcta: "Cosas al piso" },
|
||||
],
|
||||
emoji: "💨",
|
||||
},
|
||||
{
|
||||
text: "El arcoíris aparece después de la lluvia cuando sale el sol. Tiene 7 colores: rojo, naranja, amarillo, verde, azul, índigo y violeta.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuándo aparece el arcoíris?", op: ["Después de la lluvia", "En invierno", "De noche"], correcta: "Después de la lluvia" },
|
||||
{ q: "¿Cuántos colores tiene el arcoíris?", op: ["7", "5", "10"], correcta: "7" },
|
||||
{ q: "¿Qué tiene que pasar para que aparezca?", op: ["Salir el sol", "Nevar", "Hacer frío"], correcta: "Salir el sol" },
|
||||
],
|
||||
emoji: "🌈",
|
||||
},
|
||||
{
|
||||
text: "Las nubes están formadas por gotitas de agua. Cuando se juntan muchas, puede llover. Las nubes grises traen lluvia.",
|
||||
preguntas: [
|
||||
{ q: "¿De qué están hechas las nubes?", op: ["De gotitas de agua", "De algodón", "De humo"], correcta: "De gotitas de agua" },
|
||||
{ q: "¿Qué color tienen las nubes de lluvia?", op: ["Grises", "Blancas", "Negras"], correcta: "Grises" },
|
||||
{ q: "¿Qué pasa cuando se juntan muchas gotitas?", op: ["Puede llover", "Hace sol", "Sale el arcoíris"], correcta: "Puede llover" },
|
||||
],
|
||||
emoji: "☁️",
|
||||
},
|
||||
{
|
||||
text: "El sol nos da luz y calor. Las plantas necesitan sol para crecer. Sin sol no habría vida en la Tierra.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué nos da el sol?", op: ["Luz y calor", "Agua y viento", "Nieve y frío"], correcta: "Luz y calor" },
|
||||
{ q: "¿Qué necesitan las plantas del sol?", op: ["Luz para crecer", "Agua para beber", "Sombra para dormir"], correcta: "Luz para crecer" },
|
||||
{ q: "¿Qué pasaría sin sol?", op: ["No habría vida", "Haría más calor", "Las plantas crecerían más"], correcta: "No habría vida" },
|
||||
],
|
||||
emoji: "🌞",
|
||||
},
|
||||
{
|
||||
text: "En las tormentas hay rayos y truenos. Primero vemos el relámpago y después escuchamos el trueno. La lluvia fuerte moja todo.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué vemos primero en una tormenta?", op: ["El relámpago", "El trueno", "El viento"], correcta: "El relámpago" },
|
||||
{ q: "¿Qué escuchamos después?", op: ["El trueno", "La lluvia", "El relámpago"], correcta: "El trueno" },
|
||||
{ q: "¿Cómo es la lluvia en las tormentas?", op: ["Fuerte", "Suave", "Tibia"], correcta: "Fuerte" },
|
||||
],
|
||||
emoji: "⛈️",
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
let idx = 0
|
||||
for (const l of lecturas) {
|
||||
for (const p of l.preguntas) {
|
||||
exercises.push({
|
||||
id: `fra5-estaciones-${idx}`,
|
||||
stage: 5,
|
||||
skillCode: `lectura-estaciones-${idx}`,
|
||||
type: "lectura",
|
||||
prerequisites: idx === 0 ? [`fra4-animales-29`] : [`fra5-estaciones-${idx - 1}`],
|
||||
content: {
|
||||
instruction: p.q,
|
||||
text: l.text,
|
||||
audioText: l.text,
|
||||
emoji: l.emoji,
|
||||
options: p.op.map((o) => ({
|
||||
label: o,
|
||||
correct: o === p.correcta,
|
||||
id: o.toLowerCase().slice(0, 12),
|
||||
})),
|
||||
},
|
||||
functionalContext: "Centro de interés Decroly: las estaciones. Vocabulario temporal y relaciones causa-efecto del clima.",
|
||||
errorType: "omision-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 5,
|
||||
name: 'Centro de interés: "Las estaciones"',
|
||||
description: "Textos sobre primavera, verano, otoño e invierno con preguntas de comprensión (método Decroly).",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const lecturas = [
|
||||
{
|
||||
text: "La escuela de Martín abre a las 8 de la mañana. Los alumnos saludan a la maestra y se sientan en sus lugares. Empiezan con matemáticas.",
|
||||
preguntas: [
|
||||
{ q: "¿A qué hora abre la escuela?", op: ["A las 8", "A las 7", "A las 9"], correcta: "A las 8" },
|
||||
{ q: "¿Con qué materia empiezan?", op: ["Matemáticas", "Lengua", "Plástica"], correcta: "Matemáticas" },
|
||||
{ q: "¿A quién saludan los alumnos?", op: ["A la maestra", "Al director", "Al portero"], correcta: "A la maestra" },
|
||||
],
|
||||
emoji: "🏫",
|
||||
},
|
||||
{
|
||||
text: "En el recreo los niños juegan al fútbol y a la mancha. Comen la merienda que trajeron de casa. Tienen 20 minutos para jugar.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuánto dura el recreo?", op: ["20 minutos", "10 minutos", "30 minutos"], correcta: "20 minutos" },
|
||||
{ q: "¿Qué comen en el recreo?", op: ["La merienda de casa", "Comida de la escuela", "Nada"], correcta: "La merienda de casa" },
|
||||
{ q: "¿A qué juegan?", op: ["Al fútbol y la mancha", "A las cartas", "Videojuegos"], correcta: "Al fútbol y la mancha" },
|
||||
],
|
||||
emoji: "⚽",
|
||||
},
|
||||
{
|
||||
text: "La maestra de Valeria lee un cuento cada viernes. Los niños se sientan en ronda para escuchar. Después dibujan lo que más les gustó.",
|
||||
preguntas: [
|
||||
{ q: "¿Cuándo lee la maestra un cuento?", op: ["Los viernes", "Los lunes", "Todos los días"], correcta: "Los viernes" },
|
||||
{ q: "¿Cómo se sientan los niños?", op: ["En ronda", "En fila", "En sus pupitres"], correcta: "En ronda" },
|
||||
{ q: "¿Qué hacen después de escuchar?", op: ["Dibujan lo que les gustó", "Vuelven a casa", "Juegan"], correcta: "Dibujan lo que les gustó" },
|
||||
],
|
||||
emoji: "📖",
|
||||
},
|
||||
{
|
||||
text: "En la clase de Educación Física los niños corren, saltan y hacen ejercicios. Usan ropa cómoda y zapatillas. Es la clase favorita de muchos.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué es la clase favorita de muchos?", op: ["Educación Física", "Matemáticas", "Lengua"], correcta: "Educación Física" },
|
||||
{ q: "¿Qué usan los niños para Educación Física?", op: ["Ropa cómoda y zapatillas", "Uniforme", "Traje de baño"], correcta: "Ropa cómoda y zapatillas" },
|
||||
{ q: "¿Qué ejercicios hacen?", op: ["Corren, saltan y hacen ejercicios", "Leen y escriben", "Cantan y bailan"], correcta: "Corren, saltan y hacen ejercicios" },
|
||||
],
|
||||
emoji: "🏃",
|
||||
},
|
||||
{
|
||||
text: "Los amigos de Sofía la invitan a jugar en la plaza. Todos llevan sus juguetes. Comparten el tobogán y el subibaja.",
|
||||
preguntas: [
|
||||
{ q: "¿Adónde van a jugar?", op: ["A la plaza", "A la escuela", "Al cine"], correcta: "A la plaza" },
|
||||
{ q: "¿Qué llevan los niños?", op: ["Juguetes", "Cuadernos", "Merienda"], correcta: "Juguetes" },
|
||||
{ q: "¿A qué juegan en la plaza?", op: ["Tobogán y subibaja", "Fútbol", "Escondidas"], correcta: "Tobogán y subibaja" },
|
||||
],
|
||||
emoji: "🎠",
|
||||
},
|
||||
{
|
||||
text: "Tomás y su amigo Juan hacen la tarea juntos. Se ayudan con las cuentas. Cuando terminan, juegan un rato con la tablet.",
|
||||
preguntas: [
|
||||
{ q: "¿En qué se ayudan Tomás y Juan?", op: ["Con las cuentas", "Con la lectura", "Con el dibujo"], correcta: "Con las cuentas" },
|
||||
{ q: "¿Qué hacen cuando terminan la tarea?", op: ["Juegan con la tablet", "Ven televisión", "Salen a jugar"], correcta: "Juegan con la tablet" },
|
||||
{ q: "¿Con quién hace la tarea Tomás?", op: ["Con su amigo Juan", "Solo", "Con su mamá"], correcta: "Con su amigo Juan" },
|
||||
],
|
||||
emoji: "📝",
|
||||
},
|
||||
{
|
||||
text: "En el comedor escolar los niños almuerzan todos juntos. Hoy hay milanesas con puré. De postre dan manzana.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué hay de postre?", op: ["Manzana", "Helado", "Flan"], correcta: "Manzana" },
|
||||
{ q: "¿Qué hay de plato principal?", op: ["Milanesas con puré", "Pasta", "Ensalada"], correcta: "Milanesas con puré" },
|
||||
{ q: "¿Con quiénes almuerzan los niños?", op: ["Todos juntos", "Cada uno solo", "Con sus maestras"], correcta: "Todos juntos" },
|
||||
],
|
||||
emoji: "🍎",
|
||||
},
|
||||
{
|
||||
text: "Las reglas del salón: levantar la mano para hablar, no gritar, cuidar los libros y respetar a los compañeros.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué hay que hacer para hablar en clase?", op: ["Levantar la mano", "Gritar", "Pararse"], correcta: "Levantar la mano" },
|
||||
{ q: "¿Qué NO hay que hacer en clase?", op: ["Gritar", "Leer", "Escribir"], correcta: "Gritar" },
|
||||
{ q: "¿A quién hay que respetar?", op: ["A los compañeros", "Al director", "Al portero"], correcta: "A los compañeros" },
|
||||
],
|
||||
emoji: "🤚",
|
||||
},
|
||||
{
|
||||
text: "En la biblioteca de la escuela hay muchos libros. Los niños pueden llevarse un libro a casa por una semana. Después tienen que devolverlo.",
|
||||
preguntas: [
|
||||
{ q: "¿Por cuántos días pueden llevarse un libro?", op: ["Una semana", "Un día", "Un mes"], correcta: "Una semana" },
|
||||
{ q: "¿Qué tienen que hacer después?", op: ["Devolverlo", "Comprarlo", "Renovarlo"], correcta: "Devolverlo" },
|
||||
{ q: "¿Dónde queda la biblioteca?", op: ["En la escuela", "En la plaza", "En la casa"], correcta: "En la escuela" },
|
||||
],
|
||||
emoji: "📚",
|
||||
},
|
||||
{
|
||||
text: "El último día de clases hay una fiesta. Los niños traen golosinas para compartir. La maestra les da un diploma.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué reciben los niños el último día?", op: ["Un diploma", "Un juguete", "Un libro"], correcta: "Un diploma" },
|
||||
{ q: "¿Qué traen los niños para compartir?", op: ["Golosinas", "Juguetes", "Libros"], correcta: "Golosinas" },
|
||||
{ q: "¿Qué hay el último día de clases?", op: ["Una fiesta", "Un examen", "Una reunión"], correcta: "Una fiesta" },
|
||||
],
|
||||
emoji: "🎉",
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
let idx = 0
|
||||
for (const l of lecturas) {
|
||||
for (const p of l.preguntas) {
|
||||
exercises.push({
|
||||
id: `fra6-escuela-${idx}`,
|
||||
stage: 6,
|
||||
skillCode: `lectura-escuela-${idx}`,
|
||||
type: "lectura",
|
||||
prerequisites: idx === 0 ? [`fra5-estaciones-29`] : [`fra6-escuela-${idx - 1}`],
|
||||
content: {
|
||||
instruction: p.q,
|
||||
text: l.text,
|
||||
audioText: l.text,
|
||||
emoji: l.emoji,
|
||||
options: p.op.map((o) => ({
|
||||
label: o,
|
||||
correct: o === p.correcta,
|
||||
id: o.toLowerCase().slice(0, 12),
|
||||
})),
|
||||
},
|
||||
functionalContext: "Centro de interés Decroly: la escuela y los amigos. Rutina escolar, convivencia y reglas.",
|
||||
errorType: "omision-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 6,
|
||||
name: 'Centro de interés: "La escuela y los amigos"',
|
||||
description: "Textos sobre la rutina escolar, convivencia y amistad (método Decroly).",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const lecturas = [
|
||||
{
|
||||
text: "Martín dejó la campera en el patio de la escuela. Cuando volvió a buscarla, ya no estaba. Lloró todo el camino a casa.",
|
||||
preguntas: [
|
||||
{ q: "¿Por qué lloró Martín?", op: ["Perdió su campera", "Se lastimó", "Terminó la escuela"], correcta: "Perdió su campera" },
|
||||
{ q: "¿Dónde dejó la campera?", op: ["En el patio de la escuela", "En el aula", "En su casa"], correcta: "En el patio de la escuela" },
|
||||
{ q: "¿Qué pasó cuando volvió a buscarla?", op: ["Ya no estaba", "Estaba ahí", "Se la habían lavado"], correcta: "Ya no estaba" },
|
||||
],
|
||||
emoji: "😢",
|
||||
},
|
||||
{
|
||||
text: "El lunes Lucía no fue a la escuela. El martes sus amigos le contaron que hubo una obra de teatro. Lucía se puso triste.",
|
||||
preguntas: [
|
||||
{ q: "¿Por qué se puso triste Lucía?", op: ["Se perdió la obra de teatro", "No le gusta el teatro", "Sus amigos no le hablaron"], correcta: "Se perdió la obra de teatro" },
|
||||
{ q: "¿Qué día no fue a la escuela?", op: ["El lunes", "El martes", "El viernes"], correcta: "El lunes" },
|
||||
{ q: "¿Cómo se enteró Lucía?", op: ["Sus amigos le contaron", "Vio un cartel", "Se lo dijo la maestra"], correcta: "Sus amigos le contaron" },
|
||||
],
|
||||
emoji: "🎭",
|
||||
},
|
||||
{
|
||||
text: "Había muchas nubes grises. La mamá de Pedro le dijo que llevara el paraguas. Pedro dijo que no. Cuando salió, se mojó todo.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué pasó después de que Pedro salió sin paraguas?", op: ["Se mojó", "Hizo sol", "Encontró un paraguas"], correcta: "Se mojó" },
|
||||
{ q: "¿Qué le dijo la mamá a Pedro?", op: ["Que llevara el paraguas", "Que no saliera", "Que se abrigara"], correcta: "Que llevara el paraguas" },
|
||||
{ q: "¿Por qué la mamá le dijo que llevara el paraguas?", op: ["Porque había nubes grises", "Porque ella lo necesitaba", "Porque estaba lloviendo"], correcta: "Porque había nubes grises" },
|
||||
],
|
||||
emoji: "☔",
|
||||
},
|
||||
{
|
||||
text: "La maestra preguntó quién hizo la tarea. Todos levantaron la mano menos Tomás. Él había estado jugando a la pelota toda la tarde.",
|
||||
preguntas: [
|
||||
{ q: "¿Por qué Tomás no hizo la tarea?", op: ["Estuvo jugando a la pelota", "No entendió la tarea", "Estaba enfermo"], correcta: "Estuvo jugando a la pelota" },
|
||||
{ q: "¿Quiénes hicieron la tarea?", op: ["Todos menos Tomás", "Nadie", "Solo Tomás"], correcta: "Todos menos Tomás" },
|
||||
{ q: "¿Qué hizo la maestra?", op: ["Preguntó quién hizo la tarea", "Se enojó", "Los castigó"], correcta: "Preguntó quién hizo la tarea" },
|
||||
],
|
||||
emoji: "⚽",
|
||||
},
|
||||
{
|
||||
text: "Valentina encontró un pájaro en el jardín que no podía volar. Le dio agua y semillas. Al día siguiente el pájaro se fue volando.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué hizo Valentina con el pájaro?", op: ["Le dio agua y semillas", "Lo guardó en una jaula", "Lo asustó"], correcta: "Le dio agua y semillas" },
|
||||
{ q: "¿Dónde encontró al pájaro?", op: ["En el jardín", "En la calle", "En la escuela"], correcta: "En el jardín" },
|
||||
{ q: "¿Qué pasó al día siguiente?", op: ["El pájaro se fue volando", "El pájaro se quedó", "El pájaro se enfermó"], correcta: "El pájaro se fue volando" },
|
||||
],
|
||||
emoji: "🐦",
|
||||
},
|
||||
{
|
||||
text: "Sofía estudió mucho para la prueba de matemáticas. Cuando la maestra dio los resultados, Sofía tenía un 10. Saltó de alegría.",
|
||||
preguntas: [
|
||||
{ q: "¿Cómo se sintió Sofía con su resultado?", op: ["Alegre", "Triste", "Enojada"], correcta: "Alegre" },
|
||||
{ q: "¿Qué nota sacó Sofía?", op: ["10", "5", "8"], correcta: "10" },
|
||||
{ q: "¿Por qué Sofía sacó esa nota?", op: ["Porque estudió mucho", "Porque tuvo suerte", "Porque la prueba era fácil"], correcta: "Porque estudió mucho" },
|
||||
],
|
||||
emoji: "🎉",
|
||||
},
|
||||
{
|
||||
text: "El perro de Juan se escapó por la puerta abierta. Juan lo buscó por todo el barrio. Finalmente lo encontró en la plaza.",
|
||||
preguntas: [
|
||||
{ q: "¿Dónde encontró Juan a su perro?", op: ["En la plaza", "En su casa", "En la escuela"], correcta: "En la plaza" },
|
||||
{ q: "¿Por qué se escapó el perro?", op: ["Por la puerta abierta", "Por la ventana", "Por el jardín"], correcta: "Por la puerta abierta" },
|
||||
{ q: "¿Dónde buscó Juan?", op: ["Por todo el barrio", "Solo en su casa", "En la veterinaria"], correcta: "Por todo el barrio" },
|
||||
],
|
||||
emoji: "🐕",
|
||||
},
|
||||
{
|
||||
text: "La mamá de Nico le pidió que ordenara su habitación. Nico puso los juguetes en el baúl y los libros en el estante. La mamá sonrió.",
|
||||
preguntas: [
|
||||
{ q: "¿Por qué sonrió la mamá de Nico?", op: ["Porque Nico ordenó su cuarto", "Porque Nico terminó la tarea", "Porque Nico se bañó"], correcta: "Porque Nico ordenó su cuarto" },
|
||||
{ q: "¿Dónde puso los juguetes Nico?", op: ["En el baúl", "En el estante", "En la cama"], correcta: "En el baúl" },
|
||||
{ q: "¿Dónde puso los libros?", op: ["En el estante", "En el baúl", "En el piso"], correcta: "En el estante" },
|
||||
],
|
||||
emoji: "🧹",
|
||||
},
|
||||
{
|
||||
text: "Había una fila para el tobogán. Lola llegó después que Mateo pero se quería colar. Mateo le dijo que esperara su turno.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué quería hacer Lola?", op: ["Colarse en la fila", "Irse a su casa", "Jugar a otra cosa"], correcta: "Colarse en la fila" },
|
||||
{ q: "¿Quién llegó primero?", op: ["Mateo", "Lola", "Los dos igual"], correcta: "Mateo" },
|
||||
{ q: "¿Qué le dijo Mateo a Lola?", op: ["Que esperara su turno", "Que se colara", "Que se fuera"], correcta: "Que esperara su turno" },
|
||||
],
|
||||
emoji: "🛝",
|
||||
},
|
||||
{
|
||||
text: "El abuelo de Ana plantó una semilla en una maceta. Todos los días la regaba. Una mañana salió una planta verde. Ana aplaudió.",
|
||||
preguntas: [
|
||||
{ q: "¿Qué salió de la semilla?", op: ["Una planta verde", "Una flor roja", "Un árbol grande"], correcta: "Una planta verde" },
|
||||
{ q: "¿Qué hacía el abuelo todos los días?", op: ["Regaba la semilla", "Le cantaba", "La movía"], correcta: "Regaba la semilla" },
|
||||
{ q: "¿Cómo reaccionó Ana?", op: ["Aplaudió", "Se enojó", "Se fue"], correcta: "Aplaudió" },
|
||||
],
|
||||
emoji: "🌱",
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
let idx = 0
|
||||
for (const l of lecturas) {
|
||||
for (const p of l.preguntas) {
|
||||
exercises.push({
|
||||
id: `fra7-inferencia-${idx}`,
|
||||
stage: 7,
|
||||
skillCode: `lectura-inferencia-${idx}`,
|
||||
type: "lectura",
|
||||
prerequisites: idx === 0 ? [`fra6-escuela-29`] : [`fra7-inferencia-${idx - 1}`],
|
||||
content: {
|
||||
instruction: p.q,
|
||||
text: l.text,
|
||||
audioText: l.text,
|
||||
emoji: l.emoji,
|
||||
options: p.op.map((o) => ({
|
||||
label: o,
|
||||
correct: o === p.correcta,
|
||||
id: o.toLowerCase().slice(0, 12),
|
||||
})),
|
||||
},
|
||||
functionalContext: "Comprensión inferencial Decroly: deducir información no explícita en textos narrativos cortos.",
|
||||
errorType: "omision-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 7,
|
||||
name: "Comprensión inferencial",
|
||||
description: "Deducir información no explícita en textos cortos con preguntas de inferencia (método Decroly).",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const synonymPairs = [
|
||||
{ word: "contento", synonym: "alegre" },
|
||||
{ word: "grande", synonym: "enorme" },
|
||||
{ word: "bonito", synonym: "hermoso" },
|
||||
{ word: "rápido", synonym: "veloz" },
|
||||
{ word: "pequeño", synonym: "chico" },
|
||||
{ word: "difícil", synonym: "complicado" },
|
||||
{ word: "fácil", synonym: "sencillo" },
|
||||
{ word: "importante", synonym: "valioso" },
|
||||
{ word: "triste", synonym: "apenado" },
|
||||
{ word: "cansado", synonym: "agotado" },
|
||||
{ word: "inteligente", synonym: "listo" },
|
||||
{ word: "valiente", synonym: "audaz" },
|
||||
{ word: "curioso", synonym: "preguntón" },
|
||||
{ word: "famoso", synonym: "conocido" },
|
||||
{ word: "enojado", synonym: "furioso" },
|
||||
{ word: "cariñoso", synonym: "afectuoso" },
|
||||
{ word: "orgulloso", synonym: "satisfecho" },
|
||||
{ word: "tranquilo", synonym: "calmado" },
|
||||
{ word: "divertido", synonym: "entretenido" },
|
||||
{ word: "antiguo", synonym: "viejo" },
|
||||
{ word: "nuevo", synonym: "reciente" },
|
||||
{ word: "oscuro", synonym: "sombrío" },
|
||||
{ word: "brillante", synonym: "radiante" },
|
||||
{ word: "fuerte", synonym: "resistente" },
|
||||
{ word: "amable", synonym: "gentil" },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
let idx = 0
|
||||
|
||||
// Part 1: eleccion — choose the synonym
|
||||
for (let i = 0; i < synonymPairs.length; i++) {
|
||||
const p = synonymPairs[i]
|
||||
const wrong = synonymPairs[(i + 1) % synonymPairs.length].synonym
|
||||
exercises.push({
|
||||
id: `fra8-sinonimo-${i}`,
|
||||
stage: 8,
|
||||
skillCode: `sinonimo-${i}`,
|
||||
type: "eleccion",
|
||||
prerequisites: idx === 0 ? [`fra7-inferencia-9`] : [exercises[idx - 1].id],
|
||||
content: {
|
||||
instruction: `¿Cuál es un sinónimo de "${p.word}"?`,
|
||||
audioText: `Buscá un sinónimo de ${p.word}`,
|
||||
emoji: "📖",
|
||||
options: [
|
||||
{ label: p.synonym, correct: true, id: `c-${i}` },
|
||||
{ label: wrong, correct: false, id: `w1-${i}` },
|
||||
{ label: p.word, correct: false, id: `w2-${i}` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: "Enriquecer léxico identificando sinónimos de palabras comunes.. Centro de interés Decroly.",
|
||||
errorType: "confusion-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 8,
|
||||
name: "Vocabulario y sinónimos",
|
||||
description: "Enriquecer el léxico identificando sinónimos de palabras cotidianas.",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const cuentos = [
|
||||
{
|
||||
titulo: "El patito feo",
|
||||
secuencia: [
|
||||
"Un patito nació diferente a sus hermanos.",
|
||||
"Todos se burlaban de él por ser distinto.",
|
||||
"El patito se fue triste a vivir solo.",
|
||||
"Creció y se convirtió en un hermoso cisne.",
|
||||
"Todos los patos lo admiraban por su belleza.",
|
||||
],
|
||||
orden: [0, 1, 2, 3, 4],
|
||||
emoji: "🦆",
|
||||
},
|
||||
{
|
||||
titulo: "La liebre y la tortuga",
|
||||
secuencia: [
|
||||
"La liebre se burlaba de la tortuga por lenta.",
|
||||
"La tortuga la desafió a una carrera.",
|
||||
"La liebre salió corriendo muy rápido.",
|
||||
"La liebre se confió y se durmió.",
|
||||
"La tortuga pasó a la liebre y ganó la carrera.",
|
||||
],
|
||||
orden: [0, 1, 2, 3, 4],
|
||||
emoji: "🐢",
|
||||
},
|
||||
{
|
||||
titulo: "Los tres chanchitos",
|
||||
secuencia: [
|
||||
"Tres chanchitos construyeron sus casas.",
|
||||
"Uno la hizo de paja, otro de madera y otro de ladrillos.",
|
||||
"El lobo sopló y derribó la casa de paja.",
|
||||
"El lobo sopló y derribó la casa de madera.",
|
||||
"El lobo no pudo derribar la casa de ladrillos.",
|
||||
],
|
||||
orden: [0, 1, 2, 3, 4],
|
||||
emoji: "🐷",
|
||||
},
|
||||
{
|
||||
titulo: "La cigarra y la hormiga",
|
||||
secuencia: [
|
||||
"La cigarra cantaba todo el verano sin trabajar.",
|
||||
"La hormiga juntaba comida para el invierno.",
|
||||
"Llegó el invierno y la cigarra no tenía comida.",
|
||||
"La cigarra fue a pedirle comida a la hormiga.",
|
||||
"La hormiga le dio comida pero le enseñó a trabajar.",
|
||||
],
|
||||
orden: [0, 1, 2, 3, 4],
|
||||
emoji: "🐜",
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
let idx = 0
|
||||
|
||||
for (const cuento of cuentos) {
|
||||
// Part 1: ordenar secuencia (secuencia type)
|
||||
const shuffled = [...cuento.orden].sort(() => Math.random() - 0.5)
|
||||
exercises.push({
|
||||
id: `fra9-secuencia-${idx}`,
|
||||
stage: 9,
|
||||
skillCode: `secuencia-cuento-${idx}`,
|
||||
type: "secuencia",
|
||||
prerequisites: idx === 0 ? [`fra8-sinonimo-24`] : [exercises[idx - 1].id],
|
||||
content: {
|
||||
instruction: `Ordená los eventos de "${cuento.titulo}"`,
|
||||
text: cuento.secuencia.join(". "),
|
||||
audioText: `Ordená los eventos del cuento ${cuento.titulo}`,
|
||||
emoji: cuento.emoji,
|
||||
sequence: shuffled.map((s) => cuento.secuencia[s]),
|
||||
},
|
||||
functionalContext: "Ordenar eventos de un cuento clásico en secuencia narrativa.. Centro de interés Decroly.",
|
||||
errorType: "omision-letra",
|
||||
})
|
||||
idx++
|
||||
|
||||
// Part 2: comprensión (lectura type) — 2 preguntas por cuento
|
||||
const preguntas = cuento.titulo === "El patito feo"
|
||||
? [
|
||||
{ q: "¿Cómo nació el patito?", op: ["Diferente", "Igual", "Más grande"], correcta: "Diferente" },
|
||||
{ q: "¿En qué se convirtió?", op: ["Cisne", "Pato", "Ganso"], correcta: "Cisne" },
|
||||
]
|
||||
: cuento.titulo === "La liebre y la tortuga"
|
||||
? [
|
||||
{ q: "¿Quién ganó la carrera?", op: ["La tortuga", "La liebre", "Las dos"], correcta: "La tortuga" },
|
||||
{ q: "¿Por qué perdió la liebre?", op: ["Se durmió", "Corrió lento", "Se perdió"], correcta: "Se durmió" },
|
||||
]
|
||||
: cuento.titulo === "Los tres chanchitos"
|
||||
? [
|
||||
{ q: "¿Qué casa no pudo derribar el lobo?", op: ["La de ladrillos", "La de paja", "La de madera"], correcta: "La de ladrillos" },
|
||||
{ q: "¿De qué hizo la casa el primer chanchito?", op: ["Paja", "Madera", "Ladrillos"], correcta: "Paja" },
|
||||
]
|
||||
: [
|
||||
{ q: "¿Qué hizo la cigarra en verano?", op: ["Cantar", "Trabajar", "Dormir"], correcta: "Cantar" },
|
||||
{ q: "¿Qué le enseñó la hormiga?", op: ["A trabajar", "A cantar", "A bailar"], correcta: "A trabajar" },
|
||||
]
|
||||
|
||||
for (const p of preguntas) {
|
||||
exercises.push({
|
||||
id: `fra9-cuento-p${idx}`,
|
||||
stage: 9,
|
||||
skillCode: `cuento-pregunta-${idx}`,
|
||||
type: "lectura",
|
||||
prerequisites: [exercises[idx - 1].id],
|
||||
content: {
|
||||
instruction: `Sobre "${cuento.titulo}":`,
|
||||
text: p.q,
|
||||
audioText: p.q,
|
||||
emoji: cuento.emoji,
|
||||
options: p.op.map((o) => ({
|
||||
label: o,
|
||||
correct: o === p.correcta,
|
||||
id: o.toLowerCase().slice(0, 12),
|
||||
})),
|
||||
},
|
||||
functionalContext: "Comprensión de cuentos clásicos: responder preguntas sobre la trama.. Centro de interés Decroly.",
|
||||
errorType: "omision-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 9,
|
||||
name: "Secuencia narrativa",
|
||||
description: "Ordenar eventos de cuentos clásicos y responder preguntas de comprensión.",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
const mayusculas = [
|
||||
{ phrase: "el sol brilla", correct: "El sol brilla", optionA: "el sol brilla", optionB: "El sol brilla" },
|
||||
{ phrase: "mi mamá me ama", correct: "Mi mamá me ama", optionA: "mi mamá me ama", optionB: "Mi mamá me ama" },
|
||||
{ phrase: "juan juega al fútbol", correct: "Juan juega al fútbol", optionA: "juan juega al fútbol", optionB: "Juan juega al fútbol" },
|
||||
{ phrase: "en argentina hay montañas", correct: "En Argentina hay montañas", optionA: "en argentina hay montañas", optionB: "En Argentina hay montañas" },
|
||||
{ phrase: "laura tiene un gato", correct: "Laura tiene un gato", optionA: "laura tiene un gato", optionB: "Laura tiene un gato" },
|
||||
{ phrase: "la ciudad de buenos aires", correct: "La ciudad de Buenos Aires", optionA: "la ciudad de buenos aires", optionB: "La ciudad de Buenos Aires" },
|
||||
{ phrase: "el río paraná es largo", correct: "El río Paraná es largo", optionA: "el río paraná es largo", optionB: "El río Paraná es largo" },
|
||||
{ phrase: "maría y pedro son amigos", correct: "María y Pedro son amigos", optionA: "maría y pedro son amigos", optionB: "María y Pedro son amigos" },
|
||||
{ phrase: "en la plaza hay árboles", correct: "En la plaza hay árboles", optionA: "en la plaza hay árboles", optionB: "En la plaza hay árboles" },
|
||||
{ phrase: "el parque nacional nahuel huapi", correct: "El Parque Nacional Nahuel Huapi", optionA: "el parque nacional nahuel huapi", optionB: "El Parque Nacional Nahuel Huapi" },
|
||||
]
|
||||
|
||||
// Names with correct capitalization (shown the correct way, ask which is right)
|
||||
const nombres = [
|
||||
{ correcta: "Buenos Aires", wrong: "buenos aires", context: "Capital de Argentina" },
|
||||
{ correcta: "Córdoba", wrong: "córdoba", context: "Provincia argentina" },
|
||||
{ correcta: "Santa Fe", wrong: "santa fe", context: "Ciudad y provincia" },
|
||||
{ correcta: "Mendoza", wrong: "mendoza", context: "Tierra del vino" },
|
||||
{ correcta: "Tucumán", wrong: "tucumán", context: "Independencia argentina" },
|
||||
{ correcta: "Salta", wrong: "salta", context: "La linda" },
|
||||
{ correcta: "Juan", wrong: "juan", context: "Nombre de persona" },
|
||||
{ correcta: "María", wrong: "maría", context: "Nombre de persona" },
|
||||
{ correcta: "Argentina", wrong: "argentina", context: "Nuestro país" },
|
||||
{ correcta: "América", wrong: "américa", context: "El continente" },
|
||||
]
|
||||
|
||||
let idx = 0
|
||||
|
||||
// Part 1: Sentence capitalization (eleccion)
|
||||
for (const m of mayusculas) {
|
||||
exercises.push({
|
||||
id: `fra-ort-may-${idx}`,
|
||||
stage: 1,
|
||||
skillCode: `mayusculas-${idx}`,
|
||||
type: "eleccion",
|
||||
prerequisites: idx === 0 ? [] : [exercises[idx - 1].id],
|
||||
content: {
|
||||
instruction: "¿Cuál oración está escrita correctamente?",
|
||||
text: m.phrase,
|
||||
audioText: m.phrase,
|
||||
emoji: "✏️",
|
||||
options: [
|
||||
{ label: m.optionA, correct: m.optionA === m.correct, id: `a-${idx}` },
|
||||
{ label: m.optionB, correct: m.optionB === m.correct, id: `b-${idx}` },
|
||||
],
|
||||
},
|
||||
functionalContext: "Regla ortográfica: mayúscula inicial de oración.. Centro de interés Decroly.",
|
||||
errorType: "omision-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
|
||||
// Part 2: Proper noun capitalization (eleccion)
|
||||
for (const n of nombres) {
|
||||
exercises.push({
|
||||
id: `fra-ort-nombre-${idx}`,
|
||||
stage: 1,
|
||||
skillCode: `mayusculas-nombre-${idx}`,
|
||||
type: "eleccion",
|
||||
prerequisites: [exercises[idx - 1].id],
|
||||
content: {
|
||||
instruction: `¿Cómo se escribe correctamente? (${n.context})`,
|
||||
audioText: n.context,
|
||||
emoji: "🇦🇷",
|
||||
options: [
|
||||
{ label: n.correcta, correct: true, id: `c-${idx}` },
|
||||
{ label: n.wrong, correct: false, id: `w-${idx}` },
|
||||
],
|
||||
},
|
||||
functionalContext: "Regla ortográfica: mayúscula en nombres propios de personas y lugares.. Centro de interés Decroly.",
|
||||
errorType: "omision-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 1,
|
||||
name: "Mayúsculas y nombres propios",
|
||||
description: "Aprender el uso de mayúscula al inicio de oraciones y en nombres propios de personas y lugares.",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
const palabras = [
|
||||
{ word: "ca_po", correcta: "m", hint: "Lugar con flores y árboles" },
|
||||
{ word: "ca_bio", correcta: "m", hint: "Dar algo por otra cosa" },
|
||||
{ word: "so_brero", correcta: "m", hint: "Prenda que se usa en la cabeza" },
|
||||
{ word: "lám_ara", correcta: "p", hint: "Objeto que da luz" },
|
||||
{ word: "e_budo", correcta: "m", hint: "Para trasvasar líquidos" },
|
||||
{ word: "som_ra", correcta: "b", hint: "Donde no da el sol" },
|
||||
{ word: "tam_bién", correcta: "m", hint: "Significa igualmente" },
|
||||
{ word: "tam_or", correcta: "b", hint: "Instrumento musical" },
|
||||
{ word: "bom_ero", correcta: "b", hint: "Apaga incendios" },
|
||||
{ word: "cum_le", correcta: "p", hint: "Festejar años" },
|
||||
{ word: "cam_ana", correcta: "p", hint: "Suena en las iglesias" },
|
||||
{ word: "trom_a", correcta: "p", hint: "Parte del elefante" },
|
||||
{ word: "im_osible", correcta: "p", hint: "Que no se puede hacer" },
|
||||
{ word: "sim_ático", correcta: "p", hint: "Agradable" },
|
||||
{ word: "colum_a", correcta: "p", hint: "Juego de plaza" },
|
||||
{ word: "trom_eta", correcta: "p", hint: "Instrumento musical" },
|
||||
{ word: "lim_io", correcta: "p", hint: "Sin suciedad" },
|
||||
{ word: "acom_añar", correcta: "m", hint: "Ir junto a alguien" },
|
||||
{ word: "com_artir", correcta: "m", hint: "Dividir con otros" },
|
||||
{ word: "prem_o", correcta: "m", hint: "Reconocimiento" },
|
||||
{ word: "tam_oco", correcta: "p", hint: "Tampoco" },
|
||||
{ word: "rom_er", correcta: "p", hint: "Quebrar" },
|
||||
{ word: "com_rar", correcta: "p", hint: "Adquirir algo" },
|
||||
{ word: "emp_ezar", correcta: "m", hint: "Comenzar" },
|
||||
{ word: "en_erar", correcta: "m", hint: "Poner dentro de la tierra" },
|
||||
{ word: "com_leto", correcta: "m", hint: "Completo" },
|
||||
{ word: "rem_lazo", correcta: "m", hint: "Premio" },
|
||||
{ word: "am_iente", correcta: "b", hint: "Entorno" },
|
||||
{ word: "al_umbrar", correcta: "m", hint: "Iluminar" },
|
||||
{ word: "en_udo", correcta: "m", hint: "Atasco" },
|
||||
]
|
||||
|
||||
for (let i = 0; i < palabras.length; i++) {
|
||||
const p = palabras[i]
|
||||
|
||||
// Generate options: correct letter + 2 wrong ones
|
||||
const wrongOptions = ["m", "n", "p", "b", "v"].filter((l) => l !== p.correcta)
|
||||
const shuffled = [p.correcta.toUpperCase(), wrongOptions[0].toUpperCase(), wrongOptions[1].toUpperCase()].sort(() => Math.random() - 0.5)
|
||||
|
||||
const displayWord = p.word.replace("_", " ? ")
|
||||
|
||||
exercises.push({
|
||||
id: `fra-ort-mp-${i}`,
|
||||
stage: 2,
|
||||
skillCode: `mp-${i}`,
|
||||
type: "eleccion",
|
||||
prerequisites: i === 0 ? [] : [exercises[i - 1].id],
|
||||
content: {
|
||||
instruction: `Completá: ${displayWord}`,
|
||||
text: `Pista: ${p.hint}`,
|
||||
audioText: `${p.hint}. Se escribe con ${p.correcta}`,
|
||||
emoji: "📝",
|
||||
options: shuffled.map((o, j) => ({
|
||||
label: o,
|
||||
correct: o === p.correcta.toUpperCase(),
|
||||
id: `opt-${i}-${j}`,
|
||||
})),
|
||||
},
|
||||
functionalContext: "Regla ortográfica: siempre 'm' antes de 'p' y 'b' en español.. Centro de interés Decroly.",
|
||||
errorType: "omision-letra",
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 2,
|
||||
name: 'Regla "m antes de p y b"',
|
||||
description: 'Aprender la regla ortográfica: siempre se escribe "m" antes de "p" y "b".',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const sums = Array.from({ length: 15 }, (_, i) => {
|
||||
const a = Math.floor(i / 3) + 1
|
||||
const b = (i % 3) + 1
|
||||
const res = a + b
|
||||
return { a, b, res }
|
||||
})
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const s of sums) {
|
||||
const prereqs: string[] = []
|
||||
if (s.a > 1 || s.b > 1) {
|
||||
if (s.b === 1) {
|
||||
prereqs.push(`fra1-suma-${s.a - 1}-1`)
|
||||
} else {
|
||||
prereqs.push(`fra1-suma-1-${s.b - 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
exercises.push({
|
||||
id: `fra1-suma-${s.a}-${s.b}`,
|
||||
stage: 1,
|
||||
skillCode: `suma-${s.a + s.b}`,
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: prereqs,
|
||||
content: {
|
||||
instruction: `Sumá: ${s.a} + ${s.b}`,
|
||||
audioText: `${s.a} más ${s.b} es ${s.res}`,
|
||||
emoji: '🧮',
|
||||
color: '#8CB8A0',
|
||||
options: [
|
||||
{ label: String(s.res), correct: true, id: String(s.res) },
|
||||
{ label: String(s.res - 1), correct: false, id: String(s.res - 1) },
|
||||
{ label: String(s.res + 1), correct: false, id: String(s.res + 1) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: `Sumar ${s.a} + ${s.b} con método Montessori.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 1,
|
||||
name: 'Sumas de 1 dígito',
|
||||
description: 'Aprender sumas básicas del 1 al 9 con material concreto Montessori.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
type Prob = {
|
||||
question: string
|
||||
answer: number
|
||||
hint: string
|
||||
emoji: string
|
||||
distractors: number[]
|
||||
}
|
||||
|
||||
const problems: Prob[] = [
|
||||
{ question: "Compraste 3 caramelos a $2 cada uno. ¿Cuánto gastaste?", answer: 6, hint: "3 × 2", emoji: "🍬", distractors: [5, 8] },
|
||||
{ question: "Tenés $20 y querés comprar 4 juguetes iguales. ¿Cuánto cuesta cada uno?", answer: 5, hint: "20 ÷ 4", emoji: "🧸", distractors: [4, 6] },
|
||||
{ question: "Un libro cuesta $12 y una lapicera cuesta $3. ¿Cuánto cuestan los dos?", answer: 15, hint: "12 + 3", emoji: "📚", distractors: [14, 16] },
|
||||
{ question: "Tenés $15 y gastás $7 en un helado. ¿Cuánto te queda?", answer: 8, hint: "15 - 7", emoji: "🍦", distractors: [7, 9] },
|
||||
{ question: "Un cuaderno cuesta $8. Compraste 3 cuadernos. ¿Cuánto pagaste?", answer: 24, hint: "8 × 3", emoji: "📓", distractors: [22, 27] },
|
||||
{ question: "Una mesa mide 120 cm de alto. Una silla mide 45 cm. ¿Cuánto miden juntos?", answer: 165, hint: "120 + 45", emoji: "📏", distractors: [155, 175] },
|
||||
{ question: "Una botella tiene 1 litro de agua. Serviste 4 vasos de 250 ml cada uno. ¿Cuánto serviste?", answer: 1000, hint: "4 × 250", emoji: "💧", distractors: [750, 1250] },
|
||||
{ question: "Compraste 2 kilos de manzanas a $5 el kilo. ¿Cuánto pagaste?", answer: 10, hint: "2 × 5", emoji: "🍎", distractors: [8, 12] },
|
||||
{ question: "Un tren tiene 3 vagones. En cada vagón viajan 6 personas. ¿Cuántas personas viajan?", answer: 18, hint: "3 × 6", emoji: "🚂", distractors: [16, 20] },
|
||||
{ question: "Una pizza tiene 8 porciones. Comiste 3. ¿Cuántas quedan?", answer: 5, hint: "8 - 3", emoji: "🍕", distractors: [4, 6] },
|
||||
{ question: "Gastaste $50 en el supermercado con $100. ¿Cuánto vuelto recibís?", answer: 50, hint: "100 - 50", emoji: "🛒", distractors: [40, 60] },
|
||||
{ question: "Un lápiz cuesta $3 y una goma cuesta $2. Compraste 2 lápices y 1 goma. ¿Cuánto pagaste?", answer: 8, hint: "2 × 3 + 2", emoji: "✏️", distractors: [7, 10] },
|
||||
{ question: "Tu clase tiene 24 alumnos. Los dividís en 4 equipos iguales. ¿Cuántos alumnos por equipo?", answer: 6, hint: "24 ÷ 4", emoji: "👥", distractors: [5, 7] },
|
||||
{ question: "Una receta usa 250 gramos de harina. Hacés 4 recetas. ¿Cuántos gramos necesitás?", answer: 1000, hint: "250 × 4", emoji: "🧁", distractors: [800, 1200] },
|
||||
{ question: "Corriste 3 vueltas a la plaza. Cada vuelta es 400 metros. ¿Cuánto corriste?", answer: 1200, hint: "3 × 400", emoji: "🏃", distractors: [1000, 1400] },
|
||||
{ question: "Una caja tiene 12 huevos. Usaste 4 para cocinar. ¿Cuántos quedan?", answer: 8, hint: "12 - 4", emoji: "🥚", distractors: [7, 9] },
|
||||
{ question: "Compraste 5 paquetes de figuritas a $3 cada uno. ¿Cuánto pagaste?", answer: 15, hint: "5 × 3", emoji: "🃏", distractors: [14, 18] },
|
||||
{ question: "Son las 10:00. Pasan 3 horas. ¿Qué hora es?", answer: 13, hint: "10 + 3", emoji: "⏰", distractors: [12, 14] },
|
||||
{ question: "Una regla mide 30 cm. Tenés 4 reglas iguales puestas en fila. ¿Cuánto miden?", answer: 120, hint: "4 × 30", emoji: "📏", distractors: [110, 130] },
|
||||
{ question: "Compraste 2 docenas de facturas. ¿Cuántas facturas son?", answer: 24, hint: "2 × 12", emoji: "🥐", distractors: [22, 26] },
|
||||
{ question: "Tenés $100. Comprás un juguete de $35 y otro de $28. ¿Cuánto gastaste?", answer: 63, hint: "35 + 28", emoji: "🧸", distractors: [60, 65] },
|
||||
{ question: "En una pecera hay 15 peces. Compraste 8 más. ¿Cuántos peces hay?", answer: 23, hint: "15 + 8", emoji: "🐠", distractors: [22, 24] },
|
||||
{ question: "Tenés 24 lápices y los repartís entre 6 amigos. ¿Cuántos lápices recibe cada uno?", answer: 4, hint: "24 ÷ 6", emoji: "✏️", distractors: [3, 5] },
|
||||
{ question: "Gastaste $12 en la mañana y $18 en la tarde. ¿Cuánto gastaste en total?", answer: 30, hint: "12 + 18", emoji: "💰", distractors: [28, 32] },
|
||||
{ question: "Un camión lleva 45 cajas. Descargaron 18. ¿Cuántas cajas quedan?", answer: 27, hint: "45 - 18", emoji: "🚚", distractors: [26, 28] },
|
||||
{ question: "Una hormiga camina 5 cm por minuto. ¿Cuánto camina en 7 minutos?", answer: 35, hint: "5 × 7", emoji: "🐜", distractors: [33, 40] },
|
||||
{ question: "Tenés $50. Compras una remera de $22 y una gorra de $15. ¿Cuánto vuelto recibís?", answer: 13, hint: "50 - 22 - 15", emoji: "👕", distractors: [12, 15] },
|
||||
{ question: "En un álbum caben 9 figuritas por página. Tenés 6 páginas llenas. ¿Cuántas figuritas tenés?", answer: 54, hint: "9 × 6", emoji: "🖼️", distractors: [52, 56] },
|
||||
{ question: "Un paquete de galletitas trae 18. Comiste 5. ¿Cuántas quedan?", answer: 13, hint: "18 - 5", emoji: "🍪", distractors: [12, 14] },
|
||||
{ question: "Ahorraste $8 por semana durante 6 semanas. ¿Cuánto ahorraste?", answer: 48, hint: "8 × 6", emoji: "🏦", distractors: [46, 50] },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
for (let i = 0; i < problems.length; i++) {
|
||||
const p = problems[i]
|
||||
const ds = p.distractors.slice()
|
||||
if (ds.length < 2) {
|
||||
ds.push(p.answer + 2)
|
||||
}
|
||||
exercises.push({
|
||||
id: `fra10-prob-${i}`,
|
||||
stage: 10,
|
||||
skillCode: `prob-dinero-${i}`,
|
||||
type: "problema",
|
||||
prerequisites: i === 0 ? [] : [exercises[i - 1].id],
|
||||
content: {
|
||||
instruction: "Resolvé el problema:",
|
||||
text: p.question,
|
||||
audioText: p.question,
|
||||
emoji: p.emoji,
|
||||
options: [
|
||||
{ label: `$${p.answer}`, correct: true, id: `c-${i}` },
|
||||
{ label: `$${ds[0]}`, correct: false, id: `w1-${i}` },
|
||||
{ label: `$${ds[1]}`, correct: false, id: `w2-${i}` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: `Resolver problemas con dinero y medidas (vida práctica Montessori): ${p.hint}.`,
|
||||
errorType: "discriminacion-auditiva",
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 10,
|
||||
name: "Problemas con dinero y medidas",
|
||||
description: "Aplicar operaciones en contexto de la vida real con dinero ($), medidas (cm, kg, L) y tiempo.",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const sums = [
|
||||
{ a: 10, b: 5, res: 15 }, { a: 23, b: 34, res: 57 }, { a: 45, b: 12, res: 57 },
|
||||
{ a: 67, b: 22, res: 89 }, { a: 100, b: 50, res: 150 }, { a: 34, b: 56, res: 90 },
|
||||
{ a: 78, b: 21, res: 99 }, { a: 55, b: 33, res: 88 }, { a: 42, b: 47, res: 89 },
|
||||
{ a: 90, b: 15, res: 105 }, { a: 13, b: 87, res: 100 }, { a: 29, b: 71, res: 100 },
|
||||
{ a: 150, b: 75, res: 225 }, { a: 200, b: 100, res: 300 },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
for (let i = 0; i < sums.length; i++) {
|
||||
const s = sums[i]
|
||||
exercises.push({
|
||||
id: `fra2-suma-${i}`,
|
||||
stage: 2,
|
||||
skillCode: `suma2-${i}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`fra2-suma-${i > 0 ? i - 1 : 0}`],
|
||||
content: {
|
||||
instruction: `Sumá: ${s.a} + ${s.b}`,
|
||||
audioText: `${s.a} más ${s.b} es ${s.res}`,
|
||||
emoji: '➕',
|
||||
options: [
|
||||
{ label: String(s.res), correct: true, id: String(s.res) },
|
||||
{ label: String(s.res + (Math.random() > 0.5 ? 10 : -10)), correct: false, id: `wrong-${i}-a` },
|
||||
{ label: String(s.res + (Math.random() > 0.5 ? 1 : -1)), correct: false, id: `wrong-${i}-b` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: 'Sumar números de 2 y 3 dígitos con método Montessori.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 2,
|
||||
name: 'Sumas de 2-3 dígitos',
|
||||
description: 'Resolver sumas de números de 2 y 3 dígitos con material concreto.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const restas = [
|
||||
{ a: 10, b: 3, res: 7 }, { a: 50, b: 20, res: 30 }, { a: 90, b: 45, res: 45 },
|
||||
{ a: 100, b: 35, res: 65 }, { a: 200, b: 75, res: 125 }, { a: 80, b: 23, res: 57 },
|
||||
{ a: 120, b: 40, res: 80 }, { a: 55, b: 22, res: 33 }, { a: 75, b: 30, res: 45 },
|
||||
{ a: 150, b: 60, res: 90 }, { a: 300, b: 100, res: 200 }, { a: 99, b: 33, res: 66 },
|
||||
{ a: 45, b: 12, res: 33 }, { a: 180, b: 50, res: 130 },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
for (let i = 0; i < restas.length; i++) {
|
||||
const r = restas[i]
|
||||
const wrong1 = r.res - 10
|
||||
const wrong2 = r.res + 10
|
||||
exercises.push({
|
||||
id: `fra3-resta-${i}`,
|
||||
stage: 3,
|
||||
skillCode: `resta2-${i}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: i === 0 ? ['fra1-suma-9-1'] : [`fra3-resta-${i - 1}`],
|
||||
content: {
|
||||
instruction: `Restá: ${r.a} - ${r.b}`,
|
||||
audioText: `${r.a} menos ${r.b} es ${r.res}`,
|
||||
emoji: '➖',
|
||||
options: [
|
||||
{ label: String(r.res), correct: true, id: String(r.res) },
|
||||
{ label: String(wrong1 >= 0 ? wrong1 : r.res + 5), correct: false, id: `wrong-${i}-a` },
|
||||
{ label: String(wrong2), correct: false, id: `wrong-${i}-b` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: 'Restar números de 2 y 3 dígitos con método Montessori.',
|
||||
errorType: 'inversion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 3,
|
||||
name: 'Restas de 2-3 dígitos',
|
||||
description: 'Resolver restas de números de 2 y 3 dígitos con material concreto.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
function multIdx(a: number, b: number) {
|
||||
return (b - 2) * 4 + (a - 1)
|
||||
}
|
||||
|
||||
const mults = Array.from({ length: 12 }, (_, i) => {
|
||||
const a = (i % 4) + 1
|
||||
const b = Math.floor(i / 4) + 2
|
||||
return { a, b, res: a * b }
|
||||
})
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const m of mults) {
|
||||
const idx = multIdx(m.a, m.b)
|
||||
exercises.push({
|
||||
id: `fra4-mult-${m.a}-${m.b}`,
|
||||
stage: 4,
|
||||
skillCode: `mult-${m.a}x${m.b}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: idx > 0 ? [`fra4-mult-${mults[idx - 1].a}-${mults[idx - 1].b}`] : [],
|
||||
content: {
|
||||
instruction: `Multiplicá: ${m.a} × ${m.b}`,
|
||||
audioText: `${m.a} veces ${m.b} es ${m.res}`,
|
||||
emoji: '✖️',
|
||||
options: [
|
||||
{ label: String(m.res), correct: true, id: String(m.res) },
|
||||
{ label: String(m.res + m.a), correct: false, id: `wrong-${m.a}a` },
|
||||
{ label: String(m.res + m.b), correct: false, id: `wrong-${m.b}a` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: 'Multiplicar números de 1 dígito.. Método Montessori.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const lecturas = [
|
||||
{
|
||||
title: 'El sol',
|
||||
text: 'El sol brilla. Los niños juegan. Comen helado. Están contentos.',
|
||||
question: '¿Qué hacen los niños?',
|
||||
options: ['Juegan', 'Duermen', 'Estudian'],
|
||||
correcta: 'Juegan',
|
||||
emoji: '☀️',
|
||||
},
|
||||
{
|
||||
title: 'La merienda',
|
||||
text: 'Mamá prepara leche. Tomás come tostadas. ¡Qué rico!',
|
||||
question: '¿Qué prepara mamá?',
|
||||
options: ['Leche', 'Café', 'Agua'],
|
||||
correcta: 'Leche',
|
||||
emoji: '🥛',
|
||||
},
|
||||
{
|
||||
title: 'El parque',
|
||||
text: 'En el parque hay un árbol. Los pájaros cantan. Hace sol.',
|
||||
question: '¿Dónde están los pájaros?',
|
||||
options: ['En el parque', 'En el cielo', 'En casa'],
|
||||
correcta: 'En el parque',
|
||||
emoji: '🌳',
|
||||
},
|
||||
{
|
||||
title: 'La familia',
|
||||
text: 'Papá lee un libro. Mamá cocina. Los niños ayudan.',
|
||||
question: '¿Qué lee papá?',
|
||||
options: ['Un libro', 'El diario', 'Una carta'],
|
||||
correcta: 'Un libro',
|
||||
emoji: '📖',
|
||||
},
|
||||
{
|
||||
title: 'El río',
|
||||
text: 'El río corre. El pez nada. La rana salta.',
|
||||
question: '¿Qué hace la rana?',
|
||||
options: ['Salta', 'Nada', 'Vuela'],
|
||||
correcta: 'Salta',
|
||||
emoji: '🐸',
|
||||
},
|
||||
]
|
||||
|
||||
for (let i = 0; i < lecturas.length; i++) {
|
||||
const l = lecturas[i]
|
||||
exercises.push({
|
||||
id: `fra4-lectura-${i}`,
|
||||
stage: 4,
|
||||
skillCode: 'lectura-corta',
|
||||
type: 'lectura',
|
||||
prerequisites: i === 0 ? ['fra4-mult-1-2'] : [`fra4-lectura-${i - 1}`],
|
||||
content: {
|
||||
instruction: `Leé: ${l.title}`,
|
||||
text: l.text,
|
||||
audioText: l.text,
|
||||
emoji: l.emoji,
|
||||
options: l.options.map((o) => ({
|
||||
label: o,
|
||||
correct: o === l.correcta,
|
||||
id: o.toLowerCase().slice(0, 10),
|
||||
})),
|
||||
},
|
||||
functionalContext: 'Leer textos cortos con comprensión.. Método Montessori.',
|
||||
errorType: 'omision-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 4,
|
||||
name: 'Multiplicaciones y lecturas',
|
||||
description: 'Multiplicaciones de 1 dígito y lectura comprensiva de textos cortos.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const TABLAS = [2, 5, 10] as const
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
let idx = 0
|
||||
for (const t of TABLAS) {
|
||||
for (let m = 1; m <= 10; m++) {
|
||||
const correct = t * m
|
||||
exercises.push({
|
||||
id: `fra5-tabla${t}-${m}`,
|
||||
stage: 5,
|
||||
skillCode: `tabla-${t}-x-${m}`,
|
||||
type: "eleccion",
|
||||
prerequisites: idx === 0 ? [] : [exercises[idx - 1]?.id].filter(Boolean),
|
||||
content: {
|
||||
instruction: `¿Cuánto es ${t} × ${m}?`,
|
||||
audioText: `${t} por ${m} es ${correct}`,
|
||||
emoji: "🔢",
|
||||
options: [
|
||||
{ label: String(correct), correct: true, id: String(correct) },
|
||||
{ label: String(correct + 1), correct: false, id: `w1-${idx}` },
|
||||
{ label: String(correct + t), correct: false, id: `w2-${idx}` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: `Memorizar tabla del ${t} con método Montessori (tabla de Pythagore).`,
|
||||
errorType: "confusion-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 5,
|
||||
name: "Tablas de multiplicar (2, 5, 10)",
|
||||
description: "Memorizar las tablas de multiplicar del 2, 5 y 10 con material Montessori (tabla de Pythagore, cuentas doradas).",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const TABLAS = [3, 4, 6, 7, 8, 9] as const
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
let idx = 0
|
||||
for (const t of TABLAS) {
|
||||
for (let m = 1; m <= 10; m++) {
|
||||
const correct = t * m
|
||||
exercises.push({
|
||||
id: `fra6-tabla${t}-${m}`,
|
||||
stage: 6,
|
||||
skillCode: `tabla-${t}-x-${m}`,
|
||||
type: "eleccion",
|
||||
prerequisites: idx === 0 ? [] : [exercises[idx - 1]?.id].filter(Boolean),
|
||||
content: {
|
||||
instruction: `¿Cuánto es ${t} × ${m}?`,
|
||||
audioText: `${t} por ${m} es ${correct}`,
|
||||
emoji: "✖️",
|
||||
options: [
|
||||
{ label: String(correct), correct: true, id: String(correct) },
|
||||
{ label: String(correct + t), correct: false, id: `w1-${idx}` },
|
||||
{ label: String(Math.max(correct - 1, 1)), correct: false, id: `w2-${idx}` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: `Memorizar tabla del ${t} con método Montessori (tabla perforada, material de cuentas).`,
|
||||
errorType: "confusion-letra",
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 6,
|
||||
name: "Tablas de multiplicar (3, 4, 6, 7, 8, 9)",
|
||||
description: "Completar todas las tablas de multiplicar restantes con método Montessori.",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const mults = [
|
||||
{ a: 12, b: 3 }, { a: 14, b: 2 }, { a: 21, b: 4 }, { a: 23, b: 3 },
|
||||
{ a: 15, b: 5 }, { a: 18, b: 2 }, { a: 24, b: 3 }, { a: 31, b: 2 },
|
||||
{ a: 34, b: 2 }, { a: 25, b: 4 }, { a: 42, b: 2 }, { a: 56, b: 3 },
|
||||
{ a: 13, b: 6 }, { a: 27, b: 3 }, { a: 32, b: 4 }, { a: 45, b: 2 },
|
||||
{ a: 11, b: 7 }, { a: 22, b: 4 }, { a: 33, b: 3 }, { a: 41, b: 2 },
|
||||
{ a: 52, b: 3 }, { a: 63, b: 2 }, { a: 71, b: 4 }, { a: 81, b: 2 },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
for (let i = 0; i < mults.length; i++) {
|
||||
const m = mults[i]
|
||||
const res = m.a * m.b
|
||||
exercises.push({
|
||||
id: `fra7-mult-${m.a}-${m.b}`,
|
||||
stage: 7,
|
||||
skillCode: `mult-${m.a}x${m.b}`,
|
||||
type: "eleccion",
|
||||
prerequisites: i === 0 ? [] : [exercises[i - 1].id],
|
||||
content: {
|
||||
instruction: `Multiplicá: ${m.a} × ${m.b}`,
|
||||
audioText: `${m.a} por ${m.b}`,
|
||||
emoji: "✖️",
|
||||
options: [
|
||||
{ label: String(res), correct: true, id: String(res) },
|
||||
{ label: String(res + m.b), correct: false, id: `w1-${i}` },
|
||||
{ label: String(res - m.a), correct: false, id: `w2-${i}` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: `Multiplicar ${m.a} × ${m.b} con método Montessori (juego de sellos, descomposición decena-unidad).`,
|
||||
errorType: "confusion-letra",
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 7,
|
||||
name: "Multiplicación de 2 dígitos × 1 dígito",
|
||||
description: "Resolver multiplicaciones de 2 dígitos por 1 dígito descomponiendo en decenas y unidades.",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
const divisions = [
|
||||
{ a: 12, b: 2 }, { a: 12, b: 3 }, { a: 12, b: 4 }, { a: 15, b: 3 },
|
||||
{ a: 15, b: 5 }, { a: 16, b: 4 }, { a: 18, b: 2 }, { a: 18, b: 3 },
|
||||
{ a: 20, b: 4 }, { a: 20, b: 5 }, { a: 24, b: 3 }, { a: 24, b: 4 },
|
||||
{ a: 25, b: 5 }, { a: 27, b: 3 }, { a: 28, b: 4 }, { a: 30, b: 5 },
|
||||
{ a: 30, b: 6 }, { a: 32, b: 4 }, { a: 35, b: 5 }, { a: 36, b: 4 },
|
||||
{ a: 36, b: 6 }, { a: 40, b: 5 }, { a: 42, b: 6 }, { a: 45, b: 5 },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
for (let i = 0; i < divisions.length; i++) {
|
||||
const d = divisions[i]
|
||||
const res = d.a / d.b
|
||||
exercises.push({
|
||||
id: `fra8-div-${d.a}-${d.b}`,
|
||||
stage: 8,
|
||||
skillCode: `div-${d.a}-${d.b}`,
|
||||
type: "eleccion",
|
||||
prerequisites: i === 0 ? [] : [exercises[i - 1].id],
|
||||
content: {
|
||||
instruction: `Dividí: ${d.a} ÷ ${d.b}`,
|
||||
audioText: `${d.a} dividido ${d.b}`,
|
||||
emoji: "➗",
|
||||
options: [
|
||||
{ label: String(res), correct: true, id: String(res) },
|
||||
{ label: String(res + 1), correct: false, id: `w1-${i}` },
|
||||
{ label: String(Math.max(res - 1, 1)), correct: false, id: `w2-${i}` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: `Dividir ${d.a} ÷ ${d.b} con método Montessori (cuentas doradas, reparto equitativo).`,
|
||||
errorType: "discriminacion-auditiva",
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 8,
|
||||
name: "Divisiones exactas (1 dígito)",
|
||||
description: "Resolver divisiones exactas de 1 dígito con material Montessori de reparto equitativo.",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Stage } from "../types"
|
||||
|
||||
type Frac = { num: number; den: number }
|
||||
|
||||
function randFrac(): Frac {
|
||||
const denoms = [2, 3, 4]
|
||||
const d = denoms[Math.floor(Math.random() * denoms.length)]
|
||||
const n = Math.floor(Math.random() * (d - 1)) + 1
|
||||
return { num: n, den: d }
|
||||
}
|
||||
|
||||
function fracLabel(f: Frac): string {
|
||||
return `${f.num}/${f.den}`
|
||||
}
|
||||
|
||||
// Recognize which fraction is bigger
|
||||
const compares: { a: Frac; b: Frac; correct: "a" | "b" }[] = []
|
||||
for (let i = 0; i < 8; i++) {
|
||||
let a = randFrac()
|
||||
let b = randFrac()
|
||||
while (a.num / a.den === b.num / b.den) {
|
||||
b = randFrac()
|
||||
}
|
||||
compares.push({ a, b, correct: a.num / a.den > b.num / b.den ? "a" : "b" })
|
||||
}
|
||||
|
||||
// Identify the fraction (label + visual)
|
||||
const labels = [
|
||||
{ frac: { num: 1, den: 2 }, label: "un medio" },
|
||||
{ frac: { num: 1, den: 3 }, label: "un tercio" },
|
||||
{ frac: { num: 2, den: 3 }, label: "dos tercios" },
|
||||
{ frac: { num: 1, den: 4 }, label: "un cuarto" },
|
||||
{ frac: { num: 3, den: 4 }, label: "tres cuartos" },
|
||||
{ frac: { num: 2, den: 4 }, label: "dos cuartos" },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
// Part 1: Compare fractions
|
||||
for (let i = 0; i < compares.length; i++) {
|
||||
const c = compares[i]
|
||||
const aVal = c.a.num / c.a.den
|
||||
const bVal = c.b.num / c.b.den
|
||||
exercises.push({
|
||||
id: `fra9-frac-compare-${i}`,
|
||||
stage: 9,
|
||||
skillCode: `frac-compare-${i}`,
|
||||
type: "conciencia",
|
||||
prerequisites: i === 0 ? [] : [exercises[i - 1].id],
|
||||
content: {
|
||||
instruction: `¿Cuál es la fracción más grande?`,
|
||||
text: `${fracLabel(c.a)} vs ${fracLabel(c.b)}`,
|
||||
audioText: `${fracLabel(c.a)} o ${fracLabel(c.b)}, cuál es más grande`,
|
||||
emoji: "🥧",
|
||||
options: [
|
||||
{ label: fracLabel(c.a), correct: c.correct === "a", id: `a-${i}` },
|
||||
{ label: fracLabel(c.b), correct: c.correct === "b", id: `b-${i}` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: "Comparar fracciones con método Montessori (círculos de fracciones rojos).",
|
||||
errorType: "confusion-letra",
|
||||
})
|
||||
}
|
||||
|
||||
// Part 2: Identify fractions by name
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
const l = labels[i]
|
||||
const idx = compares.length + i
|
||||
exercises.push({
|
||||
id: `fra9-frac-label-${i}`,
|
||||
stage: 9,
|
||||
skillCode: `frac-label-${i}`,
|
||||
prerequisites: idx === 0 ? [] : [exercises[idx - 1].id],
|
||||
type: "eleccion",
|
||||
content: {
|
||||
instruction: `¿Cuál es "${l.label}"?`,
|
||||
audioText: `${l.label}`,
|
||||
emoji: "🔵",
|
||||
options: [
|
||||
{ label: fracLabel(l.frac), correct: true, id: `c-${i}` },
|
||||
{ label: fracLabel(labels[(i + 1) % labels.length].frac), correct: false, id: `w1-${i}` },
|
||||
{ label: fracLabel(labels[(i + 2) % labels.length].frac), correct: false, id: `w2-${i}` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: "Identificar fracciones por nombre con método Montessori.",
|
||||
errorType: "confusion-letra",
|
||||
})
|
||||
}
|
||||
|
||||
// Part 3: Shade recognition (conciencia)
|
||||
const shades = [
|
||||
{ num: 1, den: 2, text: "Si dividís una pizza en 2 partes iguales y tomás 1, ¿qué fracción comiste?" },
|
||||
{ num: 1, den: 3, text: "Si dividís una pizza en 3 partes iguales y tomás 1, ¿qué fracción comiste?" },
|
||||
{ num: 2, den: 3, text: "Si dividís una pizza en 3 partes iguales y tomás 2, ¿qué fracción comiste?" },
|
||||
{ num: 1, den: 4, text: "Si dividís una pizza en 4 partes iguales y tomás 1, ¿qué fracción comiste?" },
|
||||
{ num: 3, den: 4, text: "Si dividís una pizza en 4 partes iguales y tomás 3, ¿qué fracción comiste?" },
|
||||
{ num: 2, den: 4, text: "Si dividís una pizza en 4 partes iguales y tomás 2, ¿qué fracción comiste?" },
|
||||
{ num: 0, den: 0, text: "" },
|
||||
{ num: 0, den: 0, text: "" },
|
||||
{ num: 0, den: 0, text: "" },
|
||||
]
|
||||
|
||||
for (let i = 0; i < shades.length; i++) {
|
||||
if (!shades[i].text) continue
|
||||
const s = shades[i]
|
||||
const idx = compares.length + labels.length + i
|
||||
exercises.push({
|
||||
id: `fra9-frac-shade-${i}`,
|
||||
stage: 9,
|
||||
skillCode: `frac-shade-${i}`,
|
||||
prerequisites: idx === 0 ? [] : [exercises[idx - 1].id],
|
||||
type: "conciencia",
|
||||
content: {
|
||||
instruction: s.text,
|
||||
audioText: s.text,
|
||||
emoji: "🍕",
|
||||
options: [
|
||||
{ label: `${s.num}/${s.den}`, correct: true, id: `c-${i}` },
|
||||
{ label: `${s.den}/${s.num}`, correct: false, id: `w1-${i}` },
|
||||
{ label: `${s.num + 1}/${s.den}`, correct: false, id: `w2-${i}` },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: "Reconocer fracciones en contexto de partes de un entero (círculos Montessori).",
|
||||
errorType: "confusion-letra",
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 9,
|
||||
name: "Fracciones simples (1/2, 1/3, 1/4)",
|
||||
description: "Reconocer y comparar fracciones básicas con círculos de fracciones Montessori.",
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Exercise, Stage, TopicCurriculum } from "./types"
|
||||
|
||||
const lecturaModules: Record<string, Stage> = {}
|
||||
const numerosModules: Record<string, Stage> = {}
|
||||
|
||||
async function loadStages(
|
||||
topic: string,
|
||||
modules: Record<string, Stage>,
|
||||
stageFiles: number[],
|
||||
) {
|
||||
for (const s of stageFiles) {
|
||||
try {
|
||||
const mod = await import(`./${topic}/etapa-${s}`)
|
||||
modules[s] = mod.default
|
||||
} catch {
|
||||
// stage not yet implemented
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lecturaLoaded = false
|
||||
let numerosLoaded = false
|
||||
|
||||
export function getAllLecturaExercises(): Exercise[] {
|
||||
const stages = Object.values(getLecturaStages())
|
||||
return stages.flatMap((s) => s.exercises || [])
|
||||
}
|
||||
|
||||
export function getLecturaStages(): Record<string, Stage> {
|
||||
return lecturaModules as Record<string, Stage>
|
||||
}
|
||||
|
||||
export function getNumerosStages(): Record<string, Stage> {
|
||||
return numerosModules as Record<string, Stage>
|
||||
}
|
||||
|
||||
export async function initCurriculum() {
|
||||
if (!lecturaLoaded) {
|
||||
await loadStages("lectura", lecturaModules, [1, 2, 3, 4, 5, 6, 7, 8, 9])
|
||||
lecturaLoaded = true
|
||||
}
|
||||
if (!numerosLoaded) {
|
||||
await loadStages("numeros", numerosModules, [1, 2, 3, 4, 5, 6, 7, 8])
|
||||
numerosLoaded = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const stage: Stage = {
|
||||
id: 1,
|
||||
name: 'Sensibilización al lenguaje escrito',
|
||||
description: 'Explorar sonidos del entorno, onomatopeyas, ritmo y conciencia de que el lenguaje se puede representar.',
|
||||
exercises: [
|
||||
// --- Sonidos del entorno ---
|
||||
{
|
||||
id: 'lec1-sonido-perro',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: 'Escuchá este sonido. ¿Qué animal hace así?',
|
||||
audioText: '¡El perro hace guau guau!',
|
||||
options: [
|
||||
{ label: '🐱 Gato', correct: false },
|
||||
{ label: '🐕 Perro', correct: true },
|
||||
{ label: '🐄 Vaca', correct: false },
|
||||
],
|
||||
emoji: '🐕',
|
||||
},
|
||||
functionalContext: 'Identificar sonidos cotidianos del entorno.. Método Borel-Maisonny.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-sonido-gato',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Quién hace miau?',
|
||||
audioText: 'El gato hace miau miau',
|
||||
options: [
|
||||
{ label: '🐕 Perro', correct: false },
|
||||
{ label: '🐄 Vaca', correct: false },
|
||||
{ label: '🐱 Gato', correct: true },
|
||||
],
|
||||
emoji: '🐱',
|
||||
},
|
||||
functionalContext: 'Asociar sonidos con animales.. Método Borel-Maisonny.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-sonido-vaca',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Cuál hace muuuu?',
|
||||
audioText: 'La vaca hace muuu',
|
||||
options: [
|
||||
{ label: '🐄 Vaca', correct: true },
|
||||
{ label: '🐔 Gallina', correct: false },
|
||||
{ label: '🐑 Oveja', correct: false },
|
||||
],
|
||||
emoji: '🐄',
|
||||
},
|
||||
functionalContext: 'Reconocer sonidos de animales.. Método Borel-Maisonny.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-sonido-auto',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Qué hace run run?',
|
||||
audioText: 'El auto hace run run',
|
||||
options: [
|
||||
{ label: '🚲 Bici', correct: false },
|
||||
{ label: '🚗 Auto', correct: true },
|
||||
{ label: '✈️ Avión', correct: false },
|
||||
],
|
||||
emoji: '🚗',
|
||||
},
|
||||
functionalContext: 'Identificar sonidos de transporte.. Método Borel-Maisonny.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-sonido-lluvia',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Qué hace pío pío?',
|
||||
audioText: 'El pajarito hace pío pío',
|
||||
options: [
|
||||
{ label: '🐟 Pez', correct: false },
|
||||
{ label: '🐔 Gallina', correct: false },
|
||||
{ label: '🐤 Pájaro', correct: true },
|
||||
],
|
||||
emoji: '🐤',
|
||||
},
|
||||
functionalContext: 'Asociar onomatopeyas con animales.. Método Borel-Maisonny.',
|
||||
},
|
||||
// --- Sonidos de la naturaleza ---
|
||||
{
|
||||
id: 'lec1-sonido-abeja',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Quién hace zzzzz?',
|
||||
audioText: 'La abeja hace zzzz',
|
||||
options: [
|
||||
{ label: '🐝 Abeja', correct: true },
|
||||
{ label: '🦋 Mariposa', correct: false },
|
||||
{ label: '🐞 Mariquita', correct: false },
|
||||
],
|
||||
emoji: '🐝',
|
||||
},
|
||||
functionalContext: 'Discriminar sonidos de insectos.. Método Borel-Maisonny.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-sonido-agua',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Qué suena así: glu glu glu?',
|
||||
audioText: 'El agua hace glu glu',
|
||||
options: [
|
||||
{ label: '💧 Agua', correct: true },
|
||||
{ label: '🔥 Fuego', correct: false },
|
||||
{ label: '💨 Viento', correct: false },
|
||||
],
|
||||
emoji: '💧',
|
||||
},
|
||||
functionalContext: 'Reconocer sonidos del agua.. Método Borel-Maisonny.',
|
||||
},
|
||||
// --- Ritmo y rimas ---
|
||||
{
|
||||
id: 'lec1-ritmo-palmas',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-ritmo',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¡Vamos a aplaudir! ¿Podés seguir el ritmo? DA-da-da, DA-da-da',
|
||||
audioText: 'Vamos a aplaudir juntos',
|
||||
emoji: '👏',
|
||||
},
|
||||
functionalContext: 'Desarrollar conciencia rítmica.. Método Borel-Maisonny.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-cancion-infantil',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-ritmo',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: 'Cantemos juntos: "Estrellita, ¿dónde estás?"',
|
||||
audioText: 'Estrellita, ¿dónde estás? Me pregunto quién serás',
|
||||
emoji: '⭐',
|
||||
},
|
||||
functionalContext: 'Explorar ritmo y melodía del lenguaje.. Método Borel-Maisonny.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-silencio-ruido',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-discriminacion',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: 'A veces hay silencio, a veces hay ruido. ¿Cuándo escuchamos silencio?',
|
||||
audioText: 'El silencio es cuando no hay sonido',
|
||||
options: [
|
||||
{ label: '🔊 Música fuerte', correct: false },
|
||||
{ label: '🤫 Todos callados', correct: true },
|
||||
{ label: '📢 Gritos', correct: false },
|
||||
],
|
||||
emoji: '🤫',
|
||||
},
|
||||
functionalContext: 'Discriminar presencia y ausencia de sonido.. Método Borel-Maisonny.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-eco',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-eco',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: 'Vamos a hacer eco. Decí después de mí: PA... PA... PA',
|
||||
audioText: 'PA... PA... PA... repetí después de mí',
|
||||
options: [
|
||||
{ label: '¡Lo hice!', correct: true },
|
||||
],
|
||||
emoji: '🔊',
|
||||
},
|
||||
functionalContext: 'Tomar conciencia de la propia producción de sonidos.. Método Borel-Maisonny.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-onomatopeya-coco',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-onomatopeya',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: 'El reloj hace: tic-tac, tic-tac. ¿Cuál es el reloj?',
|
||||
audioText: 'El reloj hace tic tac',
|
||||
options: [
|
||||
{ label: '🕐 Reloj', correct: true },
|
||||
{ label: '📞 Teléfono', correct: false },
|
||||
{ label: '🚪 Puerta', correct: false },
|
||||
],
|
||||
emoji: '🕐',
|
||||
},
|
||||
functionalContext: 'Asociar objetos a sus sonidos.. Método Borel-Maisonny.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,345 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const stage: Stage = {
|
||||
id: 2,
|
||||
name: 'Conciencia fonológica',
|
||||
description: 'Rimas, conteo de sílabas, identificar sonidos iniciales y segmentación de palabras.',
|
||||
exercises: [
|
||||
// --- Rimas ---
|
||||
{
|
||||
id: 'lec2-rima-sol',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-rimas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec1-sonido-perro', 'lec1-ritmo-palmas'],
|
||||
content: {
|
||||
instruction: '¿Cuál palabra rima con SOL?',
|
||||
text: 'SOL',
|
||||
audioText: 'Sol rima con...',
|
||||
options: [
|
||||
{ label: 'SOL', correct: false },
|
||||
{ label: 'CARACOL', correct: true },
|
||||
{ label: 'LUNA', correct: false },
|
||||
],
|
||||
emoji: '☀️',
|
||||
},
|
||||
functionalContext: 'Identificar palabras que terminan igual.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-rima-pan',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-rimas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec1-sonido-perro'],
|
||||
content: {
|
||||
instruction: '¿Qué rima con PAN?',
|
||||
text: 'PAN',
|
||||
audioText: 'Pan rima con...',
|
||||
options: [
|
||||
{ label: 'MANO', correct: false },
|
||||
{ label: 'PAN', correct: false },
|
||||
{ label: 'CHAPAN', correct: true },
|
||||
],
|
||||
emoji: '🍞',
|
||||
},
|
||||
functionalContext: 'Reconocer terminaciones comunes.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-rima-flor',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-rimas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec1-sonido-perro'],
|
||||
content: {
|
||||
instruction: 'Buscá la palabra que rima con FLOR',
|
||||
text: 'FLOR',
|
||||
audioText: 'Flor rima con...',
|
||||
options: [
|
||||
{ label: 'COLOR', correct: true },
|
||||
{ label: 'HOJA', correct: false },
|
||||
{ label: 'FLOR', correct: false },
|
||||
],
|
||||
emoji: '🌸',
|
||||
},
|
||||
functionalContext: 'Identificar rimas en palabras conocidas.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-rima-miel',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-rimas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec1-sonido-abeja'],
|
||||
content: {
|
||||
instruction: '¿Cuál rima con MIEL?',
|
||||
text: 'MIEL',
|
||||
audioText: 'Miel rima con...',
|
||||
options: [
|
||||
{ label: 'MIEL', correct: false },
|
||||
{ label: 'PAN', correct: false },
|
||||
{ label: 'PINCEL', correct: true },
|
||||
],
|
||||
emoji: '🍯',
|
||||
},
|
||||
functionalContext: 'Reconocer patrones sonoros finales.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
// --- Conteo de sílabas ---
|
||||
{
|
||||
id: 'lec2-silabas-2',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-silabas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec1-eco'],
|
||||
content: {
|
||||
instruction: 'Vamos a contar: ¿cuántas palmadas tiene MANO? (MA-NO)',
|
||||
text: 'MANO',
|
||||
audioText: 'MA-NO, dos palmadas',
|
||||
options: [
|
||||
{ label: '👏 Una', correct: false },
|
||||
{ label: '👏👏 Dos', correct: true },
|
||||
{ label: '👏👏👏 Tres', correct: false },
|
||||
],
|
||||
emoji: '✋',
|
||||
},
|
||||
functionalContext: 'Desarrollar conciencia de segmentación silábica.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-silabas-3',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-silabas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-silabas-2'],
|
||||
content: {
|
||||
instruction: '¿Cuántas partes tiene VENTANA? (VEN-TA-NA)',
|
||||
text: 'VENTANA',
|
||||
audioText: 'VEN-TA-NA, tres partes',
|
||||
options: [
|
||||
{ label: '👏 Dos', correct: false },
|
||||
{ label: '👏👏👏 Tres', correct: true },
|
||||
{ label: '👏👏👏👏 Cuatro', correct: false },
|
||||
],
|
||||
emoji: '🪟',
|
||||
},
|
||||
functionalContext: 'Segmentar palabras en sílabas.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-silabas-1',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-silabas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-silabas-2'],
|
||||
content: {
|
||||
instruction: '¿Cuántas palmadas para SOL? (SOL)',
|
||||
text: 'SOL',
|
||||
audioText: 'SOL, una sola palmada',
|
||||
options: [
|
||||
{ label: '👏 Una', correct: true },
|
||||
{ label: '👏👏 Dos', correct: false },
|
||||
{ label: '👏👏👏 Tres', correct: false },
|
||||
],
|
||||
emoji: '☀️',
|
||||
},
|
||||
functionalContext: 'Reconocer palabras monosílabas.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-silabas-4',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-silabas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-silabas-3'],
|
||||
content: {
|
||||
instruction: '¿Cuántas palmadas para MARIPOSA? (MA-RI-PO-SA)',
|
||||
text: 'MARIPOSA',
|
||||
audioText: 'MA-RI-PO-SA, cuatro partes',
|
||||
options: [
|
||||
{ label: '👏 Dos', correct: false },
|
||||
{ label: '👏👏👏 Tres', correct: false },
|
||||
{ label: '👏👏👏👏 Cuatro', correct: true },
|
||||
],
|
||||
emoji: '🦋',
|
||||
},
|
||||
functionalContext: 'Segmentar palabras de 4 sílabas.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
// --- Sílaba inicial ---
|
||||
{
|
||||
id: 'lec2-inicial-ma',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-inicial',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-silabas-2'],
|
||||
content: {
|
||||
instruction: '¿Cuál empieza como MAMÁ? (MA-MÁ, MA-NO)',
|
||||
text: 'MAMÁ → MA...',
|
||||
audioText: 'Mamá empieza con MA',
|
||||
options: [
|
||||
{ label: 'MANO', correct: true },
|
||||
{ label: 'PATO', correct: false },
|
||||
{ label: 'SAPO', correct: false },
|
||||
],
|
||||
emoji: '👩',
|
||||
},
|
||||
functionalContext: 'Identificar el sonido inicial de las palabras.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-inicial-pa',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-inicial',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-inicial-ma'],
|
||||
content: {
|
||||
instruction: '¿Cuál empieza como PAPÁ? (PA-PÁ, PA-TO)',
|
||||
text: 'PAPÁ → PA...',
|
||||
audioText: 'Papá empieza con PA',
|
||||
options: [
|
||||
{ label: 'PERRO', correct: false },
|
||||
{ label: 'PATO', correct: true },
|
||||
{ label: 'CASA', correct: false },
|
||||
],
|
||||
emoji: '👨',
|
||||
},
|
||||
functionalContext: 'Generalizar identificación de sílaba inicial.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-inicial-ta',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-inicial',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-inicial-pa'],
|
||||
content: {
|
||||
instruction: 'Buscá la que empieza como TAZA (TA-ZA, TA-BLA)',
|
||||
text: 'TAZA → TA...',
|
||||
audioText: 'Taza empieza con TA',
|
||||
options: [
|
||||
{ label: 'MESA', correct: false },
|
||||
{ label: 'TAMBOR', correct: true },
|
||||
{ label: 'LÁPIZ', correct: false },
|
||||
],
|
||||
emoji: '☕',
|
||||
},
|
||||
functionalContext: 'Reconocer sílaba TA al inicio.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-inicial-sa',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-inicial',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-inicial-ta'],
|
||||
content: {
|
||||
instruction: '¿Cuál empieza como SAPO? (SA-PO, SA-LTA)',
|
||||
text: 'SAPO → SA...',
|
||||
audioText: 'Sapo empieza con SA',
|
||||
options: [
|
||||
{ label: 'SAPO', correct: false },
|
||||
{ label: 'SALTA', correct: true },
|
||||
{ label: 'RANA', correct: false },
|
||||
],
|
||||
emoji: '🐸',
|
||||
},
|
||||
functionalContext: 'Identificar sonido SA al inicio.. Método Borel-Maisonny.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
// --- Segmentación ---
|
||||
{
|
||||
id: 'lec2-segmentar-casa',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-segmentacion',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-silabas-2', 'lec2-inicial-ma'],
|
||||
content: {
|
||||
instruction: 'Separala en partes: CA-SA. ¿Cuál tiene dos partes?',
|
||||
text: 'CA-SA',
|
||||
audioText: 'CA-SA',
|
||||
options: [
|
||||
{ label: 'SOL', correct: false },
|
||||
{ label: 'CASA', correct: true },
|
||||
{ label: 'MARIPOSA', correct: false },
|
||||
],
|
||||
emoji: '🏠',
|
||||
},
|
||||
functionalContext: 'Segmentar palabras en sílabas.. Método Borel-Maisonny.',
|
||||
errorType: 'omision-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec2-segmentar-mesa',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-segmentacion',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-segmentar-casa'],
|
||||
content: {
|
||||
instruction: 'ME-SA. ¿Cuál es?',
|
||||
text: 'ME-SA',
|
||||
audioText: 'ME-SA',
|
||||
options: [
|
||||
{ label: 'MESA', correct: true },
|
||||
{ label: 'MELÓN', correct: false },
|
||||
{ label: 'SAPO', correct: false },
|
||||
],
|
||||
emoji: '🪑',
|
||||
},
|
||||
functionalContext: 'Unir segmentos para formar palabras.. Método Borel-Maisonny.',
|
||||
errorType: 'omision-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec2-segmentar-pato',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-segmentacion',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-segmentar-mesa'],
|
||||
content: {
|
||||
instruction: 'PA-TO. ¿Qué palabra formamos?',
|
||||
text: 'PA-TO',
|
||||
audioText: 'PA-TO',
|
||||
options: [
|
||||
{ label: 'PATO', correct: true },
|
||||
{ label: 'PERRO', correct: false },
|
||||
{ label: 'GATO', correct: false },
|
||||
],
|
||||
emoji: '🦆',
|
||||
},
|
||||
functionalContext: 'Sintetizar sílabas para formar palabras.. Método Borel-Maisonny.',
|
||||
errorType: 'omision-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec2-ordenar-silabas',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-orden',
|
||||
type: 'secuencia',
|
||||
prerequisites: ['lec2-segmentar-pato'],
|
||||
content: {
|
||||
instruction: 'Ordená las sílabas para formar una palabra',
|
||||
audioText: 'Ordená las sílabas',
|
||||
sequence: ['TO', 'PA'],
|
||||
emoji: '🧩',
|
||||
},
|
||||
functionalContext: 'Desarrollar habilidad de ordenamiento fonológico.. Método Borel-Maisonny.',
|
||||
errorType: 'inversion-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec2-ordenar-silabas-2',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-orden',
|
||||
type: 'secuencia',
|
||||
prerequisites: ['lec2-ordenar-silabas'],
|
||||
content: {
|
||||
instruction: 'Armá la palabra',
|
||||
audioText: 'Ordená estas sílabas',
|
||||
sequence: ['SA', 'CA'],
|
||||
emoji: '🧩',
|
||||
},
|
||||
functionalContext: 'Ordenar sílabas para formar palabras.. Método Borel-Maisonny.',
|
||||
errorType: 'inversion-letra',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
function vocalChoice(vocal: string, correctLabel: string, distractors: string[], emoji: string, instruction: string, audioText?: string) {
|
||||
const options = [
|
||||
{ label: correctLabel, correct: true, id: vocal },
|
||||
...distractors.map((d) => ({ label: d, correct: false, id: d })),
|
||||
].sort(() => Math.random() - 0.5)
|
||||
|
||||
return {
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'eleccion' as const,
|
||||
prerequisites: getVocalPrereqs(vocal),
|
||||
content: {
|
||||
instruction,
|
||||
text: correctLabel,
|
||||
options,
|
||||
audioText: audioText || `${correctLabel}`,
|
||||
emoji,
|
||||
color: getVocalColor(vocal),
|
||||
},
|
||||
functionalContext: `Identificar la vocal ${vocal.toUpperCase()} en diferentes contextos.`,
|
||||
errorType: 'confusion-letra' as const,
|
||||
}
|
||||
}
|
||||
|
||||
function getVocalColor(vocal: string): string {
|
||||
const colors: Record<string, string> = { a: '#FF6B6B', o: '#4ECDC4', i: '#45B7D1', e: '#96CEB4', u: '#DDA0DD' }
|
||||
return colors[vocal] || '#FF6B6B'
|
||||
}
|
||||
|
||||
function getVocalPrereqs(vocal: string): string[] {
|
||||
const order = ['a', 'o', 'i', 'e', 'u']
|
||||
const idx = order.indexOf(vocal)
|
||||
if (idx === 0) return ['lec2-segmentar-pato']
|
||||
return [`vocal-${order[idx - 1]}-completa`]
|
||||
}
|
||||
|
||||
const gestos: Record<string, { gesto: string, palabra: string, fonema: string, emoji: string }> = {
|
||||
a: { gesto: 'Abrí la mano como una boca grande', palabra: 'ÁRBOL', fonema: 'AAA', emoji: '🌳' },
|
||||
o: { gesto: 'Redondeá la mano como una O', palabra: 'OJO', fonema: 'OOO', emoji: '👁️' },
|
||||
i: { gesto: 'Señalá el cielo con el dedo', palabra: 'INDIO', fonema: 'III', emoji: '🪶' },
|
||||
e: { gesto: 'Estirá la mano como una trompa de elefante', palabra: 'ELEFANTE', fonema: 'EEE', emoji: '🐘' },
|
||||
u: { gesto: 'Señalá el suelo con la mano', palabra: 'UNO', fonema: 'UUU', emoji: '1️⃣' },
|
||||
}
|
||||
|
||||
function buildVocalExercises(vocal: string, index: number): any[] {
|
||||
const g = gestos[vocal]
|
||||
const exs: any[] = []
|
||||
|
||||
// Ejercicio de gesto (sensibilizacion)
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-gesto`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: getVocalPrereqs(vocal),
|
||||
content: {
|
||||
instruction: `Hacé así para la ${vocal.toUpperCase()}: ${g.gesto}`,
|
||||
audioText: `Esta es la letra ${vocal.toUpperCase()}. ${g.palabra}. ${g.fonema}`,
|
||||
emoji: g.emoji,
|
||||
color: getVocalColor(vocal),
|
||||
},
|
||||
functionalContext: `Aprender el gesto Borel-Maisonny para la vocal ${vocal.toUpperCase()}.`,
|
||||
})
|
||||
|
||||
// Reconocer la vocal entre otras
|
||||
exs.push(vocalChoice(
|
||||
vocal, vocal.toUpperCase(),
|
||||
['A', 'O', 'I', 'E', 'U'].filter((v) => v !== vocal.toUpperCase()),
|
||||
g.emoji,
|
||||
`Señalá la letra ${vocal.toUpperCase()}`,
|
||||
))
|
||||
|
||||
// ¿Con qué vocal empieza?
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-inicial`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`lec3-${vocal}-gesto`],
|
||||
content: {
|
||||
instruction: `¿Con qué vocal empieza ${g.palabra}?`,
|
||||
text: g.palabra,
|
||||
audioText: `${g.palabra} empieza con ${vocal.toUpperCase()}`,
|
||||
options: [
|
||||
{ label: vocal.toUpperCase(), correct: true, id: vocal },
|
||||
...['A', 'O', 'I', 'E', 'U'].filter((v) => v !== vocal.toUpperCase()).slice(0, 2).map((v) => ({ label: v, correct: false, id: v })),
|
||||
],
|
||||
emoji: g.emoji,
|
||||
},
|
||||
functionalContext: `Reconocer la vocal inicial en palabras.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
|
||||
// Emparejar imagen con vocal
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-emparejar`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'conciencia',
|
||||
prerequisites: [`lec3-${vocal}-inicial`],
|
||||
content: {
|
||||
instruction: `¿Cuál empieza con ${vocal.toUpperCase()}?`,
|
||||
audioText: `¿Qué empieza con ${vocal.toUpperCase()}?`,
|
||||
options: [
|
||||
{ label: g.emoji, correct: true, id: vocal },
|
||||
...Object.entries(gestos)
|
||||
.filter(([k]) => k !== vocal)
|
||||
.slice(0, 2)
|
||||
.map(([k, v]) => ({ label: v.emoji, correct: false, id: k })),
|
||||
],
|
||||
},
|
||||
functionalContext: `Asociar la vocal con imágenes que empiezan con ella.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
// Discriminación auditiva
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-auditiva`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'conciencia',
|
||||
prerequisites: [`lec3-${vocal}-emparejar`],
|
||||
content: {
|
||||
instruction: `Escuchá bien. ¿Cuál suena como ${vocal.toUpperCase()}?`,
|
||||
audioText: `${g.fonema}... ${g.fonema}... como ${g.palabra}`,
|
||||
options: [
|
||||
{ label: vocal.toUpperCase(), correct: true, id: vocal },
|
||||
...['A', 'O', 'I', 'E', 'U'].filter((v) => v !== vocal.toUpperCase()).slice(0, 2).map((v) => ({ label: v, correct: false, id: v })),
|
||||
],
|
||||
emoji: '👂',
|
||||
},
|
||||
functionalContext: `Discriminar auditivamente la vocal ${vocal.toUpperCase()}.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
|
||||
// Escribir con el dedo (trazo)
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-trazo`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'trazo',
|
||||
prerequisites: [`lec3-${vocal}-auditiva`],
|
||||
content: {
|
||||
instruction: `Trazá la letra ${vocal.toUpperCase()} con tu dedo`,
|
||||
text: vocal.toUpperCase(),
|
||||
audioText: `Trazá la ${vocal.toUpperCase()}`,
|
||||
color: getVocalColor(vocal),
|
||||
emoji: g.emoji,
|
||||
},
|
||||
functionalContext: `Desarrollar memoria motriz de la vocal ${vocal.toUpperCase()}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
// Repaso final
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-completa`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`lec3-${vocal}-trazo`],
|
||||
content: {
|
||||
instruction: `¡Repaso! Señalá la letra ${vocal.toUpperCase()}`,
|
||||
audioText: `Buscá la ${vocal.toUpperCase()}`,
|
||||
options: [
|
||||
{ label: vocal.toUpperCase(), correct: true, id: vocal },
|
||||
...['A', 'O', 'I', 'E', 'U'].filter((v) => v !== vocal.toUpperCase()).map((v) => ({ label: v, correct: false, id: v })),
|
||||
],
|
||||
emoji: g.emoji,
|
||||
color: getVocalColor(vocal),
|
||||
},
|
||||
functionalContext: `Consolidar reconocimiento de la vocal ${vocal.toUpperCase()}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
return exs
|
||||
}
|
||||
|
||||
// Additional cross-vowel discrimination exercises
|
||||
const mixedExercises: any[] = [
|
||||
{
|
||||
id: 'lec3-mix-ae',
|
||||
stage: 3,
|
||||
skillCode: 'vocal-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec3-a-completa', 'lec3-e-completa'],
|
||||
content: {
|
||||
instruction: '¿Cuál es la A y cuál es la E? Señalá la A',
|
||||
options: [
|
||||
{ label: 'A', correct: true, id: 'a' },
|
||||
{ label: 'E', correct: false, id: 'e' },
|
||||
{ label: 'O', correct: false, id: 'o' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre A y E.. Método Borel-Maisonny.',
|
||||
errorType: 'confusion-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec3-mix-iu',
|
||||
stage: 3,
|
||||
skillCode: 'vocal-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec3-i-completa', 'lec3-u-completa'],
|
||||
content: {
|
||||
instruction: '¿Cuál es la I y cuál es la U? Señalá la I',
|
||||
options: [
|
||||
{ label: 'I', correct: true, id: 'i' },
|
||||
{ label: 'U', correct: false, id: 'u' },
|
||||
{ label: 'A', correct: false, id: 'a' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre I y U.. Método Borel-Maisonny.',
|
||||
errorType: 'confusion-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec3-mix-ao',
|
||||
stage: 3,
|
||||
skillCode: 'vocal-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec3-a-completa', 'lec3-o-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la letra O',
|
||||
options: [
|
||||
{ label: 'A', correct: false, id: 'a' },
|
||||
{ label: 'O', correct: true, id: 'o' },
|
||||
{ label: 'U', correct: false, id: 'u' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre A y O.. Método Borel-Maisonny.',
|
||||
errorType: 'confusion-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec3-mix-ei',
|
||||
stage: 3,
|
||||
skillCode: 'vocal-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec3-e-completa', 'lec3-i-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la E',
|
||||
options: [
|
||||
{ label: 'I', correct: false, id: 'i' },
|
||||
{ label: 'E', correct: true, id: 'e' },
|
||||
{ label: 'U', correct: false, id: 'u' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre E e I.. Método Borel-Maisonny.',
|
||||
errorType: 'confusion-letra',
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
for (const [i, vocal] of ['a', 'o', 'i', 'e', 'u'].entries()) {
|
||||
exercises.push(...buildVocalExercises(vocal, i))
|
||||
}
|
||||
exercises.push(...mixedExercises)
|
||||
|
||||
const stage: Stage = {
|
||||
id: 3,
|
||||
name: 'Vocales A-O-I-E-U',
|
||||
description: 'Aprender las 5 vocales con gestos Borel-Maisonny, discriminación auditiva y visual, trazo y reconocimiento.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
type ConInfo = {
|
||||
letra: string
|
||||
palabra: string
|
||||
emoji: string
|
||||
silabas: string[]
|
||||
gesto: string
|
||||
}
|
||||
|
||||
const consonantes: ConInfo[] = [
|
||||
{ letra: 'M', palabra: 'MAMÁ', emoji: '👩', silabas: ['MA', 'ME', 'MI', 'MO', 'MU'], gesto: 'Manos en la boca como mmm' },
|
||||
{ letra: 'P', palabra: 'PAPÁ', emoji: '👨', silabas: ['PA', 'PE', 'PI', 'PO', 'PU'], gesto: 'Manos que abren y cierran como pompa' },
|
||||
{ letra: 'L', palabra: 'LUNA', emoji: '🌙', silabas: ['LA', 'LE', 'LI', 'LO', 'LU'], gesto: 'Lengua arriba como lámpara' },
|
||||
{ letra: 'S', palabra: 'SOL', emoji: '☀️', silabas: ['SA', 'SE', 'SI', 'SO', 'SU'], gesto: 'Dedos como serpiente sss' },
|
||||
{ letra: 'T', palabra: 'TASA', emoji: '☕', silabas: ['TA', 'TE', 'TI', 'TO', 'TU'], gesto: 'Golpecito con la mano' },
|
||||
{ letra: 'N', palabra: 'NIDO', emoji: '🪺', silabas: ['NA', 'NE', 'NI', 'NO', 'NU'], gesto: 'Nariz como nido' },
|
||||
{ letra: 'D', palabra: 'DEDO', emoji: '👉', silabas: ['DA', 'DE', 'DI', 'DO', 'DU'], gesto: 'Dedo señalando' },
|
||||
{ letra: 'R', palabra: 'RATÓN', emoji: '🐭', silabas: ['RA', 'RE', 'RI', 'RO', 'RU'], gesto: 'Lengua que vibra como rrr' },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const con of consonantes) {
|
||||
// 1. Presentación con gesto
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-gesto`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}`,
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: con.letra === 'M' ? ['lec3-u-completa'] : [`lec4-${consonantes[consonantes.indexOf(con) - 1].letra.toLowerCase()}-completa`],
|
||||
content: {
|
||||
instruction: `Hacé así para la ${con.letra}: ${con.gesto}`,
|
||||
audioText: `Esta es la letra ${con.letra}. ${con.palabra}. ${con.silabas[0].toLowerCase()}`,
|
||||
emoji: con.emoji,
|
||||
},
|
||||
functionalContext: `Conocer el gesto asociado a la consonante ${con.letra}.`,
|
||||
})
|
||||
|
||||
// 2. Reconocer la consonante
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-reconocer`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`lec4-${con.letra.toLowerCase()}-gesto`],
|
||||
content: {
|
||||
instruction: `Señalá la letra ${con.letra}`,
|
||||
text: `Buscá la ${con.letra}`,
|
||||
audioText: `¿Dónde está la ${con.letra}?`,
|
||||
options: [
|
||||
{ label: con.letra, correct: true, id: con.letra.toLowerCase() },
|
||||
...consonantes.filter(c => c.letra !== con.letra).slice(0, 3).map(c => ({ label: c.letra, correct: false, id: c.letra.toLowerCase() })),
|
||||
],
|
||||
emoji: con.emoji,
|
||||
},
|
||||
functionalContext: `Reconocer visualmente la consonante ${con.letra}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
// 3. Discriminación auditiva de la sílaba
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-auditiva`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}`,
|
||||
type: 'conciencia',
|
||||
prerequisites: [`lec4-${con.letra.toLowerCase()}-reconocer`],
|
||||
content: {
|
||||
instruction: `Escuchá. ¿Cuál suena como ${con.silabas[0]}?`,
|
||||
audioText: `${con.silabas[0]}... ${con.silabas[0]}... como ${con.palabra}`,
|
||||
options: [
|
||||
{ label: con.silabas[0], correct: true, id: con.silabas[0].toLowerCase() },
|
||||
...consonantes.filter(c => c.letra !== con.letra).slice(0, 2).map(c => ({ label: c.silabas[0], correct: false, id: c.silabas[0].toLowerCase() })),
|
||||
],
|
||||
emoji: '👂',
|
||||
},
|
||||
functionalContext: `Discriminar auditivamente la sílaba ${con.silabas[0]}.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
|
||||
// 4. Emparejar sílaba con vocal
|
||||
for (let vi = 1; vi < con.silabas.length; vi++) {
|
||||
const sil = con.silabas[vi]
|
||||
const vocal = sil[1]
|
||||
const vocales = ['A', 'E', 'I', 'O', 'U'].filter(v => v !== vocal)
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-silaba-${sil.toLowerCase()}`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}-silabas`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`lec4-${con.letra.toLowerCase()}-auditiva`, `lec4-${con.letra.toLowerCase()}-silaba-${con.silabas[vi - 1].toLowerCase()}`],
|
||||
content: {
|
||||
instruction: `¿Cuál es ${sil}?`,
|
||||
text: sil,
|
||||
audioText: `${sil}... como ${con.palabra.toLowerCase()}`,
|
||||
options: [
|
||||
{ label: sil, correct: true, id: sil.toLowerCase() },
|
||||
{ label: `${con.letra}${vocales[0]}`, correct: false, id: `${con.letra}${vocales[0]}`.toLowerCase() },
|
||||
{ label: `${con.letra}${vocales[1]}`, correct: false, id: `${con.letra}${vocales[1]}`.toLowerCase() },
|
||||
],
|
||||
emoji: con.emoji,
|
||||
},
|
||||
functionalContext: `Leer la sílaba ${sil}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Palabra con la consonante
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-palabra`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}`,
|
||||
type: 'palabra',
|
||||
prerequisites: [`lec4-${con.letra.toLowerCase()}-silaba-${con.silabas[con.silabas.length - 1].toLowerCase()}`],
|
||||
content: {
|
||||
instruction: `¿Cuál palabra empieza con ${con.letra}?`,
|
||||
audioText: `${con.palabra} empieza con ${con.letra}`,
|
||||
imageEmoji: con.emoji,
|
||||
options: [
|
||||
{ label: con.palabra, correct: true, id: con.palabra.toLowerCase() },
|
||||
{ label: 'CASA', correct: false, id: 'casa' },
|
||||
{ label: 'GATO', correct: false, id: 'gato' },
|
||||
{ label: 'Escuchar 👂', correct: false, id: 'escuchar' },
|
||||
],
|
||||
},
|
||||
functionalContext: `Asociar la consonante ${con.letra} con una palabra significativa.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
|
||||
// 6. Final: repaso completo
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-completa`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`lec4-${con.letra.toLowerCase()}-palabra`],
|
||||
content: {
|
||||
instruction: `¡Repaso! Señalá la letra ${con.letra}`,
|
||||
audioText: `Buscá la ${con.letra}`,
|
||||
options: [
|
||||
{ label: con.letra, correct: true, id: con.letra.toLowerCase() },
|
||||
...consonantes.filter(c => c.letra !== con.letra).slice(0, 3).map(c => ({ label: c.letra, correct: false, id: c.letra.toLowerCase() })),
|
||||
],
|
||||
emoji: con.emoji,
|
||||
},
|
||||
functionalContext: `Consolidar reconocimiento de la consonante ${con.letra}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// Cross-consonant discrimination exercises
|
||||
exercises.push({
|
||||
id: 'lec4-mix-mp',
|
||||
stage: 4,
|
||||
skillCode: 'consonante-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec4-m-completa', 'lec4-p-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la M',
|
||||
options: [
|
||||
{ label: 'M', correct: true, id: 'm' },
|
||||
{ label: 'P', correct: false, id: 'p' },
|
||||
{ label: 'L', correct: false, id: 'l' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre M y P.. Método Borel-Maisonny.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
exercises.push({
|
||||
id: 'lec4-mix-st',
|
||||
stage: 4,
|
||||
skillCode: 'consonante-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec4-s-completa', 'lec4-t-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la T',
|
||||
options: [
|
||||
{ label: 'S', correct: false, id: 's' },
|
||||
{ label: 'T', correct: true, id: 't' },
|
||||
{ label: 'N', correct: false, id: 'n' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre S y T.. Método Borel-Maisonny.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
exercises.push({
|
||||
id: 'lec4-mix-ld',
|
||||
stage: 4,
|
||||
skillCode: 'consonante-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec4-l-completa', 'lec4-d-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la D',
|
||||
options: [
|
||||
{ label: 'L', correct: false, id: 'l' },
|
||||
{ label: 'D', correct: true, id: 'd' },
|
||||
{ label: 'R', correct: false, id: 'r' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre L y D.. Método Borel-Maisonny.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
exercises.push({
|
||||
id: 'lec4-mix-nr',
|
||||
stage: 4,
|
||||
skillCode: 'consonante-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec4-n-completa', 'lec4-r-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la R',
|
||||
options: [
|
||||
{ label: 'N', correct: false, id: 'n' },
|
||||
{ label: 'R', correct: true, id: 'r' },
|
||||
{ label: 'M', correct: false, id: 'm' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre N y R.. Método Borel-Maisonny.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
const stage: Stage = {
|
||||
id: 4,
|
||||
name: 'Consonantes M-P-L-S-T-N-D-R',
|
||||
description: 'Aprender las consonantes M, P, L, S, T, N, D, R con gestos, sílabas, y palabras significativas.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user