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:
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user