feat(checkout): implement checkout page with form validation and routing
This commit is contained in:
parent
2b3713f6a8
commit
45f0618ae9
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
(m) => m.ProductDetailPageComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'checkout',
|
||||
loadComponent: () =>
|
||||
import('./pages/checkout-page/checkout-page.component').then(
|
||||
(m) => m.CheckoutPageComponent
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue