feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes
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,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const parent = await prisma.parent.findUnique({
|
||||
where: { id: session.user.id },
|
||||
include: {
|
||||
family: {
|
||||
include: { children: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!parent || !parent.family) {
|
||||
return NextResponse.json({ error: "Parent or family not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(parent.family.children)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { name, birthdate, profileNotes } = await request.json()
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "name required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parent = await prisma.parent.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true },
|
||||
})
|
||||
|
||||
if (!parent || !parent.familyId) {
|
||||
return NextResponse.json({ error: "Parent has no family" }, { status: 400 })
|
||||
}
|
||||
|
||||
const child = await prisma.child.create({
|
||||
data: {
|
||||
name,
|
||||
birthdate: birthdate ? new Date(birthdate) : undefined,
|
||||
profileNotes,
|
||||
familyId: parent.familyId,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(child, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { childId, exerciseId, skillCode, correct, promptLevel, responseMs, errorType, stage } = await request.json()
|
||||
|
||||
if (!childId || !exerciseId || !skillCode) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 })
|
||||
}
|
||||
|
||||
const sessionLog = await prisma.sessionLog.findFirst({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
})
|
||||
|
||||
if (!sessionLog) {
|
||||
return NextResponse.json({ error: "No active session" }, { status: 400 })
|
||||
}
|
||||
|
||||
await prisma.skillAttempt.create({
|
||||
data: {
|
||||
sessionLogId: sessionLog.id,
|
||||
skillCode,
|
||||
promptLevel: promptLevel ?? 0,
|
||||
correct,
|
||||
responseMs: responseMs ?? null,
|
||||
errorType: errorType ?? null,
|
||||
exerciseId,
|
||||
stage: stage ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Curriculum grade error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { 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 })
|
||||
}
|
||||
|
||||
const exercise = await getNextExercise(childId, topic)
|
||||
return NextResponse.json(exercise || { id: null, done: true })
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { initCurriculum, getLecturaStages, getNumerosStages } from "@/curriculum/index"
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const [totalSessions, todaySessions, milestones, fsrsCards, lastSession, weeklySessions, milestoneRecords, curriculumProgress, errorPatterns, allAttempts] =
|
||||
await Promise.all([
|
||||
prisma.sessionLog.count({ where: { childId } }),
|
||||
prisma.sessionLog.count({
|
||||
where: { childId, startedAt: { gte: today } },
|
||||
}),
|
||||
prisma.milestone.count({ where: { childId } }),
|
||||
prisma.fsrsCard.findMany({ where: { childId } }),
|
||||
prisma.sessionLog.findFirst({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
include: { skillAttempts: true },
|
||||
}),
|
||||
prisma.sessionLog.findMany({
|
||||
where: { childId, startedAt: { gte: new Date(Date.now() - 7 * 86400000) } },
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: { startedAt: true, durationSec: true },
|
||||
}),
|
||||
prisma.milestone.findMany({
|
||||
where: { childId },
|
||||
orderBy: { reachedAt: "desc" },
|
||||
select: { skillCode: true, reachedAt: true },
|
||||
}),
|
||||
prisma.curriculumProgress.findMany({
|
||||
where: { childId },
|
||||
orderBy: { stage: "asc" },
|
||||
}),
|
||||
prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
errorType: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { errorType: true },
|
||||
}),
|
||||
prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
correct: false,
|
||||
exerciseId: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { exerciseId: true, errorType: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const todayMinutes = todaySessions > 0
|
||||
? await prisma.sessionLog.aggregate({
|
||||
where: { childId, startedAt: { gte: today } },
|
||||
_sum: { durationSec: true },
|
||||
}).then((r) => Math.round((r._sum.durationSec ?? 0) / 60))
|
||||
: 0
|
||||
|
||||
const totalSkills = fsrsCards.length
|
||||
const masteredSkills = fsrsCards.filter((c) => c.reps >= 5 && c.stability > 30).length
|
||||
const masteryPercent = totalSkills > 0 ? Math.round((masteredSkills / totalSkills) * 100) : 0
|
||||
|
||||
const weeklyData = weeklySessions.map((s) => ({
|
||||
date: s.startedAt.toISOString().slice(0, 10),
|
||||
minutes: Math.round((s.durationSec ?? 0) / 60),
|
||||
}))
|
||||
|
||||
const skillBreakdown = fsrsCards.map((c) => ({
|
||||
skillCode: c.skillCode,
|
||||
stability: c.stability,
|
||||
difficulty: c.difficulty,
|
||||
reps: c.reps,
|
||||
due: c.dueAt.toISOString(),
|
||||
}))
|
||||
|
||||
const streak = await calculateStreak(childId)
|
||||
|
||||
const totalErrors = allAttempts.length
|
||||
const errorTypeCount = errorPatterns.reduce<Record<string, number>>((acc, e) => {
|
||||
const key = e.errorType || "sin-clasificar"
|
||||
acc[key] = (acc[key] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const stageProgress = curriculumProgress.map((cp) => ({
|
||||
topic: cp.topic,
|
||||
stage: cp.stage,
|
||||
completedAt: cp.completedAt?.toISOString() || null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
totalSessions,
|
||||
todaySessions,
|
||||
todayMinutes,
|
||||
masteryPercent,
|
||||
masteredSkills,
|
||||
totalSkills,
|
||||
milestones,
|
||||
streak,
|
||||
weeklyData,
|
||||
skillBreakdown,
|
||||
milestoneData: milestoneRecords,
|
||||
stageProgress,
|
||||
errorTypeCount,
|
||||
totalErrors,
|
||||
lastSession: lastSession
|
||||
? {
|
||||
startedAt: lastSession.startedAt.toISOString(),
|
||||
durationSec: lastSession.durationSec,
|
||||
skillsPracticed: lastSession.skillsPracticed,
|
||||
correctCount: lastSession.skillAttempts.filter((a) => a.correct).length,
|
||||
totalAttempts: lastSession.skillAttempts.length,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
async function calculateStreak(childId: string): Promise<number> {
|
||||
const logs = await prisma.sessionLog.findMany({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
select: { startedAt: true },
|
||||
})
|
||||
|
||||
if (logs.length === 0) return 0
|
||||
|
||||
const days = [
|
||||
...new Set(logs.map((l) => l.startedAt.toISOString().slice(0, 10))),
|
||||
].sort()
|
||||
.reverse()
|
||||
|
||||
let streak = 1
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
if (days[0] !== today && days[0] !== getYesterday()) return 0
|
||||
|
||||
for (let i = 1; i < days.length; i++) {
|
||||
const prev = new Date(days[i - 1])
|
||||
const curr = new Date(days[i])
|
||||
const diff = (prev.getTime() - curr.getTime()) / 86400000
|
||||
if (Math.round(diff) === 1) {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
function getYesterday(): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 {
|
||||
const { childId, skillCode, correct, responseMs } = await request.json()
|
||||
if (!childId || !skillCode) {
|
||||
return NextResponse.json({ error: "childId and skillCode required" }, { status: 400 })
|
||||
}
|
||||
|
||||
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("FSRS grade error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { buildFsrsCard, getRetrievability } from "@/lib/fsrs"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const dueCard = await prisma.fsrsCard.findFirst({
|
||||
where: {
|
||||
childId,
|
||||
dueAt: { lte: new Date() },
|
||||
},
|
||||
orderBy: { dueAt: "asc" },
|
||||
})
|
||||
|
||||
if (dueCard) {
|
||||
const card = buildFsrsCard({
|
||||
stability: dueCard.stability,
|
||||
difficulty: dueCard.difficulty,
|
||||
reps: dueCard.reps,
|
||||
lapses: dueCard.lapses,
|
||||
due: dueCard.dueAt,
|
||||
})
|
||||
return NextResponse.json({
|
||||
...dueCard,
|
||||
cardId: dueCard.id,
|
||||
retrievability: getRetrievability(card),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ skillCode: null })
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
const milestones = await prisma.milestone.findMany({
|
||||
where: { childId },
|
||||
orderBy: { reachedAt: "desc" },
|
||||
})
|
||||
return NextResponse.json(milestones)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { childId, skillCode } = await request.json()
|
||||
if (!childId || !skillCode) {
|
||||
return NextResponse.json({ error: "childId and skillCode required" }, { status: 400 })
|
||||
}
|
||||
const existing = await prisma.milestone.findUnique({
|
||||
where: { childId_skillCode: { childId, skillCode } },
|
||||
})
|
||||
if (existing) {
|
||||
return NextResponse.json(existing)
|
||||
}
|
||||
const milestone = await prisma.milestone.create({
|
||||
data: { childId, skillCode },
|
||||
})
|
||||
return NextResponse.json(milestone, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Milestone error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { randomUUID } from "crypto"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { deviceFingerprint, pairingCode, childId, action } = await request.json()
|
||||
|
||||
if (action === "generate") {
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
const code = randomUUID().slice(0, 8).toUpperCase()
|
||||
await prisma.pendingPairing.create({
|
||||
data: {
|
||||
code,
|
||||
childId,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ pairingCode: code })
|
||||
}
|
||||
|
||||
if (pairingCode) {
|
||||
const pending = await prisma.pendingPairing.findUnique({
|
||||
where: { code: pairingCode },
|
||||
})
|
||||
if (!pending || pending.expiresAt < new Date()) {
|
||||
return NextResponse.json({ error: "Código inválido o expirado" }, { status: 404 })
|
||||
}
|
||||
|
||||
const targetChildId = childId || pending.childId
|
||||
if (!targetChildId) {
|
||||
return NextResponse.json({ error: "Sin niño asociado" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (childId && !pending.childId) {
|
||||
await prisma.pendingPairing.update({
|
||||
where: { id: pending.id },
|
||||
data: { childId },
|
||||
})
|
||||
}
|
||||
|
||||
const fp = deviceFingerprint || pending.deviceFingerprint
|
||||
if (!fp) {
|
||||
return NextResponse.json({ error: "Se requiere deviceFingerprint" }, { status: 400 })
|
||||
}
|
||||
|
||||
const device = await prisma.device.create({
|
||||
data: {
|
||||
childId: targetChildId,
|
||||
deviceFingerprint: fp,
|
||||
role: "child",
|
||||
},
|
||||
include: { child: true },
|
||||
})
|
||||
|
||||
await prisma.pendingPairing.delete({ where: { id: pending.id } })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
childId: device.childId,
|
||||
childName: device.child.name,
|
||||
})
|
||||
}
|
||||
|
||||
if (deviceFingerprint) {
|
||||
const existingDevice = await prisma.device.findFirst({
|
||||
where: { deviceFingerprint },
|
||||
})
|
||||
if (existingDevice) {
|
||||
return NextResponse.json({
|
||||
paired: true,
|
||||
childId: existingDevice.childId,
|
||||
role: existingDevice.role,
|
||||
})
|
||||
}
|
||||
|
||||
const code = randomUUID().slice(0, 8).toUpperCase()
|
||||
await prisma.pendingPairing.create({
|
||||
data: {
|
||||
code,
|
||||
deviceFingerprint,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({
|
||||
paired: false,
|
||||
pairingCode: code,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
|
||||
} 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,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { execSync } from "child_process"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { createHash } from "crypto"
|
||||
import { readFile, mkdir } from "fs/promises"
|
||||
import { join } from "path"
|
||||
|
||||
const CACHE_DIR = join(process.cwd(), "public", "tts-cache")
|
||||
|
||||
async function ensureCacheDir() {
|
||||
if (!existsSync(CACHE_DIR)) {
|
||||
await mkdir(CACHE_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function getCachePath(text: string): string {
|
||||
const hash = createHash("md5").update(text).digest("hex")
|
||||
return join(CACHE_DIR, `${hash}.wav`)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const text = request.nextUrl.searchParams.get("text")
|
||||
if (!text || text.length > 200) {
|
||||
return NextResponse.json({ error: "text param required (max 200 chars)" }, { 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 },
|
||||
)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "TTS generation failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const audioBuffer = await readFile(cachePath)
|
||||
return new NextResponse(audioBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
"Content-Length": audioBuffer.length.toString(),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,38 @@
|
||||
import type { Metadata, Viewport } 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",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "EduEasy",
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: "cover",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="es" className={inter.variable}>
|
||||
<head>
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
</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,79 @@
|
||||
"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>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
¿No tenés cuenta?{" "}
|
||||
<a href="/parent/auth/register" className="text-primary underline">
|
||||
Crear cuenta
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
|
||||
interface ChildSummary {
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
|
||||
export default function PairingPage() {
|
||||
const [code, setCode] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [paired, setPaired] = useState(false)
|
||||
const [children, setChildren] = useState<ChildSummary[]>([])
|
||||
const [selectedChildId, setSelectedChildId] = useState("")
|
||||
const { data: session } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/children")
|
||||
.then((r) => r.json())
|
||||
.then((list) => {
|
||||
if (Array.isArray(list)) {
|
||||
setChildren(list)
|
||||
if (list.length > 0) setSelectedChildId(list[0].id)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handlePairing = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
if (!selectedChildId) {
|
||||
setError("Seleccioná un niño")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/pairing", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
pairingCode: code.toUpperCase(),
|
||||
childId: selectedChildId,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPaired(true)
|
||||
} catch {
|
||||
setError("Error de conexión")
|
||||
}
|
||||
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>
|
||||
|
||||
{children.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{children.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedChildId(c.id)}
|
||||
className={`rounded-xl px-4 py-2 font-display text-sm transition-colors ${
|
||||
c.id === selectedChildId
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{c.name || "Sin nombre"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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"
|
||||
autoFocus
|
||||
/>
|
||||
{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,88 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const router = useRouter()
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const { error: err } = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name: name || "",
|
||||
})
|
||||
|
||||
if (err) {
|
||||
setError(err.message || "Error al registrarse")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/parent")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center p-6">
|
||||
<form onSubmit={handleRegister} className="w-full max-w-sm flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-display font-bold text-foreground text-center">
|
||||
Crear cuenta · Mamá
|
||||
</h1>
|
||||
|
||||
{error && <p className="text-destructive text-sm text-center">{error}</p>}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tu nombre"
|
||||
value={name}
|
||||
onChange={(e) => setName(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"
|
||||
/>
|
||||
|
||||
<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 (mín. 8 caracteres)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
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 ? "Creando cuenta..." : "Crear cuenta"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
¿Ya tenés cuenta?{" "}
|
||||
<a href="/parent/auth/login" className="text-primary underline">
|
||||
Iniciá sesión
|
||||
</a>
|
||||
</p>
|
||||
</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,293 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { MetricCard } from "@/components/parent/metric-card"
|
||||
import { WeeklyActivityChart } from "@/components/parent/weekly-activity-chart"
|
||||
import { SkillMasteryChart } from "@/components/parent/skill-mastery-chart"
|
||||
import { MilestonesList } from "@/components/parent/milestones-list"
|
||||
import ErrorPatternsChart from "@/components/parent/error-patterns-chart"
|
||||
|
||||
interface ChildSummary {
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
totalSessions: number
|
||||
todaySessions: number
|
||||
todayMinutes: number
|
||||
masteryPercent: number
|
||||
masteredSkills: number
|
||||
totalSkills: number
|
||||
milestones: number
|
||||
milestoneData: { skillCode: string; reachedAt: string }[]
|
||||
streak: number
|
||||
weeklyData: { date: string; minutes: number }[]
|
||||
skillBreakdown: { skillCode: string; stability: number; reps: number }[]
|
||||
stageProgress: { topic: string; stage: number; completedAt: string | null }[]
|
||||
errorTypeCount: Record<string, number>
|
||||
totalErrors: number
|
||||
lastSession: {
|
||||
startedAt: string
|
||||
durationSec: number
|
||||
skillsPracticed: string
|
||||
correctCount: number
|
||||
totalAttempts: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export default function ParentDashboard() {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
const [children, setChildren] = useState<ChildSummary[]>([])
|
||||
const [selectedChildId, setSelectedChildId] = useState<string>("")
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newChildName, setNewChildName] = useState("")
|
||||
const [pairingCode, setPairingCode] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/children")
|
||||
.then((r) => r.json())
|
||||
.then((list) => {
|
||||
if (Array.isArray(list)) {
|
||||
setChildren(list)
|
||||
if (list.length > 0) setSelectedChildId(list[0].id)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedChildId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
fetch(`/api/dashboard/summary?childId=${selectedChildId}`)
|
||||
.then((r) => r.json())
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [selectedChildId])
|
||||
|
||||
const handleCreateChild = async () => {
|
||||
if (!newChildName.trim()) return
|
||||
setCreating(true)
|
||||
const res = await fetch("/api/children", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newChildName.trim() }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const child = await res.json()
|
||||
setChildren((prev) => [...prev, child])
|
||||
setSelectedChildId(child.id)
|
||||
setNewChildName("")
|
||||
}
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const handleGeneratePairingCode = async () => {
|
||||
if (!selectedChildId) return
|
||||
const res = await fetch("/api/pairing", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "generate", childId: selectedChildId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.pairingCode) {
|
||||
setPairingCode(data.pairingCode)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.push("/parent/auth/login")
|
||||
}
|
||||
}, [session, isPending, router])
|
||||
|
||||
if (isPending || !session) {
|
||||
return (
|
||||
<div className="p-4 text-center text-muted-foreground">Cargando...</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedChild = children.find((c) => c.id === selectedChildId)
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 max-w-2xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-display font-bold">Dashboard</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bienvenida, {session.user?.name || "Mamá"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-border">
|
||||
<h3 className="text-lg font-display font-semibold mb-4">Agregar un niño</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newChildName}
|
||||
onChange={(e) => setNewChildName(e.target.value)}
|
||||
placeholder="Nombre de la nena"
|
||||
className="flex-1 rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateChild}
|
||||
disabled={creating || !newChildName.trim()}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 font-display font-semibold disabled:opacity-50"
|
||||
>
|
||||
{creating ? "..." : "Agregar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{children.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setSelectedChildId(c.id)}
|
||||
className={`rounded-xl px-4 py-2 font-display font-medium text-sm transition-colors ${
|
||||
c.id === selectedChildId
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{c.name || "Sin nombre"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Cargando...</div>
|
||||
) : data ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<MetricCard title="Racha" value={`${data.streak} días`} variant="primary" />
|
||||
<MetricCard
|
||||
title="Hoy"
|
||||
value={`${data.todayMinutes} min`}
|
||||
subtitle={data.todaySessions > 0 ? `${data.todaySessions} sesiones` : undefined}
|
||||
variant="success"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Maestría"
|
||||
value={`${data.masteryPercent}%`}
|
||||
subtitle={`${data.masteredSkills}/${data.totalSkills} skills`}
|
||||
variant="accent"
|
||||
/>
|
||||
<MetricCard title="Logros" value={data.milestones} variant="default" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">
|
||||
Actividad semanal
|
||||
</h3>
|
||||
<WeeklyActivityChart data={data.weeklyData} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">
|
||||
Maestría por skill
|
||||
</h3>
|
||||
<SkillMasteryChart data={data.skillBreakdown} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">Logros</h3>
|
||||
<MilestonesList data={data.milestoneData} />
|
||||
</div>
|
||||
|
||||
{data.stageProgress.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">Progreso curricular</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{data.stageProgress.map((sp) => (
|
||||
<div key={`${sp.topic}-${sp.stage}`} className="flex justify-between items-center">
|
||||
<span className="font-medium">
|
||||
{sp.topic === "lectura" ? "Lectura" : "Números"} — Etapa {sp.stage}
|
||||
</span>
|
||||
<span className={sp.completedAt ? "text-green-600" : "text-yellow-600"}>
|
||||
{sp.completedAt ? "✅ Completada" : "🔵 En curso"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.totalErrors > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">Patrones de error</h3>
|
||||
<ErrorPatternsChart data={data.errorTypeCount} total={data.totalErrors} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.lastSession && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-2">Última sesión</h3>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{new Date(data.lastSession.startedAt).toLocaleDateString("es-AR")}</p>
|
||||
<p>
|
||||
{Math.round((data.lastSession.durationSec ?? 0) / 60)} min ·{" "}
|
||||
{data.lastSession.correctCount}/{data.lastSession.totalAttempts} correctas
|
||||
</p>
|
||||
<p className="text-xs mt-1">Skills: {data.lastSession.skillsPracticed || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 mt-2">
|
||||
<button
|
||||
onClick={handleGeneratePairingCode}
|
||||
className="bg-secondary text-secondary-foreground touch-target rounded-xl px-6 font-display font-semibold"
|
||||
>
|
||||
Emparejar nuevo iPad
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push("/parent/auth/pairing")}
|
||||
className="bg-muted text-foreground touch-target rounded-xl font-display"
|
||||
>
|
||||
Ingresar código
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Seleccioná un niño para ver su progreso
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pairingCode && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-xl max-w-sm w-full text-center space-y-4">
|
||||
<h3 className="text-lg font-display font-semibold">Código de emparejamiento</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ingresá este código en el iPad de la nena
|
||||
</p>
|
||||
<p className="text-3xl tracking-[0.3em] font-bold font-display text-primary select-all">
|
||||
{pairingCode}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setPairingCode("")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 py-3 font-display font-semibold"
|
||||
>
|
||||
Listo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const navItems = [
|
||||
{ href: "/parent/dashboard", label: "Dashboard", emoji: "📊" },
|
||||
{ href: "/parent/configuracion", label: "Configuración", emoji: "⚙️" },
|
||||
]
|
||||
|
||||
export default function ParentLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const { data: session } = authClient.useSession()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
if (
|
||||
pathname.startsWith("/parent/auth")
|
||||
) {
|
||||
return <div className="min-h-dvh bg-background">{children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="text-foreground touch-target rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-lg font-display font-bold">EduEasy</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground hidden sm:block">
|
||||
{session?.user?.name || "Mamá"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => authClient.signOut()}
|
||||
className="text-sm text-muted-foreground underline"
|
||||
>
|
||||
Salir
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-20"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<nav
|
||||
className="fixed left-0 top-0 bottom-0 w-64 bg-white shadow-lg p-4 pt-16"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl px-4 py-3 font-display font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span>{item.emoji}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1">{children}</main>
|
||||
</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