fix(checkout): restore cart on every exit

This commit is contained in:
2026-08-21 11:04:09 -03:00
parent f056c32f49
commit 0bd6dc3b22
2 changed files with 76 additions and 31 deletions

View File

@@ -587,14 +587,29 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.stepper.next).toHaveBeenCalledOnce();
});
it('keeps the checkout purchase intact when navigating away', async () => {
it('restores the cart when navigating away from checkout', async () => {
const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(true);
expect(checkoutServiceStub.cancelPurchase).not.toHaveBeenCalled();
expect(cartServiceStub.loadCart).toHaveBeenCalled();
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(component.createdPurchaseId()).toBeNull();
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
});
it('prevents leaving checkout when the cart cannot be restored', async () => {
checkoutServiceStub.cancelPurchase.mockRejectedValue({
error: { message: 'No se pudo cancelar la compra.' },
});
const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(false);
expect(cartServiceStub.loadCart).not.toHaveBeenCalled();
expect(toastServiceStub.danger).toHaveBeenCalledWith('No se pudo cancelar la compra.');
expect(component.createdPurchaseId()).toBe(25);
});

View File

@@ -81,6 +81,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private transferPollingRunId = 0;
private paymentMethodRequestId = 0;
private navigationStarted = false;
private restoreCartPromise: Promise<boolean> | null = null;
@ViewChild(StepperComponent) stepper!: StepperComponent;
@@ -184,7 +185,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
})),
quantity: item.quantity,
variantId: item.source_variant_id,
variants: item.variants,
};
}
@@ -193,29 +193,16 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
return;
}
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
return;
}
this.isRestoringCart.set(true);
this.globalLoadingService.start();
try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
await firstValueFrom(this.cartService.loadCart());
this.navigationStarted = true;
const restored = await this.restoreCheckoutCart();
if (!restored) return;
await this.router.navigate(['/'], {
queryParams: { openCart: true },
});
} catch (error) {
console.error('Failed to restore cart for editing:', error);
this.showRequestError(error, 'No se pudo recuperar el carrito para modificar la compra.');
} finally {
this.globalLoadingService.stop();
this.isRestoringCart.set(false);
}
}
@@ -251,29 +238,71 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
protected async onCancel(): Promise<void> {
if (await this.canDeactivate()) {
void this.router.navigate(['/']);
}
void this.router.navigate(['/']);
}
public async canDeactivate(): Promise<boolean> {
this.stopQrPolling();
this.stopTransferPolling();
if (this.restoreCartPromise) {
return this.restoreCartPromise;
}
if (this.navigationStarted) {
return true;
}
// The checkout cart remains attached to its purchase. Once the user adds a
// new item, the cart API creates a separate active cart automatically.
this.globalLoadingService.start();
try {
await firstValueFrom(this.cartService.loadCart());
} catch (error) {
console.error('Failed to load the active cart after leaving checkout:', error);
this.showRequestError(error, 'No se pudo cargar el carrito.');
return await this.restoreCheckoutCart();
} finally {
this.globalLoadingService.stop();
}
}
private restoreCheckoutCart(): Promise<boolean> {
if (this.restoreCartPromise) {
return this.restoreCartPromise;
}
return true;
this.restoreCartPromise = this.performCartRestore().finally(() => {
this.restoreCartPromise = null;
});
return this.restoreCartPromise;
}
private async performCartRestore(): Promise<boolean> {
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
return true;
}
this.isRestoringCart.set(true);
try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
try {
await firstValueFrom(this.cartService.loadCart());
} catch (error) {
console.error('Failed to load the restored cart:', error);
this.showRequestError(error, 'La compra se canceló, pero no se pudo cargar el carrito.');
}
this.navigationStarted = true;
return true;
} catch (error) {
console.error('Failed to restore the checkout cart:', error);
this.showRequestError(error, 'No se pudo recuperar el carrito.');
return false;
} finally {
this.isRestoringCart.set(false);
}
}
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
@@ -608,6 +637,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
return;
}
this.createdPurchaseId.set(purchaseId);
try {
const purchase = await this.checkoutService
.withCustomLoading()
@@ -675,7 +706,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const response = error.error as ApiErrorResponse | null;
if (response?.code === 'purchase.expired' || this.hasExpiredPurchase()) {
this.navigationStarted = true;
this.stopQrPolling();
this.stopTransferPolling();
this.toastService.danger(