-
+@if (isLoadingPurchase()) {
+
+
+ Cargando compra...
+
+} @else {
+
+
-
- @if (isEditingItems()) {
-
-
Terminá de modificar las cantidades para continuar con el pago.
+
- }
-
-
+ @if (isEditingItems()) {
+
+
Terminá de modificar las cantidades para continuar con el pago.
+
+ }
+
+
-
+}
diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.scss b/src/app/features/store/pages/checkout-page/checkout-page.component.scss
index c06082e..c533094 100644
--- a/src/app/features/store/pages/checkout-page/checkout-page.component.scss
+++ b/src/app/features/store/pages/checkout-page/checkout-page.component.scss
@@ -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);
+ }
+}
diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts
index 0f35f4d..af9b7d0 100644
--- a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts
+++ b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts
@@ -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);
});
diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.ts
index 861270a..502a303 100644
--- a/src/app/features/store/pages/checkout-page/checkout-page.component.ts
+++ b/src/app/features/store/pages/checkout-page/checkout-page.component.ts
@@ -77,6 +77,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
});
protected readonly createdPurchase = signal(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(['/']);
diff --git a/src/app/shared/components/stepper/stepper.component.spec.ts b/src/app/shared/components/stepper/stepper.component.spec.ts
index e0081b4..f377043 100644
--- a/src/app/shared/components/stepper/stepper.component.spec.ts
+++ b/src/app/shared/components/stepper/stepper.component.spec.ts
@@ -14,7 +14,7 @@ import { StepComponent } from './step.component';
Content 2
- `
+ `,
})
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();
diff --git a/src/app/shared/components/stepper/stepper.component.ts b/src/app/shared/components/stepper/stepper.component.ts
index 08ee4c5..fc92dcf 100644
--- a/src/app/shared/components/stepper/stepper.component.ts
+++ b/src/app/shared/components/stepper/stepper.component.ts
@@ -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();