diff --git a/AGENTS.md b/AGENTS.md index 759d4f6..30df774 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Bikontrol/ Clean Architecture layers: `API` (controllers) → `Application` (services, DTOs; AutoMapper 12 pinned) → `Domain` (entities with soft deletes) → `Persistence` (EF Core, `DbContext`, migrations) + `Infrastructure` → `Shared`. In Development the API takes DB/JWT values from `.env` via the root scripts (fallback: `Bikontrol.API/appsettings.Development.json`, gitignored, created from the committed `.example` template). Repositories never persist on their own — services (or `ITransactionManager`) call `SaveChangesAsync`; integration tests (`Bikontrol.Tests.Integration`, Testcontainers + Postgres) pin the write paths because the unit fakes do not persist. ## Frontend Architecture -Angular 22 SPA in `bikontrol-web/src/app` (Tailwind + SCSS, PWA via `ngsw-config.json`, SweetAlert2 dialogs). Tests are Vitest via Angular's `@angular/build:unit-test` builder (`npm test` → `ng test --watch=false`), zoneless. +Angular 22 SPA in `bikontrol-web/src/app` (Tailwind + SCSS, PWA via `ngsw-config.json`, SweetAlert2 dialogs). Tests are Vitest via Angular's `@angular/build:unit-test` builder (`npm test` → `ng test --watch=false`), zoneless. i18n is runtime and dependency-free (`shared/i18n`, Spanish default, ES/EN switcher; see `docs/specs/i18n.md`). ## Quality Gates & Delivery - Backend line coverage gate **80%** (`node scripts/check-coverage.mjs 80`, merged cobertura). Frontend coverage gate via Vitest v8 (`npm run test:ui:coverage`; thresholds in `bikontrol-web/angular.json`) scoped to TS logic (templates/bootstrap excluded) and ratcheted up over time — current baseline ~74% lines. diff --git a/bikontrol-web/src/app/modules/marketing/landing/landing.component.html b/bikontrol-web/src/app/modules/marketing/landing/landing.component.html index f19b7ad..d169237 100644 --- a/bikontrol-web/src/app/modules/marketing/landing/landing.component.html +++ b/bikontrol-web/src/app/modules/marketing/landing/landing.component.html @@ -3,39 +3,41 @@
Bikontrol logo - Bikontrol + {{ 'app.name' | t }} +
+
+ +
-

- El mantenimiento de tu moto, bajo control. + {{ 'landing.hero.title' | t }}

-

- Registrá tus motos, seguí cada service y recibí un aviso antes de que venza. Simple, privado y - gratis para dueños de motos. -

+

{{ 'landing.hero.subtitle' | t }}

Empezar gratis{{ 'landing.cta.start' | t }} Ya tengo cuenta{{ 'landing.cta.haveAccount' | t }} @if (demoEnabled) { }
@@ -56,15 +58,15 @@

