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 bikontrol-web/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const routes: Routes = [
pathMatch: 'full',
canActivate: [rootRedirectGuard],
loadComponent: () =>
import('./modules/auth/pages/login/login.component').then((c) => c.LoginComponent),
import('./modules/marketing/landing/landing.component').then((c) => c.LandingComponent),
},

// Auth
Expand Down
136 changes: 136 additions & 0 deletions bikontrol-web/src/app/modules/marketing/landing/landing.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<div class="min-h-screen bg-bg">
<!-- Header -->
<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>
</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.
</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>

<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
>
<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
>
@if (demoEnabled) {
<button
type="button"
(click)="onDemoLogin()"
[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' }}
</button>
}
</div>
@if (errorMessage()) {
<p class="text-red-500 text-sm mt-3 font-bold">{{ errorMessage() }}</p>
}
</section>

<!-- 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) {
<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>
</div>
}
</div>
</section>

<!-- 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>
<ol class="grid grid-cols-1 sm:grid-cols-3 gap-6">
@for (step of steps; track step.title; 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>
</li>
}
</ol>
</div>
</section>

<!-- 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>
<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>
</ul>
</div>
</section>

<!--
Screenshots: the plan wants real captures (see docs/screenshots/README.md).
Add them to bikontrol-web/public/screenshots/ and render a section here, e.g.:
<img src="screenshots/dashboard.webp" alt="Resumen de la moto" />
Do not ship invented/mocked screenshots.
-->

<!-- 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>
<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
>
</section>

<!-- Footer -->
<footer class="border-t border-gray-200">
<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>
<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>
</nav>
</div>
</footer>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:host {
display: block;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
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 { LandingComponent } from './landing.component';

@Component({ selector: 'app-stub', standalone: true, template: '' })
class StubComponent {}

describe('LandingComponent', () => {
let authServiceMock: any;

beforeEach(() => {
authServiceMock = { demoLogin: vi.fn(() => of({ token: 't' })) };
TestBed.configureTestingModule({
imports: [LandingComponent],
providers: [
// Wildcard route so the demo login's navigate(['/dashboard']) resolves.
provideRouter([{ path: '**', component: StubComponent }]),
{ provide: AuthService, useValue: authServiceMock },
{
provide: HttpErrorService,
useValue: { message: (e: any, f: string) => e?.message ?? f },
},
],
});
});

it('renders the value proposition and primary CTA', () => {
const fixture = TestBed.createComponent(LandingComponent);
fixture.detectChanges();

const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('El mantenimiento de tu moto, bajo control.');
expect(text).toContain('Empezar gratis');
expect(text).toContain('Cómo funciona');
});

it('starts a demo session when the demo is enabled', () => {
const component = TestBed.createComponent(LandingComponent).componentInstance;

component.onDemoLogin();

// environment.demoEnabled is true in the dev/test environment.
expect(authServiceMock.demoLogin).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
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';

/**
* 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).
*/
@Component({
selector: 'app-landing',
imports: [RouterLink],
templateUrl: './landing.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './landing.component.scss',
})
export class LandingComponent {
private readonly authService = inject(AuthService);
private readonly router = inject(Router);
private readonly httpError = inject(HttpErrorService);

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.',
},
];

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.',
},
];

onDemoLogin(): void {
if (!this.demoEnabled || this.demoLoading()) return;
this.demoLoading.set(true);
this.errorMessage.set(null);
this.authService.demoLogin().subscribe({
next: () => this.router.navigate(['/dashboard']),
error: (error) => {
this.demoLoading.set(false);
this.errorMessage.set(this.httpError.message(error, 'No se pudo iniciar la demo.'));
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ describe('rootRedirectGuard', () => {
expect(result).toEqual({ redirectedTo: '/dashboard' });
});

it('should redirect to /login for guests', () => {
it('should allow guests through to the landing page', () => {
authService.isAuthenticated.mockReturnValue(false);

const result = TestBed.runInInjectionContext(() => rootRedirectGuard({} as any, {} as any));

expect(router.parseUrl).toHaveBeenCalledWith('/login');
expect(result).toEqual({ redirectedTo: '/login' });
expect(result).toBe(true);
expect(router.parseUrl).not.toHaveBeenCalled();
});
});
3 changes: 2 additions & 1 deletion bikontrol-web/src/app/shared/guards/root-redirect.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export const rootRedirectGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);

return router.parseUrl(authService.isAuthenticated() ? '/dashboard' : '/login');
// Already signed in → straight to the dashboard. Guests get the landing page.
return authService.isAuthenticated() ? router.parseUrl('/dashboard') : true;
};
1 change: 1 addition & 0 deletions docs/specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ Detail specs for Bikontrol. Start with [AGENTS.md](../../AGENTS.md) for the proj
- [reminders.md](reminders.md) — maintenance reminder engine (due rules, daily job, dedupe, preferences)
- [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)
14 changes: 14 additions & 0 deletions docs/specs/landing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Landing

Public marketing page at the **root path** (`modules/marketing/landing`). The
`rootRedirectGuard` sends authenticated users straight to `/dashboard` and lets
guests see the landing; CTAs link to `/register`, `/login` (and a demo button
when `environment.demoEnabled` is on).

Sections: hero + value proposition, value props, "cómo funciona", trust, final
CTA and footer (Ayuda / Términos / Privacidad).

**Screenshots are pending.** The plan asks for real captures (see
[docs/screenshots/](../screenshots/)); add them to
`bikontrol-web/public/screenshots/` and render them in the marked spot in the
template. Do not ship invented/mocked screenshots.
29 changes: 29 additions & 0 deletions e2e/tests/landing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test';

/**
* Landing (plan 5.1): guests see the marketing page at `/`, while authenticated
* users are sent straight to the dashboard.
*/

test('guests see the landing and authenticated users are redirected to the dashboard', async ({
page,
}) => {
// --- Guest: the landing renders -------------------------------------------
await page.goto('/');
await expect(page.getByRole('heading', { name: /El mantenimiento de tu moto/ })).toBeVisible();
await expect(page.getByRole('link', { name: 'Empezar gratis' })).toBeVisible();

// --- Authenticated: root goes to the dashboard -----------------------------
const email = `landing+${Date.now()}@bikontrol.test`;
const password = 'E2ePassword123!';
await page.goto('/register');
await page.getByLabel('Nombre completo').fill('Landing Rider');
await page.getByLabel('Correo').fill(email);
await page.getByLabel('Contraseña', { exact: true }).fill(password);
await page.getByLabel('Confirmar contraseña').fill(password);
await page.getByRole('button', { name: 'Registrarse' }).click();
await expect(page).toHaveURL(/\/dashboard\/home/);

await page.goto('/');
await expect(page).toHaveURL(/\/dashboard\/home/);
});
Loading