feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes

This commit is contained in:
renato97
2026-07-21 15:31:46 -03:00
commit fa8e0c58ce
120 changed files with 16013 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
"use client"
import { useState } from "react"
import { motion } from "framer-motion"
import { GatoMascota } from "./gato-mascota"
interface AudioUnlockProps {
onUnlock: () => void
}
export default function AudioUnlock({ onUnlock }: AudioUnlockProps) {
const [pressed, setPressed] = useState(false)
const handleUnlock = () => {
if (typeof window !== "undefined") {
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext
if (AudioCtx) {
const ctx = new AudioCtx()
ctx.resume().then(() => {
ctx.close()
})
}
}
setPressed(true)
setTimeout(onUnlock, 600)
}
return (
<div className="min-h-dvh bg-background flex flex-col items-center justify-center p-8 gap-8">
<GatoMascota mood="happy" message="¡Hola! Tocá la pantalla para empezar" size="lg" />
<motion.button
onClick={handleUnlock}
disabled={pressed}
className="bg-primary text-primary-foreground touch-target-lg rounded-full text-2xl px-12 py-6 font-display font-bold shadow-lg"
whileTap={{ scale: 0.95 }}
animate={pressed ? { scale: 1.1, opacity: 0 } : {}}
>
{pressed ? "¡Empezamos!" : "TOCÁ ACÁ"}
</motion.button>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
"use client"
import { speak } from "@/lib/speech"
export interface CPAExerciseProps {
value: number
stage: "concrete" | "pictoric" | "abstract"
onCorrect: () => void
onIncorrect: () => void
}
export function generateCPAItems(value: number, stage: string): Array<{ emoji: string; id: number }> {
const pool = ["🍎", "🐱", "⭐", "🌸", "🎈", "🐶", "🍪", "🌈", "🦋", "🧸"]
return Array.from({ length: value }, (_, i) => ({
emoji: pool[i % pool.length],
id: i,
}))
}
export function speakCPAInstruction(value: number, stage: string) {
if (stage === "concrete") {
speak(`Tocá ${value} ${getItemName(value)}`)
} else if (stage === "pictoric") {
speak(`¿Cuántos hay? Señalá el número`)
} else {
speak(`¿Este número es el ${value}?`)
}
}
function getItemName(n: number): string {
if (n === 1) return "elemento"
return "elementos"
}
+58
View File
@@ -0,0 +1,58 @@
"use client"
import { useState } from "react"
import { motion } from "framer-motion"
export interface CuisenaireRodProps {
value: number
color: string
length: number
draggable?: boolean
onDrop?: (value: number) => void
}
const rodColors: Record<number, string> = {
1: "#FFFFFF",
2: "#E74C3C",
3: "#2ECC71",
4: "#E91E63",
5: "#F1C40F",
6: "#1ABC9C",
7: "#2C3E50",
8: "#3498DB",
9: "#9B59B6",
10: "#E67E22",
}
const rodLengths: Record<number, number> = {
1: 20, 2: 40, 3: 60, 4: 80, 5: 100,
6: 120, 7: 140, 8: 160, 9: 180, 10: 200,
}
export { rodColors, rodLengths }
export function CuisenaireRod({ value, color, length, draggable = false }: CuisenaireRodProps) {
const [isDragging, setIsDragging] = useState(false)
return (
<motion.div
className={`rounded-lg border border-border/50 shadow-sm touch-target select-none ${
draggable ? "cursor-grab" : ""
} ${isDragging ? "cursor-grabbing z-10" : ""}`}
style={{
width: `${length}px`,
minWidth: `${length}px`,
height: "40px",
backgroundColor: color,
borderColor: value === 1 ? "#ddd" : undefined,
}}
whileTap={draggable ? { scale: 1.08 } : undefined}
drag={draggable}
dragElastic={0.1}
dragMomentum={false}
onDragStart={() => setIsDragging(true)}
onDragEnd={() => setIsDragging(false)}
dragConstraints={{ left: -400, right: 400, top: -300, bottom: 300 }}
/>
)
}
+56
View File
@@ -0,0 +1,56 @@
"use client"
import { motion } from "framer-motion"
interface GatoMascotaProps {
mood?: "happy" | "thinking" | "celebrate" | "coach"
message?: string
size?: "sm" | "md" | "lg"
}
const moods = {
happy: {
emoji: "😺",
animation: { y: [0, -5, 0] },
transition: { repeat: Infinity, duration: 2 },
},
thinking: {
emoji: "🤔",
animation: { rotate: [0, -10, 10, 0] },
transition: { repeat: Infinity, duration: 1.5 },
},
celebrate: {
emoji: "🎉",
animation: { scale: [1, 1.2, 1] },
transition: { repeat: Infinity, duration: 0.8 },
},
coach: {
emoji: "🐱",
animation: { y: 0 },
transition: { duration: 0 },
},
}
export function GatoMascota({ mood = "happy", message, size = "md" }: GatoMascotaProps) {
const config = moods[mood]
const sizeClass = size === "sm" ? "text-3xl" : size === "lg" ? "text-7xl" : "text-5xl"
return (
<div className="flex flex-col items-center gap-2">
<motion.span
className={`${sizeClass} block select-none`}
animate={config.animation}
transition={config.transition}
>
{config.emoji}
</motion.span>
{message && (
<p className="text-foreground/80 text-lg font-display font-medium text-center max-w-xs">
{message}
</p>
)}
</div>
)
}
export default GatoMascota
+53
View File
@@ -0,0 +1,53 @@
"use client"
import { useRouter } from "next/navigation"
import { GatoMascota } from "./gato-mascota"
const CHILD_ID_KEY = "edueasy_child_id"
interface Props {
onRetry?: () => void
}
export default function NoChildError({ onRetry }: Props) {
const router = useRouter()
const hasChildId = typeof window !== "undefined" && localStorage.getItem(CHILD_ID_KEY)
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
<GatoMascota mood="coach" message={hasChildId ? "Ups, hubo un problema" : "Todavía no estoy conectado"} size="lg" />
<div className="text-center max-w-xs">
{hasChildId ? (
<p className="text-foreground/60">
No pude conectar con el servidor. Revisá la conexión.
</p>
) : (
<>
<p className="text-foreground/60">Pedile a mamá que te empareje desde su celular.</p>
<p className="text-foreground/40 text-sm mt-1">Mamá va a Dashboard Emparejar nuevo iPad</p>
</>
)}
</div>
<div className="flex gap-3">
{hasChildId && onRetry && (
<button
onClick={onRetry}
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
>
Reintentar
</button>
)}
<button
onClick={() => router.push("/child/pairing")}
className={`touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold ${
hasChildId
? "bg-secondary text-secondary-foreground"
: "bg-primary text-primary-foreground"
}`}
>
{hasChildId ? "Emparejar otro iPad" : "Ir a emparejar"}
</button>
</div>
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { motion } from "framer-motion"
interface TemporizadorVisualProps {
durationMinutes: number
onComplete: () => void
running: boolean
}
export default function TemporizadorVisual({
durationMinutes,
onComplete,
running,
}: TemporizadorVisualProps) {
const [remaining, setRemaining] = useState(durationMinutes * 60)
const totalSec = durationMinutes * 60
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
useEffect(() => {
if (running) {
intervalRef.current = setInterval(() => {
setRemaining((prev) => {
if (prev <= 1) {
clearInterval(intervalRef.current!)
onCompleteRef.current()
return 0
}
return prev - 1
})
}, 1000)
} else {
if (intervalRef.current) clearInterval(intervalRef.current)
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [running])
const fraction = remaining / totalSec
const degrees = fraction * 360
const minutes = Math.floor(remaining / 60)
const seconds = remaining % 60
return (
<div className="flex flex-col items-center gap-2">
<div className="relative w-20 h-20">
<svg className="w-full h-full -rotate-90" viewBox="0 0 100 100">
<circle
cx="50" cy="50" r="42"
fill="none"
stroke="#E8E0D8"
strokeWidth="8"
/>
<circle
cx="50" cy="50" r="42"
fill="none"
stroke={fraction > 0.3 ? "#8CB8A0" : "#D4A574"}
strokeWidth="8"
strokeDasharray="264"
strokeDashoffset={264 * (1 - fraction)}
strokeLinecap="round"
className="transition-all duration-1000 ease-linear"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-lg font-display font-bold text-foreground">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
</div>
</div>
)
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import { useState, useRef, useCallback, useEffect } from "react"
import { getStroke } from "perfect-freehand"
interface TrazoLetraProps {
letra: string
color?: string
onComplete?: () => void
promptLevel?: number
}
export default function TrazoLetra({
letra,
color = "#8CB8A0",
onComplete,
promptLevel = 0,
}: TrazoLetraProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isDrawing, setIsDrawing] = useState(false)
const points = useRef<number[][]>([])
const [showLetter, setShowLetter] = useState(promptLevel < 2)
useEffect(() => {
if (promptLevel < 2) {
drawLetterGuide(letra)
}
}, [letra, promptLevel])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
canvas.style.touchAction = "none"
}, [])
const drawLetterGuide = (l: string) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
ctx.font = `${h * 0.6}px "Fredoka", sans-serif`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.globalAlpha = 0.15
ctx.fillStyle = "#2D3436"
ctx.fillText(l.toUpperCase(), w / 2, h / 2)
ctx.globalAlpha = 1
}
const startDrawing = useCallback((e: React.PointerEvent) => {
setIsDrawing(true)
points.current = [[e.clientX, e.clientY, e.pressure || 0.5]]
const canvas = canvasRef.current
if (canvas) canvas.setPointerCapture(e.pointerId)
}, [])
const draw = useCallback((e: React.PointerEvent) => {
if (!isDrawing) return
points.current.push([e.clientX, e.clientY, e.pressure || 0.5])
renderStroke()
}, [isDrawing])
const endDrawing = useCallback(() => {
setIsDrawing(false)
if (points.current.length > 5 && onComplete) {
onComplete()
}
points.current = []
}, [onComplete])
const renderStroke = () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const stroke = getStroke(points.current, {
size: 12,
thinning: 0.5,
smoothing: 0.5,
streamline: 0.5,
})
if (stroke.length < 2) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (showLetter) drawLetterGuide(letra)
ctx.beginPath()
ctx.moveTo(stroke[0][0], stroke[0][1])
for (let i = 1; i < stroke.length; i++) {
ctx.lineTo(stroke[i][0], stroke[i][1])
}
ctx.strokeStyle = color
ctx.lineWidth = 3
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.fillStyle = color
ctx.fill()
if (promptLevel < 3) {
drawDirectionArrow(ctx, letra)
}
}
const drawDirectionArrow = (ctx: CanvasRenderingContext2D, l: string) => {
const w = ctx.canvas.width
const h = ctx.canvas.height
ctx.save()
ctx.globalAlpha = 0.3
ctx.strokeStyle = "#8CB8A0"
ctx.lineWidth = 2
ctx.fillStyle = "#8CB8A0"
const arrowPaths: Record<string, { sx: number; sy: number; ex: number; ey: number }> = {
a: { sx: w * 0.3, sy: h * 0.7, ex: w * 0.7, ey: h * 0.3 },
e: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
i: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
o: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.6, ey: h * 0.6 },
u: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
}
const path = arrowPaths[l.toLowerCase()]
if (path) {
ctx.beginPath()
ctx.moveTo(path.sx, path.sy)
ctx.lineTo(path.ex, path.ey)
ctx.stroke()
const angle = Math.atan2(path.ey - path.sy, path.ex - path.sx)
const sz = 6
ctx.beginPath()
ctx.moveTo(path.ex, path.ey)
ctx.lineTo(path.ex - sz * Math.cos(angle - Math.PI / 6), path.ey - sz * Math.sin(angle - Math.PI / 6))
ctx.lineTo(path.ex - sz * Math.cos(angle + Math.PI / 6), path.ey - sz * Math.sin(angle + Math.PI / 6))
ctx.closePath()
ctx.fill()
}
ctx.restore()
}
return (
<canvas
ref={canvasRef}
width={300}
height={300}
className="w-[250px] h-[250px] touch-none rounded-2xl bg-white shadow-md"
onPointerDown={startDrawing}
onPointerMove={draw}
onPointerUp={endDrawing}
onPointerCancel={endDrawing}
/>
)
}
+43
View File
@@ -0,0 +1,43 @@
"use client"
import { motion } from "framer-motion"
export interface VocalCardProps {
letra: string
fonema: string
palabra: string
imageEmoji: string
color?: string
onClick: () => void
disabled?: boolean
}
export default function VocalCard({
letra,
fonema,
palabra,
imageEmoji,
color = "#8CB8A0",
onClick,
disabled = false,
}: VocalCardProps) {
return (
<motion.button
onClick={onClick}
disabled={disabled}
className={`touch-target-lg rounded-3xl flex flex-col items-center justify-center gap-3 p-6 shadow-md ${disabled ? "opacity-40" : ""}`}
style={{ backgroundColor: color }}
whileTap={{ scale: 0.92 }}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
>
<span className="text-5xl font-display font-bold text-white drop-shadow-sm">
{letra.toUpperCase()}
</span>
<span className="text-3xl">{imageEmoji}</span>
<span className="text-white/90 text-lg font-display font-medium">
{palabra}
</span>
</motion.button>
)
}
@@ -0,0 +1,57 @@
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { speak } from "@/lib/speech"
import { GatoMascota } from "@/components/child/gato-mascota"
import type { Exercise } from "@/curriculum/types"
interface Props {
exercise: Exercise
onComplete: (correct: boolean) => void
}
export default function EjercicioConciencia({ exercise, onComplete }: Props) {
const { content } = exercise
const options = content.options || []
const handleChoose = async (opt: (typeof options)[0]) => {
if (content.audioText) await speak(content.audioText)
onComplete(opt.correct)
}
return (
<div className="flex flex-col items-center gap-6">
<GatoMascota mood="coach" message={content.instruction} size="sm" />
{content.text && (
<p className="text-2xl font-display font-bold text-center">{content.text}</p>
)}
{content.emoji && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="text-6xl"
>
{content.emoji}
</motion.div>
)}
<div className="flex flex-wrap gap-4 justify-center max-w-md">
{options.map((opt, i) => (
<motion.button
key={`${opt.label}-${i}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
onClick={() => handleChoose(opt)}
className="touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
>
{opt.label}
</motion.button>
))}
</div>
</div>
)
}
@@ -0,0 +1,46 @@
"use client"
import { motion } from "framer-motion"
import { speak } from "@/lib/speech"
import { GatoMascota } from "@/components/child/gato-mascota"
import type { Exercise } from "@/curriculum/types"
interface Props {
exercise: Exercise
onComplete: (correct: boolean) => void
}
export default function EjercicioEleccion({ exercise, onComplete }: Props) {
const { content } = exercise
const options = content.options || []
const handleChoose = async (opt: (typeof options)[0]) => {
if (content.audioText) await speak(content.audioText)
onComplete(opt.correct)
}
return (
<div className="flex flex-col items-center gap-6">
<GatoMascota mood="coach" message={content.instruction} size="sm" />
{content.text && (
<p className="text-2xl font-display font-bold text-center">{content.text}</p>
)}
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
{options.map((opt, i) => (
<motion.button
key={`${opt.label}-${i}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.12 }}
onClick={() => handleChoose(opt)}
className="touch-target-lg rounded-2xl px-8 py-6 text-3xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
>
{opt.label}
</motion.button>
))}
</div>
</div>
)
}
@@ -0,0 +1,56 @@
"use client"
import { motion } from "framer-motion"
import { speak } from "@/lib/speech"
import { GatoMascota } from "@/components/child/gato-mascota"
import type { Exercise } from "@/curriculum/types"
interface Props {
exercise: Exercise
onComplete: (correct: boolean) => void
}
export default function EjercicioLectura({ exercise, onComplete }: Props) {
const { content } = exercise
const options = content.options || []
const handleChoose = async (opt: (typeof options)[0]) => {
if (content.audioText) await speak(content.audioText)
onComplete(opt.correct)
}
return (
<div className="flex flex-col items-center gap-6">
<GatoMascota mood="coach" message={content.instruction} size="sm" />
{content.text && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="bg-card border rounded-2xl p-6 max-w-md text-center"
>
<p className="text-xl font-display leading-relaxed">{content.text}</p>
</motion.div>
)}
{content.imageEmoji && (
<div className="text-6xl">{content.imageEmoji}</div>
)}
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
{options.map((opt, i) => (
<motion.button
key={`${opt.label}-${i}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.12 }}
onClick={() => handleChoose(opt)}
className="touch-target-lg rounded-2xl px-8 py-5 text-lg font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
>
{opt.label}
</motion.button>
))}
</div>
</div>
)
}
@@ -0,0 +1,60 @@
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { speak } from "@/lib/speech"
import { GatoMascota } from "@/components/child/gato-mascota"
import type { Exercise } from "@/curriculum/types"
interface Props {
exercise: Exercise
onComplete: (correct: boolean) => void
}
export default function EjercicioPalabra({ exercise, onComplete }: Props) {
const { content } = exercise
const options = content.options || []
const handleChoose = async (opt: (typeof options)[0]) => {
if (opt.id === "escuchar") {
if (content.audioText) await speak(content.audioText)
return
}
onComplete(opt.correct)
}
return (
<div className="flex flex-col items-center gap-6">
<GatoMascota mood="coach" message={content.instruction} size="sm" />
{content.imageEmoji && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="text-6xl"
>
{content.imageEmoji}
</motion.div>
)}
<div className="flex flex-wrap gap-4 justify-center max-w-md">
{options.map((opt, i) => (
<motion.button
key={`${opt.label}-${i}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
onClick={() => handleChoose(opt)}
className={`touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform ${
opt.id === "escuchar"
? "bg-accent text-accent-foreground"
: "bg-white"
}`}
>
{opt.label}
</motion.button>
))}
</div>
</div>
)
}
@@ -0,0 +1,54 @@
"use client"
import { useState } from "react"
import { motion, Reorder } from "framer-motion"
import { speak } from "@/lib/speech"
import { GatoMascota } from "@/components/child/gato-mascota"
import type { Exercise } from "@/curriculum/types"
interface Props {
exercise: Exercise
onComplete: (correct: boolean) => void
}
export default function EjercicioSecuencia({ exercise, onComplete }: Props) {
const { content } = exercise
const sequence = content.sequence || []
const [items, setItems] = useState(() =>
[...sequence].sort(() => Math.random() - 0.5),
)
const handleReorder = (newOrder: string[]) => {
setItems(newOrder)
if (newOrder.every((item, i) => item === sequence[i])) {
if (content.audioText) speak(content.audioText)
onComplete(true)
}
}
return (
<div className="flex flex-col items-center gap-6">
<GatoMascota mood="coach" message={content.instruction} size="sm" />
<Reorder.Group
axis="y"
values={items}
onReorder={handleReorder}
className="flex flex-col gap-3 max-w-xs w-full"
>
{items.map((item) => (
<Reorder.Item
key={item}
value={item}
className="touch-target-lg rounded-2xl px-6 py-4 text-xl font-display font-bold bg-white shadow-md cursor-grab active:cursor-grabbing text-center"
>
{item}
</Reorder.Item>
))}
</Reorder.Group>
<p className="text-sm text-foreground/40">Arrastrá para ordenar</p>
</div>
)
}
@@ -0,0 +1,61 @@
"use client"
import { motion } from "framer-motion"
import { speak } from "@/lib/speech"
import { GatoMascota } from "@/components/child/gato-mascota"
import type { Exercise } from "@/curriculum/types"
interface Props {
exercise: Exercise
onComplete: (correct: boolean) => void
}
export default function EjercicioSensibilizacion({ exercise, onComplete }: Props) {
const { content } = exercise
const hasOptions = content.options && content.options.length > 0
const handleClick = async (option: { label: string; correct: boolean }) => {
if (content.audioText) await speak(content.audioText)
onComplete(option.correct)
}
return (
<div className="flex flex-col items-center gap-6">
<GatoMascota mood="coach" message={content.instruction} size="sm" />
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-6xl text-center"
>
{content.emoji && <span>{content.emoji}</span>}
</motion.div>
{hasOptions && (
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
{content.options!.map((opt, i) => (
<motion.button
key={opt.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
onClick={() => handleClick(opt)}
className="touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
>
{opt.label}
</motion.button>
))}
</div>
)}
{!hasOptions && (
<button
onClick={() => onComplete(true)}
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>
)
}
+66
View File
@@ -0,0 +1,66 @@
"use client"
import { motion } from "framer-motion"
import { speak } from "@/lib/speech"
import { GatoMascota } from "@/components/child/gato-mascota"
import TrazoLetra from "@/components/child/trazo-letra"
import type { Exercise } from "@/curriculum/types"
interface Props {
exercise: Exercise
onComplete: (correct: boolean) => void
}
export default function EjercicioTrazo({ exercise, onComplete }: Props) {
const { content } = exercise
const charToTrace = content.text && content.text.length === 1
? content.text.toUpperCase()
: null
const handleAudio = async () => {
if (content.audioText) await speak(content.audioText)
}
return (
<div className="flex flex-col items-center gap-6">
<GatoMascota mood="coach" message={content.instruction} size="sm" />
{charToTrace ? (
<>
<TrazoLetra
letra={charToTrace}
color={content.color || '#8CB8A0'}
onComplete={() => onComplete(true)}
promptLevel={0}
/>
{content.audioText && (
<button
onClick={handleAudio}
className="text-sm text-primary underline"
>
Escuchar instrucción
</button>
)}
</>
) : (
<>
{content.emoji && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="text-6xl"
>
{content.emoji}
</motion.div>
)}
<button
onClick={() => onComplete(true)}
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>
)
}
@@ -0,0 +1,34 @@
"use client"
import dynamic from "next/dynamic"
import type { Exercise, ExerciseType } from "@/curriculum/types"
const EjercicioSensibilizacion = dynamic(() => import("./ejercicio-sensibilizacion"), { ssr: false })
const EjercicioConciencia = dynamic(() => import("./ejercicio-conciencia"), { ssr: false })
const EjercicioEleccion = dynamic(() => import("./ejercicio-eleccion"), { ssr: false })
const EjercicioSecuencia = dynamic(() => import("./ejercicio-secuencia"), { ssr: false })
const EjercicioPalabra = dynamic(() => import("./ejercicio-palabra"), { ssr: false })
const EjercicioLectura = dynamic(() => import("./ejercicio-lectura"), { ssr: false })
const EjercicioTrazo = dynamic(() => import("./ejercicio-trazo"), { ssr: false })
const componentMap: Record<ExerciseType, React.ComponentType<{ exercise: Exercise; onComplete: (correct: boolean) => void }>> = {
sensibilizacion: EjercicioSensibilizacion,
conciencia: EjercicioConciencia,
trazo: EjercicioTrazo,
eleccion: EjercicioEleccion,
emparejar: EjercicioConciencia,
secuencia: EjercicioSecuencia,
palabra: EjercicioPalabra,
problema: EjercicioLectura,
lectura: EjercicioLectura,
}
interface Props {
exercise: Exercise
onComplete: (correct: boolean) => void
}
export default function ExerciseDispatcher({ exercise, onComplete }: Props) {
const Component = componentMap[exercise.type] || EjercicioSensibilizacion
return <Component exercise={exercise} onComplete={onComplete} />
}
@@ -0,0 +1,68 @@
"use client"
const errorLabels: Record<string, string> = {
"discriminacion-auditiva": "Discriminación auditiva",
"confusion-letra": "Confusión de letra",
"silaba-dificil": "Sílaba difícil",
"omision-letra": "Omisión de letra",
"inversion-letra": "Inversión de letra",
}
const errorColors: Record<string, string> = {
"discriminacion-auditiva": "bg-blue-200 text-blue-800",
"confusion-letra": "bg-red-200 text-red-800",
"silaba-dificil": "bg-yellow-200 text-yellow-800",
"omision-letra": "bg-purple-200 text-purple-800",
"inversion-letra": "bg-orange-200 text-orange-800",
}
interface Props {
data: Record<string, number>
total: number
}
export default function ErrorPatternsChart({ data, total }: Props) {
const entries = Object.entries(data)
if (total === 0) {
return (
<p className="text-sm text-muted-foreground text-center py-4">
Sin errores registrados. ¡Bien ahí!
</p>
)
}
return (
<div className="space-y-2">
{entries.map(([key, count]) => {
const pct = Math.round((count / total) * 100)
return (
<div key={key}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium">{errorLabels[key] || key}</span>
<span className="text-muted-foreground">
{count} ({pct}%)
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
key === "discriminacion-auditiva"
? "bg-blue-400"
: key === "confusion-letra"
? "bg-red-400"
: key === "silaba-dificil"
? "bg-yellow-400"
: key === "omision-letra"
? "bg-purple-400"
: "bg-orange-400"
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils"
interface MetricCardProps {
title: string
value: string | number
subtitle?: string
variant?: "default" | "primary" | "success" | "accent"
className?: string
}
const variants = {
default: "bg-white",
primary: "bg-primary/10 border-primary/20",
success: "bg-success/10 border-success/20",
accent: "bg-accent/10 border-accent/20",
}
export function MetricCard({ title, value, subtitle, variant = "default", className }: MetricCardProps) {
return (
<div className={cn("rounded-2xl p-4 shadow-sm border border-border", variants[variant], className)}>
<p className="text-sm text-muted-foreground">{title}</p>
<p className="text-3xl font-display font-bold text-foreground mt-1">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
interface MilestoneData {
skillCode: string
reachedAt: string
}
const skillLabels: Record<string, string> = {
"vocal-a": "Letra A",
"vocal-e": "Letra E",
"vocal-i": "Letra I",
"vocal-o": "Letra O",
"vocal-u": "Letra U",
"numero-1": "Número 1",
"numero-2": "Número 2",
"numero-3": "Número 3",
"numero-4": "Número 4",
"numero-5": "Número 5",
}
export function MilestonesList({ data }: { data: MilestoneData[] }) {
if (!data || data.length === 0) {
return (
<div className="text-center text-muted-foreground italic py-4">
Todavía no hay logros
</div>
)
}
return (
<div className="flex flex-wrap gap-2">
{data.map((m) => (
<div
key={m.skillCode}
className="bg-primary/10 border border-primary/20 rounded-xl px-3 py-2 text-sm font-display font-medium"
>
{skillLabels[m.skillCode] || m.skillCode}
</div>
))}
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
"use client"
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
Radar,
ResponsiveContainer,
} from "recharts"
interface SkillData {
skillCode: string
stability: number
reps: number
}
export function SkillMasteryChart({ data }: { data: SkillData[] }) {
if (!data || data.length === 0) {
return (
<div className="text-center text-muted-foreground italic py-8">
Sin skills todavía
</div>
)
}
const chartData = data
.filter((s) => s.reps > 0)
.slice(0, 8)
.map((s) => ({
skill: s.skillCode.replace("vocal-", "").replace("numero-", "num "),
mastery: Math.min(Math.round((s.stability / 100) * 100), 100),
}))
if (chartData.length === 0) {
return (
<div className="text-center text-muted-foreground italic py-8">
Sin skills todavía
</div>
)
}
return (
<div className="w-full h-48">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<PolarGrid stroke="#E8E0D8" />
<PolarAngleAxis dataKey="skill" tick={{ fontSize: 11, fill: "#6B7280" }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} />
<Radar
name="Maestría"
dataKey="mastery"
stroke="#8CB8A0"
fill="#8CB8A0"
fillOpacity={0.3}
/>
</RadarChart>
</ResponsiveContainer>
</div>
)
}
@@ -0,0 +1,52 @@
"use client"
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from "recharts"
interface WeeklyData {
date: string
minutes: number
}
export function WeeklyActivityChart({ data }: { data: WeeklyData[] }) {
if (!data || data.length === 0) {
return (
<div className="text-center text-muted-foreground italic py-8">
No hay actividad esta semana
</div>
)
}
const daysOfWeek = ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"]
const chartData = data.map((d) => {
const day = new Date(d.date).getDay()
return {
name: daysOfWeek[day],
minutos: d.minutes,
}
})
return (
<div className="w-full h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E8E0D8" />
<XAxis dataKey="name" tick={{ fontSize: 12, fill: "#6B7280" }} />
<YAxis tick={{ fontSize: 12, fill: "#6B7280" }} />
<Tooltip
contentStyle={{ borderRadius: 12, border: "1px solid #E8E0D8" }}
formatter={(value: number) => [`${value} min`, "Actividad"]}
/>
<Bar dataKey="minutos" fill="#8CB8A0" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+26
View File
@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }