diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 8811a3b..76e6cc6 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -83,7 +83,14 @@ jobs: # repetition is tool output, not something to factor out, so exclude # the Migrations folder from copy-paste detection. Migration Up is # still executed by the integration fixture and reviewed in the PR. - "/d:sonar.cpd.exclusions=**/Migrations/**" + # + # Also excluded from copy-paste detection: + # - i18n dictionaries: `es` and `en` mirror each other by design + # (same keys, translated values), which CPD reads as duplication. + # - component templates: Tailwind markup (cards, form fields) is + # presentation, not logic; factoring it out adds indirection with + # no behavioural gain. + "/d:sonar.cpd.exclusions=**/Migrations/**,**/shared/i18n/translations/*.ts,**/*.html" ) if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then args+=( diff --git a/bikontrol-web/src/app/modules/auth/auth-imports.ts b/bikontrol-web/src/app/modules/auth/auth-imports.ts index 9eeb959..56aff85 100644 --- a/bikontrol-web/src/app/modules/auth/auth-imports.ts +++ b/bikontrol-web/src/app/modules/auth/auth-imports.ts @@ -1,5 +1,14 @@ import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; +import { TranslatePipe } from '../../shared/i18n/translate.pipe'; +import { LanguageSwitcherComponent } from '../../shared/i18n/language-switcher.component'; -export const AUTH_IMPORTS = [CommonModule, FormsModule, ReactiveFormsModule, RouterModule]; +export const AUTH_IMPORTS = [ + CommonModule, + FormsModule, + ReactiveFormsModule, + RouterModule, + TranslatePipe, + LanguageSwitcherComponent, +]; diff --git a/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.html b/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.html index 8678c5e..3a327a0 100644 --- a/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.html +++ b/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.html @@ -2,10 +2,10 @@
Bikontrol logo -

Confirmar correo

+

{{ 'auth.confirm.title' | t }}

