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
+ {{ '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) {
{{ i + 1 }}
-
{{ step.title }}
-
{{ step.description }}
+
{{ step.titleKey | t }}
+
{{ step.descKey | t }}
}
@@ -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: `
+
+ `,
+ 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).