feat: implement loading state for checkout process and enhance stepper component

This commit is contained in:
2026-07-28 09:25:04 -03:00
parent c41c60f349
commit 7845c8f20e
6 changed files with 219 additions and 49 deletions

View File

@@ -1,11 +1,17 @@
<div class="checkout-page">
<div
class="checkout-page__stepper-col"
[class.checkout-page__stepper-col--editing]="isEditingItems()"
[attr.aria-hidden]="isEditingItems()"
[attr.inert]="isEditingItems() ? '' : null"
>
<app-stepper #stepper>
@if (isLoadingPurchase()) {
<div class="checkout-page__loading" role="status" aria-live="polite">
<span class="checkout-page__spinner" aria-hidden="true"></span>
<span>Cargando compra...</span>
</div>
} @else {
<div class="checkout-page">
<div
class="checkout-page__stepper-col"
[class.checkout-page__stepper-col--editing]="isEditingItems()"
[attr.aria-hidden]="isEditingItems()"
[attr.inert]="isEditingItems() ? '' : null"
>
<app-stepper #stepper [initialStepIndex]="checkoutStepIndex()">
<app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step
[form]="form"
@@ -34,32 +40,33 @@
(retryQrPolling)="retryQrPolling()"
/>
</app-step>
</app-stepper>
</div>
@if (isEditingItems()) {
<div class="checkout-page__editing-notice" role="status">
<p>Terminá de modificar las cantidades para continuar con el pago.</p>
</app-stepper>
</div>
}
<div class="checkout-page__cart-col">
<app-cart
title="COMPRA"
[items]="mappedCartItems()"
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[allowEditing]="
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
"
[allowRemove]="false"
[persistQuantityChanges]="false"
[editing]="isEditingItems()"
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
backgroundColor="transparent"
(editingChange)="onEditingItemsChange($event)"
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
/>
@if (isEditingItems()) {
<div class="checkout-page__editing-notice" role="status">
<p>Terminá de modificar las cantidades para continuar con el pago.</p>
</div>
}
<div class="checkout-page__cart-col">
<app-cart
title="COMPRA"
[items]="mappedCartItems()"
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[allowEditing]="
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
"
[allowRemove]="false"
[persistQuantityChanges]="false"
[editing]="isEditingItems()"
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
backgroundColor="transparent"
(editingChange)="onEditingItemsChange($event)"
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
/>
</div>
</div>
</div>
}

View File

