feat: implement loading state for checkout process and enhance stepper component
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(['/']);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user