feat: implement loading state for checkout process and enhance stepper component
This commit is contained in:
@@ -1,11 +1,17 @@
|
|||||||
<div class="checkout-page">
|
@if (isLoadingPurchase()) {
|
||||||
<div
|
<div class="checkout-page__loading" role="status" aria-live="polite">
|
||||||
class="checkout-page__stepper-col"
|
<span class="checkout-page__spinner" aria-hidden="true"></span>
|
||||||
[class.checkout-page__stepper-col--editing]="isEditingItems()"
|
<span>Cargando compra...</span>
|
||||||
[attr.aria-hidden]="isEditingItems()"
|
</div>
|
||||||
[attr.inert]="isEditingItems() ? '' : null"
|
} @else {
|
||||||
>
|
<div class="checkout-page">
|
||||||
<app-stepper #stepper>
|
<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-step label="Datos" [isValid]="isStep1Valid()">
|
||||||
<app-checkout-data-step
|
<app-checkout-data-step
|
||||||
[form]="form"
|
[form]="form"
|
||||||
@@ -34,32 +40,33 @@
|
|||||||
(retryQrPolling)="retryQrPolling()"
|
(retryQrPolling)="retryQrPolling()"
|
||||||
/>
|
/>
|
||||||
</app-step>
|
</app-step>
|
||||||
</app-stepper>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
<div class="checkout-page__cart-col">
|
@if (isEditingItems()) {
|
||||||
<app-cart
|
<div class="checkout-page__editing-notice" role="status">
|
||||||
title="COMPRA"
|
<p>Terminá de modificar las cantidades para continuar con el pago.</p>
|
||||||
[items]="mappedCartItems()"
|
</div>
|
||||||
[subtotal]="cartSubtotal()"
|
}
|
||||||
[discount]="cartDiscount()"
|
|
||||||
[total]="cartTotal()"
|
<div class="checkout-page__cart-col">
|
||||||
[allowEditing]="
|
<app-cart
|
||||||
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
|
title="COMPRA"
|
||||||
"
|
[items]="mappedCartItems()"
|
||||||
[allowRemove]="false"
|
[subtotal]="cartSubtotal()"
|
||||||
[persistQuantityChanges]="false"
|
[discount]="cartDiscount()"
|
||||||
[editing]="isEditingItems()"
|
[total]="cartTotal()"
|
||||||
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
|
[allowEditing]="
|
||||||
backgroundColor="transparent"
|
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
|
||||||
(editingChange)="onEditingItemsChange($event)"
|
"
|
||||||
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
|
[allowRemove]="false"
|
||||||
/>
|
[persistQuantityChanges]="false"
|
||||||
|
[editing]="isEditingItems()"
|
||||||
|
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
|
||||||
|
backgroundColor="transparent"
|
||||||
|
(editingChange)="onEditingItemsChange($event)"
|
||||||
|
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|||||||
@@ -36,10 +36,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__cart-col {
|
&__cart-col {
|
||||||
|
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 = {
|
const purchase = {
|
||||||
id: 25,
|
id: 25,
|
||||||
cart_id: null,
|
cart_id: null,
|
||||||
|
status: 'created',
|
||||||
|
payment_method: null,
|
||||||
|
transfer_payer_dni: null,
|
||||||
nombre_apellido: 'Datos de la compra',
|
nombre_apellido: 'Datos de la compra',
|
||||||
email: 'compra@example.com',
|
email: 'compra@example.com',
|
||||||
dni: '11111111',
|
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 () => {
|
it('updates a purchase item while editing and refreshes checkout totals', async () => {
|
||||||
const updatedPurchase = {
|
const updatedPurchase = {
|
||||||
id: 25,
|
id: 25,
|
||||||
@@ -318,12 +414,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
quantity: 3,
|
quantity: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith(
|
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith('tenant-test', 25, 91, 3);
|
||||||
'tenant-test',
|
|
||||||
25,
|
|
||||||
91,
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
expect(component.createdPurchase()).toBe(updatedPurchase);
|
expect(component.createdPurchase()).toBe(updatedPurchase);
|
||||||
expect(component.isUpdatingItem()).toBe(false);
|
expect(component.isUpdatingItem()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
|
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
|
||||||
|
protected readonly isLoadingPurchase = signal(true);
|
||||||
|
protected readonly checkoutStepIndex = signal(0);
|
||||||
|
|
||||||
protected readonly cartSubtotal = computed(() => {
|
protected readonly cartSubtotal = computed(() => {
|
||||||
const purchase = this.createdPurchase();
|
const purchase = this.createdPurchase();
|
||||||
@@ -198,10 +200,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.isPreparingItemEdit.set(true);
|
this.isPreparingItemEdit.set(true);
|
||||||
try {
|
try {
|
||||||
const purchase = await this.checkoutService.prepareItemEditing(
|
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
|
||||||
tenant.codigo,
|
|
||||||
purchaseId,
|
|
||||||
);
|
|
||||||
this.createdPurchase.set(purchase);
|
this.createdPurchase.set(purchase);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to prepare purchase item editing:', error);
|
console.error('Failed to prepare purchase item editing:', error);
|
||||||
@@ -574,8 +573,39 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
|
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.createdPurchaseId.set(purchase.id);
|
||||||
this.createdPurchase.set(purchase);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load purchase:', error);
|
console.error('Failed to load purchase:', error);
|
||||||
void this.router.navigate(['/']);
|
void this.router.navigate(['/']);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { StepComponent } from './step.component';
|
|||||||
<div id="content-2">Content 2</div>
|
<div id="content-2">Content 2</div>
|
||||||
</app-step>
|
</app-step>
|
||||||
</app-stepper>
|
</app-stepper>
|
||||||
`
|
`,
|
||||||
})
|
})
|
||||||
class TestHostComponent {
|
class TestHostComponent {
|
||||||
@ViewChild('stepper') stepper!: StepperComponent;
|
@ViewChild('stepper') stepper!: StepperComponent;
|
||||||
@@ -25,7 +25,7 @@ class TestHostComponent {
|
|||||||
describe('StepperComponent & StepComponent', () => {
|
describe('StepperComponent & StepComponent', () => {
|
||||||
async function setup() {
|
async function setup() {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [TestHostComponent, StepperComponent, StepComponent]
|
imports: [TestHostComponent, StepperComponent, StepComponent],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(TestHostComponent);
|
const fixture = TestBed.createComponent(TestHostComponent);
|
||||||
@@ -50,6 +50,18 @@ describe('StepperComponent & StepComponent', () => {
|
|||||||
expect(content2).toBeNull();
|
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 () => {
|
it('advances to step 2 when next() is called and current step is valid', async () => {
|
||||||
const { fixture, component } = await setup();
|
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 { StepComponent } from './step.component';
|
||||||
import { NgClass } from '@angular/common';
|
import { NgClass } from '@angular/common';
|
||||||
|
|
||||||
@@ -11,7 +17,8 @@ import { NgClass } from '@angular/common';
|
|||||||
})
|
})
|
||||||
export class StepperComponent {
|
export class StepperComponent {
|
||||||
readonly steps = contentChildren(StepComponent);
|
readonly steps = contentChildren(StepComponent);
|
||||||
readonly currentStepIndex = signal(0);
|
readonly initialStepIndex = input(0);
|
||||||
|
readonly currentStepIndex = linkedSignal(() => this.initialStepIndex());
|
||||||
|
|
||||||
next() {
|
next() {
|
||||||
const currentSteps = this.steps();
|
const currentSteps = this.steps();
|
||||||
|
|||||||
Reference in New Issue
Block a user