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:
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