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
+72
View File
@@ -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>
)
}
+91
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+70
View File
@@ -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>
)
}
+11
View File
@@ -0,0 +1,11 @@
export default function ParentLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-dvh bg-background">
{children}
</div>
)
}
+21
View File
@@ -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
}