@@ -36,10 +36,33 @@
}
&__cart-col {
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
.checkout-page__loading {
display: grid;
min-height: 420px;
place-items: center;
align-content: center;
gap: 1rem;
color: #666666;
}
.checkout-page__spinner {
width: 3rem;
height: 3rem;
border: 0.25rem solid rgba(32, 32, 32, 0.08);
border-top-color: var(--tenant-primary);
border-radius: 50%;
animation: checkout-page-spin 0.8s linear infinite;
}
@keyframes checkout-page-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -234,6 +234,9 @@ describe('CheckoutPageComponent payment validation', () => {
const purchase = {
id: 25,
cart_id: null,
status: 'created',
payment_method: null,
transfer_payer_dni: null,
nombre_apellido: 'Datos de la compra',
email: 'compra@example.com',
dni: '11111111',
@@ -293,6 +296,99 @@ describe('CheckoutPageComponent payment validation', () => {
});
});
it('keeps the checkout hidden while the purchase is loading', async () => {
let resolvePurchase!: (purchase: any) => void;
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockReturnValue(
new Promise((resolve) => {
resolvePurchase = resolve;
}),
);
const { component } = createComponent();
expect(component.isLoadingPurchase()).toBe(true);
resolvePurchase({
id: 25,
status: 'created',
payment_method: null,
transfer_payer_dni: null,
items: [],
subtotal: '0.00',
total: '0.00',
});
await Promise.resolve();
expect(component.isLoadingPurchase()).toBe(false);
expect(component.checkoutStepIndex()).toBe(0);
});
it('opens a pending purchase on the payment step and restores its payment method', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
payment_method: 'transfer',
transfer_payer_dni: '12345678',
items: [],
subtotal: '100.00',
total: '100.00',
});
const { component } = createComponent();
await Promise.resolve();
expect(component.checkoutStepIndex()).toBe(1);
expect(component.selectedPaymentMethod()).toBe('transfer');
expect(component.transferDni()).toBe('12345678');
expect(component.isLoadingPurchase()).toBe(false);
});
it('generates a new QR when reopening a pending QR purchase', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
payment_method: 'qr',
transfer_payer_dni: null,
items: [],
subtotal: '100.00',
total: '100.00',
});
const { component } = createComponent();
await Promise.resolve();
await Promise.resolve();
expect(component.checkoutStepIndex()).toBe(1);
expect(component.selectedPaymentMethod()).toBe('qr');
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith(
'tenant-test',
25,
'qr',
);
expect(component.qrData()).toBe('qr-value');
expect(component.qrPaymentStatus()).toBe('waiting');
});
it.each(['paid', 'cancelled', 'rejected', 'expired'])(
'redirects a %s purchase to its status page',
async (status) => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status,
payment_method: null,
transfer_payer_dni: null,
items: [],
subtotal: '100.00',
total: '100.00',
});
createComponent();
await Promise.resolve();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
},
);
it('updates a purchase item while editing and refreshes checkout totals', async () => {
const updatedPurchase = {
id: 25,
@@ -318,12 +414,7 @@ describe('CheckoutPageComponent payment validation', () => {
quantity: 3,
});
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith(
'tenant-test',
25,
91,
3,
);
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith('tenant-test', 25, 91, 3);
expect(component.createdPurchase()).toBe(updatedPurchase);
expect(component.isUpdatingItem()).toBe(false);
});

View File

@@ -77,6 +77,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
});
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
protected readonly isLoadingPurchase = signal(true);
protected readonly checkoutStepIndex = signal(0);
protected readonly cartSubtotal = computed(() => {
const purchase = this.createdPurchase();
@@ -198,10 +200,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.isPreparingItemEdit.set(true);
try {
const purchase = await this.checkoutService.prepareItemEditing(
tenant.codigo,
purchaseId,
);
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
this.createdPurchase.set(purchase);
} catch (error) {
console.error('Failed to prepare purchase item editing:', error);
@@ -574,8 +573,39 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
if (
purchase.status === 'paid' ||
purchase.status === 'cancelled' ||
purchase.status === 'rejected' ||
purchase.status === 'expired'
) {
this.navigateToPurchaseStatus(purchaseId);
return;
}
if (purchase.status !== 'created' && purchase.status !== 'pending_payment') {
void this.router.navigate(['/']);
return;
}
this.createdPurchaseId.set(purchase.id);
this.createdPurchase.set(purchase);
this.checkoutStepIndex.set(purchase.status === 'pending_payment' ? 1 : 0);
if (
purchase.status === 'pending_payment' &&
this.paymentMethods.some((method) => method.id === purchase.payment_method)
) {
this.selectedPaymentMethod.set(purchase.payment_method as PaymentMethod);
this.transferDni.set(purchase.transfer_payer_dni ?? '');
}
this.isLoadingPurchase.set(false);
if (purchase.status === 'pending_payment' && purchase.payment_method === 'qr') {
await this.selectPaymentMethod('qr');
}
} catch (error) {
console.error('Failed to load purchase:', error);
void this.router.navigate(['/']);

View File

@@ -14,7 +14,7 @@ import { StepComponent } from './step.component';
<div id="content-2">Content 2</div>
</app-step>
</app-stepper>
`
`,
})
class TestHostComponent {
@ViewChild('stepper') stepper!: StepperComponent;
@@ -25,7 +25,7 @@ class TestHostComponent {
describe('StepperComponent & StepComponent', () => {
async function setup() {
await TestBed.configureTestingModule({
imports: [TestHostComponent, StepperComponent, StepComponent]
imports: [TestHostComponent, StepperComponent, StepComponent],
}).compileComponents();
const fixture = TestBed.createComponent(TestHostComponent);
@@ -50,6 +50,18 @@ describe('StepperComponent & StepComponent', () => {
expect(content2).toBeNull();
});
it('can initialize on a specified step', async () => {
await TestBed.configureTestingModule({
imports: [StepperComponent, StepComponent],
}).compileComponents();
const fixture = TestBed.createComponent(StepperComponent);
fixture.componentRef.setInput('initialStepIndex', 1);
fixture.detectChanges();
expect(fixture.componentInstance.currentStepIndex()).toBe(1);
});
it('advances to step 2 when next() is called and current step is valid', async () => {
const { fixture, component } = await setup();

View File

@@ -1,4 +1,10 @@
import { ChangeDetectionStrategy, Component, contentChildren, signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
contentChildren,
input,
linkedSignal,
} from '@angular/core';
import { StepComponent } from './step.component';
import { NgClass } from '@angular/common';
@@ -11,7 +17,8 @@ import { NgClass } from '@angular/common';
})
export class StepperComponent {
readonly steps = contentChildren(StepComponent);
readonly currentStepIndex = signal(0);
readonly initialStepIndex = input(0);
readonly currentStepIndex = linkedSignal(() => this.initialStepIndex());
next() {
const currentSteps = this.steps();