feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user