feat: EduEasy checkpoint - plan, scaffolding, pedagogia modules, components child+parent, API routes, Prisma schema, Docker infra, Better Auth
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { toNextJsHandler } from "better-auth/next-js"
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth)
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { randomUUID } from "crypto"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { deviceFingerprint, pairingCode: incomingCode } = await request.json()
|
||||
|
||||
if (incomingCode) {
|
||||
// Step 2: Parent's device confirms pairing
|
||||
const device = await prisma.device.findFirst({
|
||||
where: { deviceFingerprint: incomingCode },
|
||||
include: { child: true },
|
||||
})
|
||||
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: "Código inválido" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
childId: device.childId,
|
||||
childName: device.child.name,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 1: Child device requests pairing
|
||||
const existingDevice = await prisma.device.findFirst({
|
||||
where: { deviceFingerprint },
|
||||
})
|
||||
|
||||
if (existingDevice) {
|
||||
return NextResponse.json({
|
||||
paired: true,
|
||||
childId: existingDevice.childId,
|
||||
})
|
||||
}
|
||||
|
||||
const pairingCode = randomUUID().slice(0, 8)
|
||||
|
||||
return NextResponse.json({
|
||||
paired: false,
|
||||
pairingCode,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Pairing error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { childId, session, attempts } = body
|
||||
|
||||
if (!childId || !session) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 })
|
||||
}
|
||||
|
||||
const sessionLog = await prisma.sessionLog.create({
|
||||
data: {
|
||||
childId,
|
||||
startedAt: new Date(session.startedAt),
|
||||
endedAt: session.endedAt ? new Date(session.endedAt) : undefined,
|
||||
durationSec: session.durationSec,
|
||||
skillsPracticed: session.skillsPracticed,
|
||||
skillAttempts: {
|
||||
create: (attempts || []).map((a: any) => ({
|
||||
skillCode: a.skillCode,
|
||||
promptLevel: a.promptLevel ?? 0,
|
||||
correct: a.correct,
|
||||
responseMs: a.responseMs,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, sessionId: sessionLog.id })
|
||||
} catch (error) {
|
||||
console.error("Sync error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--radius: 1rem;
|
||||
--background: 38 100% 97%;
|
||||
--foreground: 200 6% 19%;
|
||||
--primary: 150 22% 64%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 200 24% 53%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--accent: 30 48% 64%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--muted: 30 20% 88%;
|
||||
--muted-foreground: 200 4% 40%;
|
||||
--card: 0 0% 100%;
|
||||
--border: 30 20% 88%;
|
||||
--ring: 150 22% 64%;
|
||||
--success: 130 25% 61%;
|
||||
--destructive: 0 62% 68%;
|
||||
--warning: 35 85% 62%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.touch-target {
|
||||
@apply min-h-[60px] min-w-[60px];
|
||||
}
|
||||
.touch-target-lg {
|
||||
@apply min-h-[80px] min-w-[80px];
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS Safari quirks fixes */
|
||||
input, textarea {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import "./globals.css"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "EduEasy",
|
||||
description: "Plataforma educativa infantil",
|
||||
viewport: "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "EduEasy",
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
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" />
|
||||
</head>
|
||||
<body className="dvh-screen overflow-x-hidden">{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function RootPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const host = window.location.hostname
|
||||
if (host.includes("simulacro")) {
|
||||
router.replace("/parent")
|
||||
} else {
|
||||
router.replace("/child")
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const { error: err } = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (err) {
|
||||
setError(err.message || "Error al iniciar sesión")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/parent")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center p-6">
|
||||
<form onSubmit={handleLogin} className="w-full max-w-sm flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-display font-bold text-foreground text-center">
|
||||
EduEasy · Mamá
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<p className="text-destructive text-sm text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<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"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(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
|
||||
/>
|
||||
|
||||
<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 ? "Entrando..." : "Iniciar sesión"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
|
||||
export default function PairingPage() {
|
||||
const [code, setCode] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [paired, setPaired] = useState(false)
|
||||
const { data: session } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
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)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPaired(true)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col items-center justify-center p-6 gap-4">
|
||||
<p className="text-muted-foreground">Iniciá sesión primero para emparejar</p>
|
||||
<button
|
||||
onClick={() => router.push("/parent/auth/login")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 py-3 font-display"
|
||||
>
|
||||
Ir a login
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col items-center justify-center p-6 gap-6">
|
||||
<h1 className="text-2xl font-display font-bold">Emparejar iPad</h1>
|
||||
|
||||
{paired ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<GatoMascota mood="celebrate" message="iPad emparejado con éxito" size="lg" />
|
||||
<button
|
||||
onClick={() => router.push("/parent")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 py-3 font-display"
|
||||
>
|
||||
Volver al dashboard
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handlePairing} className="flex flex-col gap-4 w-full max-w-sm">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Ingresá el código de 8 caracteres que aparece en el iPad
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
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
|
||||
/>
|
||||
{error && <p className="text-destructive text-sm text-center">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length < 8}
|
||||
className="bg-secondary text-secondary-foreground touch-target rounded-xl text-lg font-display font-semibold disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Emparejando..." : "Emparejar"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function ConfigPage() {
|
||||
const { data: session } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh p-6">
|
||||
<h1 className="text-2xl font-display font-bold mb-6">Configuración</h1>
|
||||
|
||||
<div className="flex flex-col gap-4 max-w-md">
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">Email</p>
|
||||
<p className="font-medium">{session?.user?.email || "—"}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => router.push("/parent/auth/pairing")}
|
||||
className="bg-secondary text-secondary-foreground touch-target rounded-xl text-lg font-display font-semibold"
|
||||
>
|
||||
Emparejar nuevo iPad
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push("/parent")}
|
||||
className="bg-muted text-foreground touch-target rounded-xl font-display"
|
||||
>
|
||||
Volver al dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function ParentDashboard() {
|
||||
const { data: session } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
if (!session) {
|
||||
router.push("/parent/auth/login")
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh p-6">
|
||||
<header className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-bold">EduEasy</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Bienvenida, {session.user?.name || "Mamá"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => authClient.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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function ParentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function ParentPage() {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
if (session) {
|
||||
router.replace("/parent/dashboard")
|
||||
} else {
|
||||
router.replace("/parent/auth/login")
|
||||
}
|
||||
}, [session, isPending, router])
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user