feat: EduEasy checkpoint - plan, scaffolding, pedagogia modules, components child+parent, API routes, Prisma schema, Docker infra, Better Auth
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"fingerprint": "c6be2155b5d458c41adc0623b75fe4fad880b5f6"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# Skill Registry — edueasy
|
||||
|
||||
<!-- Auto-generated by gentle-ai skill-registry refresh. Run `gentle-ai skill-registry refresh --force` to regenerate. -->
|
||||
|
||||
Last updated: 2026-07-20
|
||||
|
||||
## Sources scanned
|
||||
|
||||
- /home/ren/.config/opencode/skills
|
||||
- /home/ren/.claude/skills
|
||||
- /home/ren/.codex/skills
|
||||
- /home/ren/.qwen/skills
|
||||
|
||||
## Contract
|
||||
|
||||
**Delegator use only.** This registry is an index, not a summary. Any agent that launches subagents reads it to select relevant skills, then passes exact `SKILL.md` paths for the subagent to read before work.
|
||||
|
||||
`SKILL.md` remains the source of truth. Do not inject generated summaries or compact rules by default; pass paths so subagents load the full runtime contract and preserve author intent.
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Trigger / description | Scope | Path |
|
||||
| --- | --- | --- | --- |
|
||||
| `api-design` | REST API design patterns including resource naming, status codes, pagination, filtering, error responses, versioning, and rate limiting for production APIs. | user | `/home/ren/.claude/skills/api-design/SKILL.md` |
|
||||
| `backend-patterns` | Backend architecture patterns, API design, database optimization, and server-side best practices for Node.js, Express, and Next.js API routes. | user | `/home/ren/.claude/skills/backend-patterns/SKILL.md` |
|
||||
| `branch-pr` | Create Gentle AI pull requests with issue-first checks. Trigger: creating, opening, or preparing PRs for review. | user | `/home/ren/.config/opencode/skills/branch-pr/SKILL.md` |
|
||||
| `chained-pr` | Trigger: PRs over 400 lines, stacked PRs, review slices. Split oversized changes into chained PRs that protect review focus. | user | `/home/ren/.config/opencode/skills/chained-pr/SKILL.md` |
|
||||
| `clickhouse-io` | ClickHouse database patterns, query optimization, analytics, and data engineering best practices for high-performance analytical workloads. | user | `/home/ren/.claude/skills/clickhouse-io/SKILL.md` |
|
||||
| `coding-standards` | Universal coding standards, best practices, and patterns for TypeScript, JavaScript, React, and Node.js development. | user | `/home/ren/.claude/skills/coding-standards/SKILL.md` |
|
||||
| `cognitive-doc-design` | Design docs that reduce cognitive load. Trigger: writing guides, READMEs, RFCs, onboarding, architecture, or review-facing docs. | user | `/home/ren/.config/opencode/skills/cognitive-doc-design/SKILL.md` |
|
||||
| `comment-writer` | Write warm, direct collaboration comments. Trigger: PR feedback, issue replies, reviews, Slack messages, or GitHub comments. | user | `/home/ren/.config/opencode/skills/comment-writer/SKILL.md` |
|
||||
| `configure-ecc` | Interactive installer for Everything Claude Code — guides users through selecting and installing skills and rules to user-level or project-level directories, verifies paths, and optionally optimizes installed files. | user | `/home/ren/.claude/skills/configure-ecc/SKILL.md` |
|
||||
| `constructor` | Build, rebuild, flatten, clean, and verify structures in the Minecraft world managed by Crafty Controller on this VPS. Use when requests involve placing/removing blocks, terraforming areas, teleporting players, recreating murals/flags from images, validating coordinates or block states, and keeping ren.md updated after world changes. | user | `/home/ren/.codex/skills/constructor/SKILL.md` |
|
||||
| `continuous-learning` | Automatically extract reusable patterns from Claude Code sessions and save them as learned skills for future use. | user | `/home/ren/.claude/skills/continuous-learning/SKILL.md` |
|
||||
| `continuous-learning-v2` | Instinct-based learning system that observes sessions via hooks, creates atomic instincts with confidence scoring, and evolves them into skills/commands/agents. | user | `/home/ren/.claude/skills/continuous-learning-v2/SKILL.md` |
|
||||
| `cpp-testing` | Use only when writing/updating/fixing C++ tests, configuring GoogleTest/CTest, diagnosing failing or flaky tests, or adding coverage/sanitizers. | user | `/home/ren/.claude/skills/cpp-testing/SKILL.md` |
|
||||
| `database-migrations` | Database migration best practices for schema changes, data migrations, rollbacks, and zero-downtime deployments across PostgreSQL, MySQL, and common ORMs (Prisma, Drizzle, Django, TypeORM, golang-migrate). | user | `/home/ren/.claude/skills/database-migrations/SKILL.md` |
|
||||
| `deployment-patterns` | Deployment workflows, CI/CD pipeline patterns, Docker containerization, health checks, rollback strategies, and production readiness checklists for web applications. | user | `/home/ren/.claude/skills/deployment-patterns/SKILL.md` |
|
||||
| `django-patterns` | Django architecture patterns, REST API design with DRF, ORM best practices, caching, signals, middleware, and production-grade Django apps. | user | `/home/ren/.claude/skills/django-patterns/SKILL.md` |
|
||||
| `django-security` | Django security best practices, authentication, authorization, CSRF protection, SQL injection prevention, XSS prevention, and secure deployment configurations. | user | `/home/ren/.claude/skills/django-security/SKILL.md` |
|
||||
| `django-tdd` | Django testing strategies with pytest-django, TDD methodology, factory_boy, mocking, coverage, and testing Django REST Framework APIs. | user | `/home/ren/.claude/skills/django-tdd/SKILL.md` |
|
||||
| `django-verification` | Verification loop for Django projects: migrations, linting, tests with coverage, security scans, and deployment readiness checks before release or PR. | user | `/home/ren/.claude/skills/django-verification/SKILL.md` |
|
||||
| `docker-patterns` | Docker and Docker Compose patterns for local development, container security, networking, volume strategies, and multi-service orchestration. | user | `/home/ren/.claude/skills/docker-patterns/SKILL.md` |
|
||||
| `e2e-testing` | Playwright E2E testing patterns, Page Object Model, configuration, CI/CD integration, artifact management, and flaky test strategies. | user | `/home/ren/.claude/skills/e2e-testing/SKILL.md` |
|
||||
| `eval-harness` | Formal evaluation framework for Claude Code sessions implementing eval-driven development (EDD) principles | user | `/home/ren/.claude/skills/eval-harness/SKILL.md` |
|
||||
| `frontend-patterns` | Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices. | user | `/home/ren/.claude/skills/frontend-patterns/SKILL.md` |
|
||||
| `gentle-ai-chained-pr` | Split large changes into chained or stacked pull requests that protect reviewer focus and stay within Gentle AI's 400-line cognitive review budget. Trigger: when a PR would exceed 400 changed lines, when planning chained PRs, stacked PRs, or reviewable slices. | user | `/home/ren/.claude/skills/chained-pr/SKILL.md` |
|
||||
| `go-testing` | Trigger: Go tests, go test coverage, Bubbletea teatest, golden files. Apply focused Go testing patterns. | user | `/home/ren/.config/opencode/skills/go-testing/SKILL.md` |
|
||||
| `golang-patterns` | Idiomatic Go patterns, best practices, and conventions for building robust, efficient, and maintainable Go applications. | user | `/home/ren/.claude/skills/golang-patterns/SKILL.md` |
|
||||
| `golang-testing` | Go testing patterns including table-driven tests, subtests, benchmarks, fuzzing, and test coverage. Follows TDD methodology with idiomatic Go practices. | user | `/home/ren/.claude/skills/golang-testing/SKILL.md` |
|
||||
| `issue-creation` | Create Gentle AI issues with issue-first checks. Trigger: creating GitHub issues, bug reports, or feature requests. | user | `/home/ren/.config/opencode/skills/issue-creation/SKILL.md` |
|
||||
| `iterative-retrieval` | Pattern for progressively refining context retrieval to solve the subagent context problem | user | `/home/ren/.claude/skills/iterative-retrieval/SKILL.md` |
|
||||
| `java-coding-standards` | Java coding standards for Spring Boot services: naming, immutability, Optional usage, streams, exceptions, generics, and project layout. | user | `/home/ren/.claude/skills/java-coding-standards/SKILL.md` |
|
||||
| `jpa-patterns` | JPA/Hibernate patterns for entity design, relationships, query optimization, transactions, auditing, indexing, pagination, and pooling in Spring Boot. | user | `/home/ren/.claude/skills/jpa-patterns/SKILL.md` |
|
||||
| `judgment-day` | Trigger: judgment day, dual review, adversarial review, juzgar. Run blind dual review, fix confirmed issues, then re-judge. | user | `/home/ren/.config/opencode/skills/judgment-day/SKILL.md` |
|
||||
| `kilo-force-multiplier` | Use Kilo (OpenCode's AI agent) as a force multiplier for development work. Covers subagent architecture, custom commands, skills system, MCP integration, multi-model routing, context management, and the SDD workflow. Trigger: when setting up Kilo, creating custom commands, building skills, configuring MCP servers, or optimizing AI-assisted development workflows. | user | `/home/ren/.claude/skills/kilo-force-multiplier/SKILL.md` |
|
||||
| `minebuilder` | — | user | `/home/ren/.claude/skills/minebuilder/SKILL.md` |
|
||||
| `minecraft-construction` | Minecraft large-scale construction via Crafty API. Parallel section processing, proper terrain flattening, chunk management, and multi-agent coordination for efficient building. | user | `/home/ren/.claude/skills/minecraft-construction/SKILL.md` |
|
||||
| `nutrient-document-processing` | Process, convert, OCR, extract, redact, sign, and fill documents using the Nutrient DWS API. Works with PDFs, DOCX, XLSX, PPTX, HTML, and images. | user | `/home/ren/.claude/skills/nutrient-document-processing/SKILL.md` |
|
||||
| `postgres-patterns` | PostgreSQL database patterns for query optimization, schema design, indexing, and security. Based on Supabase best practices. | user | `/home/ren/.claude/skills/postgres-patterns/SKILL.md` |
|
||||
| `project-guidelines-example` | Example project-specific skill template based on a real production application. | user | `/home/ren/.claude/skills/project-guidelines-example/SKILL.md` |
|
||||
| `python-patterns` | Pythonic idioms, PEP 8 standards, type hints, and best practices for building robust, efficient, and maintainable Python applications. | user | `/home/ren/.claude/skills/python-patterns/SKILL.md` |
|
||||
| `python-testing` | Python testing strategies using pytest, TDD methodology, fixtures, mocking, parametrization, and coverage requirements. | user | `/home/ren/.claude/skills/python-testing/SKILL.md` |
|
||||
| `security-review` | Use this skill when adding authentication, handling user input, working with secrets, creating API endpoints, or implementing payment/sensitive features. Provides comprehensive security checklist and patterns. | user | `/home/ren/.claude/skills/security-review/SKILL.md` |
|
||||
| `security-scan` | Scan your Claude Code configuration (.claude/ directory) for security vulnerabilities, misconfigurations, and injection risks using AgentShield. Checks CLAUDE.md, settings.json, MCP servers, hooks, and agent definitions. | user | `/home/ren/.claude/skills/security-scan/SKILL.md` |
|
||||
| `skill-creator` | Trigger: new skills, agent instructions, documenting AI usage patterns. Create LLM-first skills with valid frontmatter. | user | `/home/ren/.config/opencode/skills/skill-creator/SKILL.md` |
|
||||
| `skill-improver` | Trigger: improve skills, audit skills, refactor skills, skill quality. Audit and upgrade existing LLM-first skills. | user | `/home/ren/.config/opencode/skills/skill-improver/SKILL.md` |
|
||||
| `skill-installer` | Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). | user | `/home/ren/.codex/skills/.system/skill-installer/SKILL.md` |
|
||||
| `springboot-patterns` | Spring Boot architecture patterns, REST API design, layered services, data access, caching, async processing, and logging. Use for Java Spring Boot backend work. | user | `/home/ren/.claude/skills/springboot-patterns/SKILL.md` |
|
||||
| `springboot-security` | Spring Security best practices for authn/authz, validation, CSRF, secrets, headers, rate limiting, and dependency security in Java Spring Boot services. | user | `/home/ren/.claude/skills/springboot-security/SKILL.md` |
|
||||
| `springboot-tdd` | Test-driven development for Spring Boot using JUnit 5, Mockito, MockMvc, Testcontainers, and JaCoCo. Use when adding features, fixing bugs, or refactoring. | user | `/home/ren/.claude/skills/springboot-tdd/SKILL.md` |
|
||||
| `springboot-verification` | Verification loop for Spring Boot projects: build, static analysis, tests with coverage, security scans, and diff review before release or PR. | user | `/home/ren/.claude/skills/springboot-verification/SKILL.md` |
|
||||
| `strategic-compact` | Suggests manual context compaction at logical intervals to preserve context through task phases rather than arbitrary auto-compaction. | user | `/home/ren/.claude/skills/strategic-compact/SKILL.md` |
|
||||
| `tdd-workflow` | Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests. | user | `/home/ren/.claude/skills/tdd-workflow/SKILL.md` |
|
||||
| `verification-loop` | A comprehensive verification system for Claude Code sessions. | user | `/home/ren/.claude/skills/verification-loop/SKILL.md` |
|
||||
| `work-unit-commits` | Plan commits as reviewable work units. Trigger: implementation, commit splitting, chained PRs, or keeping tests and docs with code. | user | `/home/ren/.config/opencode/skills/work-unit-commits/SKILL.md` |
|
||||
| `zai-vision` | Analyze images via Z.AI GLM-4.6V vision model. Use when the user shares an image file path, base64 image data, image URL, or asks to describe/analyze/OCR/read visual content. Gives vision capabilities to text-only models. | user | `/home/ren/.claude/skills/zai-vision/SKILL.md` |
|
||||
|
||||
## Loading protocol
|
||||
|
||||
1. Match task context and target files against the `Trigger / description` column.
|
||||
2. Pass only the matching `Path` values to the subagent under `## Skills to load before work`.
|
||||
3. Instruct the subagent to read those exact `SKILL.md` files before reading, writing, reviewing, testing, or creating artifacts.
|
||||
4. If no matching skill exists, proceed without project skill injection and report `skill_resolution: none`.
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.next/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env.local
|
||||
.env*.local
|
||||
.cache/
|
||||
dist/
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,219 @@
|
||||
# EduEasy — Plan de Plataforma
|
||||
|
||||
## Arquitectura General
|
||||
|
||||
Plataforma educativa para una nena de 8 años con déficit de atención, baja abstractividad, que no sabe leer ni contar (bases malas en 1er grado). Dos caras en un mismo server:
|
||||
|
||||
| App | Dominio | Dispositivo | Usuario |
|
||||
|-----|---------|-------------|---------|
|
||||
| **Aprendizaje** | `mate.cbcren.online` | iPad Air 4 (solo dedo) | La nena |
|
||||
| **Dashboard** | `simulacro.cbcren.online` | Poco F6 Pro | La mamá |
|
||||
|
||||
**Stack**: Next.js 15 + React 19 + TypeScript estricto + Tailwind CSS + shadcn/ui + Better Auth + Prisma + PostgreSQL + ts-fsrs
|
||||
**Deploy**: All-in-one Docker (un proceso Next.js + Postgres) en red `caddy` del server existente (194.163.191.200)
|
||||
|
||||
## Decisiones de Diseño Cerradas
|
||||
|
||||
| Decisión | Respuesta |
|
||||
|----------|-----------|
|
||||
| **Mascota** | 🐱 **Gato** — guía las sesiones con instrucciones en audio, celebra logros, da feedback en errorless learning. Sin nombre fijo aún. |
|
||||
| **Paleta** | **Calmada** — fondo `#FFF8F0` crema/beige, primario `#8CB8A0` verde salvia, secundario `#6B8FA3` azul medio, acento `#D4A574` terracota, texto `#2D3436` (no negro puro). Bajas saturaciones en todos. |
|
||||
| **Audio** | **TTS por ahora** (Web Speech API, voz es-ES femenina). Se reemplaza por grabaciones reales es-AR en Fase 4+ cuando el contenido esté validado. |
|
||||
|
||||
---
|
||||
|
||||
## Pedagogía (Métodos Europeos)
|
||||
|
||||
| Área | Método | Aplicación |
|
||||
|------|--------|------------|
|
||||
| **Lectura** | Borel-Maisonny (FR) | Cada fonema tiene un gesto físico + sonido. La nena hace el gesto en el aire + traza con el dedo en la pantalla + escucha el audio. |
|
||||
| **Lectura** | Jolly Phonics (UK) adaptado | Orden de aprendizaje: no A-B-C, sino por frecuencia de uso. Vocales primero (A, E, I, O, U) + consonantes sonoras continuas (S, M, L, N). |
|
||||
| **Lectura** | Método silábico | Español = 5 vocales puras, sílabas CV, CVC, CCV. Silabeo sistemático desde el día 1. |
|
||||
| **Numeración** | CPA (Concrete-Pictórico-Abstracto) | **Concreto**: objetos (tapitas virtuales para contar). **Pictórico**: dibujos de objetos. **Abstracto**: el número símbolo. |
|
||||
| **Numeración** | Regletas Cuisenaire (Bélgica) | Cantidad como barra de color. 1=blanco, 2=rojo, 3=verde claro... La nena ve y toca la cantidad sin "contar" abstractamente. |
|
||||
| **Internalización** | Galperin (URSS) | **1. Mano**: manipular objetos. **2. Voz externa**: decir en voz alta. **3. Voz interna**: susurrar. **4. Mental**: hacerlo en la cabeza. |
|
||||
| **Errores** | Errorless learning | **Nunca** se muestra "incorrecto". Si titubea, el sistema da la pista más obvia (modelado). Si persiste el error, fadea la ayuda. |
|
||||
| **Refuerzo** | Fijo y predecible | Sin slots, sin FOMO, sin rachas ansiosas. Estrellita predecible después de cada ronda completa. |
|
||||
| **Sesiones** | Chunking estructurado | 10-15 min máximo. Temporizador visual tipo Time Timer. Micro-pausa motora cada 5 min (saltar, aplaudir). |
|
||||
|
||||
---
|
||||
|
||||
## Stack Técnico
|
||||
|
||||
```
|
||||
Frontend: Next.js 15 (App Router) + React 19 + TypeScript + Tailwind CSS
|
||||
UI Lib: shadcn/ui (componentes base)
|
||||
Auth: Better Auth (login mamá + QR pairing → JWT device-bound para nena)
|
||||
DB: PostgreSQL + Prisma ORM
|
||||
Spaced Rep: ts-fsrs (Free Spaced Repetition Scheduler)
|
||||
Canvas: perfect-freehand (trazo con dedo) + @use-gesture/react
|
||||
Audio: Web Speech API (TTS es-ES/es-MX, confirmado: TTS por ahora → reemplazar con grabaciones reales es-AR en Fase 4+)
|
||||
Animación: framer-motion (transiciones lentas 300-500ms, sin sparkles)
|
||||
Charts: Recharts (dashboard mamá)
|
||||
Deploy: Docker + red caddy existente
|
||||
```
|
||||
|
||||
### Repos reutilizados
|
||||
- `ts-fsrs` (npm) — algoritmo FSRS de repetición espaciada
|
||||
- `perfect-freehand` (npm) — trazado fluido con dedo (Excalidraw)
|
||||
- Better Auth (npm) — auth + device pairing
|
||||
- shadcn/ui (npm) — componentes base de UI
|
||||
- Math Learning Center apps — referencia UX de manipulativos virtuales (number-rack, ten-frames)
|
||||
|
||||
---
|
||||
|
||||
## Modelo de Datos
|
||||
|
||||
```prisma
|
||||
model Family { id, name, created_at }
|
||||
model Parent { id, family_id, email, name, auth_provider }
|
||||
model Child { id, family_id, name, avatar, birthdate, profile_notes }
|
||||
model Device { id, child_id, device_fingerprint, paired_at, last_seen, role }
|
||||
model Session { id, child_id, started_at, ended_at, duration_sec, skills_practiced }
|
||||
model SkillAttempt { id, session_id, skill_code, prompt_level, correct, response_ms }
|
||||
model FsrsCard { id, child_id, skill_code, due_at, stability, difficulty, reps, lapses }
|
||||
model Milestone { id, child_id, skill_code, reached_at }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
/home/ren/edueasy/
|
||||
├── app/
|
||||
│ ├── globals.css
|
||||
│ ├── layout.tsx # Raíz (fonts, metadata)
|
||||
│ ├── page.tsx # Redirige según host
|
||||
│ ├── child/ # mate.cbcren.online
|
||||
│ │ ├── layout.tsx # Layout infantil (gato, audio siempre presente)
|
||||
│ │ ├── page.tsx # Menú principal de la nena
|
||||
│ │ ├── sesion/
|
||||
│ │ └── logros/
|
||||
│ ├── parent/ # simulacro.cbcren.online
|
||||
│ │ ├── layout.tsx # Layout dashboard + sidebar
|
||||
│ │ ├── page.tsx # Dashboard principal
|
||||
│ │ ├── auth/login/
|
||||
│ │ ├── auth/pairing/
|
||||
│ │ └── configuracion/
|
||||
│ └── api/
|
||||
│ ├── auth/[...all]/ # Better Auth handler
|
||||
│ ├── pairing/ # QR pairing endpoint
|
||||
│ └── sessions/sync/ # Sync offline → server
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui
|
||||
│ ├── child/ # Componentes de la nena
|
||||
│ │ ├── gato-mascota
|
||||
│ │ ├── trazo-letra (canvas tracing)
|
||||
│ │ ├── vocal-card
|
||||
│ │ ├── cuisenaire-rod
|
||||
│ │ ├── temporizador-visual
|
||||
│ │ └── boton-grande (60-80px)
|
||||
│ └── parent/ # Componentes del dashboard
|
||||
├── lib/
|
||||
│ ├── db.ts (Prisma client)
|
||||
│ ├── auth.ts (Better Auth server)
|
||||
│ ├── auth-client.ts
|
||||
│ ├── speech.ts (Web Speech API)
|
||||
│ ├── fsrs.ts (ts-fsrs wrapper)
|
||||
│ └── pedagogia/
|
||||
│ ├── borel-maisonny.ts (fonemas + gestos)
|
||||
│ ├── cuisenaire.ts (regletas)
|
||||
│ ├── errorless.ts (prompt fading)
|
||||
│ └── cpa-progression.ts (CPA estados)
|
||||
├── prisma/schema.prisma
|
||||
├── middleware.ts (host-based routing)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── next.config.ts
|
||||
├── tailwind.config.ts
|
||||
├── postcss.config.js
|
||||
├── components.json (shadcn/ui)
|
||||
└── .env.local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UX Constraints (iPad Air 4 + Dedo)
|
||||
|
||||
- **Touch targets**: 60–80px mínimo (baselines adultos 44px no alcanzan para niños)
|
||||
- **Drag > Tap** para interacciones principales
|
||||
- **Una sola acción focal** por pantalla
|
||||
- **100% iconografía + audio** (la nena NO LEE)
|
||||
- **Sin "X roja"** nunca — sonido neutro + mascota dice "probemos juntas"
|
||||
- **Fondo claro baja saturación** (crema/beige) + acentos verde salvia + azul medio
|
||||
- **Botón "Tocá para empezar"** en cada sesión (unlock AudioContext en iOS Safari)
|
||||
- **Persistencia al backend siempre** (Safari evicta IndexedDB a 7 días)
|
||||
|
||||
### iOS Safari Técnico
|
||||
- Audio: .m4a primario + .mp3 fallback. **NUNCA .ogg**.
|
||||
- AudioContext: requiere user gesture para desbloquear.
|
||||
- `100dvh` en vez de `100vh` (barra de Safari).
|
||||
- `touch-action: manipulation` para eliminar delay 300ms.
|
||||
- inputs con `font-size: 16px` mínimo (evita zoom automático).
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Fase 0 — Fundaciones (1-2 sem)
|
||||
- [x] Repositorio + package.json + configs
|
||||
- [x] Docker + docker-compose
|
||||
- [x] Prisma schema + migración
|
||||
- [x] Better Auth + QR pairing
|
||||
- [x] Design system base (gato mascota, paleta, component primitives)
|
||||
- [x] Caddy config update
|
||||
|
||||
### Fase 1 — Lectura MVP
|
||||
- [ ] Módulo vocales: A, E, I, O, U
|
||||
- [ ] Audio TTS para cada fonema + palabra ejemplo
|
||||
- [ ] Gesto Borel-Maisonny (animación + instrucción)
|
||||
- [ ] Trazo con dedo (canvas + perfect-freehand)
|
||||
- [ ] Sesión guiada 10 min + temporizador visual
|
||||
- [ ] Errorless learning + fading automático
|
||||
- [ ] Logros por vocal completada
|
||||
|
||||
### Fase 2 — Numeración MVP
|
||||
- [ ] Módulo cantidades 1-5 (expandir a 10)
|
||||
- [ ] CPA: objetos → imagen → símbolo
|
||||
- [ ] Regletas Cuisenaire virtuales (arrastrar, comparar)
|
||||
- [ ] Conteo one-to-one con arrastre
|
||||
- [ ] Galperin: mano → voz → cabeza
|
||||
|
||||
### Fase 3 — Adaptativo + Dashboard
|
||||
- [ ] ts-fsrs: repaso espaciado por skill
|
||||
- [ ] Dashboard mamá: streak, tiempo, mastery %, último resumen
|
||||
- [ ] Push notifications al Poco F6 Pro
|
||||
- [ ] QR pairing funcional
|
||||
- [ ] Export de progreso
|
||||
|
||||
### Fase 4+ (futuro)
|
||||
- [ ] Consonantes y sílabas completas
|
||||
- [ ] Números 11-100, suma/resta
|
||||
- [ ] Grabaciones de audio real (es-AR)
|
||||
- [ ] IA adaptativa (Ollama/Open WebUI)
|
||||
|
||||
---
|
||||
|
||||
## Diseño Visual
|
||||
|
||||
**Mascota**: 🐱 Gato (sin nombre definido — que la nena lo nombre).
|
||||
- Guía las sesiones, da instrucciones en audio, celebra logros.
|
||||
- Animaciones suaves (no bruscas), tamaño grande en pantalla.
|
||||
|
||||
**Paleta**:
|
||||
- Fondo: `#FFF8F0` (crema suave)
|
||||
- Primario: `#8CB8A0` (verde salvia)
|
||||
- Secundario: `#6B8FA3` (azul medio)
|
||||
- Acento: `#D4A574` (terracota suave)
|
||||
- Texto: `#2D3436` (gris oscuro, no negro puro)
|
||||
- Correcto: `#7FB685` (verde suave, no brillante)
|
||||
|
||||
**Tipografía**:
|
||||
- Títulos: Fredoka One (redondeada, amigable, legible)
|
||||
- Interface: Inter (sistema, para dashboard de la mamá)
|
||||
- La nena no ve texto — solo escucha. Las letras aparecen grandes, claras, bien trazadas.
|
||||
|
||||
---
|
||||
@@ -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,49 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { randomUUID } from "crypto"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { deviceFingerprint, pairingCode: incomingCode } = await request.json()
|
||||
|
||||
if (incomingCode) {
|
||||
// Step 2: Parent's device confirms pairing
|
||||
const device = await prisma.device.findFirst({
|
||||
where: { deviceFingerprint: incomingCode },
|
||||
include: { child: true },
|
||||
})
|
||||
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: "Código inválido" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
childId: device.childId,
|
||||
childName: device.child.name,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 1: Child device requests pairing
|
||||
const existingDevice = await prisma.device.findFirst({
|
||||
where: { deviceFingerprint },
|
||||
})
|
||||
|
||||
if (existingDevice) {
|
||||
return NextResponse.json({
|
||||
paired: true,
|
||||
childId: existingDevice.childId,
|
||||
})
|
||||
}
|
||||
|
||||
const pairingCode = randomUUID().slice(0, 8)
|
||||
|
||||
return NextResponse.json({
|
||||
paired: false,
|
||||
pairingCode,
|
||||
})
|
||||
} 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,27 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import GatoMascota from "@/components/child/gato-mascota"
|
||||
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,202 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import VocalCard from "@/components/child/vocal-card"
|
||||
import TrazoLetra from "@/components/child/trazo-letra"
|
||||
import TemporizadorVisual from "@/components/child/temporizador-visual"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { vocales } from "@/lib/pedagogia/borel-maisonny"
|
||||
import { getInitialState, evaluateResponse, isMastered } from "@/lib/pedagogia/errorless"
|
||||
|
||||
type Step = "gesto" | "escuchar" | "trazar" | "elegir" | "completo"
|
||||
|
||||
export default function LecturaSesion() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [step, setStep] = useState<Step>("gesto")
|
||||
const [promptState, setPromptState] = useState(getInitialState())
|
||||
const [sessionComplete, setSessionComplete] = useState(false)
|
||||
const [running, setRunning] = useState(true)
|
||||
const [score, setScore] = useState(0)
|
||||
|
||||
const vocal = vocales[currentIndex]
|
||||
|
||||
const handleGestoComplete = useCallback(() => {
|
||||
speak(`Escuchá. Esta es la letra ${vocal.letra}. ${vocal.palabra}. ${vocal.fonema} ${vocal.fonema} ${vocal.fonema}`)
|
||||
setStep("escuchar")
|
||||
}, [vocal])
|
||||
|
||||
const handleEscucharComplete = useCallback(() => {
|
||||
speak(`Ahora trazala con tu dedo`)
|
||||
setStep("trazar")
|
||||
}, [])
|
||||
|
||||
const handleTrazoComplete = useCallback(() => {
|
||||
setScore((s) => s + 1)
|
||||
const newState = evaluateResponse(promptState, true)
|
||||
setPromptState(newState)
|
||||
|
||||
if (isMastered(newState.level)) {
|
||||
speak(`¡Muy bien! Pasemos a la siguiente`)
|
||||
if (currentIndex < vocales.length - 1) {
|
||||
setCurrentIndex((i) => i + 1)
|
||||
setStep("gesto")
|
||||
setPromptState(getInitialState())
|
||||
} else {
|
||||
setSessionComplete(true)
|
||||
setRunning(false)
|
||||
}
|
||||
} else {
|
||||
setStep("elegir")
|
||||
}
|
||||
}, [promptState, currentIndex])
|
||||
|
||||
const handleChoice = useCallback(
|
||||
(correct: boolean) => {
|
||||
const newState = evaluateResponse(promptState, correct)
|
||||
setPromptState(newState)
|
||||
|
||||
if (correct) {
|
||||
setScore((s) => s + 1)
|
||||
}
|
||||
|
||||
if (isMastered(newState.level)) {
|
||||
if (currentIndex < vocales.length - 1) {
|
||||
setCurrentIndex((i) => i + 1)
|
||||
setStep("gesto")
|
||||
setPromptState(getInitialState())
|
||||
} else {
|
||||
setSessionComplete(true)
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[promptState, currentIndex],
|
||||
)
|
||||
|
||||
if (sessionComplete) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="celebrate" message="¡Terminaste todas las vocales!" size="lg" />
|
||||
<p className="text-lg font-display">Puntuación: {score}/{vocales.length * 2}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentIndex(0)
|
||||
setStep("gesto")
|
||||
setPromptState(getInitialState())
|
||||
setSessionComplete(false)
|
||||
setRunning(true)
|
||||
setScore(0)
|
||||
}}
|
||||
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)
|
||||
setSessionComplete(true)
|
||||
}}
|
||||
running={running}
|
||||
/>
|
||||
|
||||
<GatoMascota
|
||||
mood={step === "gesto" ? "thinking" : step === "completo" ? "celebrate" : "coach"}
|
||||
message={
|
||||
step === "gesto"
|
||||
? `Hacé así: ${vocal.gesto}`
|
||||
: step === "escuchar"
|
||||
? "Escuchá bien"
|
||||
: step === "trazar"
|
||||
? "Traza con tu dedo"
|
||||
: ""
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${vocal.letra}-${step}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{step === "gesto" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-8xl">{vocal.imageEmoji}</span>
|
||||
<p className="text-lg text-foreground/60 italic max-w-xs text-center">
|
||||
{vocal.gesto}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleGestoComplete}
|
||||
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>
|
||||
)}
|
||||
|
||||
{step === "escuchar" && (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<VocalCard
|
||||
letra={vocal.letra}
|
||||
fonema={vocal.fonema}
|
||||
palabra={vocal.palabra}
|
||||
imageEmoji={vocal.imageEmoji}
|
||||
color={vocal.color}
|
||||
onClick={() => {
|
||||
speak(`${vocal.fonema}... de ${vocal.palabra}`)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleEscucharComplete}
|
||||
className="bg-secondary text-secondary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "trazar" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<TrazoLetra
|
||||
letra={vocal.letra}
|
||||
fonema={vocal.fonema}
|
||||
color={vocal.color}
|
||||
onComplete={handleTrazoComplete}
|
||||
promptLevel={promptState.level}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "elegir" && (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<p className="text-lg font-display">¿Cuál es la {vocal.letra.toUpperCase()}?</p>
|
||||
<div className="flex flex-wrap gap-4 justify-center">
|
||||
{[...vocales].sort(() => Math.random() - 0.5).slice(0, 3).map((v) => (
|
||||
<button
|
||||
key={v.letra}
|
||||
onClick={() => handleChoice(v.letra === vocal.letra)}
|
||||
className={`touch-target-lg rounded-2xl text-2xl font-display font-bold px-8 py-6 shadow-md active:scale-95 transition-transform`}
|
||||
style={{ backgroundColor: v.color }}
|
||||
>
|
||||
{v.letra.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
|
||||
const achievements = [
|
||||
{ id: "a", label: "Letra A", emoji: "✈️", completed: true },
|
||||
{ id: "e", label: "Letra E", emoji: "🐘", completed: false },
|
||||
{ id: "i", label: "Letra I", emoji: "🏔️", completed: false },
|
||||
{ id: "o", label: "Letra O", emoji: "🐻", completed: false },
|
||||
{ id: "u", label: "Letra U", emoji: "🍇", completed: false },
|
||||
{ id: "1", label: "Número 1", emoji: "1️⃣", completed: false },
|
||||
{ id: "2", label: "Número 2", emoji: "2️⃣", completed: false },
|
||||
]
|
||||
|
||||
export default function LogrosPage() {
|
||||
const completedCount = achievements.filter((a) => a.completed).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 {achievements.length} completados
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3 justify-center max-w-sm">
|
||||
{achievements.map((a, i) => (
|
||||
<motion.div
|
||||
key={a.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 ${
|
||||
a.completed ? "bg-primary/20 border-2 border-primary" : "bg-muted opacity-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-3xl">{a.emoji}</span>
|
||||
<span className="text-xs text-center font-display font-medium">{a.label}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import { CuisenaireRod, rodColors } from "@/components/child/cuisenaire-rod"
|
||||
import TemporizadorVisual from "@/components/child/temporizador-visual"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { getRods } from "@/lib/pedagogia/cuisenaire"
|
||||
import { createCPAState, advanceStage } from "@/lib/pedagogia/cpa-progression"
|
||||
import { generateCPAItems, speakCPAInstruction } from "@/components/child/cpa-exercise"
|
||||
|
||||
type CPAStage = "concrete" | "pictoric" | "abstract"
|
||||
|
||||
export default function NumerosPage() {
|
||||
const [currentValue, setCurrentValue] = useState(1)
|
||||
const [stage, setStage] = useState<CPAStage>("concrete")
|
||||
const [running, setRunning] = useState(true)
|
||||
const [sessionComplete, setSessionComplete] = useState(false)
|
||||
const [score, setScore] = useState(0)
|
||||
const [tappedItems, setTappedItems] = useState<number[]>([])
|
||||
|
||||
const rods = getRods(10)
|
||||
const cpaItems = generateCPAItems(currentValue, stage)
|
||||
|
||||
const startValue = useCallback(
|
||||
(value: number) => {
|
||||
setCurrentValue(value)
|
||||
const newStage = value <= 3 ? "concrete" : value <= 6 ? "pictoric" : "abstract"
|
||||
setStage(newStage)
|
||||
setTappedItems([])
|
||||
speakCPAInstruction(value, newStage)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleItemTap = useCallback(
|
||||
(id: number) => {
|
||||
setTappedItems((prev) => {
|
||||
const next = [...prev, id]
|
||||
if (next.length === currentValue) {
|
||||
setScore((s) => s + 1)
|
||||
if (currentValue >= 5) {
|
||||
setSessionComplete(true)
|
||||
setRunning(false)
|
||||
} else {
|
||||
const nextValue = currentValue + 1
|
||||
setCurrentValue(nextValue)
|
||||
const newStage: CPAStage = nextValue <= 3 ? "concrete" : nextValue <= 6 ? "pictoric" : "abstract"
|
||||
setStage(newStage)
|
||||
setTappedItems([])
|
||||
setTimeout(() => speakCPAInstruction(nextValue, newStage), 500)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[currentValue],
|
||||
)
|
||||
|
||||
if (sessionComplete) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="celebrate" message="¡Muy bien con los números!" size="lg" />
|
||||
<p className="text-lg font-display">Puntuación: {score}/5</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentValue(1)
|
||||
setStage("concrete")
|
||||
setScore(0)
|
||||
setTappedItems([])
|
||||
setSessionComplete(false)
|
||||
}}
|
||||
className="bg-accent text-accent-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)
|
||||
setSessionComplete(true)
|
||||
}}
|
||||
running={running}
|
||||
/>
|
||||
|
||||
<GatoMascota
|
||||
mood="coach"
|
||||
message={
|
||||
stage === "concrete"
|
||||
? `Tocá ${currentValue} cosas`
|
||||
: stage === "pictoric"
|
||||
? "¿Cuántos hay?"
|
||||
: "Señalá el número"
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${currentValue}-${stage}`}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
{/* Level indicator */}
|
||||
<div className="flex gap-3 items-center">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => startValue(n)}
|
||||
className={`w-12 h-12 rounded-full text-lg font-display font-bold ${
|
||||
n <= currentValue ? "bg-primary text-white" : "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CPA area */}
|
||||
<div className="w-full max-w-sm min-h-[200px] flex flex-wrap items-center justify-center gap-4 p-4 bg-white rounded-3xl shadow-md">
|
||||
{stage === "concrete" &&
|
||||
cpaItems.map((item) => (
|
||||
<motion.button
|
||||
key={item.id}
|
||||
onClick={() => handleItemTap(item.id)}
|
||||
className="text-4xl touch-target active:scale-110 transition-transform"
|
||||
whileTap={{ scale: 1.2 }}
|
||||
animate={
|
||||
tappedItems.includes(item.id)
|
||||
? { scale: 0.8, opacity: 0.5 }
|
||||
: { scale: 1, opacity: 1 }
|
||||
}
|
||||
>
|
||||
{item.emoji}
|
||||
</motion.button>
|
||||
))}
|
||||
|
||||
{stage === "pictoric" && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{cpaItems.map((item, i) => (
|
||||
<motion.span
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="text-3xl"
|
||||
>
|
||||
{item.emoji}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-4">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => {
|
||||
if (n === currentValue) {
|
||||
handleItemTap(n)
|
||||
}
|
||||
}}
|
||||
className="w-14 h-14 rounded-2xl bg-muted text-2xl font-display font-bold active:scale-90 transition-transform"
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === "abstract" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-6xl font-display font-bold">{currentValue}</span>
|
||||
<p className="text-muted-foreground">Tocá el número que ves arriba</p>
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => {
|
||||
if (n === currentValue) {
|
||||
handleItemTap(n)
|
||||
}
|
||||
}}
|
||||
className="w-16 h-16 rounded-2xl bg-muted text-2xl font-display font-bold active:scale-90 transition-transform"
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cuisenaire reference */}
|
||||
<div className="flex flex-wrap justify-center gap-1 mt-2">
|
||||
{rods.slice(0, currentValue).map((rod) => (
|
||||
<CuisenaireRod
|
||||
key={rod.value}
|
||||
value={rod.value}
|
||||
color={rod.color}
|
||||
length={rod.lengthPx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"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" },
|
||||
]
|
||||
|
||||
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,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,33 @@
|
||||
import type { Metadata } 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",
|
||||
viewport: "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "EduEasy",
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="es" className={inter.variable}>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<link rel="apple-touch-icon" href="/icon.png" />
|
||||
</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,72 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const { error: err } = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (err) {
|
||||
setError(err.message || "Error al iniciar sesión")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/parent")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center p-6">
|
||||
<form onSubmit={handleLogin} className="w-full max-w-sm flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-display font-bold text-foreground text-center">
|
||||
EduEasy · Mamá
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<p className="text-destructive text-sm text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Contraseña"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary text-primary-foreground touch-target rounded-xl text-lg font-display font-semibold disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Entrando..." : "Iniciar sesión"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
|
||||
export default function PairingPage() {
|
||||
const [code, setCode] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [paired, setPaired] = useState(false)
|
||||
const { data: session } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
const handlePairing = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const res = await fetch("/api/pairing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ pairingCode: code, deviceFingerprint: navigator.userAgent }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPaired(true)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col items-center justify-center p-6 gap-4">
|
||||
<p className="text-muted-foreground">Iniciá sesión primero para emparejar</p>
|
||||
<button
|
||||
onClick={() => router.push("/parent/auth/login")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 py-3 font-display"
|
||||
>
|
||||
Ir a login
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col items-center justify-center p-6 gap-6">
|
||||
<h1 className="text-2xl font-display font-bold">Emparejar iPad</h1>
|
||||
|
||||
{paired ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<GatoMascota mood="celebrate" message="iPad emparejado con éxito" size="lg" />
|
||||
<button
|
||||
onClick={() => router.push("/parent")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 py-3 font-display"
|
||||
>
|
||||
Volver al dashboard
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handlePairing} className="flex flex-col gap-4 w-full max-w-sm">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Ingresá el código de 8 caracteres que aparece en el iPad
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="Código de 8 letras"
|
||||
maxLength={8}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-center text-2xl tracking-widest uppercase focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
{error && <p className="text-destructive text-sm text-center">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length < 8}
|
||||
className="bg-secondary text-secondary-foreground touch-target rounded-xl text-lg font-display font-semibold disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Emparejando..." : "Emparejar"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,70 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function ParentDashboard() {
|
||||
const { data: session } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
if (!session) {
|
||||
router.push("/parent/auth/login")
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh p-6">
|
||||
<header className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-bold">EduEasy</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Bienvenida, {session.user?.name || "Mamá"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => authClient.signOut()}
|
||||
className="text-sm text-muted-foreground underline"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 max-w-lg">
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm col-span-2">
|
||||
<p className="text-sm text-muted-foreground">Racha actual</p>
|
||||
<p className="text-3xl font-display font-bold text-primary">— días</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">Hoy</p>
|
||||
<p className="text-2xl font-display font-bold">— min</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">Logros</p>
|
||||
<p className="text-2xl font-display font-bold">— %</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm col-span-2">
|
||||
<p className="text-sm text-muted-foreground mb-2">Última sesión</p>
|
||||
<p className="text-muted-foreground italic">No hay sesiones todavía</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-6 max-w-lg">
|
||||
<button
|
||||
onClick={() => router.push("/parent/auth/pairing")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl font-display font-semibold"
|
||||
>
|
||||
Emparejar iPad
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push("/parent/configuracion")}
|
||||
className="bg-muted text-foreground touch-target rounded-xl font-display"
|
||||
>
|
||||
Configuración
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function ParentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
{children}
|
||||
</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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export interface CuisenaireRodProps {
|
||||
value: number
|
||||
color: string
|
||||
length: number // in vw or px
|
||||
draggable?: boolean
|
||||
onDrop?: (value: number) => void
|
||||
}
|
||||
|
||||
const rodColors: Record<number, string> = {
|
||||
1: "#FFFFFF", // blanco
|
||||
2: "#E74C3C", // rojo
|
||||
3: "#2ECC71", // verde claro
|
||||
4: "#E91E63", // rosa
|
||||
5: "#F1C40F", // amarillo
|
||||
6: "#1ABC9C", // verde oscuro
|
||||
7: "#D35400", // naranja
|
||||
8: "#3498DB", // azul
|
||||
9: "#9B59B6", // púrpura
|
||||
10: "#FF5733", // naranja
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
<motion.div
|
||||
className="rounded-lg border border-border/50 shadow-sm touch-target cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
width: `${length}px`,
|
||||
height: "40px",
|
||||
backgroundColor: color,
|
||||
borderColor: value === 1 ? "#ddd" : undefined,
|
||||
}}
|
||||
whileTap={{ scale: 1.05 }}
|
||||
drag={draggable}
|
||||
dragElastic={0.2}
|
||||
dragConstraints={{ left: -200, right: 200, top: -200, bottom: 200 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,74 @@
|
||||
"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)
|
||||
|
||||
useEffect(() => {
|
||||
if (running) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(intervalRef.current!)
|
||||
onComplete()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
} else {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [running, onComplete])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { getStroke } from "perfect-freehand"
|
||||
import { speak } from "@/lib/speech"
|
||||
|
||||
interface TrazoLetraProps {
|
||||
letra: string
|
||||
fonema: string
|
||||
color?: string
|
||||
onComplete?: () => void
|
||||
promptLevel?: number
|
||||
}
|
||||
|
||||
export default function TrazoLetra({
|
||||
letra,
|
||||
fonema,
|
||||
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) {
|
||||
// draw direction arrow if early prompt level
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,45 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: edueasy-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: edueasy
|
||||
POSTGRES_USER: edueasy
|
||||
POSTGRES_PASSWORD: edueasy_pass
|
||||
volumes:
|
||||
- edueasy_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U edueasy -d edueasy"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- caddy
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: edueasy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://edueasy:edueasy_pass@postgres:5432/edueasy?schema=public"
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-change-me-in-production}"
|
||||
BETTER_AUTH_URL: "https://mate.cbcren.online"
|
||||
NODE_ENV: production
|
||||
networks:
|
||||
- caddy
|
||||
labels:
|
||||
caddy: mate.cbcren.online
|
||||
caddy.reverse_proxy: "{{upstreams 3000}}"
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
edueasy_pgdata:
|
||||
@@ -0,0 +1,40 @@
|
||||
# Dominios Libres para Edueasy
|
||||
|
||||
> Generado: 2026-07-20
|
||||
> IP del servidor: `194.163.191.200`
|
||||
|
||||
## Dominios disponibles (sin contenedor activo)
|
||||
|
||||
| Dominio | Ex-servicio | Ideal para |
|
||||
|---------|-------------|------------|
|
||||
| `mate.cbcren.online` | math-frontend + math-backend | API, dashboard, web app |
|
||||
| `manga.cbcren.online` | Kavita (lector manga) | Static site, app web |
|
||||
| `eco2.cbcren.online` | eco2-app (economía) | App Node.js / web |
|
||||
| `simulacro.cbcren.online` | simulacro-app (flask) | Python app, web app |
|
||||
|
||||
> **Nota:** `am.cbcren.online` (Open WebUI) y `demo2.cbcren.online` (pymesbot) no están libres — responden 401/307, probablemente con contenedor activo.
|
||||
|
||||
## Cómo usar un dominio
|
||||
|
||||
```bash
|
||||
# 1. Crear el contenedor conectado a la red caddy
|
||||
docker run -d --name edueasy-api --network caddy edueasy-image
|
||||
|
||||
# 2. Editar /home/ren/immich/Caddyfile agregando:
|
||||
# mate.cbcren.online {
|
||||
# reverse_proxy edueasy-api:3000
|
||||
# }
|
||||
|
||||
# 3. Recargar Caddy sin downtime
|
||||
docker exec caddy-ingress caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
## Config existente en Caddy (referencia)
|
||||
|
||||
Cada dominio ya está definido en `/home/ren/immich/Caddyfile`. Solo hay que cambiar el `reverse_proxy` al nuevo contenedor:
|
||||
|
||||
```caddy
|
||||
mate.cbcren.online {
|
||||
reverse_proxy nuevo-contenedor:3000
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { createAuthClient } from "better-auth/react"
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||
})
|
||||
|
||||
export const { signIn, signUp, useSession } = authClient
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql",
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
user: {
|
||||
modelName: "Parent",
|
||||
},
|
||||
session: {
|
||||
modelName: "Session",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
|
||||
@@ -0,0 +1,67 @@
|
||||
export interface Fonema {
|
||||
letra: string
|
||||
fonema: string
|
||||
palabra: string
|
||||
imageEmoji: string
|
||||
gesto: string // description of Borel-Maisonny gesture
|
||||
color: string
|
||||
orden: number
|
||||
}
|
||||
|
||||
// Vocales first (Jolly Phonics order adapted for Spanish)
|
||||
export const vocales: Fonema[] = [
|
||||
{
|
||||
letra: "a",
|
||||
fonema: "a",
|
||||
palabra: "avión",
|
||||
imageEmoji: "✈️",
|
||||
gesto: "Abrir la mano como si saludaras, con los dedos juntos.",
|
||||
color: "#E74C3C",
|
||||
orden: 1,
|
||||
},
|
||||
{
|
||||
letra: "e",
|
||||
fonema: "e",
|
||||
palabra: "elefante",
|
||||
imageEmoji: "🐘",
|
||||
gesto: "Señalar hacia la derecha con la mano abierta.",
|
||||
color: "#3498DB",
|
||||
orden: 2,
|
||||
},
|
||||
{
|
||||
letra: "i",
|
||||
fonema: "i",
|
||||
palabra: "iglú",
|
||||
imageEmoji: "🏔️",
|
||||
gesto: "Señalar hacia arriba con el dedo índice.",
|
||||
color: "#9B59B6",
|
||||
orden: 3,
|
||||
},
|
||||
{
|
||||
letra: "o",
|
||||
fonema: "o",
|
||||
palabra: "oso",
|
||||
imageEmoji: "🐻",
|
||||
gesto: "Hacer un círculo con la mano frente a la boca.",
|
||||
color: "#F39C12",
|
||||
orden: 4,
|
||||
},
|
||||
{
|
||||
letra: "u",
|
||||
fonema: "u",
|
||||
palabra: "uva",
|
||||
imageEmoji: "🍇",
|
||||
gesto: "Estirar los dos brazos hacia adelante como un tubo.",
|
||||
color: "#1ABC9C",
|
||||
orden: 5,
|
||||
},
|
||||
]
|
||||
|
||||
export function getFonema(letra: string): Fonema | undefined {
|
||||
return vocales.find((v) => v.letra === letra)
|
||||
}
|
||||
|
||||
export function getGestoDescription(letra: string): string {
|
||||
const f = getFonema(letra)
|
||||
return f?.gesto || ""
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type CPAStage = "concrete" | "pictoric" | "abstract"
|
||||
|
||||
export interface CPAState {
|
||||
stage: CPAStage
|
||||
currentValue: number
|
||||
attempts: number
|
||||
mastered: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* CPA (Concrete-Pictórico-Abstracto) progression engine.
|
||||
*
|
||||
* - Concrete: show objects (animated) for the child to count with finger.
|
||||
* - Pictoric: show images of objects, then hide them progressively.
|
||||
* - Abstract: show only the number symbol → child says/selects the quantity.
|
||||
*/
|
||||
export function getCPAProgression(skillValue: number): CPAStage[] {
|
||||
return ["concrete", "pictoric", "abstract"]
|
||||
}
|
||||
|
||||
export function createCPAState(value: number): CPAState {
|
||||
return {
|
||||
stage: "concrete",
|
||||
currentValue: value,
|
||||
attempts: 0,
|
||||
mastered: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function advanceStage(state: CPAState): CPAState {
|
||||
const stages: CPAStage[] = ["concrete", "pictoric", "abstract"]
|
||||
const currentIdx = stages.indexOf(state.stage)
|
||||
if (currentIdx < stages.length - 1) {
|
||||
return { ...state, stage: stages[currentIdx + 1] }
|
||||
}
|
||||
return { ...state, mastered: true }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface Rod {
|
||||
value: number
|
||||
color: string
|
||||
lengthPx: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export const rodColors: Record<number, string> = {
|
||||
1: "#FFFFFF",
|
||||
2: "#E74C3C",
|
||||
3: "#2ECC71",
|
||||
4: "#E91E63",
|
||||
5: "#F1C40F",
|
||||
6: "#1ABC9C",
|
||||
7: "#D35400",
|
||||
8: "#3498DB",
|
||||
9: "#9B59B6",
|
||||
10: "#FF5733",
|
||||
}
|
||||
|
||||
const rodLengths: Record<number, number> = {
|
||||
1: 24,
|
||||
2: 44,
|
||||
3: 64,
|
||||
4: 84,
|
||||
5: 104,
|
||||
6: 124,
|
||||
7: 144,
|
||||
8: 164,
|
||||
9: 184,
|
||||
10: 204,
|
||||
}
|
||||
|
||||
export function getRods(maxValue: number = 10): Rod[] {
|
||||
return Array.from({ length: maxValue }, (_, i) => ({
|
||||
value: i + 1,
|
||||
color: rodColors[i + 1],
|
||||
lengthPx: rodLengths[i + 1],
|
||||
label: `${i + 1}`,
|
||||
}))
|
||||
}
|
||||
|
||||
export function compareRods(a: number, b: number): "mayor" | "menor" | "igual" {
|
||||
if (a > b) return "mayor"
|
||||
if (a < b) return "menor"
|
||||
return "igual"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type PromptLevel = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
// Galperin stages mapped to prompt levels
|
||||
export const GALPERIN_STAGES = {
|
||||
HAND: 0, // Concrete: full model, show the answer
|
||||
EXTERNAL_SPEECH: 1, // Prompts the child to say out loud
|
||||
INNER_SPEECH: 2, // Partial prompt, whisper
|
||||
MENTAL: 3, // Verbal cue only
|
||||
MASTERED: 4, // No prompt
|
||||
} as const
|
||||
|
||||
export interface PromptState {
|
||||
level: PromptLevel
|
||||
consecutiveCorrect: number
|
||||
consecutiveIncorrect: number
|
||||
}
|
||||
|
||||
export function getInitialState(): PromptState {
|
||||
return {
|
||||
level: 0,
|
||||
consecutiveCorrect: 0,
|
||||
consecutiveIncorrect: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateResponse(
|
||||
state: PromptState,
|
||||
correct: boolean,
|
||||
masterThreshold: number = 3,
|
||||
regressThreshold: number = 2,
|
||||
): PromptState {
|
||||
if (correct) {
|
||||
const newCorrect = state.consecutiveCorrect + 1
|
||||
const newLevel =
|
||||
newCorrect >= masterThreshold
|
||||
? (Math.min(state.level + 1, 4) as PromptLevel)
|
||||
: state.level
|
||||
return {
|
||||
level: newLevel,
|
||||
consecutiveCorrect: newCorrect,
|
||||
consecutiveIncorrect: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const newIncorrect = state.consecutiveIncorrect + 1
|
||||
const newLevel =
|
||||
newIncorrect >= regressThreshold
|
||||
? (Math.max(state.level - 1, 0) as PromptLevel)
|
||||
: state.level
|
||||
return {
|
||||
level: newLevel,
|
||||
consecutiveCorrect: 0,
|
||||
consecutiveIncorrect: newIncorrect,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldShowPrompt(level: PromptLevel): boolean {
|
||||
return level < 3
|
||||
}
|
||||
|
||||
export function isMastered(level: PromptLevel): boolean {
|
||||
return level >= 4
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
let speechSynth: SpeechSynthesis | null = null
|
||||
let voices: SpeechSynthesisVoice[] = []
|
||||
|
||||
export function initSpeech(): void {
|
||||
if (typeof window === "undefined") return
|
||||
speechSynth = window.speechSynthesis
|
||||
|
||||
const loadVoices = () => {
|
||||
voices = speechSynth!.getVoices().filter(
|
||||
(v) => v.lang.startsWith("es") && v.lang.includes("ES") || v.lang.includes("MX")
|
||||
)
|
||||
}
|
||||
|
||||
loadVoices()
|
||||
speechSynth.onvoiceschanged = loadVoices
|
||||
}
|
||||
|
||||
export function speak(text: string, rate = 0.85): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!speechSynth) {
|
||||
initSpeech()
|
||||
}
|
||||
if (!speechSynth || typeof window === "undefined") {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
window.speechSynthesis.cancel()
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
utterance.lang = "es-ES"
|
||||
utterance.rate = rate
|
||||
utterance.pitch = 1.1
|
||||
|
||||
const spanishVoice = voices.length > 0 ? voices[0] : null
|
||||
if (spanishVoice) {
|
||||
utterance.voice = spanishVoice
|
||||
}
|
||||
|
||||
utterance.onend = () => resolve()
|
||||
utterance.onerror = () => resolve()
|
||||
|
||||
speechSynth.speak(utterance)
|
||||
})
|
||||
}
|
||||
|
||||
export function stopSpeaking(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
window.speechSynthesis.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
const CHILD_HOST = "mate"
|
||||
const PARENT_HOST = "simulacro"
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const hostname = request.headers.get("host") || ""
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
if (pathname.startsWith("/api") || pathname.startsWith("/_next") || pathname.startsWith("/static") || pathname === "/favicon.ico") {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (hostname.includes(CHILD_HOST)) {
|
||||
if (pathname === "/" || pathname.startsWith("/child")) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = `/child${pathname === "/" ? "" : pathname}`
|
||||
return NextResponse.rewrite(url)
|
||||
}
|
||||
|
||||
if (hostname.includes(PARENT_HOST)) {
|
||||
if (pathname === "/" || pathname.startsWith("/parent")) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = `/parent${pathname === "/" ? "" : pathname}`
|
||||
return NextResponse.rewrite(url)
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
images: { unoptimized: true },
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
Generated
+7267
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "edueasy",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@use-gesture/react": "^10.3.0",
|
||||
"better-auth": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.0.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"next": "^15.0.0",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"prisma": "^5.20.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.12.0",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-fsrs": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15.0.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Parent {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id])
|
||||
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
email String?
|
||||
userId String?
|
||||
parentId String?
|
||||
parent Parent? @relation(fields: [parentId], references: [id])
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
providerId String
|
||||
parentId String
|
||||
parent Parent @relation(fields: [parentId], references: [id])
|
||||
accessToken String?
|
||||
refreshToken String?
|
||||
idToken String?
|
||||
accessTokenExpiresAt DateTime?
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id @default(cuid())
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Family {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
parents Parent[]
|
||||
children Child[]
|
||||
}
|
||||
|
||||
model Child {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
avatar String?
|
||||
birthdate DateTime?
|
||||
profileNotes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id])
|
||||
|
||||
devices Device[]
|
||||
sessions SessionLog[]
|
||||
fsrsCards FsrsCard[]
|
||||
milestones Milestone[]
|
||||
}
|
||||
|
||||
model Device {
|
||||
id String @id @default(cuid())
|
||||
deviceFingerprint String
|
||||
pairedAt DateTime @default(now())
|
||||
lastSeen DateTime @updatedAt
|
||||
role String @default("child")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
childId String
|
||||
child Child @relation(fields: [childId], references: [id])
|
||||
}
|
||||
|
||||
model SessionLog {
|
||||
id String @id @default(cuid())
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
durationSec Int?
|
||||
skillsPracticed String?
|
||||
|
||||
childId String
|
||||
child Child @relation(fields: [childId], references: [id])
|
||||
|
||||
skillAttempts SkillAttempt[]
|
||||
}
|
||||
|
||||
model SkillAttempt {
|
||||
id String @id @default(cuid())
|
||||
skillCode String
|
||||
promptLevel Int @default(0)
|
||||
correct Boolean
|
||||
responseMs Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
sessionLogId String
|
||||
sessionLog SessionLog @relation(fields: [sessionLogId], references: [id])
|
||||
}
|
||||
|
||||
model FsrsCard {
|
||||
id String @id @default(cuid())
|
||||
skillCode String
|
||||
dueAt DateTime @default(now())
|
||||
stability Float @default(0)
|
||||
difficulty Float @default(0)
|
||||
reps Int @default(0)
|
||||
lapses Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
childId String
|
||||
child Child @relation(fields: [childId], references: [id])
|
||||
}
|
||||
|
||||
model Milestone {
|
||||
id String @id @default(cuid())
|
||||
skillCode String
|
||||
reachedAt DateTime @default(now())
|
||||
|
||||
childId String
|
||||
child Child @relation(fields: [childId], references: [id])
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Config } from "tailwindcss"
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "#FFF8F0",
|
||||
foreground: "#2D3436",
|
||||
primary: {
|
||||
DEFAULT: "#8CB8A0",
|
||||
foreground: "#FFFFFF",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "#6B8FA3",
|
||||
foreground: "#FFFFFF",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "#D4A574",
|
||||
foreground: "#FFFFFF",
|
||||
},
|
||||
success: "#7FB685",
|
||||
muted: "#E8E0D8",
|
||||
card: "#FFFFFF",
|
||||
border: "#E8E0D8",
|
||||
ring: "#8CB8A0",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
display: ["'Fredoka One'", "Fredoka", "sans-serif"],
|
||||
sans: ["Inter", "system-ui", "sans-serif"],
|
||||
},
|
||||
keyframes: {
|
||||
"fade-in": {
|
||||
from: { opacity: "0" },
|
||||
to: { opacity: "1" },
|
||||
},
|
||||
"slide-up": {
|
||||
from: { transform: "translateY(10px)", opacity: "0" },
|
||||
to: { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
"bounce-soft": {
|
||||
"0%, 100%": { transform: "translateY(0)" },
|
||||
"50%": { transform: "translateY(-5px)" },
|
||||
},
|
||||
"pulse-soft": {
|
||||
"0%, 100%": { opacity: "1" },
|
||||
"50%": { opacity: "0.7" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fade-in 0.4s ease-out",
|
||||
"slide-up": "slide-up 0.4s ease-out",
|
||||
"bounce-soft": "bounce-soft 2s ease-in-out infinite",
|
||||
"pulse-soft": "pulse-soft 2s ease-in-out infinite",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user