feat: implement checkout data and payment steps as standalone components

- Add `CheckoutDataStepComponent` for handling user data input with reactive forms.
- Create `CheckoutPaymentStepComponent` for managing payment method selection and display.
- Refactor `CheckoutPageComponent` to utilize the new components, improving modularity and readability.
- Introduce SCSS styles for both new components, ensuring consistent design with existing styles.
- Update HTML structure in `checkout-page.component.html` to integrate the new components.
- Define models for checkout forms and payment methods in `checkout-page.models.ts`.
This commit is contained in:
ncoronel 2026-07-03 11:31:09 -03:00
parent bfbbfb9c09
commit 811f127deb
10 changed files with 698 additions and 595 deletions

View File

@ -0,0 +1,95 @@
<form
class="checkout-form"
novalidate
[formGroup]="form()"
(ngSubmit)="submit()"
>
<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>
<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>
<div class="checkout-form__actions">
<app-button
type="button"
variant="cancel"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
(click)="cancelStep.emit()"
>
Cancelar
</app-button>
<app-button
type="submit"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
>
Continuar
</app-button>
</div>
</form>

View File

@ -0,0 +1,48 @@
.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;
@media (max-width: 540px) {
grid-template-columns: 1fr;
}
}
&__action-btn {
display: block;
width: 100%;
}
}
.form-field {
display: grid;
gap: 0.35rem;
&__label {
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-secondary, #6c757d);
letter-spacing: 0;
}
&__required {
color: var(--bs-danger, #dc3545);
margin-left: 2px;
}
&__error {
font-size: 0.75rem;
color: var(--bs-danger, #dc3545);
}
}

View File

@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { CheckoutForm } from './checkout-page.models';
@Component({
selector: 'app-checkout-data-step',
standalone: true,
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
templateUrl: './checkout-data-step.component.html',
styleUrl: './checkout-data-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutDataStepComponent {
readonly form = input.required<CheckoutForm>();
readonly cancelStep = output<void>();
readonly continueStep = output<void>();
protected updateField(field: keyof CheckoutForm['controls'], value: string | number): void {
this.form().controls[field].setValue(String(value));
}
protected showControlError(controlName: keyof CheckoutForm['controls']): boolean {
const control = this.form().controls[controlName];
return control.invalid && control.touched;
}
protected getControlError(controlName: keyof CheckoutForm['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 'Ingresa un email valido.';
return 'El valor ingresado no es valido.';
}
protected submit(): void {
const form = this.form();
if (form.invalid) {
form.markAllAsTouched();
return;
}
this.continueStep.emit();
}
}

View File

@ -1,252 +1,26 @@
<div class="container-xl px-3 px-md-4 py-4">
<div class="checkout-page">
<div class="checkout-page__stepper-col">
<app-stepper #stepper>
<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>
<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>
<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-checkout-data-step
[form]="form"
(cancelStep)="onCancel()"
(continueStep)="onStep1Continue()"
/>
</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>
<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>
<app-button
type="button"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
>
Finalizar compra
</app-button>
</div>
</div>
<app-checkout-payment-step
[paymentMethods]="paymentMethods"
[selectedPaymentMethod]="selectedPaymentMethod()"
[copiedTransferField]="copiedTransferField()"
[transferAccount]="transferAccount"
(paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
(cancelStep)="onCancel()"
/>
</app-step>
</app-stepper>
</div>
@ -259,6 +33,5 @@
backgroundColor="transparent"
/>
</div>
</div>
</div>

View File

@ -25,319 +25,3 @@
flex-direction: column;
}
}
.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;
@media (max-width: 540px) {
grid-template-columns: 1fr;
}
}
&__actions--payment {
margin-top: 2rem;
}
&__action-btn {
display: block;
width: 100%;
}
}
.form-field {
display: grid;
gap: 0.35rem;
&__label {
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-secondary, #6c757d);
letter-spacing: 0;
}
&__required {
color: var(--bs-danger, #dc3545);
margin-left: 2px;
}
&__error {
font-size: 0.75rem;
color: var(--bs-danger, #dc3545);
}
}
.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;
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,30 +1,26 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal, ViewChild } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { startWith } from 'rxjs';
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';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
type PaymentMethod = 'qr' | 'transferencia' | 'telepagos';
type TransferField = 'cvu' | 'alias';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { CheckoutDataStepComponent } from './checkout-data-step.component';
import { CheckoutPaymentStepComponent } from './checkout-payment-step.component';
import { CheckoutForm, PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
@Component({
selector: 'app-checkout-page',
standalone: true,
imports: [
ReactiveFormsModule,
CartComponent,
StepperComponent,
StepComponent,
InputComponent,
ButtonComponent,
IconButtonComponent
CheckoutDataStepComponent,
CheckoutPaymentStepComponent
],
templateUrl: './checkout-page.component.html',
styleUrl: './checkout-page.component.scss',
@ -37,7 +33,7 @@ export class CheckoutPageComponent {
@ViewChild(StepperComponent) stepper!: StepperComponent;
protected readonly form = this.formBuilder.nonNullable.group({
protected readonly form: CheckoutForm = this.formBuilder.nonNullable.group({
nombre: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email]],
dni: ['', [Validators.required]],
@ -59,21 +55,27 @@ export class CheckoutPageComponent {
return cart.items.map((item) => this.mapCartItemToMock(item));
});
protected readonly isStep1Valid = computed(() => this.form.valid);
protected readonly paymentMethods: ReadonlyArray<{ id: PaymentMethod; label: string }> = [
protected readonly isStep1Valid = signal(this.form.valid);
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
{ 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 = {
protected readonly transferAccount: TransferAccount = {
titular: 'Nombre y Apellido',
entidad: 'TelePagos',
cvu: '0000000000000000000000',
alias: 'telepagos.ar'
};
constructor() {
this.form.statusChanges
.pipe(startWith(this.form.status))
.subscribe(() => this.isStep1Valid.set(this.form.valid));
}
private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? '';
let product = fullName;
@ -104,28 +106,7 @@ export class CheckoutPageComponent {
};
}
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;
}
protected onStep1Continue(): void {
this.stepper.next();
}

View File

@ -0,0 +1,23 @@
import { FormControl, FormGroup } from '@angular/forms';
export type PaymentMethod = 'qr' | 'transferencia' | 'telepagos';
export type TransferField = 'cvu' | 'alias';
export interface PaymentMethodOption {
id: PaymentMethod;
label: string;
}
export interface TransferAccount {
titular: string;
entidad: string;
cvu: string;
alias: string;
}
export type CheckoutForm = FormGroup<{
nombre: FormControl<string>;
email: FormControl<string>;
dni: FormControl<string>;
telefono: FormControl<string>;
}>;

View File

@ -0,0 +1,142 @@
<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)="requestCopy('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)="requestCopy('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>
<div class="checkout-payment__actions">
<app-button
type="button"
variant="cancel"
hostClass="checkout-payment__action-btn"
buttonClass="w-100"
(click)="cancelStep.emit()"
>
Cancelar
</app-button>
<app-button
type="button"
hostClass="checkout-payment__action-btn"
buttonClass="w-100"
>
Finalizar compra
</app-button>
</div>
</div>

View File

@ -0,0 +1,278 @@
.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;
}
}
&__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 2rem;
@media (max-width: 540px) {
grid-template-columns: 1fr;
}
}
&__action-btn {
display: block;
width: 100%;
}
}
.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;
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

@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
@Component({
selector: 'app-checkout-payment-step',
standalone: true,
imports: [ButtonComponent, IconButtonComponent],
templateUrl: './checkout-payment-step.component.html',
styleUrl: './checkout-payment-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutPaymentStepComponent {
readonly paymentMethods = input.required<ReadonlyArray<PaymentMethodOption>>();
readonly selectedPaymentMethod = input.required<PaymentMethod>();
readonly copiedTransferField = input<TransferField | null>(null);
readonly transferAccount = input.required<TransferAccount>();
readonly paymentMethodChange = output<PaymentMethod>();
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly cancelStep = output<void>();
protected selectPaymentMethod(method: PaymentMethod): void {
this.paymentMethodChange.emit(method);
}
protected requestCopy(field: TransferField, value: string): void {
this.copyTransferValue.emit({ field, value });
}
}