From a3360ec6e40427d4a629af38dfe03c8e2be8b675 Mon Sep 17 00:00:00 2001 From: renato97 Date: Sat, 25 Jul 2026 22:07:28 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20fase=202+3=20=E2=80=94=20expansion=20ma?= =?UTF-8?q?siva=20multi-perfil=20+=20bug=20fixes=20+=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 2 (expansion): - Francesca: +6 etapas matematicas (5-10), +7 etapas lectura (3-9), +2 ortografia - Sebastian: +8 etapas matematicas (4-11), +6 lectura, +3 historia, +3 geografia, +1 banderas - Nuevas paginas: ortografia, lectura, geografia, matematicas - ~900 ejercicios nuevos, paridad ~500 por perfil Fase 3 (bug fixes + hardening): - Fix: SessionLog auto-create en grade route - Fix: engine cache (no borrar antes de revisar) - Fix: FSRS cards actualizadas en flujo curriculum - Fix: responseMs real (no hardcodeado 3000) - Fix: options dentro de content en sebastian etapa-1 - Fix: trazo-letra consonantes crash - Security: execFileSync anti-command-injection en TTS - DB: 18 cascade deletes + indices + unique constraints - APIs debug: reset, state, seed - Docker: espeak-ng + prisma + user/group - E2E: 23 tests nuevos (curriculum-flow.spec.ts) - Code review: tailwind colors, middleware dev mode, eslint config Verificado: tsc exit 0, build exit 0, 61 E2E tests pass --- Dockerfile | 13 +- app/api/curriculum/grade/route.ts | 49 +- app/api/curriculum/progress/route.ts | 135 ++--- app/api/debug/reset/route.ts | 25 + app/api/debug/seed/route.ts | 112 ++++ app/api/debug/state/route.ts | 70 +++ app/api/tts/route.ts | 18 +- app/francesca/ortografia/page.tsx | 12 + app/francesca/page.tsx | 17 +- app/layout.tsx | 2 +- app/page.tsx | 78 ++- app/sebastian/geografia/page.tsx | 12 + app/sebastian/lectura/page.tsx | 12 + app/sebastian/matematicas/page.tsx | 12 + app/sebastian/page.tsx | 18 +- components/child/exercise-session.tsx | 5 +- components/child/trazo-letra.tsx | 22 + curriculum/francesca-lectura/etapa-3.ts | 131 +++++ curriculum/francesca-lectura/etapa-4.ts | 131 +++++ curriculum/francesca-lectura/etapa-5.ts | 131 +++++ curriculum/francesca-lectura/etapa-6.ts | 131 +++++ curriculum/francesca-lectura/etapa-7.ts | 131 +++++ curriculum/francesca-lectura/etapa-8.ts | 67 +++ curriculum/francesca-lectura/etapa-9.ts | 131 +++++ curriculum/francesca-ortografia/etapa-1.ts | 88 +++ curriculum/francesca-ortografia/etapa-2.ts | 76 +++ curriculum/francesca/etapa-10.ts | 80 +++ curriculum/francesca/etapa-5.ts | 41 ++ curriculum/francesca/etapa-6.ts | 41 ++ curriculum/francesca/etapa-7.ts | 44 ++ curriculum/francesca/etapa-8.ts | 44 ++ curriculum/francesca/etapa-9.ts | 135 +++++ curriculum/sebastian-banderas/etapa-2.ts | 64 +++ curriculum/sebastian-geografia/etapa-1.ts | 81 +++ curriculum/sebastian-geografia/etapa-2.ts | 52 ++ curriculum/sebastian-geografia/etapa-3.ts | 58 ++ curriculum/sebastian-historia/etapa-3.ts | 142 +++++ curriculum/sebastian-historia/etapa-4.ts | 142 +++++ curriculum/sebastian-historia/etapa-5.ts | 142 +++++ curriculum/sebastian-lectura/etapa-1.ts | 108 ++++ curriculum/sebastian-lectura/etapa-2.ts | 108 ++++ curriculum/sebastian-lectura/etapa-3.ts | 108 ++++ curriculum/sebastian-lectura/etapa-4.ts | 178 ++++++ curriculum/sebastian-lectura/etapa-5.ts | 217 ++++++++ curriculum/sebastian-lectura/etapa-6.ts | 211 +++++++ curriculum/sebastian/etapa-1.ts | 55 +- curriculum/sebastian/etapa-10.ts | 68 +++ curriculum/sebastian/etapa-11.ts | 95 ++++ curriculum/sebastian/etapa-4.ts | 47 ++ curriculum/sebastian/etapa-5.ts | 45 ++ curriculum/sebastian/etapa-6.ts | 45 ++ curriculum/sebastian/etapa-7.ts | 102 ++++ curriculum/sebastian/etapa-8.ts | 64 +++ curriculum/sebastian/etapa-9.ts | 58 ++ e2e/api-curriculum.spec.ts | 11 +- e2e/curriculum-flow.spec.ts | 110 ++++ e2e/exercise-content.spec.ts | 54 +- eslint.config.mjs | 8 +- fase2.md | 620 +++++++++++++++++++++ fase3.md | 369 ++++++++++++ lib/curriculum/engine.ts | 18 +- middleware.ts | 16 +- next.config.ts | 1 + package-lock.json | 35 +- package.json | 5 +- prisma/schema.prisma | 51 +- tailwind.config.ts | 5 + 67 files changed, 5294 insertions(+), 213 deletions(-) create mode 100644 app/api/debug/reset/route.ts create mode 100644 app/api/debug/seed/route.ts create mode 100644 app/api/debug/state/route.ts create mode 100644 app/francesca/ortografia/page.tsx create mode 100644 app/sebastian/geografia/page.tsx create mode 100644 app/sebastian/lectura/page.tsx create mode 100644 app/sebastian/matematicas/page.tsx create mode 100644 curriculum/francesca-lectura/etapa-3.ts create mode 100644 curriculum/francesca-lectura/etapa-4.ts create mode 100644 curriculum/francesca-lectura/etapa-5.ts create mode 100644 curriculum/francesca-lectura/etapa-6.ts create mode 100644 curriculum/francesca-lectura/etapa-7.ts create mode 100644 curriculum/francesca-lectura/etapa-8.ts create mode 100644 curriculum/francesca-lectura/etapa-9.ts create mode 100644 curriculum/francesca-ortografia/etapa-1.ts create mode 100644 curriculum/francesca-ortografia/etapa-2.ts create mode 100644 curriculum/francesca/etapa-10.ts create mode 100644 curriculum/francesca/etapa-5.ts create mode 100644 curriculum/francesca/etapa-6.ts create mode 100644 curriculum/francesca/etapa-7.ts create mode 100644 curriculum/francesca/etapa-8.ts create mode 100644 curriculum/francesca/etapa-9.ts create mode 100644 curriculum/sebastian-banderas/etapa-2.ts create mode 100644 curriculum/sebastian-geografia/etapa-1.ts create mode 100644 curriculum/sebastian-geografia/etapa-2.ts create mode 100644 curriculum/sebastian-geografia/etapa-3.ts create mode 100644 curriculum/sebastian-historia/etapa-3.ts create mode 100644 curriculum/sebastian-historia/etapa-4.ts create mode 100644 curriculum/sebastian-historia/etapa-5.ts create mode 100644 curriculum/sebastian-lectura/etapa-1.ts create mode 100644 curriculum/sebastian-lectura/etapa-2.ts create mode 100644 curriculum/sebastian-lectura/etapa-3.ts create mode 100644 curriculum/sebastian-lectura/etapa-4.ts create mode 100644 curriculum/sebastian-lectura/etapa-5.ts create mode 100644 curriculum/sebastian-lectura/etapa-6.ts create mode 100644 curriculum/sebastian/etapa-10.ts create mode 100644 curriculum/sebastian/etapa-11.ts create mode 100644 curriculum/sebastian/etapa-4.ts create mode 100644 curriculum/sebastian/etapa-5.ts create mode 100644 curriculum/sebastian/etapa-6.ts create mode 100644 curriculum/sebastian/etapa-7.ts create mode 100644 curriculum/sebastian/etapa-8.ts create mode 100644 curriculum/sebastian/etapa-9.ts create mode 100644 e2e/curriculum-flow.spec.ts create mode 100644 fase2.md create mode 100644 fase3.md diff --git a/Dockerfile b/Dockerfile index d988054..12038f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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,21 @@ 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 USER nextjs diff --git a/app/api/curriculum/grade/route.ts b/app/api/curriculum/grade/route.ts index ccee70a..1aa9dd1 100644 --- a/app/api/curriculum/grade/route.ts +++ b/app/api/curriculum/grade/route.ts @@ -1,5 +1,6 @@ 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 { @@ -9,13 +10,16 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }) } - const sessionLog = await prisma.sessionLog.findFirst({ + // 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) { - return NextResponse.json({ error: "No active session" }, { status: 400 }) + sessionLog = await prisma.sessionLog.create({ + data: { childId, startedAt: new Date() }, + }) } await prisma.skillAttempt.create({ @@ -31,6 +35,47 @@ export async function POST(request: NextRequest) { }, }) + // 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) diff --git a/app/api/curriculum/progress/route.ts b/app/api/curriculum/progress/route.ts index 6da0953..247d88a 100644 --- a/app/api/curriculum/progress/route.ts +++ b/app/api/curriculum/progress/route.ts @@ -4,74 +4,79 @@ import { initCurriculum, getLecturaStages, getNumerosStages } from "@/curriculum import { getCurrentStage, getNextExercise } from "@/lib/curriculum/engine" export async function GET(request: NextRequest) { - 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, + 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 }) } - }) - const errorTypes = await prisma.skillAttempt.groupBy({ - by: ["errorType"], - where: { - errorType: { not: null }, - sessionLog: { childId }, - }, - _count: { errorType: true }, - }) + await initCurriculum() - const errorPatterns = errorTypes.map((e) => ({ - errorType: e.errorType, - count: e._count.errorType, - })) + const stages = topic === "lectura" ? getLecturaStages() : getNumerosStages() + const stageKeys = Object.keys(stages).map(Number).sort((a, b) => a - b) - const activeProgress = progressRows.find((p) => !p.completedAt) - const currentStage = activeProgress?.stage || 1 + const progress = prisma.curriculumProgress.findMany({ + where: { childId, topic }, + orderBy: { stage: "asc" }, + }) - return NextResponse.json({ - currentStage, - stages: stagesInfo, - errorPatterns, - hasActiveSession: true, - }) + 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 }) + } } diff --git a/app/api/debug/reset/route.ts b/app/api/debug/reset/route.ts new file mode 100644 index 0000000..a7a76e5 --- /dev/null +++ b/app/api/debug/reset/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server" +import { prisma } from "@/lib/db" + +export async function POST(request: NextRequest) { + 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 }) + } +} diff --git a/app/api/debug/seed/route.ts b/app/api/debug/seed/route.ts new file mode 100644 index 0000000..e0875e3 --- /dev/null +++ b/app/api/debug/seed/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server" +import { prisma } from "@/lib/db" +import { getStagesForProfile } from "@/lib/curriculum/engine" + +export async function POST(request: NextRequest) { + 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 }) + } +} diff --git a/app/api/debug/state/route.ts b/app/api/debug/state/route.ts new file mode 100644 index 0000000..495f73c --- /dev/null +++ b/app/api/debug/state/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server" +import { prisma } from "@/lib/db" +import { getStagesForProfile } from "@/lib/curriculum/engine" + +export async function GET(request: NextRequest) { + 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 }) + } +} diff --git a/app/api/tts/route.ts b/app/api/tts/route.ts index a115528..aa890e4 100644 --- a/app/api/tts/route.ts +++ b/app/api/tts/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server" -import { execSync } from "child_process" +import { execFileSync } from "child_process" import { existsSync, mkdirSync } from "fs" import { createHash } from "crypto" import { readFile, mkdir } from "fs/promises" @@ -24,15 +24,23 @@ export async function GET(request: NextRequest) { 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 { - execSync( - `espeak-ng -v es-mx -s 140 -p 60 "${text.replace(/"/g, '\\"')}" -w "${cachePath}"`, - { timeout: 10000 }, - ) + 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 }) } diff --git a/app/francesca/ortografia/page.tsx b/app/francesca/ortografia/page.tsx new file mode 100644 index 0000000..728938a --- /dev/null +++ b/app/francesca/ortografia/page.tsx @@ -0,0 +1,12 @@ +import ExerciseSession from "@/components/child/exercise-session" + +export default function FrancescaOrtografia() { + return ( + + ) +} diff --git a/app/francesca/page.tsx b/app/francesca/page.tsx index 280ae00..68a0e82 100644 --- a/app/francesca/page.tsx +++ b/app/francesca/page.tsx @@ -8,8 +8,9 @@ import AudioUnlock from "@/components/child/audio-unlock" const FRANCESCA_KEY = "edueasy_child_francesca" const activities = [ - { id: "matematicas", label: "Matemáticas", icon: "🔢", description: "Sumas, restas y multiplicaciones" }, - { id: "lecturas", label: "Lecturas cortas", icon: "📖", description: "Leé textos chiquitos" }, + { 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" }, ] @@ -23,6 +24,16 @@ export default function FrancescaHome() { 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 setAudioReady(true)} /> } @@ -46,7 +57,7 @@ export default function FrancescaHome() { {activities.map((a) => ( ))} + +
+ +
) } diff --git a/app/sebastian/geografia/page.tsx b/app/sebastian/geografia/page.tsx new file mode 100644 index 0000000..d06000b --- /dev/null +++ b/app/sebastian/geografia/page.tsx @@ -0,0 +1,12 @@ +import ExerciseSession from "@/components/child/exercise-session" + +export default function SebastianGeografia() { + return ( + + ) +} diff --git a/app/sebastian/lectura/page.tsx b/app/sebastian/lectura/page.tsx new file mode 100644 index 0000000..078860d --- /dev/null +++ b/app/sebastian/lectura/page.tsx @@ -0,0 +1,12 @@ +import ExerciseSession from "@/components/child/exercise-session" + +export default function SebastianLectura() { + return ( + + ) +} diff --git a/app/sebastian/matematicas/page.tsx b/app/sebastian/matematicas/page.tsx new file mode 100644 index 0000000..8dc0ffa --- /dev/null +++ b/app/sebastian/matematicas/page.tsx @@ -0,0 +1,12 @@ +import ExerciseSession from "@/components/child/exercise-session" + +export default function SebastianMatematicas() { + return ( + + ) +} diff --git a/app/sebastian/page.tsx b/app/sebastian/page.tsx index 54bc73a..b70c102 100644 --- a/app/sebastian/page.tsx +++ b/app/sebastian/page.tsx @@ -8,8 +8,10 @@ import AudioUnlock from "@/components/child/audio-unlock" const SEBASTIAN_KEY = "edueasy_child_sebastian" const activities = [ - { id: "sumas", label: "Sumas avanzadas", icon: "➕", description: "Sumas de 2 y 3 dígitos" }, - { id: "historia", label: "Historia argentina", icon: "🇦🇷", description: "Personajes y eventos" }, + { 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" }, @@ -24,6 +26,16 @@ export default function SebastianHome() { 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 setAudioReady(true)} /> } @@ -47,7 +59,7 @@ export default function SebastianHome() { {activities.map((a) => (