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>
)
}