diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.html b/src/app/features/store/pages/checkout-page/checkout-page.component.html index 30a3e1c..71b7365 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.html +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.html @@ -1,11 +1,17 @@ -
-
- +@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();