@if (loading()) { -

Confirmando tu correo...

+

{{ 'auth.confirm.loading' | t }}

} @else if (success()) { {{ successMessage() }} } @else { @@ -17,7 +17,7 @@

Confirmar correo

routerLink="/login" class="w-full py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors" > - Ir al inicio de sesión + {{ 'auth.confirm.goToLogin' | t }}
diff --git a/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.spec.ts b/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.spec.ts index b4f00aa..8f98b1f 100644 --- a/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.spec.ts +++ b/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.spec.ts @@ -51,7 +51,7 @@ describe('ConfirmEmailComponent', () => { expect(authServiceMock.confirmEmail).toHaveBeenCalledWith('user@example.com', 'token-abc'); expect(component.success()).toBe(true); - expect(component.successMessage()).toBe('Correo confirmado.'); + expect(component.successMessage()).toBe('Correo confirmado. Ya puedes iniciar sesión.'); expect(component.loading()).toBe(false); }); diff --git a/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.ts b/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.ts index 59a2010..fbb6bd9 100644 --- a/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.ts +++ b/bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.ts @@ -2,10 +2,12 @@ import { Component, OnInit, ChangeDetectionStrategy, signal, inject } from '@ang import { ActivatedRoute, RouterModule } from '@angular/router'; import { AuthService } from '../../services/auth.service'; import { HttpErrorService } from '../../../../shared/services/http-error.service'; +import { TranslatePipe } from '../../../../shared/i18n/translate.pipe'; +import { I18nService } from '../../../../shared/i18n/i18n.service'; @Component({ selector: 'app-confirm-email', - imports: [RouterModule], + imports: [RouterModule, TranslatePipe], templateUrl: './confirm-email.component.html', changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './confirm-email.component.scss', @@ -14,6 +16,7 @@ export class ConfirmEmailComponent implements OnInit { private route = inject(ActivatedRoute); private authService = inject(AuthService); private httpError = inject(HttpErrorService); + private i18n = inject(I18nService); readonly loading = signal(true); readonly success = signal(false); @@ -26,15 +29,15 @@ export class ConfirmEmailComponent implements OnInit { if (!token || !email) { this.loading.set(false); - this.errorMessage.set('El enlace de confirmación es inválido o está incompleto.'); + this.errorMessage.set(this.i18n.t('auth.confirm.linkInvalid')); return; } this.authService.confirmEmail(email, token).subscribe({ - next: (response) => { + next: () => { this.loading.set(false); this.success.set(true); - this.successMessage.set(response.message); + this.successMessage.set(this.i18n.t('auth.confirm.success')); }, error: (error) => { this.loading.set(false); diff --git a/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.html b/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.html index 708e0dc..3d2802f 100644 --- a/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.html +++ b/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.html @@ -2,28 +2,26 @@
Bikontrol logo -

Recuperar contraseña

-

- Ingresa tu correo y te enviaremos un enlace para restablecer tu contraseña. -

+

{{ 'auth.forgot.title' | t }}

+

{{ 'auth.forgot.subtitle' | t }}

- + @if (isInvalid('email') && f['email'].errors?.['required']) { - El correo es obligatorio. + {{ 'common.emailRequired' | t }} } @if (isInvalid('email') && f['email'].errors?.['email']) { - Ingresa un correo válido. + {{ 'common.emailInvalid' | t }} }
@@ -32,7 +30,7 @@

Recuperar contrase [disabled]="form.invalid" class="w-full py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors disabled:bg-accent disabled:cursor-not-allowed" > - Enviar enlace + {{ 'auth.forgot.submit' | t }} @if (successMessage()) { @@ -51,7 +49,7 @@

Recuperar contrase routerLink="/login" class="w-full mt-6 py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors" > - Volver al inicio de sesión + {{ 'common.backToLogin' | t }}

diff --git a/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.spec.ts b/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.spec.ts index 0a95ebe..63318ba 100644 --- a/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.spec.ts +++ b/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.spec.ts @@ -49,7 +49,9 @@ describe('ForgotPasswordComponent', () => { component.onSubmit(); expect(authServiceMock.forgotPassword).toHaveBeenCalledWith('user@example.com'); - expect(component.successMessage()).toBe('Revisa tu correo.'); + expect(component.successMessage()).toBe( + 'Si el correo está registrado, recibirás un enlace para restablecer tu contraseña.', + ); expect(component.errorMessage()).toBeNull(); }); diff --git a/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.ts b/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.ts index 47dd33b..06c3363 100644 --- a/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.ts +++ b/bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.ts @@ -10,10 +10,12 @@ import { RouterModule } from '@angular/router'; import { AuthService } from '../../services/auth.service'; import { HttpErrorService } from '../../../../shared/services/http-error.service'; import { isInvalid as formIsInvalid } from '../../../../shared/utils/form.utils'; +import { TranslatePipe } from '../../../../shared/i18n/translate.pipe'; +import { I18nService } from '../../../../shared/i18n/i18n.service'; @Component({ selector: 'app-forgot-password', - imports: [FormsModule, ReactiveFormsModule, RouterModule], + imports: [FormsModule, ReactiveFormsModule, RouterModule, TranslatePipe], templateUrl: './forgot-password.component.html', changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './forgot-password.component.scss', @@ -22,6 +24,7 @@ export class ForgotPasswordComponent { private fb = inject(FormBuilder); private authService = inject(AuthService); private httpError = inject(HttpErrorService); + private i18n = inject(I18nService); form: FormGroup; readonly submitted = signal(false); @@ -50,8 +53,8 @@ export class ForgotPasswordComponent { if (this.form.invalid) return; this.authService.forgotPassword(this.form.value.email).subscribe({ - next: (response) => { - this.successMessage.set(response.message); + next: () => { + this.successMessage.set(this.i18n.t('auth.forgot.success')); }, error: (error) => { this.errorMessage.set(this.httpError.message(error)); diff --git a/bikontrol-web/src/app/modules/auth/pages/login/login.component.html b/bikontrol-web/src/app/modules/auth/pages/login/login.component.html index 92e47ab..0965677 100644 --- a/bikontrol-web/src/app/modules/auth/pages/login/login.component.html +++ b/bikontrol-web/src/app/modules/auth/pages/login/login.component.html @@ -2,30 +2,32 @@
Bikontrol logo -

Bikontrol

+

{{ 'app.name' | t }}

- + @if (isInvalid('email') && f['email'].errors?.['required']) { - El correo es obligatorio. + {{ 'common.emailRequired' | t }} } @if (isInvalid('email') && f['email'].errors?.['email']) { - Ingresa un correo válido. + {{ 'common.emailInvalid' | t }} }
- + Bikontrol placeholder="••••••••" /> @if (isInvalid('password') && f['password'].errors?.['required']) { - La contraseña es obligatoria. + + {{ 'common.passwordRequired' | t }} + } @if (isInvalid('password') && f['password'].errors?.['minlength']) { - Debe tener al menos 6 caracteres. + {{ 'common.passwordMin' | t }} }
@if (errorMessage()) { @@ -68,7 +72,7 @@

Bikontrol

[disabled]="resendLoading()" class="w-full mt-3 py-2 border border-accent text-primary rounded-lg hover:bg-gray-50 transition-colors text-sm disabled:opacity-50" > - {{ resendLoading() ? 'Enviando...' : 'Reenviar correo de confirmación' }} + {{ (resendLoading() ? 'auth.login.resendLoading' : 'auth.login.resend') | t }} } @@ -81,7 +85,7 @@

Bikontrol

- o continuar con + {{ 'auth.login.orContinue' | t }}
@@ -92,7 +96,7 @@

Bikontrol

routerLink="/register" class="w-full mt-6 py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors" > - Registrarse + {{ 'auth.login.register' | t }} @if (demoEnabled) { @@ -102,16 +106,19 @@

Bikontrol

[disabled]="demoLoading()" class="w-full mt-3 py-2 border border-accent text-primary rounded-lg hover:bg-gray-50 transition-colors text-sm disabled:opacity-50" > - {{ demoLoading() ? 'Cargando demo...' : '¿Querés probar la demo?' }} + {{ (demoLoading() ? 'auth.login.demoLoading' : 'auth.login.demo') | t }} } -

- Ayuda - · - Términos - · - Privacidad -

+
diff --git a/bikontrol-web/src/app/modules/auth/pages/login/login.component.spec.ts b/bikontrol-web/src/app/modules/auth/pages/login/login.component.spec.ts index dd9a7bd..a79f81a 100644 --- a/bikontrol-web/src/app/modules/auth/pages/login/login.component.spec.ts +++ b/bikontrol-web/src/app/modules/auth/pages/login/login.component.spec.ts @@ -132,7 +132,7 @@ describe('LoginComponent', () => { component.onResendConfirmation(); expect(authServiceMock.resendConfirmation).toHaveBeenCalledWith('user@example.com'); - expect(component.resendMessage()).toBe('Enviado'); + expect(component.resendMessage()).toBe('Te enviamos un nuevo enlace de confirmación.'); }); it('should log in to the demo and navigate when demo is enabled', () => { diff --git a/bikontrol-web/src/app/modules/auth/pages/login/login.component.ts b/bikontrol-web/src/app/modules/auth/pages/login/login.component.ts index c4ecaff..7c91d3d 100644 --- a/bikontrol-web/src/app/modules/auth/pages/login/login.component.ts +++ b/bikontrol-web/src/app/modules/auth/pages/login/login.component.ts @@ -5,6 +5,7 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { AUTH_IMPORTS } from '../../auth-imports'; import { HttpErrorService } from '../../../../shared/services/http-error.service'; import { isInvalid as formIsInvalid } from '../../../../shared/utils/form.utils'; +import { I18nService } from '../../../../shared/i18n/i18n.service'; import { environment } from '@env/environment'; declare global { @@ -25,6 +26,7 @@ export class LoginComponent implements AfterViewInit { private authService = inject(AuthService); private router = inject(Router); private httpError = inject(HttpErrorService); + private i18n = inject(I18nService); loginForm: FormGroup; readonly submitted = signal(false); @@ -93,9 +95,9 @@ export class LoginComponent implements AfterViewInit { this.resendLoading.set(true); this.resendMessage.set(null); this.authService.resendConfirmation(email).subscribe({ - next: (response) => { + next: () => { this.resendLoading.set(false); - this.resendMessage.set(response.message); + this.resendMessage.set(this.i18n.t('auth.login.resendSent')); }, error: (error) => { this.resendLoading.set(false); @@ -113,7 +115,7 @@ export class LoginComponent implements AfterViewInit { 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('auth.login.demoError'))); }, }); } @@ -148,7 +150,7 @@ export class LoginComponent implements AfterViewInit { onGoogleCredential(response: { credential?: string }): void { if (!response?.credential) { - this.errorMessage.set('No se pudo obtener la credencial de Google.'); + this.errorMessage.set(this.i18n.t('auth.login.googleError')); return; } diff --git a/bikontrol-web/src/app/modules/auth/pages/register/register.component.html b/bikontrol-web/src/app/modules/auth/pages/register/register.component.html index e7fa43c..94726a2 100644 --- a/bikontrol-web/src/app/modules/auth/pages/register/register.component.html +++ b/bikontrol-web/src/app/modules/auth/pages/register/register.component.html @@ -2,13 +2,12 @@
Bikontrol logo -

Crear cuenta

+

+ {{ 'auth.register.title' | t }} +

@if (confirmationSent()) { -

- Te enviamos un correo de confirmación. Revisá tu bandeja (y la carpeta de spam) y hacé clic - en el enlace para activar tu cuenta. -

+

{{ 'auth.register.confirmSent' | t }}

@if (resendMessage()) { @@ -35,49 +34,51 @@

Crear cuenta

routerLink="/login" class="w-full mt-6 py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors" > - Ir al inicio de sesión + {{ 'auth.confirm.goToLogin' | t }} } @else {
- + @if (isInvalid('fullName')) { - El nombre es obligatorio. + + {{ 'common.passwordRequired' | t }} + }
- + @if (isInvalid('email') && f['email'].errors?.['required']) { - El correo es obligatorio. + {{ 'common.emailRequired' | t }} } @if (isInvalid('email') && f['email'].errors?.['email']) { - Formato de correo inválido. + {{ 'common.emailInvalid' | t }} }
- + Crear cuenta placeholder="••••••••" /> @if (isInvalid('password') && f['password'].errors?.['required']) { - La contraseña es obligatoria. - } - @if (isInvalid('password') && f['password'].errors?.['minlength']) { - Debe tener al menos 6 caracteres. + {{ 'common.passwordRequired' | t }} } + @if (isInvalid('password') && f['password'].errors?.['minlength']) { + {{ 'common.passwordMin' | t }} + }
- + Crear cuenta placeholder="••••••••" /> @if (isInvalid('confirmPassword') && f['confirmPassword'].errors?.['required']) { - Debes confirmar la contraseña. + + {{ 'common.confirmRequired' | t }} + } @if ( registerForm.errors?.['passwordMismatch'] && (f['confirmPassword'].dirty || f['confirmPassword'].touched) ) { - Las contraseñas no coinciden. + + {{ 'common.passwordMismatch' | t }} + }
@@ -122,7 +127,7 @@

Crear cuenta

[disabled]="registerForm.invalid" class="w-full py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors disabled:bg-accent disabled:cursor-not-allowed" > - Registrarse + {{ 'auth.register.submit' | t }} @if (errorMessage()) { @@ -136,17 +141,20 @@

Crear cuenta

routerLink="/login" class="w-full mt-6 py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors" > - Volver al inicio de sesión + {{ 'common.backToLogin' | t }} } -

- Ayuda - · - Términos - · - Privacidad -

+
+ +

+ {{ 'common.help' | t }} + · + {{ 'common.terms' | t }} + · + {{ 'common.privacy' | t }} +

+
diff --git a/bikontrol-web/src/app/modules/auth/pages/register/register.component.spec.ts b/bikontrol-web/src/app/modules/auth/pages/register/register.component.spec.ts index bf54a21..375c1a5 100644 --- a/bikontrol-web/src/app/modules/auth/pages/register/register.component.spec.ts +++ b/bikontrol-web/src/app/modules/auth/pages/register/register.component.spec.ts @@ -135,7 +135,7 @@ describe('RegisterComponent', () => { component.onResendConfirmation(); expect(authServiceMock.resendConfirmation).toHaveBeenCalledWith('user@example.com'); - expect(component.resendMessage()).toBe('Enviado'); + expect(component.resendMessage()).toBe('Te enviamos un nuevo enlace de confirmación.'); }); it('should use the generic fallback message when the backend response is empty', () => { diff --git a/bikontrol-web/src/app/modules/auth/pages/register/register.component.ts b/bikontrol-web/src/app/modules/auth/pages/register/register.component.ts index 3418dff..4f0b448 100644 --- a/bikontrol-web/src/app/modules/auth/pages/register/register.component.ts +++ b/bikontrol-web/src/app/modules/auth/pages/register/register.component.ts @@ -5,6 +5,7 @@ import { Router } from '@angular/router'; import { AUTH_IMPORTS } from '../../auth-imports'; import { HttpErrorService } from '../../../../shared/services/http-error.service'; import { isInvalid as formIsInvalid } from '../../../../shared/utils/form.utils'; +import { I18nService } from '../../../../shared/i18n/i18n.service'; @Component({ selector: 'app-register', @@ -18,6 +19,7 @@ export class RegisterComponent { private authService = inject(AuthService); private router = inject(Router); private httpError = inject(HttpErrorService); + private i18n = inject(I18nService); registerForm: FormGroup; readonly submitted = signal(false); @@ -87,9 +89,9 @@ export class RegisterComponent { this.resendLoading.set(true); this.resendMessage.set(null); this.authService.resendConfirmation(this.registeredEmail).subscribe({ - next: (response) => { + next: () => { this.resendLoading.set(false); - this.resendMessage.set(response.message); + this.resendMessage.set(this.i18n.t('auth.login.resendSent')); }, error: (error) => { this.resendLoading.set(false); diff --git a/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.html b/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.html index 0fcbc46..e10f7f1 100644 --- a/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.html +++ b/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.html @@ -2,19 +2,19 @@
Bikontrol logo -

Nueva contraseña

+

{{ 'auth.reset.title' | t }}

@if (linkInvalid()) { - El enlace de recuperación es inválido o está incompleto. Solicita uno nuevo. + {{ 'auth.reset.linkInvalid' | t }} }
- + Nueva contraseña @if (isInvalid('newPassword') && f['newPassword'].errors?.['required']) { - La contraseña es obligatoria. + + {{ 'common.passwordRequired' | t }} + } @if (isInvalid('newPassword') && f['newPassword'].errors?.['minlength']) { - Debe tener al menos 6 caracteres. + {{ 'common.passwordMin' | t }} }
- + Nueva contraseña @if (isInvalid('confirmPassword') && f['confirmPassword'].errors?.['required']) { - Debes confirmar la contraseña. + {{ 'common.confirmRequired' | t }} } @if ( form.errors?.['passwordMismatch'] && (f['confirmPassword'].dirty || f['confirmPassword'].touched) ) { - Las contraseñas no coinciden. + + {{ 'common.passwordMismatch' | t }} + }
@@ -57,7 +61,7 @@

Nueva contraseña - Restablecer contraseña + {{ 'auth.reset.submit' | t }} @if (successMessage()) { diff --git a/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.spec.ts b/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.spec.ts index 19fc1e2..098b3c4 100644 --- a/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.spec.ts +++ b/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.spec.ts @@ -89,7 +89,7 @@ describe('ResetPasswordComponent', () => { 'token-abc', '123456', ); - expect(component.successMessage()).toBe('Contraseña actualizada.'); + expect(component.successMessage()).toBe('Contraseña actualizada. Ya puedes iniciar sesión.'); vi.runAllTimers(); expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); vi.useRealTimers(); diff --git a/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.ts b/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.ts index a7c76fb..4ed4d2b 100644 --- a/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.ts +++ b/bikontrol-web/src/app/modules/auth/pages/reset-password/reset-password.component.ts @@ -17,10 +17,12 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { AuthService } from '../../services/auth.service'; import { HttpErrorService } from '../../../../shared/services/http-error.service'; import { isInvalid as formIsInvalid } from '../../../../shared/utils/form.utils'; +import { TranslatePipe } from '../../../../shared/i18n/translate.pipe'; +import { I18nService } from '../../../../shared/i18n/i18n.service'; @Component({ selector: 'app-reset-password', - imports: [FormsModule, ReactiveFormsModule, RouterModule], + imports: [FormsModule, ReactiveFormsModule, RouterModule, TranslatePipe], templateUrl: './reset-password.component.html', changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './reset-password.component.scss', @@ -31,6 +33,7 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { private authService = inject(AuthService); private router = inject(Router); private httpError = inject(HttpErrorService); + private i18n = inject(I18nService); form: FormGroup; readonly submitted = signal(false); @@ -82,8 +85,8 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { this.authService .resetPassword(this.email!, this.token!, this.form.value.newPassword) .subscribe({ - next: (response) => { - this.successMessage.set(response.message); + next: () => { + this.successMessage.set(this.i18n.t('auth.reset.success')); this.redirectTimer = setTimeout(() => this.router.navigate(['/login']), 2000); }, error: (error) => { diff --git a/bikontrol-web/src/app/shared/components/bottom-nav/bottom-nav.component.html b/bikontrol-web/src/app/shared/components/bottom-nav/bottom-nav.component.html index e754c28..22a7f11 100644 --- a/bikontrol-web/src/app/shared/components/bottom-nav/bottom-nav.component.html +++ b/bikontrol-web/src/app/shared/components/bottom-nav/bottom-nav.component.html @@ -15,13 +15,13 @@ /> - Inicio + {{ 'nav.home' | t }} - Estadísticas + {{ 'nav.statistics' | t }} - + - Perfil + {{ 'nav.profile' | t }} diff --git a/bikontrol-web/src/app/shared/components/bottom-nav/bottom-nav.component.ts b/bikontrol-web/src/app/shared/components/bottom-nav/bottom-nav.component.ts index 6f88d87..e9f5386 100644 --- a/bikontrol-web/src/app/shared/components/bottom-nav/bottom-nav.component.ts +++ b/bikontrol-web/src/app/shared/components/bottom-nav/bottom-nav.component.ts @@ -1,9 +1,10 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { TranslatePipe } from '../../i18n/translate.pipe'; @Component({ selector: 'app-bottom-nav', - imports: [RouterModule], + imports: [RouterModule, TranslatePipe], templateUrl: './bottom-nav.component.html', changeDetection: ChangeDetectionStrategy.Eager, styleUrl: './bottom-nav.component.scss', diff --git a/bikontrol-web/src/app/shared/components/top-nav/top-nav.component.html b/bikontrol-web/src/app/shared/components/top-nav/top-nav.component.html index 1aa1881..e30fc34 100644 --- a/bikontrol-web/src/app/shared/components/top-nav/top-nav.component.html +++ b/bikontrol-web/src/app/shared/components/top-nav/top-nav.component.html @@ -2,7 +2,11 @@ class="bg-gray-800 text-white shadow-md p-4 flex items-center justify-between fixed w-full z-20" > @if (!showBackButton) { -

+ } @@ -74,14 +85,14 @@ [class.translate-x-0]="sidebarOpen()" [class.-translate-x-full]="!sidebarOpen()" > -
Menú
+
{{ 'nav.menu' | t }}
diff --git a/bikontrol-web/src/app/shared/components/top-nav/top-nav.component.ts b/bikontrol-web/src/app/shared/components/top-nav/top-nav.component.ts index 1faaa64..f76eac4 100644 --- a/bikontrol-web/src/app/shared/components/top-nav/top-nav.component.ts +++ b/bikontrol-web/src/app/shared/components/top-nav/top-nav.component.ts @@ -10,11 +10,13 @@ import { import { NavigationEnd } from '@angular/router'; import { Router, RouterModule } from '@angular/router'; import { AuthService } from '../../../modules/auth/services/auth.service'; +import { TranslatePipe } from '../../i18n/translate.pipe'; +import { LanguageSwitcherComponent } from '../../i18n/language-switcher.component'; import { Subscription, filter } from 'rxjs'; @Component({ selector: 'app-top-nav', - imports: [RouterModule], + imports: [RouterModule, TranslatePipe, LanguageSwitcherComponent], templateUrl: './top-nav.component.html', changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './top-nav.component.scss', diff --git a/bikontrol-web/src/app/shared/i18n/translations/en.ts b/bikontrol-web/src/app/shared/i18n/translations/en.ts index 1383478..1d79888 100644 --- a/bikontrol-web/src/app/shared/i18n/translations/en.ts +++ b/bikontrol-web/src/app/shared/i18n/translations/en.ts @@ -2,6 +2,23 @@ export const en: Record = { 'app.name': 'Bikontrol', + // Common + 'common.email': 'Email', + 'common.emailPlaceholder': 'you@example.com', + 'common.password': 'Password', // NOSONAR: i18n label, not a credential + 'common.newPassword': 'New password', + 'common.confirmPassword': 'Confirm password', + 'common.emailRequired': 'Email is required.', + 'common.emailInvalid': 'Enter a valid email.', + 'common.passwordRequired': 'Password is required.', + 'common.passwordMin': 'Must be at least 6 characters.', + 'common.confirmRequired': 'Please confirm the password.', + 'common.passwordMismatch': 'Passwords do not match.', + 'common.backToLogin': 'Back to sign in', + 'common.help': 'Help', + 'common.terms': 'Terms', + 'common.privacy': 'Privacy', + // Landing 'landing.help': 'Help', 'landing.signIn': 'Sign in', @@ -40,4 +57,56 @@ export const en: Record = { 'landing.footer.help': 'Help', 'landing.footer.terms': 'Terms', 'landing.footer.privacy': 'Privacy', + + // Login + 'auth.login.forgot': 'Forgot your password?', + 'auth.login.submit': 'Sign in', + 'auth.login.orContinue': 'or continue with', + 'auth.login.register': 'Sign up', + 'auth.login.demo': 'Want to try the demo?', + 'auth.login.demoLoading': 'Loading demo...', + 'auth.login.resend': 'Resend confirmation email', + 'auth.login.resendLoading': 'Sending...', + 'auth.login.resendSent': 'We sent you a new confirmation link.', + 'auth.login.googleError': 'Could not get the Google credential.', + 'auth.login.demoError': 'Could not start the demo.', + + // Register + 'auth.register.title': 'Create account', + 'auth.register.fullName': 'Full name', + 'auth.register.fullNamePlaceholder': 'e.g. John Doe', + 'auth.register.confirmSent': + 'We emailed you a confirmation link. Check your inbox (and spam folder) and click the link to activate your account.', + 'auth.register.submit': 'Sign up', + + // Forgot password + 'auth.forgot.title': 'Reset password', + 'auth.forgot.subtitle': 'Enter your email and we will send you a link to reset your password.', + 'auth.forgot.submit': 'Send link', + 'auth.forgot.success': + 'If the email is registered, you will receive a link to reset your password.', + + // Reset password + 'auth.reset.title': 'New password', + 'auth.reset.linkInvalid': 'The recovery link is invalid or incomplete. Request a new one.', + 'auth.reset.submit': 'Reset password', + 'auth.reset.success': 'Password updated. You can now sign in.', + + // Confirm email + 'auth.confirm.title': 'Confirm email', + 'auth.confirm.loading': 'Confirming your email...', + 'auth.confirm.linkInvalid': 'The confirmation link is invalid or incomplete.', + 'auth.confirm.success': 'Email confirmed. You can now sign in.', + 'auth.confirm.goToLogin': 'Go to sign in', + + // App shell + 'nav.openMenu': 'Open menu', + 'nav.back': 'Back', + 'nav.profileMenu': 'Open profile menu', + 'nav.myProfile': 'My profile', + 'nav.logout': 'Sign out', + 'nav.menu': 'Menu', + 'nav.home': 'Home', + 'nav.statistics': 'Statistics', + 'nav.profile': 'Profile', }; diff --git a/bikontrol-web/src/app/shared/i18n/translations/es.ts b/bikontrol-web/src/app/shared/i18n/translations/es.ts index 4195bce..605d02c 100644 --- a/bikontrol-web/src/app/shared/i18n/translations/es.ts +++ b/bikontrol-web/src/app/shared/i18n/translations/es.ts @@ -2,6 +2,23 @@ export const es: Record = { 'app.name': 'Bikontrol', + // Common + 'common.email': 'Correo', + 'common.emailPlaceholder': 'correo@example.com', + 'common.password': 'Contraseña', // NOSONAR: i18n label, not a credential + 'common.newPassword': 'Nueva contraseña', + 'common.confirmPassword': 'Confirmar contraseña', + 'common.emailRequired': 'El correo es obligatorio.', + 'common.emailInvalid': 'Ingresa un correo válido.', + 'common.passwordRequired': 'La contraseña es obligatoria.', + 'common.passwordMin': 'Debe tener al menos 6 caracteres.', + 'common.confirmRequired': 'Debes confirmar la contraseña.', + 'common.passwordMismatch': 'Las contraseñas no coinciden.', + 'common.backToLogin': 'Volver al inicio de sesión', + 'common.help': 'Ayuda', + 'common.terms': 'Términos', + 'common.privacy': 'Privacidad', + // Landing 'landing.help': 'Ayuda', 'landing.signIn': 'Ingresar', @@ -41,4 +58,58 @@ export const es: Record = { 'landing.footer.help': 'Ayuda', 'landing.footer.terms': 'Términos', 'landing.footer.privacy': 'Privacidad', + + // Login + 'auth.login.forgot': '¿Olvidaste tu contraseña?', + 'auth.login.submit': 'Ingresar', + 'auth.login.orContinue': 'o continuar con', + 'auth.login.register': 'Registrarse', + 'auth.login.demo': '¿Querés probar la demo?', + 'auth.login.demoLoading': 'Cargando demo...', + 'auth.login.resend': 'Reenviar correo de confirmación', + 'auth.login.resendLoading': 'Enviando...', + 'auth.login.resendSent': 'Te enviamos un nuevo enlace de confirmación.', + 'auth.login.googleError': 'No se pudo obtener la credencial de Google.', + 'auth.login.demoError': 'No se pudo iniciar la demo.', + + // Register + 'auth.register.title': 'Crear cuenta', + 'auth.register.fullName': 'Nombre completo', + 'auth.register.fullNamePlaceholder': 'Ej: Juan Pérez', + 'auth.register.confirmSent': + 'Te enviamos un correo de confirmación. Revisá tu bandeja (y la carpeta de spam) y hacé clic en el enlace para activar tu cuenta.', + 'auth.register.submit': 'Registrarse', + + // Forgot password + 'auth.forgot.title': 'Recuperar contraseña', + 'auth.forgot.subtitle': + 'Ingresa tu correo y te enviaremos un enlace para restablecer tu contraseña.', + 'auth.forgot.submit': 'Enviar enlace', + 'auth.forgot.success': + 'Si el correo está registrado, recibirás un enlace para restablecer tu contraseña.', + + // Reset password + 'auth.reset.title': 'Nueva contraseña', + 'auth.reset.linkInvalid': + 'El enlace de recuperación es inválido o está incompleto. Solicita uno nuevo.', + 'auth.reset.submit': 'Restablecer contraseña', + 'auth.reset.success': 'Contraseña actualizada. Ya puedes iniciar sesión.', + + // Confirm email + 'auth.confirm.title': 'Confirmar correo', + 'auth.confirm.loading': 'Confirmando tu correo...', + 'auth.confirm.linkInvalid': 'El enlace de confirmación es inválido o está incompleto.', + 'auth.confirm.success': 'Correo confirmado. Ya puedes iniciar sesión.', + 'auth.confirm.goToLogin': 'Ir al inicio de sesión', + + // App shell + 'nav.openMenu': 'Abrir menú', + 'nav.back': 'Volver', + 'nav.profileMenu': 'Abrir menú de perfil', + 'nav.myProfile': 'Mi perfil', + 'nav.logout': 'Cerrar sesión', + 'nav.menu': 'Menú', + 'nav.home': 'Inicio', + 'nav.statistics': 'Estadísticas', + 'nav.profile': 'Perfil', }; diff --git a/docs/specs/i18n.md b/docs/specs/i18n.md index 941f778..1510a5b 100644 --- a/docs/specs/i18n.md +++ b/docs/specs/i18n.md @@ -8,8 +8,9 @@ Runtime i18n with a minimal, dependency-free service (`shared/i18n`): - **`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 +**Coverage is incremental.** Done: **landing**, **auth** (login, register, forgot/reset/confirm) +and the **app shell** (top/bottom nav, incl. the ES/EN switcher in the profile menu). Pending +(follow-up PR): 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