feat(checkout): implement checkout page with form validation and routing

This commit is contained in:
ncoronel 2026-07-03 10:54:43 -03:00
parent 2b3713f6a8
commit 45f0618ae9
6 changed files with 350 additions and 9 deletions

View File

@ -15,8 +15,8 @@
}
</div>
<div class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap w-100 justify-content-between justify-content-md-end">
<div class="input-group store-layout__search flex-grow-1" role="search">
<div class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"

View File

@ -1,13 +1,7 @@
:host {
display: flex;
min-height: 100dvh;
background:
radial-gradient(
circle at top,
color-mix(in srgb, white 88%, var(--tenant-primary)) 0%,
transparent 58%
),
#f6f6f6;
background: #f5f5f5;
color: #202020;
}

View File

@ -0,0 +1,137 @@
<div class="container-xl px-3 px-md-4 py-4">
<div class="checkout-page">
<!-- Left: Stepper with steps -->
<div class="checkout-page__stepper-col">
<app-stepper #stepper>
<!-- Step 1: Datos -->
<app-step label="Datos" [isValid]="isStep1Valid()">
<form
class="checkout-form"
novalidate
[formGroup]="form"
(ngSubmit)="onContinue()"
>
<div class="checkout-form__fields">
<!-- Nombre y Apellido -->
<div class="form-field">
<label class="form-field__label" for="checkout-nombre">
Nombre y Apellido <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-nombre"
type="text"
placeholder="Descripción"
[value]="form.controls.nombre.value"
[invalid]="showControlError('nombre')"
(valueChange)="updateField('nombre', $event)"
/>
@if (getControlError('nombre'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
<!-- Email -->
<div class="form-field">
<label class="form-field__label" for="checkout-email">
Email <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-email"
type="email"
placeholder="Descripción"
[value]="form.controls.email.value"
[invalid]="showControlError('email')"
(valueChange)="updateField('email', $event)"
/>
@if (getControlError('email'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
<!-- DNI -->
<div class="form-field">
<label class="form-field__label" for="checkout-dni">
DNI <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-dni"
type="text"
placeholder="Descripción"
[value]="form.controls.dni.value"
[invalid]="showControlError('dni')"
(valueChange)="updateField('dni', $event)"
/>
@if (getControlError('dni'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
<!-- Teléfono -->
<div class="form-field">
<label class="form-field__label" for="checkout-telefono">
Teléfono <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-telefono"
type="text"
placeholder="Descripción"
[value]="form.controls.telefono.value"
[invalid]="showControlError('telefono')"
(valueChange)="updateField('telefono', $event)"
/>
@if (getControlError('telefono'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
</div>
<!-- Actions -->
<div class="checkout-form__actions">
<app-button
type="button"
variant="cancel"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
(click)="onCancel()"
>
Cancelar
</app-button>
<app-button
type="submit"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
>
Continuar
</app-button>
</div>
</form>
</app-step>
<!-- Step 2: Pago (placeholder) -->
<app-step label="Pago">
<div class="checkout-payment-placeholder">
<p>Próximamente: sección de pago.</p>
</div>
</app-step>
</app-stepper>
</div>
<!-- Right: Cart detail -->
<div class="checkout-page__cart-col">
<app-cart
[items]="mappedCartItems()"
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
backgroundColor="transparent"
/>
</div>
</div>
</div>

View File

@ -0,0 +1,86 @@
.checkout-page {
display: grid;
grid-template-columns: 1fr 420px;
gap: 2rem;
align-items: start;
padding: 2rem 0;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
// Left column
&__stepper-col {
border-radius: 4px;
min-height: 420px;
}
// Right column
&__cart-col {
border: 1px solid #ececec;
border-radius: 4px;
padding: 1.25rem 1.5rem;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
// Form
.checkout-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
&__fields {
display: grid;
gap: 1.25rem;
}
&__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 0.5rem;
}
&__action-btn {
display: block;
width: 100%;
}
}
// Field label & error
.form-field {
display: grid;
gap: 0.35rem;
&__label {
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-secondary, #6c757d);
text-transform: none;
letter-spacing: 0;
}
&__required {
color: var(--bs-danger, #dc3545);
margin-left: 2px;
}
&__error {
font-size: 0.75rem;
color: var(--bs-danger, #dc3545);
}
}
// Payment placeholder
.checkout-payment-placeholder {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
color: var(--bs-secondary, #6c757d);
font-size: 0.9rem;
}

View File

@ -0,0 +1,117 @@
import { ChangeDetectionStrategy, Component, computed, inject, ViewChild } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CartItem } from '../../../../core/services/cart/cart.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
@Component({
selector: 'app-checkout-page',
standalone: true,
imports: [
ReactiveFormsModule,
CartComponent,
StepperComponent,
StepComponent,
InputComponent,
ButtonComponent
],
templateUrl: './checkout-page.component.html',
styleUrl: './checkout-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutPageComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly cartService = inject(CartService);
private readonly router = inject(Router);
@ViewChild(StepperComponent) stepper!: StepperComponent;
protected readonly form = this.formBuilder.nonNullable.group({
nombre: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email]],
dni: ['', [Validators.required]],
telefono: ['', [Validators.required]]
});
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
return cart ? parseFloat(cart.subtotal) : 0;
});
protected readonly cartDiscount = computed(() => 0);
protected readonly cartTotal = computed(() => this.cartSubtotal() - this.cartDiscount());
protected readonly mappedCartItems = computed<CartItemMock[]>(() => {
const cart = this.cartService.cart();
if (!cart || !cart.items) return [];
return cart.items.map((item) => this.mapCartItemToMock(item));
});
protected readonly isStep1Valid = computed(() => this.form.valid);
private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? '';
let product = fullName;
let attributes: { label: string; value: string }[] = [];
const match = fullName.match(/^(.*?)\s*\((.*?)\)$/);
if (match) {
product = match[1];
const attributesString = match[2];
attributes = attributesString.split(',').map((attr) => {
const parts = attr.split(':');
if (parts.length === 2) {
return { label: parts[0].trim(), value: parts[1].trim() };
}
return { label: '', value: attr.trim() };
});
}
return {
productVariantId: item.product_variant_id,
imageUrl: item.product?.imagen ?? null,
product,
originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null,
attributes,
quantity: item.cantidad
};
}
protected updateField(field: keyof typeof this.form.controls, value: string | number): void {
this.form.controls[field].setValue(String(value));
}
protected showControlError(controlName: keyof typeof this.form.controls): boolean {
const control = this.form.controls[controlName];
return control.invalid && control.touched;
}
protected getControlError(controlName: keyof typeof this.form.controls): string | null {
const control = this.form.controls[controlName];
if (!this.showControlError(controlName)) return null;
if (control.hasError('required')) return 'Este campo es obligatorio.';
if (control.hasError('email')) return 'Ingresá un email válido.';
return 'El valor ingresado no es válido.';
}
protected onContinue(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.stepper.next();
}
protected onCancel(): void {
void this.router.navigate(['/']);
}
}

View File

@ -44,6 +44,13 @@ export const routes: Routes = [
import('./pages/product-detail-page/product-detail-page.component').then(
(m) => m.ProductDetailPageComponent
)
},
{
path: 'checkout',
loadComponent: () =>
import('./pages/checkout-page/checkout-page.component').then(
(m) => m.CheckoutPageComponent
)
}
]
}