feat: EduEasy checkpoint - plan, scaffolding, pedagogia modules, components child+parent, API routes, Prisma schema, Docker infra, Better Auth

This commit is contained in:
Renato
2026-07-20 17:58:49 +02:00
commit e69b18abaa
49 changed files with 9842 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
"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({
children,
}: {
children: React.ReactNode
}) {
const [audioReady, setAudioReady] = useState(false)
useEffect(() => {
document.body.style.overscrollBehavior = "none"
}, [])
if (!audioReady) {
return <AudioUnlock onUnlock={() => setAudioReady(true)} />
}
return (
<div className="min-h-dvh bg-background flex flex-col">
{children}
</div>
)
}
+202
View File
@@ -0,0 +1,202 @@
"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"
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>
)
}
+45
View File
@@ -0,0 +1,45 @@
"use client"
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 },
]
export default function LogrosPage() {
const completedCount = achievements.filter((a) => a.completed).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
</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>
</div>
)
}
+217
View File
@@ -0,0 +1,217 @@
"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>
)
}
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>
)
}
+33
View File
@@ -0,0 +1,33 @@
"use client"
import { useRouter } from "next/navigation"
import { GatoMascota } from "@/components/child/gato-mascota"
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" },
]
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 flex-col gap-4 w-full max-w-md">
{activities.map((a) => (
<button
key={a.id}
onClick={() => router.push(`/child/${a.id}`)}
className={`${a.color} 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>
<span>{a.label}</span>
</button>
))}
</div>
</div>
)
}