- @for (feature of features; track feature.title) { + @for (feature of features; track feature.titleKey) {
✓
-

{{ feature.title }}

-

{{ feature.description }}

+

{{ feature.titleKey | t }}

+

{{ feature.descKey | t }}

}
@@ -73,17 +75,19 @@

{{ feature.title }}

-

Cómo funciona

+

+ {{ 'landing.how.title' | t }} +

    - @for (step of steps; track step.title; let i = $index) { + @for (step of steps; track step.titleKey; let i = $index) {
  1. {{ i + 1 }}
    -

    {{ step.title }}

    -

    {{ step.description }}

    +

    {{ step.titleKey | t }}

    +

    {{ step.descKey | t }}

  2. }
@@ -93,11 +97,11 @@

{{ step.title }}

-

Pensado para que confíes tus datos

+

{{ 'landing.trust.title' | t }}

    -
  • Backups verificados de la base de datos.
  • -
  • Cuenta y datos tuyos: los descargás o los borrás cuando quieras.
  • -
  • Sin publicidad ni venta de tus datos.
  • +
  • {{ 'landing.trust.backups' | t }}
  • +
  • {{ 'landing.trust.data' | t }}
  • +
  • {{ 'landing.trust.noAds' | t }}
@@ -111,12 +115,12 @@

Pensado para que confíes tus da
-

Empezá en 2 minutos

-

Cargá tu primer moto y tu plan de mantenimiento hoy mismo.

+

{{ 'landing.final.title' | t }}

+

{{ 'landing.final.subtitle' | t }}

Crear mi cuenta{{ 'landing.final.button' | t }}
@@ -125,11 +129,11 @@

Empezá en 2 minutos

- Bikontrol — mantenimiento de motos para dueños. + {{ 'landing.footer.tagline' | t }}
diff --git a/bikontrol-web/src/app/modules/marketing/landing/landing.component.spec.ts b/bikontrol-web/src/app/modules/marketing/landing/landing.component.spec.ts index bd68916..dd7fe13 100644 --- a/bikontrol-web/src/app/modules/marketing/landing/landing.component.spec.ts +++ b/bikontrol-web/src/app/modules/marketing/landing/landing.component.spec.ts @@ -4,6 +4,7 @@ import { provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { AuthService } from '../../auth/services/auth.service'; import { HttpErrorService } from '../../../shared/services/http-error.service'; +import { I18nService } from '../../../shared/i18n/i18n.service'; import { LandingComponent } from './landing.component'; @Component({ selector: 'app-stub', standalone: true, template: '' }) @@ -13,6 +14,7 @@ describe('LandingComponent', () => { let authServiceMock: any; beforeEach(() => { + localStorage.clear(); // default language is Spanish authServiceMock = { demoLogin: vi.fn(() => of({ token: 't' })) }; TestBed.configureTestingModule({ imports: [LandingComponent], @@ -46,4 +48,16 @@ describe('LandingComponent', () => { // environment.demoEnabled is true in the dev/test environment. expect(authServiceMock.demoLogin).toHaveBeenCalled(); }); + + it('renders English after switching the language at runtime', () => { + const fixture = TestBed.createComponent(LandingComponent); + fixture.detectChanges(); + + TestBed.inject(I18nService).setLanguage('en'); + fixture.detectChanges(); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('Your motorcycle maintenance, under control.'); + expect(text).toContain('Start for free'); + }); }); diff --git a/bikontrol-web/src/app/modules/marketing/landing/landing.component.ts b/bikontrol-web/src/app/modules/marketing/landing/landing.component.ts index 58402d8..aaaa4db 100644 --- a/bikontrol-web/src/app/modules/marketing/landing/landing.component.ts +++ b/bikontrol-web/src/app/modules/marketing/landing/landing.component.ts @@ -3,15 +3,19 @@ import { Router, RouterLink } from '@angular/router'; import { environment } from '@env/environment'; import { AuthService } from '../../auth/services/auth.service'; import { HttpErrorService } from '../../../shared/services/http-error.service'; +import { I18nService } from '../../../shared/i18n/i18n.service'; +import { TranslatePipe } from '../../../shared/i18n/translate.pipe'; +import { LanguageSwitcherComponent } from '../../../shared/i18n/language-switcher.component'; /** * Public marketing landing (root path for guests). Value proposition, how it * works and CTAs; authenticated users are sent to the dashboard by - * `rootRedirectGuard`. Real screenshots are pending (see docs/specs/landing.md). + * `rootRedirectGuard`. Texts come from the i18n dictionaries. Real screenshots + * are pending (see docs/specs/landing.md). */ @Component({ selector: 'app-landing', - imports: [RouterLink], + imports: [RouterLink, TranslatePipe, LanguageSwitcherComponent], templateUrl: './landing.component.html', changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './landing.component.scss', @@ -20,40 +24,23 @@ export class LandingComponent { private readonly authService = inject(AuthService); private readonly router = inject(Router); private readonly httpError = inject(HttpErrorService); + private readonly i18n = inject(I18nService); readonly demoEnabled = environment.demoEnabled; readonly demoLoading = signal(false); readonly errorMessage = signal(null); readonly features = [ - { - title: 'No te olvides más del service', - description: 'Recordatorios por kilometraje o por tiempo, antes de que venza.', - }, - { - title: 'Todo tu historial en un lugar', - description: 'Qué le hiciste, cuándo y a qué kilometraje, siempre a mano.', - }, - { - title: 'Sabés cuánto gastás', - description: 'Costo por moto, por año y por kilómetro.', - }, - { - title: 'Tus datos, tuyos', - description: 'Descargá todo cuando quieras y borrá tu cuenta en un clic.', - }, + { titleKey: 'landing.feature.reminders.title', descKey: 'landing.feature.reminders.desc' }, + { titleKey: 'landing.feature.history.title', descKey: 'landing.feature.history.desc' }, + { titleKey: 'landing.feature.costs.title', descKey: 'landing.feature.costs.desc' }, + { titleKey: 'landing.feature.data.title', descKey: 'landing.feature.data.desc' }, ]; readonly steps = [ - { title: 'Creá tu cuenta', description: 'En un minuto, con tu correo o con Google.' }, - { - title: 'Cargá tu moto y tu plan', - description: 'Elegí tus mantenimientos; te sugerimos los esenciales.', - }, - { - title: 'Recibí los avisos', - description: 'Por email y notificaciones, antes de que toque el service.', - }, + { titleKey: 'landing.step.account.title', descKey: 'landing.step.account.desc' }, + { titleKey: 'landing.step.plan.title', descKey: 'landing.step.plan.desc' }, + { titleKey: 'landing.step.alerts.title', descKey: 'landing.step.alerts.desc' }, ]; onDemoLogin(): void { @@ -64,7 +51,7 @@ export class LandingComponent { next: () => this.router.navigate(['/dashboard']), error: (error) => { this.demoLoading.set(false); - this.errorMessage.set(this.httpError.message(error, 'No se pudo iniciar la demo.')); + this.errorMessage.set(this.httpError.message(error, this.i18n.t('landing.demoError'))); }, }); } diff --git a/bikontrol-web/src/app/shared/i18n/i18n.service.spec.ts b/bikontrol-web/src/app/shared/i18n/i18n.service.spec.ts new file mode 100644 index 0000000..3022342 --- /dev/null +++ b/bikontrol-web/src/app/shared/i18n/i18n.service.spec.ts @@ -0,0 +1,52 @@ +import { I18nService } from './i18n.service'; + +describe('I18nService', () => { + beforeEach(() => localStorage.clear()); + + it('defaults to Spanish', () => { + const service = new I18nService(); + + expect(service.lang()).toBe('es'); + expect(service.t('landing.cta.start')).toBe('Empezar gratis'); + }); + + it('switches language at runtime and persists it', () => { + const service = new I18nService(); + + service.setLanguage('en'); + + expect(service.lang()).toBe('en'); + expect(service.t('landing.cta.start')).toBe('Start for free'); + expect(localStorage.getItem('bikontrol.lang')).toBe('en'); + }); + + it('restores the stored language on init', () => { + localStorage.setItem('bikontrol.lang', 'en'); + + const service = new I18nService(); + + expect(service.lang()).toBe('en'); + }); + + it('falls back to the key when a translation is missing', () => { + const service = new I18nService(); + + expect(service.t('missing.key')).toBe('missing.key'); + }); + + it('interpolates {param} placeholders', () => { + const service = new I18nService(); + + expect(service.t('Hola {name}, tenés {n} avisos', { name: 'Santi', n: 3 })).toBe( + 'Hola Santi, tenés 3 avisos', + ); + }); + + it('ignores an unsupported language', () => { + const service = new I18nService(); + + service.setLanguage('fr' as never); + + expect(service.lang()).toBe('es'); + }); +}); diff --git a/bikontrol-web/src/app/shared/i18n/i18n.service.ts b/bikontrol-web/src/app/shared/i18n/i18n.service.ts new file mode 100644 index 0000000..2a31e5f --- /dev/null +++ b/bikontrol-web/src/app/shared/i18n/i18n.service.ts @@ -0,0 +1,56 @@ +import { Injectable, signal } from '@angular/core'; +import { es } from './translations/es'; +import { en } from './translations/en'; + +export type Language = 'es' | 'en'; + +const STORAGE_KEY = 'bikontrol.lang'; + +const DICTIONARIES: Record> = { es, en }; + +/** + * Minimal runtime i18n: a flat key→string dictionary per language, a signal for + * the current language and `t()` lookups with `{param}` interpolation. Spanish + * is the default and the fallback. No external dependency (keeps the app small). + */ +@Injectable({ providedIn: 'root' }) +export class I18nService { + readonly languages: readonly Language[] = ['es', 'en']; + private readonly current = signal(readInitialLanguage()); + readonly lang = this.current.asReadonly(); + + setLanguage(lang: Language): void { + if (!DICTIONARIES[lang]) return; + this.current.set(lang); + try { + localStorage.setItem(STORAGE_KEY, lang); + } catch { + // Storage can be unavailable (private mode); the switch still works in-memory. + } + if (typeof document !== 'undefined') { + document.documentElement.lang = lang; + } + } + + t(key: string, params?: Record): string { + let value = DICTIONARIES[this.current()][key] ?? es[key] ?? key; + if (params) { + for (const [name, replacement] of Object.entries(params)) { + value = value.split(`{${name}}`).join(String(replacement)); + } + } + return value; + } +} + +function readInitialLanguage(): Language { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'es' || stored === 'en') { + return stored; + } + } catch { + // Ignore unavailable storage. + } + return 'es'; +} diff --git a/bikontrol-web/src/app/shared/i18n/language-switcher.component.ts b/bikontrol-web/src/app/shared/i18n/language-switcher.component.ts new file mode 100644 index 0000000..7926f1d --- /dev/null +++ b/bikontrol-web/src/app/shared/i18n/language-switcher.component.ts @@ -0,0 +1,29 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { I18nService } from './i18n.service'; + +/** Two-button ES/EN switch that flips the language at runtime. */ +@Component({ + selector: 'app-language-switcher', + template: ` +
+ @for (language of i18n.languages; track language) { + + } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LanguageSwitcherComponent { + readonly i18n = inject(I18nService); +} diff --git a/bikontrol-web/src/app/shared/i18n/translate.pipe.ts b/bikontrol-web/src/app/shared/i18n/translate.pipe.ts new file mode 100644 index 0000000..a68f649 --- /dev/null +++ b/bikontrol-web/src/app/shared/i18n/translate.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { I18nService } from './i18n.service'; + +/** + * `{{ 'some.key' | t }}`. Impure on purpose: it must re-evaluate when the active + * language changes (the key itself does not). Fine for an app of this size. + */ +@Pipe({ name: 't', standalone: true, pure: false }) +export class TranslatePipe implements PipeTransform { + private readonly i18n = inject(I18nService); + + transform(key: string, params?: Record): string { + return this.i18n.t(key, params); + } +} diff --git a/bikontrol-web/src/app/shared/i18n/translations/en.ts b/bikontrol-web/src/app/shared/i18n/translations/en.ts new file mode 100644 index 0000000..1383478 --- /dev/null +++ b/bikontrol-web/src/app/shared/i18n/translations/en.ts @@ -0,0 +1,43 @@ +// English dictionary. Missing keys fall back to Spanish, then to the key itself. +export const en: Record = { + 'app.name': 'Bikontrol', + + // Landing + 'landing.help': 'Help', + 'landing.signIn': 'Sign in', + 'landing.createAccount': 'Create account', + 'landing.hero.title': 'Your motorcycle maintenance, under control.', + 'landing.hero.subtitle': + 'Add your motorcycles, track every service and get a heads-up before it is due. Simple, private and free for motorcycle owners.', + 'landing.cta.start': 'Start for free', + 'landing.cta.haveAccount': 'I already have an account', + 'landing.cta.demo': 'Try the demo', + 'landing.cta.demoLoading': 'Loading demo…', + 'landing.demoError': 'Could not start the demo.', + 'landing.feature.reminders.title': 'Never forget a service again', + 'landing.feature.reminders.desc': 'Reminders by mileage or time, before it is due.', + 'landing.feature.history.title': 'All your history in one place', + 'landing.feature.history.desc': 'What you did, when, and at what mileage — always at hand.', + 'landing.feature.costs.title': 'Know what you spend', + 'landing.feature.costs.desc': 'Cost per motorcycle, per year and per kilometre.', + 'landing.feature.data.title': 'Your data, yours', + 'landing.feature.data.desc': 'Download everything anytime and delete your account in one click.', + 'landing.how.title': 'How it works', + 'landing.step.account.title': 'Create your account', + 'landing.step.account.desc': 'In a minute, with your email or Google.', + 'landing.step.plan.title': 'Add your motorcycle and plan', + 'landing.step.plan.desc': 'Pick your maintenance items; we suggest the essentials.', + 'landing.step.alerts.title': 'Get the alerts', + 'landing.step.alerts.desc': 'By email and notifications, before the service is due.', + 'landing.trust.title': 'Built so you can trust your data with us', + 'landing.trust.backups': 'Verified database backups.', + 'landing.trust.data': 'Your account and data: download or delete them whenever you want.', + 'landing.trust.noAds': 'No ads and we never sell your data.', + 'landing.final.title': 'Get started in 2 minutes', + 'landing.final.subtitle': 'Add your first motorcycle and maintenance plan today.', + 'landing.final.button': 'Create my account', + 'landing.footer.tagline': 'Bikontrol — motorcycle maintenance for owners.', + 'landing.footer.help': 'Help', + 'landing.footer.terms': 'Terms', + 'landing.footer.privacy': 'Privacy', +}; diff --git a/bikontrol-web/src/app/shared/i18n/translations/es.ts b/bikontrol-web/src/app/shared/i18n/translations/es.ts new file mode 100644 index 0000000..4195bce --- /dev/null +++ b/bikontrol-web/src/app/shared/i18n/translations/es.ts @@ -0,0 +1,44 @@ +// Spanish dictionary (default + fallback). Flat keys grouped by surface. +export const es: Record = { + 'app.name': 'Bikontrol', + + // Landing + 'landing.help': 'Ayuda', + 'landing.signIn': 'Ingresar', + 'landing.createAccount': 'Crear cuenta', + 'landing.hero.title': 'El mantenimiento de tu moto, bajo control.', + 'landing.hero.subtitle': + 'Registrá tus motos, seguí cada service y recibí un aviso antes de que venza. Simple, privado y gratis para dueños de motos.', + 'landing.cta.start': 'Empezar gratis', + 'landing.cta.haveAccount': 'Ya tengo cuenta', + 'landing.cta.demo': 'Probar la demo', + 'landing.cta.demoLoading': 'Cargando demo…', + 'landing.demoError': 'No se pudo iniciar la demo.', + 'landing.feature.reminders.title': 'No te olvides más del service', + 'landing.feature.reminders.desc': + 'Recordatorios por kilometraje o por tiempo, antes de que venza.', + 'landing.feature.history.title': 'Todo tu historial en un lugar', + 'landing.feature.history.desc': 'Qué le hiciste, cuándo y a qué kilometraje, siempre a mano.', + 'landing.feature.costs.title': 'Sabés cuánto gastás', + 'landing.feature.costs.desc': 'Costo por moto, por año y por kilómetro.', + 'landing.feature.data.title': 'Tus datos, tuyos', + 'landing.feature.data.desc': 'Descargá todo cuando quieras y borrá tu cuenta en un clic.', + 'landing.how.title': 'Cómo funciona', + 'landing.step.account.title': 'Creá tu cuenta', + 'landing.step.account.desc': 'En un minuto, con tu correo o con Google.', + 'landing.step.plan.title': 'Cargá tu moto y tu plan', + 'landing.step.plan.desc': 'Elegí tus mantenimientos; te sugerimos los esenciales.', + 'landing.step.alerts.title': 'Recibí los avisos', + 'landing.step.alerts.desc': 'Por email y notificaciones, antes de que toque el service.', + 'landing.trust.title': 'Pensado para que confíes tus datos', + 'landing.trust.backups': 'Backups verificados de la base de datos.', + 'landing.trust.data': 'Cuenta y datos tuyos: los descargás o los borrás cuando quieras.', + 'landing.trust.noAds': 'Sin publicidad ni venta de tus datos.', + 'landing.final.title': 'Empezá en 2 minutos', + 'landing.final.subtitle': 'Cargá tu primer moto y tu plan de mantenimiento hoy mismo.', + 'landing.final.button': 'Crear mi cuenta', + 'landing.footer.tagline': 'Bikontrol — mantenimiento de motos para dueños.', + 'landing.footer.help': 'Ayuda', + 'landing.footer.terms': 'Términos', + 'landing.footer.privacy': 'Privacidad', +}; diff --git a/docs/specs/README.md b/docs/specs/README.md index 35aa3ed..cb24c52 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -10,3 +10,4 @@ Detail specs for Bikontrol. Start with [AGENTS.md](../../AGENTS.md) for the proj - [legal.md](legal.md) — public Terms/Privacy pages (templates pending legal review) - [help.md](help.md) — public Help/FAQ page (how-to + FAQ) - [landing.md](landing.md) — public root landing page (value prop + CTAs) +- [i18n.md](i18n.md) — runtime internationalization (Spanish default, English, switcher) diff --git a/docs/specs/i18n.md b/docs/specs/i18n.md new file mode 100644 index 0000000..941f778 --- /dev/null +++ b/docs/specs/i18n.md @@ -0,0 +1,16 @@ +# Internationalization (i18n) + +Runtime i18n with a minimal, dependency-free service (`shared/i18n`): + +- **`I18nService`** — flat `key → string` dictionaries (`translations/es.ts`, `en.ts`), a `lang` + signal (**default `es`**, fallback `es`), `t(key, params?)` with `{param}` interpolation, and + `setLanguage()` which persists to `localStorage` (`bikontrol.lang`) and updates ``. +- **`TranslatePipe`** (`{{ 'key' | t }}`) — impure on purpose so it re-renders on language change. +- **`LanguageSwitcherComponent`** (ES/EN), currently in the landing header. + +**Coverage is incremental.** Done: **landing**. Pending (follow-up PRs): auth pages, app shell +(nav), dashboard pages (home, motorcycle summary, maintenance, statistics, profile) and the +legal/help long-form text. + +When adding user-facing text, add the key to **both** dictionaries; a missing key falls back to +Spanish, then to the key itself (so untranslated strings remain visible, not blank).