feat(checkout): enhance checkout page with payment methods and form validation

This commit is contained in:
ncoronel 2026-07-03 11:27:53 -03:00
parent 41bb5e7c9e
commit bfbbfb9c09
3 changed files with 558 additions and 135 deletions

View File

@ -1,137 +1,264 @@
<div class="container-xl px-3 px-md-4 py-4">
<div class="checkout-page">
<div class="checkout-page">
<!-- Left: Stepper with steps -->
<div class="checkout-page__stepper-col">
<app-stepper #stepper>
<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">
<app-step label="Datos" [isValid]="isStep1Valid()">
<form
class="checkout-form"
novalidate
[formGroup]="form"
(ngSubmit)="onContinue()"
>
<div class="checkout-form__fields">
<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="Descripcion"
[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>
<!-- 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 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="Descripcion"
[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>
<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="Descripcion"
[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>
<div class="form-field">
<label class="form-field__label" for="checkout-telefono">
Telefono <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-telefono"
type="text"
placeholder="Descripcion"
[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>
<!-- 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 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>
<app-step label="Pago">
<div class="checkout-payment">
<div class="checkout-payment__content">
<section class="payment-methods" aria-labelledby="payment-methods-title">
<h2 id="payment-methods-title" class="payment-methods__title">Selecciona el metodo de pago</h2>
<div class="payment-methods__list" role="radiogroup" aria-label="Metodo de pago">
@for (method of paymentMethods; track method.id) {
<label
class="payment-method"
[class.is-selected]="selectedPaymentMethod() === method.id"
>
<input
class="payment-method__radio"
type="radio"
name="payment-method"
[value]="method.id"
[checked]="selectedPaymentMethod() === method.id"
(change)="selectPaymentMethod(method.id)"
/>
<span class="payment-method__label">
@if (method.id === 'telepagos') {
<span class="telepagos-logo" aria-label="TelePagos">
<span class="telepagos-logo__tele">tele</span><span class="telepagos-logo__pagos">pagos</span>
</span>
} @else {
{{ method.label }}
}
</span>
<i class="fa-solid fa-angle-right payment-method__chevron" aria-hidden="true"></i>
</label>
}
</div>
</section>
<section class="payment-panel" aria-live="polite">
@if (selectedPaymentMethod() === 'qr') {
<div class="payment-panel__card">
<h3 class="payment-panel__title">Ingresa a tu billetera y escanea el siguiente QR</h3>
<div class="payment-panel__divider"></div>
<div class="qr-code" aria-label="QR de pago">
<span class="qr-code__finder qr-code__finder--tl"></span>
<span class="qr-code__finder qr-code__finder--tr"></span>
<span class="qr-code__finder qr-code__finder--bl"></span>
</div>
</div>
} @else if (selectedPaymentMethod() === 'transferencia') {
<div class="payment-panel__card">
<h3 class="payment-panel__title">Transferi a la siguiente cuenta desde cualquier billetera</h3>
<div class="payment-panel__divider"></div>
<div class="payment-panel__account">
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
<div class="payment-detail">
<span class="payment-detail__label">Titular:</span>
<strong class="payment-detail__value">{{ transferAccount.titular }}</strong>
</div>
<div class="payment-detail">
<span class="payment-detail__label">Entidad:</span>
<strong class="payment-detail__value">{{ transferAccount.entidad }}</strong>
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">CVU:</span>
<strong class="payment-detail__value">{{ transferAccount.cvu }}</strong>
<app-icon-button
variant="copy"
ariaLabel="Copiar CVU"
(click)="copyTransferValue('cvu', transferAccount.cvu)"
/>
</div>
@if (copiedTransferField() === 'cvu') {
<small class="payment-detail__feedback">CVU copiado</small>
}
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">Alias:</span>
<strong class="payment-detail__value">{{ transferAccount.alias }}</strong>
<app-icon-button
variant="copy"
ariaLabel="Copiar alias"
(click)="copyTransferValue('alias', transferAccount.alias)"
/>
</div>
@if (copiedTransferField() === 'alias') {
<small class="payment-detail__feedback">Alias copiado</small>
}
</div>
</div>
} @else {
<div class="payment-panel__card">
<h3 class="payment-panel__title">Descarga la app de TelePagos para finalizar la compra</h3>
<div class="payment-panel__divider"></div>
<div class="store-badges" aria-label="Tiendas disponibles">
<div class="store-badge">
<i class="fa-brands fa-google-play" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Disponible en</small>
<strong>Google Play</strong>
</span>
</div>
<div class="store-badge">
<i class="fa-brands fa-apple" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Descargalo en</small>
<strong>App Store</strong>
</span>
</div>
</div>
</div>
}
</section>
</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>
<div class="checkout-form__actions checkout-form__actions--payment">
<app-button
type="button"
variant="cancel"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
(click)="onCancel()"
>
Cancelar
</app-button>
<!-- 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>
}
<app-button
type="button"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
>
Finalizar compra
</app-button>
</div>
</div>
</app-step>
<!-- 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>
</app-stepper>
</div>
</form>
</app-step>
<div class="checkout-page__cart-col">
<app-cart
[items]="mappedCartItems()"
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
backgroundColor="transparent"
/>
</div>
<!-- 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

@ -1,6 +1,6 @@
.checkout-page {
display: grid;
grid-template-columns: 1fr 420px;
grid-template-columns: minmax(0, 1fr) 420px;
gap: 2rem;
align-items: start;
padding: 2rem 0;
@ -9,13 +9,12 @@
grid-template-columns: 1fr;
}
// Left column
&__stepper-col {
min-width: 0;
border-radius: 4px;
min-height: 420px;
}
// Right column
&__cart-col {
border: 1px solid #ececec;
border-radius: 4px;
@ -27,7 +26,6 @@
}
}
// Form
.checkout-form {
display: flex;
flex-direction: column;
@ -43,6 +41,14 @@
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 0.5rem;
@media (max-width: 540px) {
grid-template-columns: 1fr;
}
}
&__actions--payment {
margin-top: 2rem;
}
&__action-btn {
@ -51,7 +57,6 @@
}
}
// Field label & error
.form-field {
display: grid;
gap: 0.35rem;
@ -60,7 +65,6 @@
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-secondary, #6c757d);
text-transform: none;
letter-spacing: 0;
}
@ -75,12 +79,265 @@
}
}
// Payment placeholder
.checkout-payment-placeholder {
.checkout-payment {
display: grid;
gap: 1rem;
&__content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 330px);
gap: 2rem;
align-items: start;
@media (max-width: 760px) {
grid-template-columns: 1fr;
}
}
}
.payment-methods {
min-width: 0;
&__title {
margin: 0 0 1.75rem;
color: #8f8f8f;
font-size: 0.95rem;
font-weight: 700;
}
&__list {
display: grid;
gap: 1.1rem;
}
}
.payment-method {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.85rem;
padding: 0.35rem 0;
color: #666666;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: #4f4f4f;
}
&.is-selected {
color: var(--tenant-primary, #6376f3);
}
&__radio {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--tenant-primary, #6376f3);
cursor: pointer;
}
&__label {
min-width: 0;
font-size: 1rem;
font-weight: 700;
line-height: 1.2;
}
&__chevron {
color: #9f9f9f;
font-size: 0.95rem;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
&.is-selected &__chevron,
&:hover &__chevron {
opacity: 1;
transform: translateX(0);
}
}
.telepagos-logo {
display: inline-block;
font-size: 1.45rem;
font-weight: 800;
line-height: 1;
letter-spacing: -0.03em;
background: linear-gradient(90deg, #0a69d8 0 78%, #f6a11a 78% 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.payment-panel {
min-width: 0;
&__card {
min-height: 260px;
padding: 1.5rem 1.75rem;
border-radius: 0.5rem;
background: #ffffff;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
&__title {
max-width: 18rem;
margin: 0;
color: #8b8b8b;
font-size: 1rem;
font-weight: 700;
line-height: 1.35;
}
&__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #e7e7e7;
}
&__account {
width: 100%;
display: grid;
gap: 0.75rem;
color: #7a7a7a;
}
&__eyebrow {
margin: 0;
color: #838383;
font-size: 0.95rem;
letter-spacing: 0.06em;
}
}
.payment-detail {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
gap: 0.35rem;
align-items: center;
font-size: 0.98rem;
&--copy {
grid-template-columns: auto auto auto;
}
&__label {
color: #8a8a8a;
}
&__value {
color: #5e5e5e;
font-weight: 700;
}
&__feedback {
display: block;
margin-top: -0.25rem;
color: var(--tenant-primary, #6376f3);
font-size: 0.78rem;
font-weight: 700;
}
}
.qr-code {
position: relative;
width: min(170px, 100%);
aspect-ratio: 1;
border: 8px solid #ffffff;
background-color: #ffffff;
background-image:
linear-gradient(
90deg,
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
),
linear-gradient(
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
);
background-size: 18px 18px;
background-position: 0 0, 9px 9px;
}
.qr-code__finder {
position: absolute;
width: 38px;
height: 38px;
border: 5px solid #111111;
background: #ffffff;
box-shadow: inset 0 0 0 8px #ffffff, inset 0 0 0 14px #111111;
&--tl {
top: 10px;
left: 10px;
}
&--tr {
top: 10px;
right: 10px;
}
&--bl {
bottom: 10px;
left: 10px;
}
}
.store-badges {
width: 100%;
max-width: 210px;
display: grid;
gap: 0.8rem;
}
.store-badge {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
color: var(--bs-secondary, #6c757d);
font-size: 0.9rem;
gap: 0.75rem;
padding: 0.75rem 0.95rem;
border-radius: 0.85rem;
background: #111111;
color: #ffffff;
box-shadow: 0 10px 24px rgba(17, 17, 17, 0.16);
i {
font-size: 1.35rem;
}
&__text {
display: grid;
text-align: left;
line-height: 1.1;
small {
font-size: 0.62rem;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.8;
}
strong {
font-size: 1rem;
font-weight: 700;
}
}
}

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, computed, inject, ViewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal, ViewChild } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
@ -9,6 +9,10 @@ import { StepperComponent } from '../../../../shared/components/stepper/stepper.
import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
type PaymentMethod = 'qr' | 'transferencia' | 'telepagos';
type TransferField = 'cvu' | 'alias';
@Component({
selector: 'app-checkout-page',
@ -19,7 +23,8 @@ import { ButtonComponent } from '../../../../shared/components/button/button.com
StepperComponent,
StepComponent,
InputComponent,
ButtonComponent
ButtonComponent,
IconButtonComponent
],
templateUrl: './checkout-page.component.html',
styleUrl: './checkout-page.component.scss',
@ -55,6 +60,19 @@ export class CheckoutPageComponent {
});
protected readonly isStep1Valid = computed(() => this.form.valid);
protected readonly paymentMethods: ReadonlyArray<{ id: PaymentMethod; label: string }> = [
{ id: 'qr', label: 'QR' },
{ id: 'transferencia', label: 'Transferencia' },
{ id: 'telepagos', label: 'TelePagos' }
];
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
protected readonly copiedTransferField = signal<TransferField | null>(null);
protected readonly transferAccount = {
titular: 'Nombre y Apellido',
entidad: 'TelePagos',
cvu: '0000000000000000000000',
alias: 'telepagos.ar'
};
private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? '';
@ -114,4 +132,25 @@ export class CheckoutPageComponent {
protected onCancel(): void {
void this.router.navigate(['/']);
}
protected selectPaymentMethod(method: PaymentMethod): void {
this.selectedPaymentMethod.set(method);
}
protected async copyTransferValue(field: TransferField, value: string): Promise<void> {
if (!globalThis.navigator?.clipboard?.writeText) return;
try {
await globalThis.navigator.clipboard.writeText(value);
this.copiedTransferField.set(field);
setTimeout(() => {
if (this.copiedTransferField() === field) {
this.copiedTransferField.set(null);
}
}, 1800);
} catch {
this.copiedTransferField.set(null);
}
}
}