feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
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"
|
||||
|
||||
export default function LecturaSesion() {
|
||||
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 startedAtRef = useRef(new Date().toISOString())
|
||||
|
||||
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 res = await fetch(`/api/curriculum/next?childId=${childId}&topic=lectura`)
|
||||
const data = await res.json()
|
||||
if (data.done || !data.id) {
|
||||
setPageState("complete")
|
||||
return null
|
||||
}
|
||||
setExercise(data)
|
||||
setPageState("ready")
|
||||
return data
|
||||
} catch {
|
||||
setPageState("error")
|
||||
return null
|
||||
}
|
||||
}, [getChildId])
|
||||
|
||||
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 {
|
||||
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: 3000,
|
||||
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))
|
||||
}
|
||||
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 items-center justify-center">
|
||||
<GatoMascota mood="coach" message="Preparando ejercicios..." size="sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageState === "error") {
|
||||
return <NoChildError onRetry={resetSession} />
|
||||
}
|
||||
|
||||
if (pageState === "complete") {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="celebrate" message="¡Terminaste todos los ejercicios por ahora!" 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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
|
||||
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 [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 {ALL_SKILLS.length} completados
|
||||
</p>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
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"
|
||||
|
||||
const CHILD_ID_KEY = "edueasy_child_id"
|
||||
const META_KEY = "edueasy_child_meta"
|
||||
|
||||
type PageState = "loading" | "ready" | "complete" | "error"
|
||||
|
||||
export default function NumerosSesion() {
|
||||
const [pageState, setPageState] = useState<PageState>("loading")
|
||||
const [exercise, setExercise] = useState<any | null>(null)
|
||||
const [running, setRunning] = useState(true)
|
||||
const [score, setScore] = useState(0)
|
||||
const [totalAttempts, setTotalAttempts] = useState(0)
|
||||
const childIdRef = useRef<string>("")
|
||||
const startedAtRef = useRef(new Date().toISOString())
|
||||
|
||||
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 res = await fetch(`/api/curriculum/next?childId=${childId}&topic=numeros`)
|
||||
const data = await res.json()
|
||||
if (data.done || !data.id) {
|
||||
setPageState("complete")
|
||||
return null
|
||||
}
|
||||
setExercise(data)
|
||||
setPageState("ready")
|
||||
return data
|
||||
} catch {
|
||||
setPageState("error")
|
||||
return null
|
||||
}
|
||||
}, [getChildId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchNext()
|
||||
}, [fetchNext])
|
||||
|
||||
const gradeAttempt = useCallback(async (ex: any, 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 {
|
||||
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: 3000,
|
||||
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))
|
||||
}
|
||||
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 items-center justify-center">
|
||||
<GatoMascota mood="coach" message="Preparando ejercicios de números..." size="sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageState === "error") {
|
||||
return <NoChildError onRetry={resetSession} />
|
||||
}
|
||||
|
||||
if (pageState === "complete") {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="celebrate" message="¡Terminaste todos los ejercicios de números por ahora!" 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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"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" },
|
||||
{ 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 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user