Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,41 @@
<header class="max-w-5xl mx-auto flex items-center justify-between px-4 py-4">
<div class="flex items-center gap-2">
<img src="logo.webp" alt="Bikontrol logo" class="w-9 h-9" />
<span class="text-xl font-bold text-primary">Bikontrol</span>
<span class="text-xl font-bold text-primary">{{ 'app.name' | t }}</span>
</div>
<div class="flex items-center gap-3">
<app-language-switcher />
<nav class="flex items-center gap-3 text-sm">
<a routerLink="/help" class="hidden sm:inline text-primary hover:underline">{{
'landing.help' | t
}}</a>
<a routerLink="/login" class="text-primary hover:underline">{{ 'landing.signIn' | t }}</a>
<a
routerLink="/register"
class="px-3 py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors font-semibold"
>{{ 'landing.createAccount' | t }}</a
>
</nav>
</div>
<nav class="flex items-center gap-3 text-sm">
<a routerLink="/help" class="hidden sm:inline text-primary hover:underline">Ayuda</a>
<a routerLink="/login" class="text-primary hover:underline">Ingresar</a>
<a
routerLink="/register"
class="px-3 py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors font-semibold"
>Crear cuenta</a
>
</nav>
</header>

<!-- Hero -->
<section class="max-w-5xl mx-auto px-4 pt-8 pb-12 text-center">
<h1 class="text-3xl sm:text-4xl font-bold text-primary leading-tight">
El mantenimiento de tu moto, bajo control.
{{ 'landing.hero.title' | t }}
</h1>
<p class="text-gray-600 mt-4 max-w-xl mx-auto">
Registrá tus motos, seguí cada service y recibí un aviso antes de que venza. Simple, privado y
gratis para dueños de motos.
</p>
<p class="text-gray-600 mt-4 max-w-xl mx-auto">{{ 'landing.hero.subtitle' | t }}</p>

