feat(checkout): implement checkout page with form validation and routing
This commit is contained in:
@@ -15,8 +15,8 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</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="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 flex-grow-1" role="search">
|
<div class="input-group store-layout__search" role="search">
|
||||||
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
||||||
<input
|
<input
|
||||||
id="store-search-input"
|
id="store-search-input"
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
background:
|
background: #f5f5f5;
|
||||||
radial-gradient(
|
|
||||||
circle at top,
|
|
||||||
color-mix(in srgb, white 88%, var(--tenant-primary)) 0%,
|
|
||||||
transparent 58%
|
|
||||||
),
|
|
||||||
#f6f6f6;
|
|
||||||
color: #202020;
|
color: #202020;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(['/']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,13 @@ export const routes: Routes = [
|
|||||||
import('./pages/product-detail-page/product-detail-page.component').then(
|
import('./pages/product-detail-page/product-detail-page.component').then(
|
||||||
(m) => m.ProductDetailPageComponent
|
(m) => m.ProductDetailPageComponent
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'checkout',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/checkout-page/checkout-page.component').then(
|
||||||
|
(m) => m.CheckoutPageComponent
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user