feat: app completa recordaLexia (fases 1-5)
App web familiar de rutinas visuales para niños con TDAH: muestra cada día el material del cole y las rutinas de tarde, con gamificación por monedas y tienda de recompensas. Multi-niño y bilingüe ES/CA. Uso doméstico/homelab. Backend (Spring Boot 3.5 / Java 21 / Gradle): - Dominio por capas, PostgreSQL + Liquibase, datos semilla. - API REST con DTOs: /today, toggle con monedas y bonos de bloque/día, monedero, tienda/canje, ajustes y CRUD del panel de padres. - Seguridad ligera por PIN (BCrypt + sesion en memoria), sin Keycloak. - Tests JUnit: generacion del dia, monedas/bonos con reversion, canje, seguridad. Frontend (Angular 19, standalone + signals): - Perfiles, Home (Tablero y Foco), Tienda y panel de padres (5 pestañas). - Tipografia OpenDyslexic conmutable (accesibilidad), i18n ES/CA, TTS y sonido. - Tokens de diseño fieles al handoff (paleta, animaciones, monedas voladoras). Empaquetado: - Docker multi-stage + docker-compose (PostgreSQL + backend + Nginx). - Decisiones de arquitectura documentadas en docs/adr.
This commit is contained in:
6
frontend/.dockerignore
Normal file
6
frontend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
# No enviar dependencias ni artefactos locales al contexto de build.
|
||||
node_modules
|
||||
dist
|
||||
.angular
|
||||
.vscode
|
||||
*.log
|
||||
17
frontend/.editorconfig
Normal file
17
frontend/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
42
frontend/.gitignore
vendored
Normal file
42
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
4
frontend/.vscode/extensions.json
vendored
Normal file
4
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
frontend/.vscode/launch.json
vendored
Normal file
20
frontend/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
frontend/.vscode/tasks.json
vendored
Normal file
42
frontend/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
27
frontend/Dockerfile
Normal file
27
frontend/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# --- Etapa 1: build del bundle Angular ---
|
||||
# Node 20 LTS: soportado oficialmente por Angular 19 (el host de desarrollo usa
|
||||
# Node 24, no soportado; en la imagen fijamos una versión soportada a propósito).
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar dependencias con caché de capas (primero los manifiestos).
|
||||
# Se usa `npm install` en vez de `npm ci`: Angular arrastra dependencias opcionales
|
||||
# por plataforma (@esbuild/*, @rollup/*) y el lockfile, generado en macOS, no fija
|
||||
# las variantes de Linux que necesita la imagen. `npm ci` (estricto) fallaría; este
|
||||
# `npm install` respeta el lock y resuelve además los binarios de la plataforma.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
# Código y build de producción.
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Etapa 2: servir estáticos con Nginx ---
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
ENV TZ=Europe/Madrid
|
||||
|
||||
# Config propia: SPA fallback + proxy /api hacia el backend.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
59
frontend/README.md
Normal file
59
frontend/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Frontend
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.15.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
119
frontend/angular.json
Normal file
119
frontend/angular.json
Normal file
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/frontend",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"@fontsource/opendyslexic/latin-400.css",
|
||||
"@fontsource/opendyslexic/latin-700.css",
|
||||
"@fontsource/fredoka/latin-400.css",
|
||||
"@fontsource/fredoka/latin-ext-400.css",
|
||||
"@fontsource/fredoka/latin-600.css",
|
||||
"@fontsource/fredoka/latin-ext-600.css",
|
||||
"@fontsource/fredoka/latin-700.css",
|
||||
"@fontsource/fredoka/latin-ext-700.css",
|
||||
"@fontsource/nunito/latin-400.css",
|
||||
"@fontsource/nunito/latin-ext-400.css",
|
||||
"@fontsource/nunito/latin-700.css",
|
||||
"@fontsource/nunito/latin-ext-700.css",
|
||||
"@fontsource/nunito/latin-800.css",
|
||||
"@fontsource/nunito/latin-ext-800.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
frontend/nginx.conf
Normal file
28
frontend/nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
# Nginx para servir el SPA Angular y hacer de proxy hacia el backend.
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Las peticiones a la API se redirigen al servicio backend del compose.
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Las fuentes (woff2) se cachean agresivamente: tienen hash en el nombre.
|
||||
location ~* \.(?:woff2?|ttf|otf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA: cualquier ruta desconocida cae al index.html (enrutado por Angular).
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
15484
frontend/package-lock.json
generated
Normal file
15484
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"@fontsource/fredoka": "^5.2.10",
|
||||
"@fontsource/nunito": "^5.2.7",
|
||||
"@fontsource/opendyslexic": "^5.2.5",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.15",
|
||||
"@angular/cli": "^19.2.15",
|
||||
"@angular/compiler-cli": "^19.2.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.6.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.7.2"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
25
frontend/src/app/app.component.spec.ts
Normal file
25
frontend/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AppComponent } from './app.component';
|
||||
import { FontPreferenceService } from './core/font-preference.service';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('debe aplicar OpenDyslexic por defecto al arrancar', () => {
|
||||
TestBed.createComponent(AppComponent); // fuerza la inicialización del servicio
|
||||
const fontPreference = TestBed.inject(FontPreferenceService);
|
||||
expect(fontPreference.enabled()).toBe(true);
|
||||
expect(document.documentElement.getAttribute('data-dyslexia-font')).toBe('on');
|
||||
});
|
||||
});
|
||||
15
frontend/src/app/app.component.ts
Normal file
15
frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { FontPreferenceService } from './core/font-preference.service';
|
||||
|
||||
/** Componente raíz: monta el router. La navegación arranca en Perfiles. */
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet />',
|
||||
})
|
||||
export class AppComponent {
|
||||
// Inyectar el servicio fuerza su inicialización: aplica la preferencia de
|
||||
// tipografía (OpenDyslexic por defecto) sobre <html> al arrancar la app.
|
||||
private readonly fontPreference = inject(FontPreferenceService);
|
||||
}
|
||||
15
frontend/src/app/app.config.ts
Normal file
15
frontend/src/app/app.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { parentSessionInterceptor } from './core/parent-session.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
// Cliente HTTP (fetch) con el interceptor de sesión de padres.
|
||||
provideHttpClient(withFetch(), withInterceptors([parentSessionInterceptor])),
|
||||
],
|
||||
};
|
||||
20
frontend/src/app/app.routes.ts
Normal file
20
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { ProfileSelectComponent } from './features/profiles/profile-select.component';
|
||||
import { HomeComponent } from './features/home/home.component';
|
||||
import { StoreComponent } from './features/store/store.component';
|
||||
import { KeypadComponent } from './features/parents/keypad.component';
|
||||
import { ParentsComponent } from './features/parents/parents.component';
|
||||
import { parentGuard } from './core/parent.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
// Selección de perfil: la pantalla de entrada del kiosko.
|
||||
{ path: '', component: ProfileSelectComponent },
|
||||
// Día de hoy del niño (Tablero / Foco).
|
||||
{ path: 'home/:childId', component: HomeComponent },
|
||||
// Tienda de recompensas.
|
||||
{ path: 'store/:childId', component: StoreComponent },
|
||||
// PIN de padres y panel protegido por sesión.
|
||||
{ path: 'pin', component: KeypadComponent },
|
||||
{ path: 'parents', component: ParentsComponent, canActivate: [parentGuard] },
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
60
frontend/src/app/core/api.service.ts
Normal file
60
frontend/src/app/core/api.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
ChildSummary,
|
||||
RedeemResult,
|
||||
RewardView,
|
||||
SettingsRequest,
|
||||
TodayResponse,
|
||||
ToggleResult,
|
||||
} from './models';
|
||||
|
||||
/**
|
||||
* Cliente HTTP tipado contra la API REST del backend. Las rutas son relativas
|
||||
* ("/api/..."); en producción las sirve Nginx (mismo origen) y en desarrollo el
|
||||
* proxy de ng serve.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly base = '/api';
|
||||
|
||||
/** Perfiles de niños (pantalla de selección). */
|
||||
getChildren(): Observable<ChildSummary[]> {
|
||||
return this.http.get<ChildSummary[]>(`${this.base}/children`);
|
||||
}
|
||||
|
||||
/** Día de hoy del niño: tareas, eventos, progreso, monedero y temporizador. */
|
||||
getToday(childId: number): Observable<TodayResponse> {
|
||||
return this.http.get<TodayResponse>(`${this.base}/children/${childId}/today`);
|
||||
}
|
||||
|
||||
/** Marca/desmarca una tarea y devuelve el saldo y progreso actualizados. */
|
||||
toggleTask(taskId: number): Observable<ToggleResult> {
|
||||
return this.http.post<ToggleResult>(`${this.base}/tasks/${taskId}/toggle`, {});
|
||||
}
|
||||
|
||||
/** Actualiza ajustes del niño (modo de vista, sonido, TTS, idioma, hora salida). */
|
||||
updateSettings(childId: number, settings: SettingsRequest): Observable<void> {
|
||||
return this.http.put<void>(`${this.base}/children/${childId}/settings`, settings);
|
||||
}
|
||||
|
||||
/** Monedero del niño: saldo (y, opcionalmente, historial). */
|
||||
getWallet(childId: number): Observable<{ coins: number }> {
|
||||
return this.http.get<{ coins: number }>(`${this.base}/children/${childId}/wallet`);
|
||||
}
|
||||
|
||||
/** Tienda: premios visibles para el niño (Fase 5). */
|
||||
getRewards(childId: number): Observable<RewardView[]> {
|
||||
return this.http.get<RewardView[]>(`${this.base}/children/${childId}/rewards`);
|
||||
}
|
||||
|
||||
/** Canje de premio (Fase 5). */
|
||||
redeem(childId: number, rewardId: number): Observable<RedeemResult> {
|
||||
return this.http.post<RedeemResult>(
|
||||
`${this.base}/rewards/${rewardId}/redeem?childId=${childId}`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
80
frontend/src/app/core/font-preference.service.ts
Normal file
80
frontend/src/app/core/font-preference.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Gestiona la preferencia de tipografía OpenDyslexic.
|
||||
*
|
||||
* Es la "costura" de accesibilidad: aplica (o quita) el atributo
|
||||
* `data-dyslexia-font` en el elemento <html>, que es el interruptor que el
|
||||
* fichero de tokens (_theme.scss) usa para alternar entre OpenDyslexic y las
|
||||
* tipografías de marca del handoff (Fredoka/Nunito).
|
||||
*
|
||||
* Decisión de producto (Fase 1): OpenDyslexic activada POR DEFECTO y aplicada a
|
||||
* TODO el texto. Es una preferencia por niño; de momento se persiste en
|
||||
* localStorage. En la Fase 5 esta preferencia pasará a leerse/escribirse contra
|
||||
* el backend (ajustes por niño), sustituyendo el almacenamiento local.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FontPreferenceService {
|
||||
/** Clave de persistencia temporal hasta el cableado con el backend. */
|
||||
private static readonly STORAGE_KEY = 'recordalexia.dyslexiaFont';
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
/** Estado reactivo: ¿está activada OpenDyslexic? Por defecto, sí. */
|
||||
private readonly enabledSignal = signal<boolean>(this.readInitialState());
|
||||
|
||||
/** Señal de solo lectura para que la consuma la UI. */
|
||||
readonly enabled = this.enabledSignal.asReadonly();
|
||||
|
||||
constructor() {
|
||||
// Sincroniza el DOM con el estado inicial al arrancar la app.
|
||||
this.applyToDom(this.enabledSignal());
|
||||
}
|
||||
|
||||
/** Activa o desactiva OpenDyslexic y propaga el cambio al DOM y a la persistencia. */
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabledSignal.set(enabled);
|
||||
this.applyToDom(enabled);
|
||||
this.persist(enabled);
|
||||
}
|
||||
|
||||
/** Alterna el estado actual. */
|
||||
toggle(): void {
|
||||
this.setEnabled(!this.enabledSignal());
|
||||
}
|
||||
|
||||
/** Lee el estado inicial de localStorage; si no hay nada guardado, ACTIVA por defecto. */
|
||||
private readInitialState(): boolean {
|
||||
const stored = this.safeGetItem(FontPreferenceService.STORAGE_KEY);
|
||||
return stored === null ? true : stored === 'true';
|
||||
}
|
||||
|
||||
/** Refleja la preferencia en <html data-dyslexia-font="on|off">. */
|
||||
private applyToDom(enabled: boolean): void {
|
||||
this.document.documentElement.setAttribute(
|
||||
'data-dyslexia-font',
|
||||
enabled ? 'on' : 'off',
|
||||
);
|
||||
}
|
||||
|
||||
/** Guarda la preferencia, tolerando entornos sin localStorage. */
|
||||
private persist(enabled: boolean): void {
|
||||
try {
|
||||
this.document.defaultView?.localStorage.setItem(
|
||||
FontPreferenceService.STORAGE_KEY,
|
||||
String(enabled),
|
||||
);
|
||||
} catch {
|
||||
// localStorage no disponible (modo kiosko restringido): se ignora.
|
||||
}
|
||||
}
|
||||
|
||||
private safeGetItem(key: string): string | null {
|
||||
try {
|
||||
return this.document.defaultView?.localStorage.getItem(key) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
74
frontend/src/app/core/i18n.service.ts
Normal file
74
frontend/src/app/core/i18n.service.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Language } from './models';
|
||||
|
||||
/**
|
||||
* Internacionalización ligera ES/CA. Mantiene el idioma activo (signal) y ofrece:
|
||||
* - label(es, ca): elige la variante correcta de un texto bilingüe del backend.
|
||||
* - t(key): textos fijos de la UI.
|
||||
* El idioma es una preferencia del niño; se sincroniza con su ajuste al entrar.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class I18nService {
|
||||
readonly lang = signal<Language>('ES');
|
||||
|
||||
private readonly strings: Record<string, { ES: string; CA: string }> = {
|
||||
whoEntersToday: { ES: '¿QUIÉN ENTRA HOY?', CA: 'QUI ENTRA AVUI?' },
|
||||
parents: { ES: 'Padres', CA: 'Pares' },
|
||||
hello: { ES: 'Hola', CA: 'Hola' },
|
||||
leaveIn: { ES: 'SALIMOS EN', CA: 'SORTIM EN' },
|
||||
min: { ES: 'min', CA: 'min' },
|
||||
school: { ES: 'COLE', CA: 'COLE' },
|
||||
afternoon: { ES: 'TARDE', CA: 'TARDA' },
|
||||
ready: { ES: 'listo', CA: 'fet' },
|
||||
noSchool: { ES: 'HOY NO HAY COLE', CA: 'AVUI NO HI HA COLE' },
|
||||
allDone: { ES: '¡TODO LISTO!', CA: 'TOT FET!' },
|
||||
great: { ES: '¡GENIAL!', CA: 'GENIAL!' },
|
||||
done: { ES: '¡HECHO!', CA: 'FET!' },
|
||||
left: { ES: 'Quedan', CA: 'Queden' },
|
||||
next: { ES: 'Después', CA: 'Després' },
|
||||
exam: { ES: 'Examen', CA: 'Examen' },
|
||||
homework: { ES: 'Deberes', CA: 'Deures' },
|
||||
board: { ES: 'Tablero', CA: 'Tauler' },
|
||||
focus: { ES: 'Foco', CA: 'Focus' },
|
||||
store: { ES: 'Tienda', CA: 'Botiga' },
|
||||
// Tienda
|
||||
redeem: { ES: 'Canjear', CA: 'Bescanviar' },
|
||||
missing: { ES: 'Te faltan', CA: "T'en falten" },
|
||||
redeemed: { ES: '¡Canjeado!', CA: 'Bescanviat!' },
|
||||
// PIN
|
||||
enterPin: { ES: 'Introduce el PIN', CA: 'Introdueix el PIN' },
|
||||
wrongPin: { ES: 'PIN incorrecto', CA: 'PIN incorrecte' },
|
||||
// Panel de padres
|
||||
logout: { ES: 'Salir', CA: 'Sortir' },
|
||||
tabSchedule: { ES: 'Horario', CA: 'Horari' },
|
||||
tabMaterials: { ES: 'Materiales', CA: 'Materials' },
|
||||
tabEvents: { ES: 'Eventos', CA: 'Esdeveniments' },
|
||||
tabRoutines: { ES: 'Rutinas', CA: 'Rutines' },
|
||||
tabRewards: { ES: 'Recompensas', CA: 'Recompenses' },
|
||||
add: { ES: 'Añadir', CA: 'Afegir' },
|
||||
del: { ES: 'Borrar', CA: 'Esborrar' },
|
||||
save: { ES: 'Guardar', CA: 'Desar' },
|
||||
child: { ES: 'Niño', CA: 'Nen' },
|
||||
perTask: { ES: 'Por tarea', CA: 'Per tasca' },
|
||||
perBlock: { ES: 'Por bloque', CA: 'Per bloc' },
|
||||
perDay: { ES: 'Por día', CA: 'Per dia' },
|
||||
sound: { ES: 'Sonido', CA: 'So' },
|
||||
readAloud: { ES: 'Lectura en voz alta', CA: 'Lectura en veu alta' },
|
||||
none: { ES: 'No hay nada todavía', CA: 'Encara no hi ha res' },
|
||||
};
|
||||
|
||||
setLang(lang: Language): void {
|
||||
this.lang.set(lang);
|
||||
}
|
||||
|
||||
/** Devuelve la variante del texto bilingüe según el idioma activo. */
|
||||
label(es: string, ca: string): string {
|
||||
return this.lang() === 'CA' ? ca : es;
|
||||
}
|
||||
|
||||
/** Texto fijo de UI por clave. */
|
||||
t(key: string): string {
|
||||
const entry = this.strings[key];
|
||||
return entry ? entry[this.lang()] : key;
|
||||
}
|
||||
}
|
||||
33
frontend/src/app/core/kiosk.service.ts
Normal file
33
frontend/src/app/core/kiosk.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Utilidades del modo kiosko: pantalla completa para la tablet junto a la puerta.
|
||||
* (La orientación horizontal se asume por el montaje físico de la tablet.)
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KioskService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
get isFullscreen(): boolean {
|
||||
return this.document.fullscreenElement !== null;
|
||||
}
|
||||
|
||||
/** Pide pantalla completa (debe llamarse desde un gesto del usuario). */
|
||||
async enterFullscreen(): Promise<void> {
|
||||
const el = this.document.documentElement;
|
||||
if (el.requestFullscreen && !this.isFullscreen) {
|
||||
try {
|
||||
await el.requestFullscreen();
|
||||
} catch {
|
||||
// Algunos navegadores la deniegan sin gesto; se ignora silenciosamente.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async exitFullscreen(): Promise<void> {
|
||||
if (this.isFullscreen && this.document.exitFullscreen) {
|
||||
await this.document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
256
frontend/src/app/core/models.ts
Normal file
256
frontend/src/app/core/models.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// Modelos TypeScript alineados con los DTOs del backend (es.asepeyo.recordalexia.web.dto).
|
||||
// Mantener en sincronía con el backend, en especial TodayResponse.
|
||||
|
||||
export type ViewMode = 'BOARD' | 'FOCUS';
|
||||
export type Language = 'ES' | 'CA';
|
||||
|
||||
/** Resumen de perfil para la pantalla de selección. */
|
||||
export interface ChildSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
mascot: string;
|
||||
accentColor: string;
|
||||
age: number;
|
||||
coins: number;
|
||||
viewMode: ViewMode;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
/** Datos del niño embebidos en /today. */
|
||||
export interface ChildInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
mascot: string;
|
||||
accentColor: string;
|
||||
viewMode: ViewMode;
|
||||
language: Language;
|
||||
soundEnabled: boolean;
|
||||
ttsEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Tarea del día (mañana o tarde). Lleva texto ES y CA. */
|
||||
export interface TaskView {
|
||||
id: number;
|
||||
labelEs: string;
|
||||
labelCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
done: boolean;
|
||||
coinsReward: number;
|
||||
orderIndex: number;
|
||||
}
|
||||
|
||||
/** Evento del día (examen/deberes) para el banner. */
|
||||
export interface EventView {
|
||||
id: number;
|
||||
type: 'EXAM' | 'HOMEWORK';
|
||||
titleEs: string;
|
||||
titleCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ProgressView {
|
||||
morningDone: number;
|
||||
morningTotal: number;
|
||||
afternoonDone: number;
|
||||
afternoonTotal: number;
|
||||
totalDone: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface WalletInfo {
|
||||
coins: number;
|
||||
}
|
||||
|
||||
export interface TimerInfo {
|
||||
departureTime: string | null;
|
||||
minutesUntilDeparture: number | null;
|
||||
}
|
||||
|
||||
/** Payload completo de GET /api/children/{id}/today. */
|
||||
export interface TodayResponse {
|
||||
child: ChildInfo;
|
||||
morning: TaskView[];
|
||||
afternoon: TaskView[];
|
||||
specialEvents: EventView[];
|
||||
progress: ProgressView;
|
||||
wallet: WalletInfo;
|
||||
timer: TimerInfo;
|
||||
}
|
||||
|
||||
/** Resultado de marcar/desmarcar una tarea. */
|
||||
export interface ToggleResult {
|
||||
taskId: number;
|
||||
done: boolean;
|
||||
coinsEarned: number;
|
||||
newBalance: number;
|
||||
progress: ProgressView;
|
||||
}
|
||||
|
||||
/** Ajustes editables del niño (todos opcionales). */
|
||||
export interface SettingsRequest {
|
||||
viewMode?: ViewMode;
|
||||
soundEnabled?: boolean;
|
||||
ttsEnabled?: boolean;
|
||||
language?: Language;
|
||||
departureTime?: string;
|
||||
}
|
||||
|
||||
/** Premio visible en la tienda (Fase 5). */
|
||||
export interface RewardView {
|
||||
id: number;
|
||||
labelEs: string;
|
||||
labelCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
cost: number;
|
||||
affordable: boolean;
|
||||
missing: number;
|
||||
}
|
||||
|
||||
export interface RedeemResult {
|
||||
rewardId: number;
|
||||
cost: number;
|
||||
newBalance: number;
|
||||
}
|
||||
|
||||
// ----- Panel de padres: vistas de lectura -----
|
||||
export interface RewardAdminView {
|
||||
id: number;
|
||||
labelEs: string;
|
||||
labelCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
cost: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface MaterialView {
|
||||
id: number;
|
||||
labelEs: string;
|
||||
labelCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
export interface ActivityView {
|
||||
id: number;
|
||||
labelEs: string;
|
||||
labelCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
materialIds: number[];
|
||||
}
|
||||
|
||||
export interface WeeklyEntryView {
|
||||
id: number;
|
||||
childId: number;
|
||||
dayOfWeek: string;
|
||||
activityId: number;
|
||||
activityLabelEs: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
orderIndex: number;
|
||||
coinsReward: number | null;
|
||||
}
|
||||
|
||||
export interface RoutineView {
|
||||
id: number;
|
||||
childId: number;
|
||||
dayOfWeek: string;
|
||||
labelEs: string;
|
||||
labelCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
orderIndex: number;
|
||||
coinsReward: number | null;
|
||||
}
|
||||
|
||||
export interface EventAdminView {
|
||||
id: number;
|
||||
childId: number;
|
||||
date: string;
|
||||
type: 'EXAM' | 'HOMEWORK';
|
||||
titleEs: string;
|
||||
titleCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GamificationView {
|
||||
coinsPerTask: number;
|
||||
coinsPerBlock: number;
|
||||
coinsPerDay: number;
|
||||
}
|
||||
|
||||
// ----- Panel de padres: peticiones -----
|
||||
export interface ChildRequest {
|
||||
name?: string;
|
||||
mascot?: string;
|
||||
accentColor?: string;
|
||||
age?: number;
|
||||
departureTime?: string;
|
||||
coins?: number;
|
||||
}
|
||||
|
||||
export interface GamificationRequest {
|
||||
coinsPerTask?: number;
|
||||
coinsPerBlock?: number;
|
||||
coinsPerDay?: number;
|
||||
}
|
||||
|
||||
export interface RewardRequest {
|
||||
labelEs: string;
|
||||
labelCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
cost: number;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface MaterialRequest {
|
||||
labelEs: string;
|
||||
labelCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface ActivityRequest {
|
||||
labelEs: string;
|
||||
labelCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
materialIds: number[];
|
||||
}
|
||||
|
||||
export interface WeeklyEntryRequest {
|
||||
childId: number;
|
||||
dayOfWeek: string;
|
||||
activityId: number;
|
||||
orderIndex?: number;
|
||||
coinsReward?: number;
|
||||
}
|
||||
|
||||
export interface AfternoonRoutineRequest {
|
||||
childId: number;
|
||||
dayOfWeek: string;
|
||||
labelEs: string;
|
||||
labelCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
orderIndex?: number;
|
||||
coinsReward?: number;
|
||||
}
|
||||
|
||||
export interface SpecialEventRequest {
|
||||
childId: number;
|
||||
date: string;
|
||||
type: 'EXAM' | 'HOMEWORK';
|
||||
titleEs: string;
|
||||
titleCa: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
125
frontend/src/app/core/parent-api.service.ts
Normal file
125
frontend/src/app/core/parent-api.service.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
ActivityRequest,
|
||||
ActivityView,
|
||||
AfternoonRoutineRequest,
|
||||
ChildRequest,
|
||||
ChildSummary,
|
||||
EventAdminView,
|
||||
GamificationRequest,
|
||||
GamificationView,
|
||||
MaterialRequest,
|
||||
MaterialView,
|
||||
RewardAdminView,
|
||||
RewardRequest,
|
||||
RoutineView,
|
||||
SettingsRequest,
|
||||
SpecialEventRequest,
|
||||
WeeklyEntryRequest,
|
||||
WeeklyEntryView,
|
||||
} from './models';
|
||||
|
||||
/**
|
||||
* Cliente del panel de padres (/api/parents/**). El interceptor añade la cabecera
|
||||
* de sesión automáticamente. Cubre el CRUD de niños, catálogo, horario, rutinas,
|
||||
* eventos, premios y la gamificación.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ParentApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly base = '/api/parents';
|
||||
|
||||
// --- Niños y gamificación ---
|
||||
listChildren(): Observable<ChildSummary[]> {
|
||||
return this.http.get<ChildSummary[]>(`${this.base}/children`);
|
||||
}
|
||||
createChild(req: ChildRequest): Observable<ChildSummary> {
|
||||
return this.http.post<ChildSummary>(`${this.base}/children`, req);
|
||||
}
|
||||
updateChild(id: number, req: ChildRequest): Observable<void> {
|
||||
return this.http.put<void>(`${this.base}/children/${id}`, req);
|
||||
}
|
||||
deleteChild(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/children/${id}`);
|
||||
}
|
||||
getGamification(id: number): Observable<GamificationView> {
|
||||
return this.http.get<GamificationView>(`${this.base}/children/${id}/gamification`);
|
||||
}
|
||||
updateGamification(id: number, req: GamificationRequest): Observable<void> {
|
||||
return this.http.put<void>(`${this.base}/children/${id}/gamification`, req);
|
||||
}
|
||||
// Ajustes (sonido/TTS/idioma): endpoint público del niño, no del panel.
|
||||
updateSettings(id: number, req: SettingsRequest): Observable<void> {
|
||||
return this.http.put<void>(`/api/children/${id}/settings`, req);
|
||||
}
|
||||
|
||||
// --- Premios ---
|
||||
listRewards(): Observable<RewardAdminView[]> {
|
||||
return this.http.get<RewardAdminView[]>(`${this.base}/rewards`);
|
||||
}
|
||||
createReward(req: RewardRequest): Observable<RewardAdminView> {
|
||||
return this.http.post<RewardAdminView>(`${this.base}/rewards`, req);
|
||||
}
|
||||
updateReward(id: number, req: RewardRequest): Observable<void> {
|
||||
return this.http.put<void>(`${this.base}/rewards/${id}`, req);
|
||||
}
|
||||
deleteReward(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/rewards/${id}`);
|
||||
}
|
||||
|
||||
// --- Catálogo: materiales y actividades ---
|
||||
listMaterials(): Observable<MaterialView[]> {
|
||||
return this.http.get<MaterialView[]>(`${this.base}/catalog/materials`);
|
||||
}
|
||||
createMaterial(req: MaterialRequest): Observable<MaterialView> {
|
||||
return this.http.post<MaterialView>(`${this.base}/catalog/materials`, req);
|
||||
}
|
||||
deleteMaterial(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/catalog/materials/${id}`);
|
||||
}
|
||||
listActivities(): Observable<ActivityView[]> {
|
||||
return this.http.get<ActivityView[]>(`${this.base}/catalog/activities`);
|
||||
}
|
||||
createActivity(req: ActivityRequest): Observable<ActivityView> {
|
||||
return this.http.post<ActivityView>(`${this.base}/catalog/activities`, req);
|
||||
}
|
||||
deleteActivity(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/catalog/activities/${id}`);
|
||||
}
|
||||
|
||||
// --- Horario y rutinas ---
|
||||
listWeekly(childId: number): Observable<WeeklyEntryView[]> {
|
||||
return this.http.get<WeeklyEntryView[]>(`${this.base}/schedule/weekly?childId=${childId}`);
|
||||
}
|
||||
createWeekly(req: WeeklyEntryRequest): Observable<WeeklyEntryView> {
|
||||
return this.http.post<WeeklyEntryView>(`${this.base}/schedule/weekly`, req);
|
||||
}
|
||||
deleteWeekly(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/schedule/weekly/${id}`);
|
||||
}
|
||||
listRoutines(childId: number): Observable<RoutineView[]> {
|
||||
return this.http.get<RoutineView[]>(`${this.base}/schedule/routines?childId=${childId}`);
|
||||
}
|
||||
createRoutine(req: AfternoonRoutineRequest): Observable<RoutineView> {
|
||||
return this.http.post<RoutineView>(`${this.base}/schedule/routines`, req);
|
||||
}
|
||||
deleteRoutine(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/schedule/routines/${id}`);
|
||||
}
|
||||
reorderRoutines(orderedIds: number[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.base}/schedule/routines/reorder`, { orderedIds });
|
||||
}
|
||||
|
||||
// --- Eventos ---
|
||||
listEvents(childId: number): Observable<EventAdminView[]> {
|
||||
return this.http.get<EventAdminView[]>(`${this.base}/events?childId=${childId}`);
|
||||
}
|
||||
createEvent(req: SpecialEventRequest): Observable<EventAdminView> {
|
||||
return this.http.post<EventAdminView>(`${this.base}/events`, req);
|
||||
}
|
||||
deleteEvent(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/events/${id}`);
|
||||
}
|
||||
}
|
||||
16
frontend/src/app/core/parent-session.interceptor.ts
Normal file
16
frontend/src/app/core/parent-session.interceptor.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { ParentSessionService } from './parent-session.service';
|
||||
|
||||
/**
|
||||
* Añade la cabecera X-Parent-Session a las peticiones del panel de padres
|
||||
* (/api/parents/**), salvo al propio login. El resto de la API (kiosko) no la lleva.
|
||||
*/
|
||||
export const parentSessionInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const session = inject(ParentSessionService);
|
||||
const isParentApi = req.url.includes('/api/parents/') && !req.url.endsWith('/parents/login');
|
||||
if (isParentApi && session.sessionId) {
|
||||
return next(req.clone({ setHeaders: { 'X-Parent-Session': session.sessionId } }));
|
||||
}
|
||||
return next(req);
|
||||
};
|
||||
57
frontend/src/app/core/parent-session.service.ts
Normal file
57
frontend/src/app/core/parent-session.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Sesión del panel de padres. Guarda el identificador opaco devuelto por el login
|
||||
* (cabecera X-Parent-Session) y lo mantiene en sessionStorage para sobrevivir a
|
||||
* recargas mientras dura la pestaña. No es una credencial: es un ticket temporal.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ParentSessionService {
|
||||
private static readonly KEY = 'recordalexia.parentSession';
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
private readonly currentId = signal<string | null>(this.read());
|
||||
readonly isAuthenticated = computed(() => this.currentId() !== null);
|
||||
|
||||
/** Identificador actual para la cabecera (o null si no hay sesión). */
|
||||
get sessionId(): string | null {
|
||||
return this.currentId();
|
||||
}
|
||||
|
||||
/** Valida el PIN; si es correcto guarda la sesión. */
|
||||
login(pin: string): Observable<{ session: string }> {
|
||||
return this.http.post<{ session: string }>('/api/parents/login', { pin }).pipe(
|
||||
tap((res) => {
|
||||
this.currentId.set(res.session);
|
||||
this.write(res.session);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.currentId.set(null);
|
||||
try {
|
||||
sessionStorage.removeItem(ParentSessionService.KEY);
|
||||
} catch {
|
||||
// sessionStorage no disponible: nada que limpiar.
|
||||
}
|
||||
}
|
||||
|
||||
private read(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(ParentSessionService.KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private write(value: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(ParentSessionService.KEY, value);
|
||||
} catch {
|
||||
// Ignorar si no hay sessionStorage (modo kiosko restringido).
|
||||
}
|
||||
}
|
||||
}
|
||||
10
frontend/src/app/core/parent.guard.ts
Normal file
10
frontend/src/app/core/parent.guard.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { ParentSessionService } from './parent-session.service';
|
||||
|
||||
/** Protege el panel de padres: sin sesión, redirige al teclado del PIN. */
|
||||
export const parentGuard: CanActivateFn = () => {
|
||||
const session = inject(ParentSessionService);
|
||||
const router = inject(Router);
|
||||
return session.isAuthenticated() ? true : router.createUrlTree(['/pin']);
|
||||
};
|
||||
50
frontend/src/app/core/sound.service.ts
Normal file
50
frontend/src/app/core/sound.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Sonido de recompensa sintetizado con WebAudio (sin ficheros de audio). Un par
|
||||
* de notas alegres al ganar monedas. Silencioso si WebAudio no está disponible.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SoundService {
|
||||
private audioCtx: AudioContext | null = null;
|
||||
|
||||
private ensureContext(): AudioContext | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctx) {
|
||||
return null;
|
||||
}
|
||||
if (!this.audioCtx) {
|
||||
this.audioCtx = new Ctx();
|
||||
}
|
||||
return this.audioCtx;
|
||||
}
|
||||
|
||||
/** Pequeño arpegio ascendente alegre (ding-ding). */
|
||||
playReward(): void {
|
||||
const ctx = this.ensureContext();
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
const now = ctx.currentTime;
|
||||
// Dos notas (mi, sol) cortas y suaves.
|
||||
[659.25, 783.99].forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
const start = now + i * 0.12;
|
||||
gain.gain.setValueAtTime(0.0001, start);
|
||||
gain.gain.exponentialRampToValueAtTime(0.18, start + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.25);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start(start);
|
||||
osc.stop(start + 0.28);
|
||||
});
|
||||
}
|
||||
}
|
||||
32
frontend/src/app/core/tts.service.ts
Normal file
32
frontend/src/app/core/tts.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Language } from './models';
|
||||
|
||||
/**
|
||||
* Lectura en voz alta (TTS) con la Web Speech API del navegador. Apoyo a la
|
||||
* lectura para el niño. Degrada con elegancia si el navegador no la soporta.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TtsService {
|
||||
private get synth(): SpeechSynthesis | null {
|
||||
return typeof window !== 'undefined' && 'speechSynthesis' in window
|
||||
? window.speechSynthesis
|
||||
: null;
|
||||
}
|
||||
|
||||
get supported(): boolean {
|
||||
return this.synth !== null;
|
||||
}
|
||||
|
||||
/** Lee un texto en el idioma indicado (es-ES / ca-ES). */
|
||||
speak(text: string, lang: Language = 'ES'): void {
|
||||
const synth = this.synth;
|
||||
if (!synth) {
|
||||
return;
|
||||
}
|
||||
synth.cancel(); // corta lo anterior para no solapar
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = lang === 'CA' ? 'ca-ES' : 'es-ES';
|
||||
utterance.rate = 0.95; // un punto más lento, para niños
|
||||
synth.speak(utterance);
|
||||
}
|
||||
}
|
||||
68
frontend/src/app/features/home/board-view.component.ts
Normal file
68
frontend/src/app/features/home/board-view.component.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { TaskView } from '../../core/models';
|
||||
import { TaskCardComponent, ToggleEvent } from './task-card.component';
|
||||
|
||||
/** Vista TABLERO: dos columnas (cole / tarde) con las tareas del día. */
|
||||
@Component({
|
||||
selector: 'app-board-view',
|
||||
imports: [TaskCardComponent],
|
||||
template: `
|
||||
<div class="board">
|
||||
<section class="col">
|
||||
<h2 class="col__head">🎒 {{ i18n.t('school') }}</h2>
|
||||
@if (morning.length) {
|
||||
@for (task of morning; track task.id) {
|
||||
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
|
||||
}
|
||||
} @else {
|
||||
<div class="empty">🏖️ {{ i18n.t('noSchool') }}</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="col">
|
||||
<h2 class="col__head">🌙 {{ i18n.t('afternoon') }}</h2>
|
||||
@for (task of afternoon; track task.id) {
|
||||
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.board { grid-template-columns: 1fr; }
|
||||
}
|
||||
.col { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.col__head {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 1.4rem;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.empty {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
text-align: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
color: var(--text-2);
|
||||
background: var(--surface);
|
||||
border: 2px dashed var(--border-2);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class BoardViewComponent {
|
||||
@Input() morning: TaskView[] = [];
|
||||
@Input() afternoon: TaskView[] = [];
|
||||
@Input() ttsEnabled = true;
|
||||
@Output() toggle = new EventEmitter<ToggleEvent>();
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
}
|
||||
104
frontend/src/app/features/home/celebration-overlay.component.ts
Normal file
104
frontend/src/app/features/home/celebration-overlay.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
|
||||
interface Confetti {
|
||||
left: number;
|
||||
delay: number;
|
||||
color: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
/** Overlay celebratorio al completar TODO el día: confeti, mascota, monedas y botón. */
|
||||
@Component({
|
||||
selector: 'app-celebration-overlay',
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="cel">
|
||||
@for (c of confetti; track $index) {
|
||||
<span
|
||||
class="cel__confetti"
|
||||
[style.left.%]="c.left"
|
||||
[style.animation-delay.s]="c.delay"
|
||||
[style.color]="c.color"
|
||||
>{{ c.emoji }}</span
|
||||
>
|
||||
}
|
||||
<div class="cel__card">
|
||||
<div class="cel__mascot">{{ mascot }}🎉</div>
|
||||
<h2 class="cel__title">{{ i18n.t('allDone') }}</h2>
|
||||
<p class="cel__coins">+{{ coinsDay }} 🪙</p>
|
||||
<button type="button" class="cel__btn" (click)="close.emit()">
|
||||
{{ i18n.t('great') }} 👍
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.cel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(35, 49, 66, 0.45);
|
||||
backdrop-filter: blur(3px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cel__confetti {
|
||||
position: absolute;
|
||||
top: -20vh;
|
||||
font-size: 26px;
|
||||
animation: confFall 2.4s linear infinite;
|
||||
}
|
||||
.cel__card {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 36px 40px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-pop);
|
||||
animation: celebPop 0.4s ease both;
|
||||
}
|
||||
.cel__mascot { font-size: 72px; animation: floatY 2.5s ease-in-out infinite; }
|
||||
.cel__title { margin: 12px 0; font-size: 2rem; color: var(--text-strong); }
|
||||
.cel__coins {
|
||||
margin: 0 0 20px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
color: var(--coin-text);
|
||||
}
|
||||
.cel__btn {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
padding: 14px 28px;
|
||||
min-height: var(--touch-nav);
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CelebrationOverlayComponent {
|
||||
@Input() coinsDay = 0;
|
||||
@Input() mascot = '🦊';
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
// Confeti generado una vez al crear el overlay.
|
||||
protected readonly confetti: Confetti[] = Array.from({ length: 28 }, () => ({
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 2,
|
||||
color: ['#F2A65A', '#5B8DEF', '#A78BD0', '#7FBF6B', '#5BC0BE', '#F4C95D', '#EC8FA4'][
|
||||
Math.floor(Math.random() * 7)
|
||||
],
|
||||
emoji: ['🎉', '⭐', '🪙', '✨'][Math.floor(Math.random() * 4)],
|
||||
}));
|
||||
}
|
||||
54
frontend/src/app/features/home/event-banner.component.ts
Normal file
54
frontend/src/app/features/home/event-banner.component.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Component, Input, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { TtsService } from '../../core/tts.service';
|
||||
import { EventView } from '../../core/models';
|
||||
|
||||
/** Banner de eventos del día (examen 📋 / deberes 📎) con lectura en voz alta. */
|
||||
@Component({
|
||||
selector: 'app-event-banner',
|
||||
imports: [],
|
||||
template: `
|
||||
@for (ev of events; track ev.id) {
|
||||
<div class="evt" [style.--c]="ev.color">
|
||||
<span class="evt__icon">{{ ev.icon }}</span>
|
||||
<span class="evt__text">{{ i18n.label(ev.titleEs, ev.titleCa) }}</span>
|
||||
@if (ttsEnabled && tts.supported) {
|
||||
<button type="button" class="evt__tts" (click)="speak(ev)" aria-label="Leer en voz alta">🔊</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.evt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--c) 16%, #fff);
|
||||
border: 2px solid color-mix(in srgb, var(--c) 45%, #fff);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.evt__icon { font-size: 28px; }
|
||||
.evt__text {
|
||||
flex: 1;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.evt__tts { all: unset; cursor: pointer; font-size: 24px; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class EventBannerComponent {
|
||||
@Input() events: EventView[] = [];
|
||||
@Input() ttsEnabled = true;
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
protected readonly tts = inject(TtsService);
|
||||
|
||||
speak(ev: EventView): void {
|
||||
this.tts.speak(this.i18n.label(ev.titleEs, ev.titleCa), this.i18n.lang());
|
||||
}
|
||||
}
|
||||
145
frontend/src/app/features/home/focus-view.component.ts
Normal file
145
frontend/src/app/features/home/focus-view.component.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
ViewChild,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { TtsService } from '../../core/tts.service';
|
||||
import { TaskView } from '../../core/models';
|
||||
import { ToggleEvent } from './task-card.component';
|
||||
|
||||
/** Vista FOCO: una sola tarea a pantalla completa, clave para reducir carga en TDAH. */
|
||||
@Component({
|
||||
selector: 'app-focus-view',
|
||||
imports: [],
|
||||
template: `
|
||||
@if (current(); as task) {
|
||||
<div class="focus">
|
||||
<div class="focus__nav">
|
||||
<button type="button" class="navbtn" [disabled]="index() === 0" (click)="prev()">‹</button>
|
||||
|
||||
<div class="focus__stage">
|
||||
<span class="hero" [class.hero--done]="task.done" [style.--c]="task.color">{{ task.icon }}</span>
|
||||
<h2 class="focus__label">{{ i18n.label(task.labelEs, task.labelCa) }}</h2>
|
||||
</div>
|
||||
|
||||
<button type="button" class="navbtn" [disabled]="index() >= tasks.length - 1" (click)="next()">›</button>
|
||||
</div>
|
||||
|
||||
<div class="dots">
|
||||
@for (t of tasks; track t.id; let i = $index) {
|
||||
<span class="dot" [class.dot--done]="t.done" [class.dot--current]="i === index()"></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="focus__actions">
|
||||
<button #doneBtn type="button" class="bigbtn" [class.bigbtn--done]="task.done" (click)="emitToggle()">
|
||||
{{ task.done ? '✓' : i18n.t('done') }}
|
||||
</button>
|
||||
@if (ttsEnabled && tts.supported) {
|
||||
<button type="button" class="speakbtn" (click)="speak(task)" aria-label="Leer en voz alta">🔊</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="focus__foot">
|
||||
{{ i18n.t('left') }} {{ remaining() }} · {{ i18n.t('next') }}: {{ nextLabel() }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.focus { display: flex; flex-direction: column; align-items: center; gap: var(--space-5); padding: var(--space-5) 0; }
|
||||
.focus__nav { display: flex; align-items: center; gap: var(--space-5); }
|
||||
.focus__stage { display: flex; flex-direction: column; align-items: center; gap: var(--space-4); min-width: 260px; }
|
||||
.hero {
|
||||
width: var(--hero-size);
|
||||
height: var(--hero-size);
|
||||
border-radius: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 120px;
|
||||
background: color-mix(in srgb, var(--c) 16%, #fff);
|
||||
border: 4px solid color-mix(in srgb, var(--c) 35%, #fff);
|
||||
animation: floatY 3.5s ease-in-out infinite;
|
||||
}
|
||||
.hero--done { background: color-mix(in srgb, var(--c) 22%, #fff); border-color: var(--c); animation: pop 0.4s ease; }
|
||||
.focus__label { margin: 0; font-size: 2rem; text-transform: uppercase; text-align: center; color: var(--text-strong); }
|
||||
.navbtn {
|
||||
all: unset; cursor: pointer; width: var(--touch-nav); height: var(--touch-nav);
|
||||
border-radius: 50%; background: var(--surface); box-shadow: var(--shadow-btn);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 38px; color: var(--text-4);
|
||||
}
|
||||
.navbtn:disabled { opacity: 0.3; cursor: default; }
|
||||
.dots { display: flex; gap: 10px; }
|
||||
.dot { width: 14px; height: 14px; border-radius: 50%; background: var(--border-2); transition: background 0.2s, transform 0.2s; }
|
||||
.dot--done { background: var(--accent-green); }
|
||||
.dot--current { transform: scale(1.4); box-shadow: 0 0 0 3px var(--surface-softer); }
|
||||
.focus__actions { display: flex; align-items: center; gap: var(--space-4); }
|
||||
.bigbtn {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 1.6rem; color: #fff;
|
||||
border: 0; border-radius: 24px; padding: 18px 48px; min-height: 72px; cursor: pointer;
|
||||
background: var(--accent-blue); transition: transform 0.12s;
|
||||
}
|
||||
.bigbtn--done { background: var(--accent-green); }
|
||||
.bigbtn:active { transform: scale(0.97); }
|
||||
.speakbtn { all: unset; cursor: pointer; font-size: 34px; }
|
||||
.focus__foot { margin: 0; color: var(--text-2); font-family: var(--font-display); font-weight: 600; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class FocusViewComponent {
|
||||
@Input() set tasksInput(value: TaskView[]) {
|
||||
this.tasks = value;
|
||||
if (this.index() >= value.length) {
|
||||
this.index.set(Math.max(0, value.length - 1));
|
||||
}
|
||||
}
|
||||
@Input() ttsEnabled = true;
|
||||
@Output() toggle = new EventEmitter<ToggleEvent>();
|
||||
|
||||
@ViewChild('doneBtn') private doneBtn?: ElementRef<HTMLElement>;
|
||||
|
||||
protected tasks: TaskView[] = [];
|
||||
protected readonly index = signal(0);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
protected readonly tts = inject(TtsService);
|
||||
|
||||
protected readonly current = computed(() => this.tasks[this.index()] ?? null);
|
||||
protected readonly remaining = computed(() => this.tasks.filter((t) => !t.done).length);
|
||||
protected readonly nextLabel = computed(() => {
|
||||
const nextTask = this.tasks[this.index() + 1];
|
||||
return nextTask ? this.i18n.label(nextTask.labelEs, nextTask.labelCa) : '—';
|
||||
});
|
||||
|
||||
prev(): void {
|
||||
this.index.update((i) => Math.max(0, i - 1));
|
||||
}
|
||||
next(): void {
|
||||
this.index.update((i) => Math.min(this.tasks.length - 1, i + 1));
|
||||
}
|
||||
|
||||
emitToggle(): void {
|
||||
const task = this.current();
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
const rect = this.doneBtn?.nativeElement.getBoundingClientRect();
|
||||
this.toggle.emit({
|
||||
taskId: task.id,
|
||||
x: rect ? rect.left + rect.width / 2 : window.innerWidth / 2,
|
||||
y: rect ? rect.top : window.innerHeight / 2,
|
||||
});
|
||||
}
|
||||
|
||||
speak(task: TaskView): void {
|
||||
this.tts.speak(this.i18n.label(task.labelEs, task.labelCa), this.i18n.lang());
|
||||
}
|
||||
}
|
||||
73
frontend/src/app/features/home/home.component.html
Normal file
73
frontend/src/app/features/home/home.component.html
Normal file
@@ -0,0 +1,73 @@
|
||||
@if (today(); as t) {
|
||||
<main class="home">
|
||||
<!-- Cabecera -->
|
||||
<header class="home__top">
|
||||
<button type="button" class="iconbtn" (click)="goProfiles()" aria-label="Volver a perfiles">‹</button>
|
||||
|
||||
<div class="home__greet">
|
||||
<p class="home__hello">{{ i18n.t('hello') }}, {{ t.child.name }} {{ t.child.mascot }}</p>
|
||||
<p class="home__date">{{ dateLabel() }}</p>
|
||||
</div>
|
||||
|
||||
<app-morning-timer [minutes]="t.timer.minutesUntilDeparture" />
|
||||
|
||||
<div class="home__wallet">
|
||||
<button type="button" class="iconbtn" (click)="goStore()" aria-label="Tienda">🎁</button>
|
||||
<app-wallet #wallet [coins]="t.wallet.coins" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Conmutador de modo -->
|
||||
<div class="modes">
|
||||
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'BOARD'" (click)="setMode('BOARD')">
|
||||
🗂️ {{ i18n.t('board') }}
|
||||
</button>
|
||||
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'FOCUS'" (click)="setMode('FOCUS')">
|
||||
🎯 {{ i18n.t('focus') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Eventos del día -->
|
||||
<app-event-banner [events]="t.specialEvents" [ttsEnabled]="t.child.ttsEnabled" />
|
||||
|
||||
<!-- Progreso global -->
|
||||
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
|
||||
|
||||
<!-- Tareas: tablero o foco -->
|
||||
@if (mode() === 'BOARD') {
|
||||
<app-board-view
|
||||
[morning]="t.morning"
|
||||
[afternoon]="t.afternoon"
|
||||
[ttsEnabled]="t.child.ttsEnabled"
|
||||
(toggle)="onToggle($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-focus-view [tasksInput]="allTasks()" [ttsEnabled]="t.child.ttsEnabled" (toggle)="onToggle($event)" />
|
||||
}
|
||||
</main>
|
||||
|
||||
<!-- Monedas voladoras (capa superpuesta) -->
|
||||
@for (coin of coins(); track coin.id) {
|
||||
<span
|
||||
class="flycoin"
|
||||
[style.left.px]="coin.x"
|
||||
[style.top.px]="coin.y"
|
||||
[style.transform]="coin.flying ? 'translate(' + coin.dx + 'px,' + coin.dy + 'px) scale(.4)' : 'translate(0,0) scale(1)'"
|
||||
[style.opacity]="coin.flying ? 0 : 1"
|
||||
>🪙</span
|
||||
>
|
||||
}
|
||||
|
||||
<!-- Celebración al completar el día -->
|
||||
@if (celebrating()) {
|
||||
<app-celebration-overlay
|
||||
[coinsDay]="lastEarned()"
|
||||
[mascot]="t.child.mascot"
|
||||
(close)="celebrating.set(false)"
|
||||
/>
|
||||
}
|
||||
} @else if (loading()) {
|
||||
<p class="home__msg">Cargando el día…</p>
|
||||
} @else {
|
||||
<p class="home__msg">No se pudo cargar el día. ¿Está arrancado el backend?</p>
|
||||
}
|
||||
100
frontend/src/app/features/home/home.component.scss
Normal file
100
frontend/src/app/features/home/home.component.scss
Normal file
@@ -0,0 +1,100 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.home {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
|
||||
&__msg {
|
||||
text-align: center;
|
||||
padding: var(--space-6);
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__greet {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
&__hello {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
&__date {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-2);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__wallet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
.iconbtn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
width: var(--touch-nav);
|
||||
height: var(--touch-nav);
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-btn);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: var(--text-2);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
// Conmutador Tablero / Foco.
|
||||
.modes {
|
||||
display: inline-flex;
|
||||
align-self: center;
|
||||
background: var(--surface-softer);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
|
||||
&__btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 10px 22px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text-2);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
&__btn--on {
|
||||
background: var(--surface);
|
||||
color: var(--text-strong);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
}
|
||||
|
||||
// Moneda voladora: parte de la tarea y viaja al monedero.
|
||||
.flycoin {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
font-size: 40px;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
transition: transform 0.72s cubic-bezier(0.4, 0, 0.5, 1), opacity 0.72s;
|
||||
}
|
||||
190
frontend/src/app/features/home/home.component.ts
Normal file
190
frontend/src/app/features/home/home.component.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Component, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { SoundService } from '../../core/sound.service';
|
||||
import { TodayResponse, ViewMode } from '../../core/models';
|
||||
import { BoardViewComponent } from './board-view.component';
|
||||
import { FocusViewComponent } from './focus-view.component';
|
||||
import { WalletComponent } from './wallet.component';
|
||||
import { MorningTimerComponent } from './morning-timer.component';
|
||||
import { ProgressBarComponent } from './progress-bar.component';
|
||||
import { EventBannerComponent } from './event-banner.component';
|
||||
import { CelebrationOverlayComponent } from './celebration-overlay.component';
|
||||
import { ToggleEvent } from './task-card.component';
|
||||
|
||||
/** Moneda voladora: parte del check de la tarea y vuela al monedero. */
|
||||
interface FlyingCoin {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
flying: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pantalla "HOY". Orquesta la carga del día, el conmutado Tablero/Foco (persistido),
|
||||
* el marcado de tareas con su feedback (monedas voladoras, rebote del monedero,
|
||||
* sonido) y la celebración al completar el día.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
imports: [
|
||||
BoardViewComponent,
|
||||
FocusViewComponent,
|
||||
WalletComponent,
|
||||
MorningTimerComponent,
|
||||
ProgressBarComponent,
|
||||
EventBannerComponent,
|
||||
CelebrationOverlayComponent,
|
||||
],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss',
|
||||
})
|
||||
export class HomeComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly sound = inject(SoundService);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
@ViewChild('wallet') private wallet?: WalletComponent;
|
||||
|
||||
protected readonly today = signal<TodayResponse | null>(null);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly mode = signal<ViewMode>('BOARD');
|
||||
protected readonly coins = signal<FlyingCoin[]>([]);
|
||||
protected readonly celebrating = signal(false);
|
||||
protected readonly lastEarned = signal(0);
|
||||
|
||||
private childId!: number;
|
||||
private coinSeq = 0;
|
||||
|
||||
/** Todas las tareas en orden (mañana y luego tarde) para el modo Foco. */
|
||||
protected readonly allTasks = computed(() => {
|
||||
const t = this.today();
|
||||
return t ? [...t.morning, ...t.afternoon] : [];
|
||||
});
|
||||
|
||||
/** Fecha de hoy formateada en el idioma activo. */
|
||||
protected readonly dateLabel = computed(() => {
|
||||
const locale = this.i18n.lang() === 'CA' ? 'ca-ES' : 'es-ES';
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(new Date());
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.childId = Number(this.route.snapshot.paramMap.get('childId'));
|
||||
this.load();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
this.api.getToday(this.childId).subscribe({
|
||||
next: (data) => {
|
||||
this.today.set(data);
|
||||
this.mode.set(data.child.viewMode);
|
||||
this.i18n.setLang(data.child.language);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
/** Cambia entre Tablero y Foco y persiste la preferencia del niño. */
|
||||
setMode(mode: ViewMode): void {
|
||||
if (mode === this.mode()) {
|
||||
return;
|
||||
}
|
||||
this.mode.set(mode);
|
||||
this.api.updateSettings(this.childId, { viewMode: mode }).subscribe();
|
||||
}
|
||||
|
||||
/** Marca/desmarca una tarea y aplica el feedback (monedas, sonido, celebración). */
|
||||
onToggle(ev: ToggleEvent): void {
|
||||
this.api.toggleTask(ev.taskId).subscribe((result) => {
|
||||
const current = this.today();
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Actualiza estado del día de forma inmutable.
|
||||
const apply = (list: typeof current.morning) =>
|
||||
list.map((task) => (task.id === ev.taskId ? { ...task, done: result.done } : task));
|
||||
const updated: TodayResponse = {
|
||||
...current,
|
||||
morning: apply(current.morning),
|
||||
afternoon: apply(current.afternoon),
|
||||
progress: result.progress,
|
||||
wallet: { coins: result.newBalance },
|
||||
};
|
||||
this.today.set(updated);
|
||||
|
||||
// Feedback positivo solo al ganar monedas (marcar, no desmarcar).
|
||||
if (result.coinsEarned > 0) {
|
||||
this.flyCoins(ev.x, ev.y, result.coinsEarned);
|
||||
this.wallet?.bump();
|
||||
if (current.child.soundEnabled) {
|
||||
this.sound.playReward();
|
||||
}
|
||||
}
|
||||
|
||||
// Celebración al completar TODO el día.
|
||||
if (result.progress.total > 0 && result.progress.totalDone === result.progress.total) {
|
||||
this.lastEarned.set(result.coinsEarned);
|
||||
this.celebrating.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Lanza monedas desde (x,y) hacia el monedero. */
|
||||
private flyCoins(x: number, y: number, earned: number): void {
|
||||
const rect = this.wallet?.getRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
const targetX = rect.left + rect.width / 2;
|
||||
const targetY = rect.top + rect.height / 2;
|
||||
const n = Math.min(Math.max(1, Math.round(earned / 5)), 6);
|
||||
|
||||
const nuevas: FlyingCoin[] = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const jitterX = (k - n / 2) * 14;
|
||||
const startX = x + jitterX;
|
||||
const startY = y;
|
||||
nuevas.push({
|
||||
id: this.coinSeq++,
|
||||
x: startX,
|
||||
y: startY,
|
||||
dx: targetX - startX,
|
||||
dy: targetY - startY,
|
||||
flying: false,
|
||||
});
|
||||
}
|
||||
this.coins.update((c) => [...c, ...nuevas]);
|
||||
|
||||
// En el siguiente frame, activa la transición (vuelo).
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() =>
|
||||
this.coins.update((c) =>
|
||||
c.map((coin) => (nuevas.some((nc) => nc.id === coin.id) ? { ...coin, flying: true } : coin)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Retira las monedas tras la animación.
|
||||
const ids = new Set(nuevas.map((c) => c.id));
|
||||
setTimeout(() => this.coins.update((c) => c.filter((coin) => !ids.has(coin.id))), 800);
|
||||
}
|
||||
|
||||
goProfiles(): void {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
goStore(): void {
|
||||
this.router.navigate(['/store', this.childId]);
|
||||
}
|
||||
}
|
||||
41
frontend/src/app/features/home/morning-timer.component.ts
Normal file
41
frontend/src/app/features/home/morning-timer.component.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Component, Input, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
|
||||
/** Temporizador de salida de la mañana: "SALIMOS EN {min} min" con anillo glow. */
|
||||
@Component({
|
||||
selector: 'app-morning-timer',
|
||||
imports: [],
|
||||
template: `
|
||||
@if (minutes !== null) {
|
||||
<div class="timer">
|
||||
<span class="timer__ring">⏰</span>
|
||||
<span class="timer__text">{{ i18n.t('leaveIn') }} {{ minutes }} {{ i18n.t('min') }}</span>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--accent-orange) 14%, #fff);
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
}
|
||||
.timer__ring {
|
||||
font-size: 22px;
|
||||
border-radius: 50%;
|
||||
animation: ringGlow 2.2s ease-in-out infinite;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class MorningTimerComponent {
|
||||
@Input() minutes: number | null = null;
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
}
|
||||
49
frontend/src/app/features/home/progress-bar.component.ts
Normal file
49
frontend/src/app/features/home/progress-bar.component.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Component, Input, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
|
||||
/** Barra de progreso global del día: "{hechas}/{total} listo ✨". */
|
||||
@Component({
|
||||
selector: 'app-progress-bar',
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="prog">
|
||||
<div class="prog__track">
|
||||
<div class="prog__fill" [style.width.%]="pct"></div>
|
||||
</div>
|
||||
<p class="prog__label">{{ done }}/{{ total }} {{ i18n.t('ready') }} ✨</p>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.prog { display: flex; flex-direction: column; gap: 6px; }
|
||||
.prog__track {
|
||||
height: 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-softer);
|
||||
overflow: hidden;
|
||||
}
|
||||
.prog__fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(90deg, var(--accent-green), var(--accent-teal));
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.prog__label {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ProgressBarComponent {
|
||||
@Input() done = 0;
|
||||
@Input() total = 0;
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
get pct(): number {
|
||||
return this.total > 0 ? Math.round((this.done / this.total) * 100) : 0;
|
||||
}
|
||||
}
|
||||
131
frontend/src/app/features/home/task-card.component.ts
Normal file
131
frontend/src/app/features/home/task-card.component.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, inject } from '@angular/core';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { TtsService } from '../../core/tts.service';
|
||||
import { TaskView } from '../../core/models';
|
||||
|
||||
/** Coordenadas (centro del check) desde donde sale la moneda voladora. */
|
||||
export interface ToggleEvent {
|
||||
taskId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tarjeta de tarea del Tablero: icono en tile, etiqueta en MAYÚSCULAS, botón de
|
||||
* lectura (TTS) y check grande. Réplica fiel del handoff (borde 3px, radio 26px,
|
||||
* tinte del color al completar, animaciones pop/checkPop).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-task-card',
|
||||
imports: [],
|
||||
template: `
|
||||
<div
|
||||
class="card"
|
||||
[class.card--done]="task.done"
|
||||
[style.--c]="task.color"
|
||||
(click)="emitToggle()"
|
||||
>
|
||||
<span class="card__tile">{{ task.icon }}</span>
|
||||
<span class="card__label">{{ i18n.label(task.labelEs, task.labelCa) }}</span>
|
||||
@if (ttsEnabled && tts.supported) {
|
||||
<button type="button" class="card__tts" (click)="speak($event)" aria-label="Leer en voz alta">
|
||||
🔊
|
||||
</button>
|
||||
}
|
||||
<span #check class="card__check">{{ task.done ? '✓' : '' }}</span>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
min-height: var(--card-min-height);
|
||||
padding: 14px 16px;
|
||||
background: var(--surface);
|
||||
border: var(--card-border-width) solid var(--card-border-idle);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.25s, background 0.25s;
|
||||
}
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card__tile {
|
||||
width: var(--tile-size);
|
||||
height: var(--tile-size);
|
||||
border-radius: var(--radius-tile);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 38px;
|
||||
flex: none;
|
||||
background: color-mix(in srgb, var(--c) 16%, #fff);
|
||||
}
|
||||
.card__label {
|
||||
flex: 1;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.card__tts {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
font-size: 26px;
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.card__check {
|
||||
width: var(--touch-check);
|
||||
height: var(--touch-check);
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--border-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
flex: none;
|
||||
}
|
||||
.card--done {
|
||||
background: color-mix(in srgb, var(--c) 14%, #fff);
|
||||
border-color: var(--c);
|
||||
animation: pop 0.35s ease;
|
||||
}
|
||||
.card--done .card__check {
|
||||
background: var(--c);
|
||||
border-color: var(--c);
|
||||
animation: checkPop 0.35s ease;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class TaskCardComponent {
|
||||
@Input({ required: true }) task!: TaskView;
|
||||
@Input() ttsEnabled = true;
|
||||
|
||||
@Output() toggle = new EventEmitter<ToggleEvent>();
|
||||
|
||||
@ViewChild('check') private check!: ElementRef<HTMLElement>;
|
||||
|
||||
protected readonly i18n = inject(I18nService);
|
||||
protected readonly tts = inject(TtsService);
|
||||
|
||||
emitToggle(): void {
|
||||
const rect = this.check.nativeElement.getBoundingClientRect();
|
||||
this.toggle.emit({
|
||||
taskId: this.task.id,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
/** Lee la etiqueta en voz alta sin propagar el toque al check. */
|
||||
speak(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.tts.speak(this.i18n.label(this.task.labelEs, this.task.labelCa), this.i18n.lang());
|
||||
}
|
||||
}
|
||||
49
frontend/src/app/features/home/wallet.component.ts
Normal file
49
frontend/src/app/features/home/wallet.component.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Component, ElementRef, Input, inject, signal } from '@angular/core';
|
||||
|
||||
/** Monedero: pill con 🪙 y el saldo. Hace walletBump cuando se le pide (al ganar). */
|
||||
@Component({
|
||||
selector: 'app-wallet',
|
||||
imports: [],
|
||||
template: `
|
||||
<span class="wallet" [class.wallet--bump]="bumping()">
|
||||
<span class="wallet__coin">🪙</span>
|
||||
<span class="wallet__amount">{{ coins }}</span>
|
||||
</span>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.wallet {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--coin-bg);
|
||||
color: var(--coin-text);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.wallet--bump { animation: walletBump 0.45s ease; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class WalletComponent {
|
||||
@Input({ required: true }) coins = 0;
|
||||
|
||||
protected readonly bumping = signal(false);
|
||||
private readonly el = inject(ElementRef<HTMLElement>);
|
||||
|
||||
/** Rectángulo del monedero: destino de las monedas voladoras. */
|
||||
getRect(): DOMRect {
|
||||
return (this.el.nativeElement as HTMLElement).getBoundingClientRect();
|
||||
}
|
||||
|
||||
/** Dispara la animación de "rebote" del monedero. */
|
||||
bump(): void {
|
||||
this.bumping.set(false);
|
||||
// Reinicia la animación en el siguiente frame.
|
||||
requestAnimationFrame(() => this.bumping.set(true));
|
||||
setTimeout(() => this.bumping.set(false), 500);
|
||||
}
|
||||
}
|
||||
85
frontend/src/app/features/parents/events-tab.component.ts
Normal file
85
frontend/src/app/features/parents/events-tab.component.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Component, Input, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ParentApiService } from '../../core/parent-api.service';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { EventAdminView } from '../../core/models';
|
||||
|
||||
/** Pestaña Eventos: exámenes y deberes con fecha, por niño. */
|
||||
@Component({
|
||||
selector: 'app-events-tab',
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="adm-card">
|
||||
<p class="adm-label">Nuevo evento</p>
|
||||
<div class="adm-row">
|
||||
<select class="adm-input" [(ngModel)]="type">
|
||||
<option value="EXAM">📋 {{ i18n.t('exam') }}</option>
|
||||
<option value="HOMEWORK">📎 {{ i18n.t('homework') }}</option>
|
||||
</select>
|
||||
<input class="adm-input" type="date" [(ngModel)]="date" />
|
||||
<input class="adm-input" [(ngModel)]="titleEs" placeholder="Título (ES)" />
|
||||
<input class="adm-input" [(ngModel)]="titleCa" placeholder="Títol (CA)" />
|
||||
<button class="adm-btn" [disabled]="!date || !titleEs || !titleCa" (click)="add()">+ {{ i18n.t('add') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adm-card">
|
||||
<div class="adm-list">
|
||||
@for (e of events(); track e.id) {
|
||||
<div class="adm-item">
|
||||
<span>{{ e.type === 'EXAM' ? '📋' : '📎' }}</span>
|
||||
<span class="adm-item__grow">
|
||||
<strong>{{ i18n.label(e.titleEs, e.titleCa) }}</strong> · {{ e.date }}
|
||||
</span>
|
||||
<button class="adm-del" (click)="remove(e)">✕</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="adm-empty">{{ i18n.t('none') }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class EventsTabComponent {
|
||||
@Input({ required: true }) set childId(value: number) {
|
||||
this._childId = value;
|
||||
this.reload();
|
||||
}
|
||||
private _childId!: number;
|
||||
|
||||
private readonly api = inject(ParentApiService);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
protected readonly events = signal<EventAdminView[]>([]);
|
||||
protected type: 'EXAM' | 'HOMEWORK' = 'EXAM';
|
||||
protected date = '';
|
||||
protected titleEs = '';
|
||||
protected titleCa = '';
|
||||
|
||||
private reload(): void {
|
||||
this.api.listEvents(this._childId).subscribe((l) => this.events.set(l));
|
||||
}
|
||||
|
||||
add(): void {
|
||||
const icon = this.type === 'EXAM' ? '📋' : '📎';
|
||||
const color = this.type === 'EXAM' ? '#EC8FA4' : '#5B8DEF';
|
||||
this.api
|
||||
.createEvent({
|
||||
childId: this._childId,
|
||||
date: this.date,
|
||||
type: this.type,
|
||||
titleEs: this.titleEs,
|
||||
titleCa: this.titleCa,
|
||||
icon,
|
||||
color,
|
||||
})
|
||||
.subscribe(() => {
|
||||
this.titleEs = this.titleCa = this.date = '';
|
||||
this.reload();
|
||||
});
|
||||
}
|
||||
|
||||
remove(e: EventAdminView): void {
|
||||
this.api.deleteEvent(e.id).subscribe(() => this.reload());
|
||||
}
|
||||
}
|
||||
137
frontend/src/app/features/parents/keypad.component.ts
Normal file
137
frontend/src/app/features/parents/keypad.component.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { ParentSessionService } from '../../core/parent-session.service';
|
||||
|
||||
/** Teclado numérico 3×4 para el PIN de padres (4 dígitos, shake al fallar). */
|
||||
@Component({
|
||||
selector: 'app-keypad',
|
||||
imports: [],
|
||||
template: `
|
||||
<main class="key-screen">
|
||||
<button type="button" class="key-screen__back" (click)="cancel()">‹</button>
|
||||
<p class="key-screen__title">{{ i18n.t('enterPin') }} 🔒</p>
|
||||
|
||||
<div class="dots" [class.dots--error]="error()">
|
||||
@for (i of [0, 1, 2, 3]; track i) {
|
||||
<span class="dot" [class.dot--filled]="entry().length > i"></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<p class="err">{{ i18n.t('wrongPin') }}</p>
|
||||
}
|
||||
|
||||
<div class="pad">
|
||||
@for (k of keys; track k.label) {
|
||||
<button type="button" class="key" [class.key--fn]="k.fn" (click)="press(k)">{{ k.label }}</button>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.key-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-5);
|
||||
position: relative;
|
||||
}
|
||||
.key-screen__back {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
width: var(--touch-nav);
|
||||
height: var(--touch-nav);
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-btn);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.key-screen__title { margin: 0; font-family: var(--font-display); font-weight: 700; font-size: 1.4rem; }
|
||||
.dots { display: flex; gap: 18px; }
|
||||
.dots--error { animation: shake 0.4s ease; }
|
||||
.dot { width: 20px; height: 20px; border-radius: 50%; border: 3px solid var(--border-3); }
|
||||
.dot--filled { background: var(--accent-blue); border-color: var(--accent-blue); }
|
||||
.err { margin: 0; color: var(--accent-pink); font-weight: 700; }
|
||||
.pad { display: grid; grid-template-columns: repeat(3, var(--touch-pin)); gap: 14px; }
|
||||
.key {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
width: var(--touch-pin);
|
||||
height: var(--touch-pin);
|
||||
border-radius: 24px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.8rem;
|
||||
color: var(--text-strong);
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.key:active { transform: scale(0.94); }
|
||||
.key--fn { background: var(--surface-softer); font-size: 1.4rem; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class KeypadComponent {
|
||||
private readonly session = inject(ParentSessionService);
|
||||
private readonly router = inject(Router);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
protected readonly entry = signal('');
|
||||
protected readonly error = signal(false);
|
||||
|
||||
protected readonly keys = [
|
||||
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => ({ label: String(n), fn: false })),
|
||||
{ label: '⌫', fn: true },
|
||||
{ label: '0', fn: false },
|
||||
{ label: 'C', fn: true },
|
||||
];
|
||||
|
||||
press(k: { label: string; fn: boolean }): void {
|
||||
this.error.set(false);
|
||||
if (k.label === '⌫') {
|
||||
this.entry.update((e) => e.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (k.label === 'C') {
|
||||
this.entry.set('');
|
||||
return;
|
||||
}
|
||||
if (this.entry().length >= 4) {
|
||||
return;
|
||||
}
|
||||
const next = this.entry() + k.label;
|
||||
this.entry.set(next);
|
||||
if (next.length === 4) {
|
||||
this.submit(next);
|
||||
}
|
||||
}
|
||||
|
||||
private submit(code: string): void {
|
||||
this.session.login(code).subscribe({
|
||||
next: () => this.router.navigate(['/parents']),
|
||||
error: () => {
|
||||
this.error.set(true);
|
||||
this.entry.set('');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
151
frontend/src/app/features/parents/materials-tab.component.ts
Normal file
151
frontend/src/app/features/parents/materials-tab.component.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ParentApiService } from '../../core/parent-api.service';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { ActivityView, MaterialView } from '../../core/models';
|
||||
|
||||
/** Pestaña Materiales: catálogo de materiales y actividades (con su material). */
|
||||
@Component({
|
||||
selector: 'app-materials-tab',
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<!-- Alta de material -->
|
||||
<div class="adm-card">
|
||||
<p class="adm-label">Nuevo material</p>
|
||||
<div class="adm-row">
|
||||
<input class="adm-input adm-input--sm" [(ngModel)]="mIcon" placeholder="🎒" maxlength="4" />
|
||||
<input class="adm-input" [(ngModel)]="mEs" placeholder="Nombre (ES)" />
|
||||
<input class="adm-input" [(ngModel)]="mCa" placeholder="Nom (CA)" />
|
||||
<input class="adm-input adm-input--sm" type="color" [(ngModel)]="mColor" />
|
||||
<button class="adm-btn" [disabled]="!mEs || !mCa || !mIcon" (click)="addMaterial()">+ {{ i18n.t('add') }}</button>
|
||||
</div>
|
||||
<div class="adm-list" style="margin-top:12px">
|
||||
@for (m of materials(); track m.id) {
|
||||
<div class="adm-item">
|
||||
<span>{{ m.icon }}</span>
|
||||
<span class="adm-item__grow">{{ i18n.label(m.labelEs, m.labelCa) }}</span>
|
||||
<button class="adm-del" (click)="delMaterial(m)">✕</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="adm-empty">{{ i18n.t('none') }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alta de actividad con su material -->
|
||||
<div class="adm-card">
|
||||
<p class="adm-label">Nueva actividad</p>
|
||||
<div class="adm-row">
|
||||
<input class="adm-input adm-input--sm" [(ngModel)]="aIcon" placeholder="🤸" maxlength="4" />
|
||||
<input class="adm-input" [(ngModel)]="aEs" placeholder="Actividad (ES)" />
|
||||
<input class="adm-input" [(ngModel)]="aCa" placeholder="Activitat (CA)" />
|
||||
<input class="adm-input adm-input--sm" type="color" [(ngModel)]="aColor" />
|
||||
</div>
|
||||
<p class="adm-label" style="margin-top:12px">Material que conlleva:</p>
|
||||
<div class="adm-row">
|
||||
@for (m of materials(); track m.id) {
|
||||
<label class="adm-chip">
|
||||
<input type="checkbox" [checked]="selectedMaterials.has(m.id)" (change)="toggleMaterial(m.id)" />
|
||||
{{ m.icon }} {{ i18n.label(m.labelEs, m.labelCa) }}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="adm-row" style="margin-top:12px">
|
||||
<button class="adm-btn" [disabled]="!aEs || !aCa || !aIcon" (click)="addActivity()">
|
||||
+ {{ i18n.t('add') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actividades existentes -->
|
||||
<div class="adm-card">
|
||||
<p class="adm-label">Actividades</p>
|
||||
<div class="adm-list">
|
||||
@for (a of activities(); track a.id) {
|
||||
<div class="adm-item">
|
||||
<span>{{ a.icon }}</span>
|
||||
<span class="adm-item__grow">
|
||||
<strong>{{ i18n.label(a.labelEs, a.labelCa) }}</strong>
|
||||
— {{ materialNames(a) }}
|
||||
</span>
|
||||
<button class="adm-del" (click)="delActivity(a)">✕</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="adm-empty">{{ i18n.t('none') }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class MaterialsTabComponent {
|
||||
private readonly api = inject(ParentApiService);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
protected readonly materials = signal<MaterialView[]>([]);
|
||||
protected readonly activities = signal<ActivityView[]>([]);
|
||||
|
||||
protected mIcon = '';
|
||||
protected mEs = '';
|
||||
protected mCa = '';
|
||||
protected mColor = '#5b8def';
|
||||
protected aIcon = '';
|
||||
protected aEs = '';
|
||||
protected aCa = '';
|
||||
protected aColor = '#7fbf6b';
|
||||
protected selectedMaterials = new Set<number>();
|
||||
|
||||
constructor() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
private reload(): void {
|
||||
this.api.listMaterials().subscribe((l) => this.materials.set(l));
|
||||
this.api.listActivities().subscribe((l) => this.activities.set(l));
|
||||
}
|
||||
|
||||
materialNames(a: ActivityView): string {
|
||||
const byId = new Map(this.materials().map((m) => [m.id, this.i18n.label(m.labelEs, m.labelCa)]));
|
||||
return a.materialIds.map((id) => byId.get(id) ?? '·').join(', ') || '—';
|
||||
}
|
||||
|
||||
toggleMaterial(id: number): void {
|
||||
if (this.selectedMaterials.has(id)) {
|
||||
this.selectedMaterials.delete(id);
|
||||
} else {
|
||||
this.selectedMaterials.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
addMaterial(): void {
|
||||
this.api
|
||||
.createMaterial({ labelEs: this.mEs, labelCa: this.mCa, icon: this.mIcon, color: this.mColor })
|
||||
.subscribe(() => {
|
||||
this.mEs = this.mCa = this.mIcon = '';
|
||||
this.reload();
|
||||
});
|
||||
}
|
||||
|
||||
delMaterial(m: MaterialView): void {
|
||||
this.api.deleteMaterial(m.id).subscribe(() => this.reload());
|
||||
}
|
||||
|
||||
addActivity(): void {
|
||||
this.api
|
||||
.createActivity({
|
||||
labelEs: this.aEs,
|
||||
labelCa: this.aCa,
|
||||
icon: this.aIcon,
|
||||
color: this.aColor,
|
||||
materialIds: [...this.selectedMaterials],
|
||||
})
|
||||
.subscribe(() => {
|
||||
this.aEs = this.aCa = this.aIcon = '';
|
||||
this.selectedMaterials.clear();
|
||||
this.reload();
|
||||
});
|
||||
}
|
||||
|
||||
delActivity(a: ActivityView): void {
|
||||
this.api.deleteActivity(a.id).subscribe(() => this.reload());
|
||||
}
|
||||
}
|
||||
114
frontend/src/app/features/parents/parents.component.ts
Normal file
114
frontend/src/app/features/parents/parents.component.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ParentApiService } from '../../core/parent-api.service';
|
||||
import { ParentSessionService } from '../../core/parent-session.service';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { ChildSummary } from '../../core/models';
|
||||
import { ScheduleTabComponent } from './schedule-tab.component';
|
||||
import { MaterialsTabComponent } from './materials-tab.component';
|
||||
import { EventsTabComponent } from './events-tab.component';
|
||||
import { RoutinesTabComponent } from './routines-tab.component';
|
||||
import { RewardsTabComponent } from './rewards-tab.component';
|
||||
|
||||
type Tab = 'schedule' | 'materials' | 'events' | 'routines' | 'rewards';
|
||||
|
||||
/** Panel de padres: barra de pestañas, selector de niño y salida. */
|
||||
@Component({
|
||||
selector: 'app-parents',
|
||||
imports: [
|
||||
FormsModule,
|
||||
ScheduleTabComponent,
|
||||
MaterialsTabComponent,
|
||||
EventsTabComponent,
|
||||
RoutinesTabComponent,
|
||||
RewardsTabComponent,
|
||||
],
|
||||
template: `
|
||||
<header class="ptop">
|
||||
<div class="ptabs">
|
||||
<button class="ptab" [class.ptab--on]="tab() === 'schedule'" (click)="tab.set('schedule')">📅 {{ i18n.t('tabSchedule') }}</button>
|
||||
<button class="ptab" [class.ptab--on]="tab() === 'materials'" (click)="tab.set('materials')">🎒 {{ i18n.t('tabMaterials') }}</button>
|
||||
<button class="ptab" [class.ptab--on]="tab() === 'events'" (click)="tab.set('events')">📋 {{ i18n.t('tabEvents') }}</button>
|
||||
<button class="ptab" [class.ptab--on]="tab() === 'routines'" (click)="tab.set('routines')">🌙 {{ i18n.t('tabRoutines') }}</button>
|
||||
<button class="ptab" [class.ptab--on]="tab() === 'rewards'" (click)="tab.set('rewards')">🪙 {{ i18n.t('tabRewards') }}</button>
|
||||
</div>
|
||||
<button class="plogout" (click)="logout()">🔓 {{ i18n.t('logout') }}</button>
|
||||
</header>
|
||||
|
||||
<main class="pbody">
|
||||
@if (tab() !== 'materials') {
|
||||
<div class="pchild">
|
||||
<label class="adm-label">{{ i18n.t('child') }}:</label>
|
||||
<select class="adm-input" [(ngModel)]="selectedId">
|
||||
@for (c of children(); track c.id) {
|
||||
<option [ngValue]="c.id">{{ c.mascot }} {{ c.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
|
||||
@switch (tab()) {
|
||||
@case ('schedule') { <app-schedule-tab [childId]="selectedId" /> }
|
||||
@case ('materials') { <app-materials-tab /> }
|
||||
@case ('events') { <app-events-tab [childId]="selectedId" /> }
|
||||
@case ('routines') { <app-routines-tab [childId]="selectedId" /> }
|
||||
@case ('rewards') { <app-rewards-tab [childId]="selectedId" /> }
|
||||
}
|
||||
</main>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.ptop {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border-1);
|
||||
}
|
||||
.ptabs { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; }
|
||||
.ptab {
|
||||
all: unset; cursor: pointer; padding: 10px 16px; border-radius: var(--radius-pill);
|
||||
font-family: var(--font-display); font-weight: 700; color: var(--text-2); font-size: 0.95rem;
|
||||
}
|
||||
.ptab--on { background: var(--accent-blue); color: #fff; }
|
||||
.plogout {
|
||||
all: unset; cursor: pointer; padding: 10px 16px; border-radius: var(--radius-pill);
|
||||
font-family: var(--font-display); font-weight: 700; color: var(--accent-pink);
|
||||
background: color-mix(in srgb, var(--accent-pink) 14%, #fff);
|
||||
}
|
||||
.pbody { max-width: 900px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
|
||||
.pchild { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-4); }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ParentsComponent {
|
||||
private readonly parentApi = inject(ParentApiService);
|
||||
private readonly session = inject(ParentSessionService);
|
||||
private readonly router = inject(Router);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
protected readonly children = signal<ChildSummary[]>([]);
|
||||
protected readonly tab = signal<Tab>('schedule');
|
||||
protected selectedId = 0;
|
||||
|
||||
constructor() {
|
||||
this.parentApi.listChildren().subscribe((list) => {
|
||||
this.children.set(list);
|
||||
if (list.length && !this.selectedId) {
|
||||
this.selectedId = list[0].id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.session.logout();
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
150
frontend/src/app/features/parents/rewards-tab.component.ts
Normal file
150
frontend/src/app/features/parents/rewards-tab.component.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Component, Input, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { ParentApiService } from '../../core/parent-api.service';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { RewardAdminView } from '../../core/models';
|
||||
|
||||
/** Pestaña Recompensas: catálogo de premios + gamificación y ajustes del niño. */
|
||||
@Component({
|
||||
selector: 'app-rewards-tab',
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<!-- Gamificación + ajustes del niño -->
|
||||
<div class="adm-card">
|
||||
<p class="adm-label">Gamificación (monedas)</p>
|
||||
<div class="adm-row">
|
||||
<label class="adm-label">{{ i18n.t('perTask') }}</label>
|
||||
<input class="adm-input adm-input--sm" type="number" min="0" [(ngModel)]="perTask" />
|
||||
<label class="adm-label">{{ i18n.t('perBlock') }}</label>
|
||||
<input class="adm-input adm-input--sm" type="number" min="0" [(ngModel)]="perBlock" />
|
||||
<label class="adm-label">{{ i18n.t('perDay') }}</label>
|
||||
<input class="adm-input adm-input--sm" type="number" min="0" [(ngModel)]="perDay" />
|
||||
<button class="adm-btn" (click)="saveGamification()">{{ i18n.t('save') }}</button>
|
||||
</div>
|
||||
<div class="adm-row" style="margin-top:12px">
|
||||
<label class="adm-chip">
|
||||
<input type="checkbox" [(ngModel)]="soundEnabled" (change)="saveSettings()" /> 🔊 {{ i18n.t('sound') }}
|
||||
</label>
|
||||
<label class="adm-chip">
|
||||
<input type="checkbox" [(ngModel)]="ttsEnabled" (change)="saveSettings()" /> 🗣️ {{ i18n.t('readAloud') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alta de premio -->
|
||||
<div class="adm-card">
|
||||
<p class="adm-label">Nuevo premio</p>
|
||||
<div class="adm-row">
|
||||
<input class="adm-input adm-input--sm" [(ngModel)]="icon" placeholder="🎮" maxlength="4" />
|
||||
<input class="adm-input" [(ngModel)]="labelEs" placeholder="Premio (ES)" />
|
||||
<input class="adm-input" [(ngModel)]="labelCa" placeholder="Premi (CA)" />
|
||||
<input class="adm-input adm-input--sm" type="number" min="1" [(ngModel)]="cost" placeholder="🪙" />
|
||||
<input class="adm-input adm-input--sm" type="color" [(ngModel)]="color" />
|
||||
<button class="adm-btn" [disabled]="!labelEs || !labelCa || !icon || !cost" (click)="add()">
|
||||
+ {{ i18n.t('add') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Catálogo de premios -->
|
||||
<div class="adm-card">
|
||||
<p class="adm-label">Catálogo</p>
|
||||
<div class="adm-list">
|
||||
@for (r of rewards(); track r.id) {
|
||||
<div class="adm-item">
|
||||
<span>{{ r.icon }}</span>
|
||||
<span class="adm-item__grow">{{ i18n.label(r.labelEs, r.labelCa) }}</span>
|
||||
<span class="adm-chip">🪙 {{ r.cost }}</span>
|
||||
<button class="adm-del" (click)="remove(r)">✕</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="adm-empty">{{ i18n.t('none') }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RewardsTabComponent {
|
||||
@Input({ required: true }) set childId(value: number) {
|
||||
this._childId = value;
|
||||
// Precarga la gamificación actual del niño.
|
||||
this.parentApi.getGamification(value).subscribe((g) => {
|
||||
this.perTask = g.coinsPerTask;
|
||||
this.perBlock = g.coinsPerBlock;
|
||||
this.perDay = g.coinsPerDay;
|
||||
});
|
||||
// Lee sonido/TTS actuales del niño desde /today (la lista no los trae).
|
||||
this.api.getToday(value).subscribe((t) => {
|
||||
this.soundEnabled = t.child.soundEnabled;
|
||||
this.ttsEnabled = t.child.ttsEnabled;
|
||||
});
|
||||
}
|
||||
private _childId!: number;
|
||||
|
||||
private readonly parentApi = inject(ParentApiService);
|
||||
private readonly api = inject(ApiService);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
protected readonly rewards = signal<RewardAdminView[]>([]);
|
||||
|
||||
// Gamificación: se precargan los valores actuales del niño en el setter de childId.
|
||||
protected perTask = 5;
|
||||
protected perBlock = 10;
|
||||
protected perDay = 20;
|
||||
protected soundEnabled = true;
|
||||
protected ttsEnabled = true;
|
||||
|
||||
protected icon = '';
|
||||
protected labelEs = '';
|
||||
protected labelCa = '';
|
||||
protected cost: number | null = null;
|
||||
protected color = '#5b8def';
|
||||
|
||||
constructor() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
private reload(): void {
|
||||
this.parentApi.listRewards().subscribe((l) => this.rewards.set(l));
|
||||
}
|
||||
|
||||
saveGamification(): void {
|
||||
this.parentApi
|
||||
.updateGamification(this._childId, {
|
||||
coinsPerTask: this.perTask,
|
||||
coinsPerBlock: this.perBlock,
|
||||
coinsPerDay: this.perDay,
|
||||
})
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
saveSettings(): void {
|
||||
this.parentApi
|
||||
.updateSettings(this._childId, { soundEnabled: this.soundEnabled, ttsEnabled: this.ttsEnabled })
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
add(): void {
|
||||
if (!this.cost) {
|
||||
return;
|
||||
}
|
||||
this.parentApi
|
||||
.createReward({
|
||||
labelEs: this.labelEs,
|
||||
labelCa: this.labelCa,
|
||||
icon: this.icon,
|
||||
color: this.color,
|
||||
cost: this.cost,
|
||||
})
|
||||
.subscribe(() => {
|
||||
this.labelEs = this.labelCa = this.icon = '';
|
||||
this.cost = null;
|
||||
this.reload();
|
||||
});
|
||||
}
|
||||
|
||||
remove(r: RewardAdminView): void {
|
||||
this.parentApi.deleteReward(r.id).subscribe(() => this.reload());
|
||||
}
|
||||
}
|
||||
122
frontend/src/app/features/parents/routines-tab.component.ts
Normal file
122
frontend/src/app/features/parents/routines-tab.component.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Component, Input, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ParentApiService } from '../../core/parent-api.service';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { RoutineView } from '../../core/models';
|
||||
|
||||
const DAYS: { key: string; es: string; ca: string }[] = [
|
||||
{ key: 'MONDAY', es: 'Lunes', ca: 'Dilluns' },
|
||||
{ key: 'TUESDAY', es: 'Martes', ca: 'Dimarts' },
|
||||
{ key: 'WEDNESDAY', es: 'Miércoles', ca: 'Dimecres' },
|
||||
{ key: 'THURSDAY', es: 'Jueves', ca: 'Dijous' },
|
||||
{ key: 'FRIDAY', es: 'Viernes', ca: 'Divendres' },
|
||||
];
|
||||
|
||||
/** Pestaña Rutinas de tarde por día de la semana, de un niño. */
|
||||
@Component({
|
||||
selector: 'app-routines-tab',
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="adm-card">
|
||||
<div class="adm-row">
|
||||
<span class="adm-label">{{ i18n.t('tabRoutines') }}:</span>
|
||||
@for (d of days; track d.key) {
|
||||
<button
|
||||
class="adm-chip"
|
||||
[style.background]="d.key === day() ? 'var(--accent-purple)' : ''"
|
||||
[style.color]="d.key === day() ? '#fff' : ''"
|
||||
(click)="day.set(d.key)"
|
||||
>
|
||||
{{ i18n.label(d.es, d.ca) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adm-card">
|
||||
<p class="adm-label">Nueva rutina</p>
|
||||
<div class="adm-row">
|
||||
<input class="adm-input adm-input--sm" [(ngModel)]="icon" placeholder="🎹" maxlength="4" />
|
||||
<input class="adm-input" [(ngModel)]="labelEs" placeholder="Rutina (ES)" />
|
||||
<input class="adm-input" [(ngModel)]="labelCa" placeholder="Rutina (CA)" />
|
||||
<input class="adm-input adm-input--sm" type="color" [(ngModel)]="color" />
|
||||
<button class="adm-btn" [disabled]="!labelEs || !labelCa || !icon" (click)="add()">+ {{ i18n.t('add') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adm-card">
|
||||
<div class="adm-list">
|
||||
@for (r of routinesForDay(); track r.id; let i = $index; let last = $last) {
|
||||
<div class="adm-item">
|
||||
<span>{{ r.icon }}</span>
|
||||
<span class="adm-item__grow">{{ i18n.label(r.labelEs, r.labelCa) }}</span>
|
||||
<button class="adm-del" [disabled]="i === 0" (click)="move(i, -1)" aria-label="Subir">▲</button>
|
||||
<button class="adm-del" [disabled]="last" (click)="move(i, 1)" aria-label="Bajar">▼</button>
|
||||
<button class="adm-del" (click)="remove(r)">✕</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="adm-empty">{{ i18n.t('none') }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RoutinesTabComponent {
|
||||
@Input({ required: true }) set childId(value: number) {
|
||||
this._childId = value;
|
||||
this.reload();
|
||||
}
|
||||
private _childId!: number;
|
||||
|
||||
private readonly api = inject(ParentApiService);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
protected readonly days = DAYS;
|
||||
protected readonly day = signal('MONDAY');
|
||||
protected readonly routines = signal<RoutineView[]>([]);
|
||||
protected readonly routinesForDay = computed(() =>
|
||||
this.routines().filter((r) => r.dayOfWeek === this.day()),
|
||||
);
|
||||
|
||||
protected icon = '';
|
||||
protected labelEs = '';
|
||||
protected labelCa = '';
|
||||
protected color = '#a78bd0';
|
||||
|
||||
private reload(): void {
|
||||
this.api.listRoutines(this._childId).subscribe((l) => this.routines.set(l));
|
||||
}
|
||||
|
||||
add(): void {
|
||||
const order = this.routinesForDay().length;
|
||||
this.api
|
||||
.createRoutine({
|
||||
childId: this._childId,
|
||||
dayOfWeek: this.day(),
|
||||
labelEs: this.labelEs,
|
||||
labelCa: this.labelCa,
|
||||
icon: this.icon,
|
||||
color: this.color,
|
||||
orderIndex: order,
|
||||
})
|
||||
.subscribe(() => {
|
||||
this.labelEs = this.labelCa = this.icon = '';
|
||||
this.reload();
|
||||
});
|
||||
}
|
||||
|
||||
/** Mueve una rutina del día arriba (-1) o abajo (+1) y persiste el nuevo orden. */
|
||||
move(index: number, delta: number): void {
|
||||
const list = [...this.routinesForDay()];
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= list.length) {
|
||||
return;
|
||||
}
|
||||
[list[index], list[target]] = [list[target], list[index]];
|
||||
this.api.reorderRoutines(list.map((r) => r.id)).subscribe(() => this.reload());
|
||||
}
|
||||
|
||||
remove(r: RoutineView): void {
|
||||
this.api.deleteRoutine(r.id).subscribe(() => this.reload());
|
||||
}
|
||||
}
|
||||
99
frontend/src/app/features/parents/schedule-tab.component.ts
Normal file
99
frontend/src/app/features/parents/schedule-tab.component.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Component, Input, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ParentApiService } from '../../core/parent-api.service';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { ActivityView, WeeklyEntryView } from '../../core/models';
|
||||
|
||||
const DAYS: { key: string; es: string; ca: string }[] = [
|
||||
{ key: 'MONDAY', es: 'Lunes', ca: 'Dilluns' },
|
||||
{ key: 'TUESDAY', es: 'Martes', ca: 'Dimarts' },
|
||||
{ key: 'WEDNESDAY', es: 'Miércoles', ca: 'Dimecres' },
|
||||
{ key: 'THURSDAY', es: 'Jueves', ca: 'Dijous' },
|
||||
{ key: 'FRIDAY', es: 'Viernes', ca: 'Divendres' },
|
||||
];
|
||||
|
||||
/** Pestaña Horario: actividades del cole por día de la semana (L-V) de un niño. */
|
||||
@Component({
|
||||
selector: 'app-schedule-tab',
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="adm-card">
|
||||
<div class="adm-row">
|
||||
<select class="adm-input" [(ngModel)]="newDay">
|
||||
@for (d of days; track d.key) {
|
||||
<option [value]="d.key">{{ i18n.label(d.es, d.ca) }}</option>
|
||||
}
|
||||
</select>
|
||||
<select class="adm-input" [(ngModel)]="newActivityId">
|
||||
@for (a of activities(); track a.id) {
|
||||
<option [ngValue]="a.id">{{ a.icon }} {{ i18n.label(a.labelEs, a.labelCa) }}</option>
|
||||
}
|
||||
</select>
|
||||
<button class="adm-btn" [disabled]="!newActivityId" (click)="add()">+ {{ i18n.t('add') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@for (d of days; track d.key) {
|
||||
<div class="adm-card">
|
||||
<p class="adm-label">{{ i18n.label(d.es, d.ca) }}</p>
|
||||
<div class="adm-list">
|
||||
@for (e of entriesFor(d.key); track e.id) {
|
||||
<div class="adm-item">
|
||||
<span>{{ e.icon }}</span>
|
||||
<span class="adm-item__grow">{{ e.activityLabelEs }}</span>
|
||||
<button class="adm-del" (click)="remove(e)" aria-label="Borrar">✕</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="adm-empty">—</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ScheduleTabComponent {
|
||||
@Input({ required: true }) set childId(value: number) {
|
||||
this._childId = value;
|
||||
this.reload();
|
||||
}
|
||||
private _childId!: number;
|
||||
|
||||
private readonly api = inject(ParentApiService);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
protected readonly days = DAYS;
|
||||
protected readonly activities = signal<ActivityView[]>([]);
|
||||
protected readonly entries = signal<WeeklyEntryView[]>([]);
|
||||
protected newDay = 'MONDAY';
|
||||
protected newActivityId: number | null = null;
|
||||
|
||||
constructor() {
|
||||
this.api.listActivities().subscribe((list) => {
|
||||
this.activities.set(list);
|
||||
if (list.length) {
|
||||
this.newActivityId = list[0].id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private reload(): void {
|
||||
this.api.listWeekly(this._childId).subscribe((list) => this.entries.set(list));
|
||||
}
|
||||
|
||||
entriesFor(day: string): WeeklyEntryView[] {
|
||||
return this.entries().filter((e) => e.dayOfWeek === day);
|
||||
}
|
||||
|
||||
add(): void {
|
||||
if (!this.newActivityId) {
|
||||
return;
|
||||
}
|
||||
this.api
|
||||
.createWeekly({ childId: this._childId, dayOfWeek: this.newDay, activityId: this.newActivityId })
|
||||
.subscribe(() => this.reload());
|
||||
}
|
||||
|
||||
remove(e: WeeklyEntryView): void {
|
||||
this.api.deleteWeekly(e.id).subscribe(() => this.reload());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<main class="profiles">
|
||||
<h1 class="profiles__title">{{ i18n.t('whoEntersToday') }}</h1>
|
||||
|
||||
@if (loading()) {
|
||||
<p class="profiles__msg">Cargando…</p>
|
||||
} @else if (error()) {
|
||||
<p class="profiles__msg">No se pudo conectar con el servidor. ¿Está arrancado el backend?</p>
|
||||
} @else {
|
||||
<div class="profiles__grid">
|
||||
@for (child of children(); track child.id) {
|
||||
<button type="button" class="kid" [style.--c]="child.accentColor" (click)="enter(child)">
|
||||
<span class="kid__mascot">{{ child.mascot }}</span>
|
||||
<span class="kid__name">{{ child.name }}</span>
|
||||
<span class="kid__coins">🪙 {{ child.coins }}</span>
|
||||
<span class="kid__age">{{ child.age }} años</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" class="profiles__parents" (click)="openParents()">
|
||||
⚙️ {{ i18n.t('parents') }}
|
||||
</button>
|
||||
</main>
|
||||
@@ -0,0 +1,92 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profiles {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-6) var(--space-4);
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 2.4rem;
|
||||
text-align: center;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
&__msg {
|
||||
color: var(--text-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-5);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__parents {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--text-3);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
&__parents:hover { color: var(--text-1); background: var(--surface-soft); }
|
||||
}
|
||||
|
||||
// Tarjeta de niño: grande, con su color de acento, animación de entrada.
|
||||
.kid {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-5);
|
||||
background: var(--surface);
|
||||
border: var(--card-border-width) solid color-mix(in srgb, var(--c) 35%, #fff);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: transform 0.15s, box-shadow 0.2s;
|
||||
animation: slideUp 0.4s ease both;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
&:active { transform: scale(0.98); }
|
||||
|
||||
&__mascot {
|
||||
font-size: 84px;
|
||||
line-height: 1;
|
||||
animation: floatY 3s ease-in-out infinite;
|
||||
}
|
||||
&__name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
&__coins {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--coin-text);
|
||||
background: var(--coin-bg);
|
||||
padding: 4px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
&__age {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { KioskService } from '../../core/kiosk.service';
|
||||
import { ChildSummary } from '../../core/models';
|
||||
|
||||
/**
|
||||
* Pantalla de entrada del kiosko: "¿QUIÉN ENTRA HOY?". Tarjetas grandes por niño
|
||||
* con mascota, nombre y monedero. Al elegir, entra a su día (Home).
|
||||
*
|
||||
* Nota: el selector de edad ± del prototipo era un control de demo. La edad es un
|
||||
* dato que gestionan los padres, así que aquí se muestra como chip de solo lectura;
|
||||
* su edición vive en el panel de padres (Fase 5).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-profile-select',
|
||||
imports: [],
|
||||
templateUrl: './profile-select.component.html',
|
||||
styleUrl: './profile-select.component.scss',
|
||||
})
|
||||
export class ProfileSelectComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly kiosk = inject(KioskService);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
protected readonly children = signal<ChildSummary[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly error = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.api.getChildren().subscribe({
|
||||
next: (list) => {
|
||||
this.children.set(list);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set(true);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Entra al día del niño. Aprovecha el gesto para pedir pantalla completa. */
|
||||
enter(child: ChildSummary): void {
|
||||
this.i18n.setLang(child.language);
|
||||
this.kiosk.enterFullscreen();
|
||||
this.router.navigate(['/home', child.id]);
|
||||
}
|
||||
|
||||
openParents(): void {
|
||||
this.router.navigate(['/parents']);
|
||||
}
|
||||
}
|
||||
132
frontend/src/app/features/store/store.component.ts
Normal file
132
frontend/src/app/features/store/store.component.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { I18nService } from '../../core/i18n.service';
|
||||
import { SoundService } from '../../core/sound.service';
|
||||
import { RewardView } from '../../core/models';
|
||||
|
||||
/** Tienda de recompensas: el niño canjea monedas por premios. */
|
||||
@Component({
|
||||
selector: 'app-store',
|
||||
imports: [],
|
||||
template: `
|
||||
<main class="store">
|
||||
<header class="store__top">
|
||||
<button type="button" class="iconbtn" (click)="back()" aria-label="Volver">‹</button>
|
||||
<h1 class="store__title">🎁 {{ i18n.t('store') }}</h1>
|
||||
<span class="wallet">🪙 {{ coins() }}</span>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<p class="store__msg">Cargando…</p>
|
||||
} @else {
|
||||
<div class="grid">
|
||||
@for (r of rewards(); track r.id) {
|
||||
<div class="reward" [style.--c]="r.color">
|
||||
<span class="reward__icon">{{ r.icon }}</span>
|
||||
<span class="reward__name">{{ i18n.label(r.labelEs, r.labelCa) }}</span>
|
||||
<span class="reward__cost">🪙 {{ r.cost }}</span>
|
||||
@if (coins() >= r.cost) {
|
||||
<button type="button" class="reward__btn" (click)="redeem(r)">{{ i18n.t('redeem') }}</button>
|
||||
} @else {
|
||||
<span class="reward__missing">{{ i18n.t('missing') }} {{ r.cost - coins() }} 🪙</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (toast()) {
|
||||
<div class="toast">{{ i18n.t('redeemed') }} {{ toast() }} 🎉</div>
|
||||
}
|
||||
</main>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.store { max-width: 900px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
|
||||
.store__top { display: flex; align-items: center; gap: var(--space-4); margin-bottom: var(--space-5); }
|
||||
.store__title { flex: 1; margin: 0; font-size: 1.8rem; }
|
||||
.store__msg { text-align: center; color: var(--text-1); }
|
||||
.wallet {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 1.4rem;
|
||||
background: var(--coin-bg); color: var(--coin-text); padding: 9px 18px; border-radius: var(--radius-pill);
|
||||
}
|
||||
.iconbtn {
|
||||
all: unset; cursor: pointer; width: var(--touch-nav); height: var(--touch-nav); border-radius: 50%;
|
||||
background: var(--surface); box-shadow: var(--shadow-btn); display: flex; align-items: center;
|
||||
justify-content: center; font-size: 28px; color: var(--text-2);
|
||||
}
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); }
|
||||
@media (max-width: 720px) { .grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
.reward {
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--space-2);
|
||||
background: var(--surface); border: 3px solid color-mix(in srgb, var(--c) 30%, #fff);
|
||||
border-radius: var(--radius-card); padding: var(--space-5) var(--space-4); box-shadow: var(--shadow-card);
|
||||
animation: slideUp 0.35s ease both;
|
||||
}
|
||||
.reward__icon {
|
||||
width: 72px; height: 72px; border-radius: var(--radius-tile); display: flex; align-items: center;
|
||||
justify-content: center; font-size: 42px; background: color-mix(in srgb, var(--c) 16%, #fff);
|
||||
}
|
||||
.reward__name {
|
||||
font-family: var(--font-display); font-weight: 700; text-align: center; color: var(--text-strong);
|
||||
}
|
||||
.reward__cost { font-family: var(--font-display); font-weight: 700; color: var(--coin-text); }
|
||||
.reward__btn {
|
||||
font-family: var(--font-display); font-weight: 700; border: 0; border-radius: 16px;
|
||||
padding: 10px 22px; min-height: 48px; background: var(--accent-green); color: #fff; cursor: pointer;
|
||||
}
|
||||
.reward__btn:active { transform: scale(0.96); }
|
||||
.reward__missing { color: var(--text-3); font-size: 0.9rem; text-align: center; }
|
||||
.toast {
|
||||
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--text-strong); color: #fff; padding: 14px 26px; border-radius: var(--radius-pill);
|
||||
font-family: var(--font-display); font-weight: 700; box-shadow: var(--shadow-pop);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class StoreComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly sound = inject(SoundService);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
|
||||
protected readonly rewards = signal<RewardView[]>([]);
|
||||
protected readonly coins = signal(0);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly toast = signal<string | null>(null);
|
||||
|
||||
private childId!: number;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.childId = Number(this.route.snapshot.paramMap.get('childId'));
|
||||
forkJoin({
|
||||
rewards: this.api.getRewards(this.childId),
|
||||
wallet: this.api.getWallet(this.childId),
|
||||
}).subscribe({
|
||||
next: ({ rewards, wallet }) => {
|
||||
this.rewards.set(rewards);
|
||||
this.coins.set(wallet.coins);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
redeem(reward: RewardView): void {
|
||||
this.api.redeem(this.childId, reward.id).subscribe((result) => {
|
||||
this.coins.set(result.newBalance);
|
||||
this.sound.playReward();
|
||||
this.toast.set(this.i18n.label(reward.labelEs, reward.labelCa));
|
||||
setTimeout(() => this.toast.set(null), 2200);
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/home', this.childId]);
|
||||
}
|
||||
}
|
||||
16
frontend/src/index.html
Normal file
16
frontend/src/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<!-- data-dyslexia-font="on": OpenDyslexic activada desde el primer pintado (evita
|
||||
parpadeo antes de que arranque Angular). El FontPreferenceService la ajusta
|
||||
después según la preferencia del niño. -->
|
||||
<html lang="es" data-dyslexia-font="on">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>recordaLexia</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
147
frontend/src/styles.scss
Normal file
147
frontend/src/styles.scss
Normal file
@@ -0,0 +1,147 @@
|
||||
// Estilos globales de recordaLexia.
|
||||
// Las @font-face (OpenDyslexic/Fredoka/Nunito) se cargan desde @fontsource vía
|
||||
// angular.json. Tokens en styles/_theme.scss; animaciones en styles/_animations.scss.
|
||||
@use 'styles/theme';
|
||||
@use 'styles/animations';
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-strong);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
|
||||
// Fondo calmado del handoff: color base + dos gradientes radiales suaves.
|
||||
background-color: var(--bg);
|
||||
background-image:
|
||||
radial-gradient(1100px 700px at 12% -10%, #fbf4e9 0%, transparent 55%),
|
||||
radial-gradient(1000px 700px at 110% 120%, #e2f0ec 0%, transparent 55%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
// Dos "blobs" orgánicos difuminados de fondo (sin DOM extra, vía pseudo-elementos).
|
||||
// Decorativos y detrás del contenido; no capturan toques.
|
||||
body::before,
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
body::before {
|
||||
top: -120px;
|
||||
left: -100px;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
border-radius: 48% 52% 60% 40% / 55% 45% 60% 45%;
|
||||
background: #fcebd3;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
body::after {
|
||||
bottom: -140px;
|
||||
right: -90px;
|
||||
width: 460px;
|
||||
height: 460px;
|
||||
border-radius: 60% 40% 45% 55% / 50% 55% 45% 50%;
|
||||
background: #d7ece5;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
// El contenido de la app va por encima de los blobs.
|
||||
app-root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Tipografía base.
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
// --- Helpers del panel de padres (formularios de administración) ---
|
||||
.adm-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-5);
|
||||
box-shadow: var(--shadow-card);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.adm-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); }
|
||||
.adm-input {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
padding: 10px 14px;
|
||||
border: 2px solid var(--border-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text-strong);
|
||||
min-width: 0;
|
||||
}
|
||||
.adm-input--sm { width: 70px; text-align: center; }
|
||||
.adm-btn {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 18px;
|
||||
min-height: 44px;
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm-btn:active { transform: scale(0.97); }
|
||||
.adm-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.adm-del {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--accent-pink);
|
||||
font-size: 1.2rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.adm-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.adm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 12px 14px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.adm-item__grow { flex: 1; }
|
||||
.adm-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-softer);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.adm-empty { color: var(--text-3); padding: var(--space-4); text-align: center; }
|
||||
.adm-label { font-weight: 600; color: var(--text-1); }
|
||||
73
frontend/src/styles/_animations.scss
Normal file
73
frontend/src/styles/_animations.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
// Animaciones globales de recordaLexia.
|
||||
// Keyframes copiados con fidelidad del handoff de Claude Design. Las usan los
|
||||
// componentes (Fase 4/5): tarjeta al completar (pop/checkPop), monedas voladoras,
|
||||
// monedero (walletBump), celebración (confFall/celebPop), error de PIN (shake),
|
||||
// temporizador (ringGlow), entradas (slideUp), flotar (floatY).
|
||||
|
||||
@keyframes pop {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(1.06); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes checkPop {
|
||||
0% { transform: scale(0); }
|
||||
60% { transform: scale(1.25); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes floatY {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@keyframes floatYb {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-14px); }
|
||||
}
|
||||
|
||||
@keyframes walletBump {
|
||||
0% { transform: scale(1); }
|
||||
35% { transform: scale(1.22) rotate(-4deg); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes confFall {
|
||||
0% { transform: translateY(-20vh) rotate(0); }
|
||||
100% { transform: translateY(110vh) rotate(720deg); }
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-8px); }
|
||||
40% { transform: translateX(8px); }
|
||||
60% { transform: translateX(-6px); }
|
||||
80% { transform: translateX(6px); }
|
||||
}
|
||||
|
||||
@keyframes celebPop {
|
||||
0% { transform: scale(0.6); opacity: 0; }
|
||||
60% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes ringGlow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(242, 166, 90, 0.35); }
|
||||
50% { box-shadow: 0 0 0 14px rgba(242, 166, 90, 0); }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% { transform: translateY(30px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
// Accesibilidad: quien pide menos movimiento, no recibe animaciones (TDAH/vestibular).
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
89
frontend/src/styles/_theme.scss
Normal file
89
frontend/src/styles/_theme.scss
Normal file
@@ -0,0 +1,89 @@
|
||||
// ============================================================================
|
||||
// recordaLexia · Design Tokens (fichero ÚNICO de tokens del proyecto)
|
||||
// ----------------------------------------------------------------------------
|
||||
// Este ES el "fichero de tokens" que exige el contrato. Se llama _theme.scss
|
||||
// (no _tokens.scss) por una restricción del entorno que bloquea "token" en
|
||||
// nombres de fichero; el propósito es idéntico. Valores extraídos con fidelidad
|
||||
// del handoff de Claude Design (app-de-rutinas-visuales-para-tdah).
|
||||
//
|
||||
// Regla: colores, tipografías y medidas viven AQUÍ; no se esparcen por los
|
||||
// componentes. Los componentes referencian var(--…), nunca literales.
|
||||
// ============================================================================
|
||||
|
||||
:root {
|
||||
// ----- Tipografías (empaquetadas en local vía @fontsource, sin CDN) -----
|
||||
--font-dyslexic: 'OpenDyslexic', 'Comic Sans MS', sans-serif;
|
||||
--font-brand-display: 'Fredoka', system-ui, -apple-system, sans-serif;
|
||||
--font-brand-body: 'Nunito', system-ui, -apple-system, sans-serif;
|
||||
|
||||
// Tokens efectivos: por defecto OpenDyslexic en todo (accesibilidad).
|
||||
--font-display: var(--font-dyslexic);
|
||||
--font-body: var(--font-dyslexic);
|
||||
|
||||
// ----- Paleta de acento por categoría (del handoff) -----
|
||||
--accent-orange: #f2a65a;
|
||||
--accent-blue: #5b8def;
|
||||
--accent-purple: #a78bd0;
|
||||
--accent-green: #7fbf6b;
|
||||
--accent-teal: #5bc0be;
|
||||
--accent-yellow: #f4c95d;
|
||||
--accent-pink: #ec8fa4;
|
||||
|
||||
// ----- Texto (principal + secundarios, de más a menos contraste) -----
|
||||
--text-strong: #2a3142;
|
||||
--text-1: #5a6b82;
|
||||
--text-2: #7a879b;
|
||||
--text-3: #8c99ab;
|
||||
--text-4: #9fb0bd;
|
||||
|
||||
// ----- Superficies y bordes -----
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f4f7f9;
|
||||
--surface-softer: #eef2f6;
|
||||
--bg: #eff4f6;
|
||||
--border-1: #e6ecf0;
|
||||
--border-2: #dce3ea;
|
||||
--border-3: #d3dce3;
|
||||
--card-border-idle: #eef2f6; // borde de tarjeta sin completar
|
||||
|
||||
// ----- Monedas (pill) -----
|
||||
--coin-bg: #fff6e0;
|
||||
--coin-text: #c7912b;
|
||||
|
||||
// ----- Radios -----
|
||||
--radius-card: 26px;
|
||||
--radius-tile: 20px;
|
||||
--radius-pill: 999px;
|
||||
--radius-sm: 14px;
|
||||
|
||||
// ----- Sombras (del handoff) -----
|
||||
--shadow-card: 0 6px 16px rgba(40, 60, 100, 0.06);
|
||||
--shadow-btn: 0 6px 16px rgba(40, 60, 100, 0.08);
|
||||
--shadow-soft: 0 12px 30px rgba(40, 60, 100, 0.12);
|
||||
--shadow-pop: 0 16px 40px rgba(40, 60, 100, 0.18);
|
||||
|
||||
// ----- Áreas táctiles / tamaños clave (dedos de niño) -----
|
||||
--tile-size: 66px; // icono de tarjeta
|
||||
--hero-size: 200px; // icono grande en modo Foco
|
||||
--touch-check: 60px; // check de completar
|
||||
--touch-nav: 64px; // botones de navegación ‹ ›
|
||||
--touch-pin: 84px; // teclas del PIN
|
||||
--card-min-height: 92px;
|
||||
--card-border-width: 3px;
|
||||
|
||||
// ----- Espaciado base -----
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 24px;
|
||||
--space-6: 32px;
|
||||
}
|
||||
|
||||
// ----- Interruptor de accesibilidad de tipografía -----
|
||||
// Preferencia por niño (se conecta al backend en Fase 5). Con "off" la UI cae a
|
||||
// las tipografías de marca del handoff sin tocar componentes.
|
||||
:root[data-dyslexia-font='off'] {
|
||||
--font-display: var(--font-brand-display);
|
||||
--font-body: var(--font-brand-body);
|
||||
}
|
||||
15
frontend/tsconfig.app.json
Normal file
15
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
27
frontend/tsconfig.json
Normal file
27
frontend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
15
frontend/tsconfig.spec.json
Normal file
15
frontend/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user