<div class="flex flex-col sm:flex-row gap-3 justify-center mt-6">
<a
routerLink="/register"
class="px-5 py-3 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors font-semibold"
>Empezar gratis</a
>{{ 'landing.cta.start' | t }}</a
>
<a
routerLink="/login"
class="px-5 py-3 border border-accent text-primary rounded-lg hover:bg-gray-50 transition-colors font-semibold"
>Ya tengo cuenta</a
>{{ 'landing.cta.haveAccount' | t }}</a
>
@if (demoEnabled) {
<button
Expand All @@ -44,7 +46,7 @@ <h1 class="text-3xl sm:text-4xl font-bold text-primary leading-tight">
[disabled]="demoLoading()"
class="px-5 py-3 border border-accent text-primary rounded-lg hover:bg-gray-50 transition-colors font-semibold disabled:opacity-50"
>
{{ demoLoading() ? 'Cargando demo…' : 'Probar la demo' }}
{{ (demoLoading() ? 'landing.cta.demoLoading' : 'landing.cta.demo') | t }}
</button>
}
</div>
Expand All @@ -56,15 +58,15 @@ <h1 class="text-3xl sm:text-4xl font-bold text-primary leading-tight">
<!-- Value props -->
<section class="max-w-5xl mx-auto px-4 pb-12">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@for (feature of features; track feature.title) {
@for (feature of features; track feature.titleKey) {
<div class="bg-white rounded-xl shadow-sm p-5 text-left">
<div
class="w-9 h-9 rounded-full bg-secondary/40 text-primary flex items-center justify-center mb-3 font-bold"
>
✓
</div>
<h2 class="font-bold text-gray-900">{{ feature.title }}</h2>
<p class="text-sm text-gray-600 mt-1">{{ feature.description }}</p>
<h2 class="font-bold text-gray-900">{{ feature.titleKey | t }}</h2>
<p class="text-sm text-gray-600 mt-1">{{ feature.descKey | t }}</p>
</div>
}
</div>
Expand All @@ -73,17 +75,19 @@ <h2 class="font-bold text-gray-900">{{ feature.title }}</h2>
<!-- How it works -->
<section class="bg-white border-y border-gray-100">
<div class="max-w-5xl mx-auto px-4 py-12">
<h2 class="text-2xl font-bold text-primary text-center mb-8">Cómo funciona</h2>
<h2 class="text-2xl font-bold text-primary text-center mb-8">
{{ 'landing.how.title' | t }}
</h2>
<ol class="grid grid-cols-1 sm:grid-cols-3 gap-6">
@for (step of steps; track step.title; let i = $index) {
@for (step of steps; track step.titleKey; let i = $index) {
<li class="text-center">
<div
class="w-10 h-10 mx-auto rounded-full bg-primary text-textlight flex items-center justify-center font-bold mb-3"
>
{{ i + 1 }}
</div>
<h3 class="font-bold text-gray-900">{{ step.title }}</h3>
<p class="text-sm text-gray-600 mt-1">{{ step.description }}</p>
<h3 class="font-bold text-gray-900">{{ step.titleKey | t }}</h3>
<p class="text-sm text-gray-600 mt-1">{{ step.descKey | t }}</p>
</li>
}
</ol>
Expand All @@ -93,11 +97,11 @@ <h3 class="font-bold text-gray-900">{{ step.title }}</h3>
<!-- Trust -->
<section class="max-w-5xl mx-auto px-4 py-12">
<div class="bg-card rounded-2xl p-6 sm:p-8 text-center">
<h2 class="text-xl font-bold text-primary mb-3">Pensado para que confíes tus datos</h2>
<h2 class="text-xl font-bold text-primary mb-3">{{ 'landing.trust.title' | t }}</h2>
<ul class="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-textdark">
<li><strong>Backups verificados</strong> de la base de datos.</li>
<li><strong>Cuenta y datos tuyos</strong>: los descargás o los borrás cuando quieras.</li>
<li><strong>Sin publicidad</strong> ni venta de tus datos.</li>
<li>{{ 'landing.trust.backups' | t }}</li>
<li>{{ 'landing.trust.data' | t }}</li>
<li>{{ 'landing.trust.noAds' | t }}</li>
</ul>
</div>
</section>
Expand All @@ -111,12 +115,12 @@ <h2 class="text-xl font-bold text-primary mb-3">Pensado para que confíes tus da

<!-- Final CTA -->
<section class="max-w-5xl mx-auto px-4 pb-16 text-center">
<h2 class="text-2xl font-bold text-primary">Empezá en 2 minutos</h2>
<p class="text-gray-600 mt-2">Cargá tu primer moto y tu plan de mantenimiento hoy mismo.</p>
<h2 class="text-2xl font-bold text-primary">{{ 'landing.final.title' | t }}</h2>
<p class="text-gray-600 mt-2">{{ 'landing.final.subtitle' | t }}</p>
<a
routerLink="/register"
class="inline-block mt-5 px-6 py-3 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors font-semibold"
>Crear mi cuenta</a
>{{ 'landing.final.button' | t }}</a
>
</section>

Expand All @@ -125,11 +129,11 @@ <h2 class="text-2xl font-bold text-primary">Empezá en 2 minutos</h2>
<div
class="max-w-5xl mx-auto px-4 py-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-gray-500"
>
<span>Bikontrol — mantenimiento de motos para dueños.</span>
<span>{{ 'landing.footer.tagline' | t }}</span>
<nav class="flex gap-4">
<a routerLink="/help" class="hover:underline">Ayuda</a>
<a routerLink="/terms" class="hover:underline">Términos</a>
<a routerLink="/privacy" class="hover:underline">Privacidad</a>
<a routerLink="/help" class="hover:underline">{{ 'landing.footer.help' | t }}</a>
<a routerLink="/terms" class="hover:underline">{{ 'landing.footer.terms' | t }}</a>
<a routerLink="/privacy" class="hover:underline">{{ 'landing.footer.privacy' | t }}</a>
</nav>
</div>
</footer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '' })
Expand All @@ -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],
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<string | null>(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 {
Expand All @@ -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')));
},
});
}
Expand Down
52 changes: 52 additions & 0 deletions bikontrol-web/src/app/shared/i18n/i18n.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
56 changes: 56 additions & 0 deletions bikontrol-web/src/app/shared/i18n/i18n.service.ts
Original file line number Diff line number Diff line change
@@ -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<Language, Record<string, string>> = { 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<Language>(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, string | number>): 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';
}
Loading
Loading