Merge branch 'feature/restore-cart-on-modify' into develop

This commit is contained in:
2026-08-21 10:14:37 -03:00
7 changed files with 157 additions and 388 deletions

View File

@@ -5,12 +5,7 @@
</div> </div>
} @else { } @else {
<div class="checkout-page"> <div class="checkout-page">
<div <div class="checkout-page__stepper-col">
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-stepper #stepper [initialStepIndex]="checkoutStepIndex()">
<app-step label="Datos" [isValid]="isStep1Valid()"> <app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step <app-checkout-data-step
@@ -45,12 +40,6 @@
</app-stepper> </app-stepper>
</div> </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 class="checkout-page__cart-col"> <div class="checkout-page__cart-col">
<app-cart <app-cart
title="COMPRA" title="COMPRA"
@@ -58,23 +47,13 @@
[subtotal]="cartSubtotal()" [subtotal]="cartSubtotal()"
[discount]="cartDiscount()" [discount]="cartDiscount()"
[total]="cartTotal()" [total]="cartTotal()"
[readonly]="!canModifyCart()" [readonly]="true"
[allowModify]="true" [allowModify]="true"
[showModifyWhenReadonly]="true" [showModifyWhenReadonly]="true"
[allowUpdateQuantity]="canUpdateCartQuantity()" [modifyAsAction]="true"
[allowUpdateVariant]="canUpdateCartVariant()" [editingDisabled]="isRestoringCart()"
[requireEditingMode]="true"
[allowDelete]="canDeleteCartItems()"
[persistQuantityChanges]="false"
[persistVariantChanges]="false"
[persistDeleteChanges]="false"
[editing]="isEditingItems()"
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
backgroundColor="transparent" backgroundColor="transparent"
(editingChange)="onEditingItemsChange($event)" (modify)="onModifyPurchase()"
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
(itemVariantChange)="onPurchaseItemVariantChange($event)"
(itemRemove)="onPurchaseItemRemove($event)"
/> />
</div> </div>
</div> </div>

View File

@@ -11,8 +11,7 @@
order: 1; order: 1;
} }
.checkout-page__stepper-col, .checkout-page__stepper-col {
.checkout-page__editing-notice {
order: 2; order: 2;
} }
} }
@@ -21,27 +20,6 @@
min-width: 0; min-width: 0;
border-radius: 4px; border-radius: 4px;
min-height: 420px; min-height: 420px;
&--editing {
display: none;
}
}
&__editing-notice {
display: grid;
min-height: 420px;
place-items: center;
padding: 2rem;
border-radius: 4px;
background: #f5f5f5;
color: #666666;
text-align: center;
p {
max-width: 360px;
margin: 0;
font-size: 14px;
}
} }
&__cart-col { &__cart-col {

View File

@@ -10,9 +10,11 @@ import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service'; import { CheckoutService } from '../../../../core/services/checkout.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { CartEditingPolicy } from '../../../../core/services/tenant.interface'; import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
import { ToastService } from '../../../../core/services/toast.service';
import { CheckoutPageComponent } from './checkout-page.component'; import { CheckoutPageComponent } from './checkout-page.component';
describe('CheckoutPageComponent payment validation', () => { describe('CheckoutPageComponent payment validation', () => {
@@ -22,7 +24,6 @@ describe('CheckoutPageComponent payment validation', () => {
updateItemQuantity: ReturnType<typeof vi.fn>; updateItemQuantity: ReturnType<typeof vi.fn>;
updateItemVariant: ReturnType<typeof vi.fn>; updateItemVariant: ReturnType<typeof vi.fn>;
removeItem: ReturnType<typeof vi.fn>; removeItem: ReturnType<typeof vi.fn>;
prepareItemEditing: ReturnType<typeof vi.fn>;
cancelPurchase: ReturnType<typeof vi.fn>; cancelPurchase: ReturnType<typeof vi.fn>;
generatePaymentIntent: ReturnType<typeof vi.fn>; generatePaymentIntent: ReturnType<typeof vi.fn>;
submitPurchaseForReview: ReturnType<typeof vi.fn>; submitPurchaseForReview: ReturnType<typeof vi.fn>;
@@ -37,6 +38,10 @@ describe('CheckoutPageComponent payment validation', () => {
}; };
let routerStub: { navigate: ReturnType<typeof vi.fn> }; let routerStub: { navigate: ReturnType<typeof vi.fn> };
let toastServiceStub: { danger: ReturnType<typeof vi.fn> }; let toastServiceStub: { danger: ReturnType<typeof vi.fn> };
let globalLoadingServiceStub: {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
let routeQueryParamMap: ReturnType<typeof convertToParamMap>; let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>; let authUserState: ReturnType<typeof signal>;
let tenantState: ReturnType< let tenantState: ReturnType<
@@ -68,7 +73,6 @@ describe('CheckoutPageComponent payment validation', () => {
updateItemQuantity: vi.fn(), updateItemQuantity: vi.fn(),
updateItemVariant: vi.fn(), updateItemVariant: vi.fn(),
removeItem: vi.fn(), removeItem: vi.fn(),
prepareItemEditing: vi.fn(),
cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }), cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }),
generatePaymentIntent: vi.fn().mockResolvedValue({ generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' }, qr_data: { qr_code: 'qr-value' },
@@ -101,6 +105,7 @@ describe('CheckoutPageComponent payment validation', () => {
}); });
routerStub = { navigate: vi.fn() }; routerStub = { navigate: vi.fn() };
toastServiceStub = { danger: vi.fn() }; toastServiceStub = { danger: vi.fn() };
globalLoadingServiceStub = { start: vi.fn(), stop: vi.fn() };
routeQueryParamMap = convertToParamMap({}); routeQueryParamMap = convertToParamMap({});
authUserState = signal(null); authUserState = signal(null);
tenantState = signal({ tenantState = signal({
@@ -122,6 +127,7 @@ describe('CheckoutPageComponent payment validation', () => {
{ provide: CartService, useValue: cartServiceStub }, { provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: tenantState } }, { provide: TenantService, useValue: { tenant: tenantState } },
{ provide: AuthService, useValue: { user: authUserState } }, { provide: AuthService, useValue: { user: authUserState } },
{ provide: GlobalLoadingService, useValue: globalLoadingServiceStub },
{ provide: ToastService, useValue: toastServiceStub }, { provide: ToastService, useValue: toastServiceStub },
{ {
provide: ActivatedRoute, provide: ActivatedRoute,
@@ -477,83 +483,30 @@ describe('CheckoutPageComponent payment validation', () => {
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
}); });
it('updates a purchase item while editing and refreshes checkout totals', async () => { it('shows the API error in a toast when restoring the cart fails', async () => {
const updatedPurchase = { const message = 'La compra venció. Iniciá una nueva compra.';
id: 25, checkoutServiceStub.cancelPurchase.mockRejectedValue({
items: [], error: { code: 'purchase.expired', message },
subtotal: '300.00',
total: '300.00',
};
checkoutServiceStub.updateItemQuantity.mockResolvedValue(updatedPurchase);
const { component } = createComponent();
component.isEditingItems.set(true);
await component.onPurchaseItemQuantityChange({
item: {
cartItemId: 91,
imageUrl: null,
product: 'Remera',
originalPrice: null,
discountedPrice: 100,
discountPercentage: null,
attributes: [],
quantity: 2,
},
quantity: 3,
}); });
const { component } = createComponent();
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith( await component.onModifyPurchase();
'tenant-test',
25, expect(toastServiceStub.danger).toHaveBeenCalledWith(message);
91, expect(routerStub.navigate).not.toHaveBeenCalled();
3, expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
null, expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
'purchase', expect(component.isRestoringCart()).toBe(false);
});
it('restores the previous cart and opens it when Modificar is clicked', async () => {
let finishNavigation!: (navigated: boolean) => void;
routerStub.navigate.mockReturnValue(
new Promise<boolean>((resolve) => {
finishNavigation = resolve;
}),
); );
expect(component.createdPurchase()).toBe(updatedPurchase);
expect(component.isUpdatingItem()).toBe(false);
});
it('keeps the payment step selected while editing and regenerates payment afterward', async () => {
const editablePurchase = {
id: 25,
status: 'created',
items: [],
subtotal: '100.00',
total: '100.00',
};
checkoutServiceStub.prepareItemEditing.mockResolvedValue(editablePurchase);
const { component } = createComponent(); const { component } = createComponent();
component.stepper = { currentStepIndex: signal(1) };
const selectPaymentMethod = vi
.spyOn(component, 'selectPaymentMethod')
.mockResolvedValue(undefined);
await component.onEditingItemsChange(true);
expect(checkoutServiceStub.prepareItemEditing).toHaveBeenCalledWith('tenant-test', 25);
expect(component.isEditingItems()).toBe(true);
expect(component.createdPurchase()).toBe(editablePurchase);
await component.onEditingItemsChange(false);
expect(component.stepper.currentStepIndex()).toBe(1);
expect(selectPaymentMethod).toHaveBeenCalledWith('qr');
expect(component.isEditingItems()).toBe(false);
});
it('shows Modificar and returns to the home cart when checkout editing is disabled', async () => {
tenantState.set({
codigo: 'tenant-test',
checkout_editing_policy: {
code: 'disabled',
allow_modify: false,
allow_delete: false,
allow_update_quantity: false,
allow_update_variant: false,
},
});
const { fixture, component } = createComponent();
component.createdPurchase.set({ component.createdPurchase.set({
id: 25, id: 25,
status: 'created', status: 'created',
@@ -577,23 +530,28 @@ describe('CheckoutPageComponent payment validation', () => {
subtotal: '200.00', subtotal: '200.00',
total: '200.00', total: '200.00',
}); });
fixture.detectChanges(); const modification = component.onModifyPurchase();
await Promise.resolve();
await Promise.resolve();
const modifyButton = (fixture.nativeElement as HTMLElement).querySelector('.cart-edit-btn'); expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(modifyButton?.textContent?.trim()).toBe('Modificar'); expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect( expect(globalLoadingServiceStub.stop).not.toHaveBeenCalled();
(fixture.nativeElement as HTMLElement).querySelector('app-quantity-selector'), expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
).toBeNull(); expect(component.createdPurchaseId()).toBeNull();
expect(component.createdPurchase()).toBeNull();
await component.onEditingItemsChange(true);
expect(component.canUpdateCartQuantity()).toBe(false);
expect(component.canModifyCart()).toBe(false);
expect(component.isEditingItems()).toBe(false);
expect(checkoutServiceStub.prepareItemEditing).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/'], { expect(routerStub.navigate).toHaveBeenCalledWith(['/'], {
queryParams: { openCart: true }, queryParams: { openCart: true },
}); });
finishNavigation(true);
await modification;
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
await component.canDeactivate();
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
}); });
it('updates customer data on the existing purchase before payment', async () => { it('updates customer data on the existing purchase before payment', async () => {

View File

@@ -21,8 +21,8 @@ import {
} from '../../../../core/services/checkout.service'; } from '../../../../core/services/checkout.service';
import { AuthService } from '../../../../core/services/auth/auth.service'; import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { BankAccount } from '../../../../core/services/tenant.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component'; import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component'; import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
@@ -66,6 +66,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly checkoutService = inject(CheckoutService); private readonly checkoutService = inject(CheckoutService);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly globalLoadingService = inject(GlobalLoadingService);
private readonly toastService = inject(ToastService);
private readonly qrPollingIntervalMs = 5_000; private readonly qrPollingIntervalMs = 5_000;
private readonly toastService = inject(ToastService); private readonly toastService = inject(ToastService);
private readonly qrPollingMaxAttempts = 9; private readonly qrPollingMaxAttempts = 9;
@@ -93,19 +95,6 @@ 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 isLoadingPurchase = signal(true);
protected readonly checkoutStepIndex = signal(0); protected readonly checkoutStepIndex = signal(0);
protected readonly canUpdateCartQuantity = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_update_quantity ?? false,
);
protected readonly canUpdateCartVariant = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_update_variant ?? false,
);
protected readonly canDeleteCartItems = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_delete ?? false,
);
protected readonly canModifyCart = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_modify ?? false,
);
protected readonly cartSubtotal = computed(() => { protected readonly cartSubtotal = computed(() => {
const purchase = this.createdPurchase(); const purchase = this.createdPurchase();
return purchase ? parseFloat(purchase.subtotal) : 0; return purchase ? parseFloat(purchase.subtotal) : 0;
@@ -134,9 +123,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly transferDni = signal<string>(''); protected readonly transferDni = signal<string>('');
protected readonly isUpdatingPurchase = signal(false); protected readonly isUpdatingPurchase = signal(false);
protected readonly isEditingItems = signal(false); protected readonly isRestoringCart = signal(false);
protected readonly isUpdatingItem = signal(false);
protected readonly isPreparingItemEdit = signal(false);
protected readonly createdPurchaseId = signal<number | null>(null); protected readonly createdPurchaseId = signal<number | null>(null);
protected readonly isGeneratingIntent = signal(false); protected readonly isGeneratingIntent = signal(false);
protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent()); protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent());
@@ -202,187 +189,39 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}; };
} }
protected async onEditingItemsChange(editing: boolean): Promise<void> { protected async onModifyPurchase(): Promise<void> {
if (this.isUpdatingItem() || this.isPreparingItemEdit()) { if (this.isRestoringCart()) {
return; return;
} }
<<<<<<< HEAD
if (editing && !this.canModifyCart()) { if (editing && !this.canModifyCart()) {
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
return;
}
this.isPreparingItemEdit.set(true);
try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
await firstValueFrom(this.cartService.loadCart());
await this.router.navigate(['/'], {
queryParams: { openCart: true },
});
} catch (error) {
this.handleCheckoutError(error, 'No se pudo restaurar el carrito para editarlo.');
} finally {
this.isPreparingItemEdit.set(false);
}
return; return;
} }
if (!editing) { this.isRestoringCart.set(true);
this.isEditingItems.set(false); this.globalLoadingService.start();
if (this.stepper?.currentStepIndex() === 1) {
void this.selectPaymentMethod(this.selectedPaymentMethod());
}
return;
}
this.isEditingItems.set(true);
this.stopQrPolling();
this.stopTransferPolling();
this.qrData.set(null);
this.qrPaymentStatus.set('idle');
this.transferAccount.set(null);
this.transferValidationStatus.set('idle');
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
this.isEditingItems.set(false);
return;
}
this.isPreparingItemEdit.set(true);
try { try {
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId); await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
this.createdPurchase.set(purchase); this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
await firstValueFrom(this.cartService.loadCart());
this.navigationStarted = true;
await this.router.navigate(['/'], {
queryParams: { openCart: true },
});
} catch (error) { } catch (error) {
this.handleCheckoutError(error, 'No se pudo preparar la compra para editarla.'); console.error('Failed to restore cart for editing:', error);
this.isEditingItems.set(false); this.showRequestError(error, 'No se pudo recuperar el carrito para modificar la compra.');
} finally { } finally {
this.isPreparingItemEdit.set(false); this.globalLoadingService.stop();
this.isRestoringCart.set(false);
>>>>>>> feature/restore-cart-on-modify
} }
} }
protected async onPurchaseItemQuantityChange(event: {
item: CartItemMock;
quantity: number;
}): Promise<void> {
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
const itemId = event.item.cartItemId;
if (
!this.canUpdateCartQuantity() ||
!tenant ||
!purchaseId ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
this.isUpdatingItem.set(true);
try {
const purchase = await this.checkoutService.updateItemQuantity(
tenant.codigo,
purchaseId,
itemId,
event.quantity,
this.createdPurchase()?.cart_id ?? null,
this.createdPurchase()?.items_source ?? 'purchase',
);
this.createdPurchase.set(purchase);
} catch (error) {
this.handleCheckoutError(error, 'No se pudo actualizar la cantidad del producto.');
} finally {
this.isUpdatingItem.set(false);
}
}
protected async onPurchaseItemVariantChange(event: {
item: CartItemMock;
variantId: number;
}): Promise<void> {
const tenant = this.tenantService.tenant();
const purchase = this.createdPurchase();
const itemId = event.item.cartItemId;
if (
!this.canUpdateCartVariant() ||
!tenant ||
!purchase ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
this.isUpdatingItem.set(true);
try {
this.createdPurchase.set(
await this.checkoutService.updateItemVariant(
tenant.codigo,
purchase.id,
itemId,
event.variantId,
event.item.quantity,
purchase.cart_id,
purchase.items_source,
),
);
} catch (error) {
this.handleCheckoutError(error, 'No se pudo actualizar la variante del producto.');
} finally {
this.isUpdatingItem.set(false);
}
}
protected async onPurchaseItemRemove(event: { item: CartItemMock }): Promise<void> {
const tenant = this.tenantService.tenant();
const purchase = this.createdPurchase();
const itemId = event.item.cartItemId;
if (
!this.canDeleteCartItems() ||
!tenant ||
!purchase ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
this.isUpdatingItem.set(true);
try {
this.createdPurchase.set(
await this.checkoutService.removeItem(
tenant.codigo,
purchase.id,
itemId,
purchase.cart_id,
purchase.items_source,
),
);
} catch (error) {
this.handleCheckoutError(error, 'No se pudo eliminar el producto de la compra.');
} finally {
this.isUpdatingItem.set(false);
}
}
protected async onStep1Continue(): Promise<void> { protected async onStep1Continue(): Promise<void> {
if (this.form.invalid || this.isUpdatingPurchase() || this.isEditingItems()) return; if (this.form.invalid || this.isUpdatingPurchase()) return;
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
@@ -405,7 +244,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
// Auto trigger intent for default option // Auto trigger intent for default option
void this.selectPaymentMethod(this.selectedPaymentMethod()); void this.selectPaymentMethod(this.selectedPaymentMethod());
} catch (error) { } catch (error) {
this.handleCheckoutError(error, 'No se pudieron actualizar los datos de la compra.'); console.error('Failed to create purchase:', error);
this.showRequestError(error, 'No se pudieron actualizar los datos de la compra.');
} finally { } finally {
this.isUpdatingPurchase.set(false); this.isUpdatingPurchase.set(false);
} }
@@ -431,13 +271,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
await firstValueFrom(this.cartService.loadCart()); await firstValueFrom(this.cartService.loadCart());
} catch (error) { } catch (error) {
console.error('Failed to load the active cart after leaving checkout:', error); console.error('Failed to load the active cart after leaving checkout:', error);
this.showRequestError(error, 'No se pudo cargar el carrito.');
} }
return true; return true;
} }
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> { protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
if (this.navigationStarted || this.isEditingItems()) { if (this.navigationStarted) {
return; return;
} }
@@ -481,17 +322,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.startQrPolling(); this.startQrPolling();
} }
} catch (error) { } catch (error) {
this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de pago.'); console.error('Failed to generate payment intent:', error);
this.showRequestError(error, 'No se pudo generar el pago.');
} finally { } finally {
this.isGeneratingIntent.set(false); this.isGeneratingIntent.set(false);
} }
} }
protected async generateTransferIntent(dni: string): Promise<void> { protected async generateTransferIntent(dni: string): Promise<void> {
if (this.isEditingItems()) {
return;
}
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
@@ -518,7 +356,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}); });
} }
} catch (error) { } catch (error) {
this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de transferencia.'); console.error('Failed to generate transfer payment intent:', error);
this.showRequestError(error, 'No se pudo generar el pago por transferencia.');
} finally { } finally {
this.isGeneratingIntent.set(false); this.isGeneratingIntent.set(false);
} }
@@ -549,7 +388,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
!purchaseId || !purchaseId ||
!tenant || !tenant ||
this.navigationStarted || this.navigationStarted ||
this.isEditingItems() ||
this.transferValidationStatus() === 'checking' this.transferValidationStatus() === 'checking'
) { ) {
return; return;
@@ -571,16 +409,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
if (purchase.status === 'paid') { if (purchase.status === 'paid') {
this.navigateToPurchaseStatus(purchaseId);
return;
}
this.scheduleTransferPoll(runId); this.scheduleTransferPoll(runId);
} catch (error) { console.error('Failed to submit transfer payment for review:', error);
const expired = this.handleCheckoutError( this.showRequestError(error, 'No se pudo enviar el pago para su validación.');
error,
'No se pudo enviar la compra a revisi\u00f3n.',
);
if (!expired && runId === this.transferPollingRunId) { if (!expired && runId === this.transferPollingRunId) {
this.transferValidationStatus.set('error'); this.transferValidationStatus.set('error');
@@ -811,57 +643,21 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
} catch (error) { } catch (error) {
console.error('Failed to load purchase:', error); console.error('Failed to load purchase:', error);
this.showRequestError(error, 'No se pudo cargar la compra.');
void this.router.navigate(['/']); void this.router.navigate(['/']);
} }
} }
private handleCheckoutError(error: unknown, fallbackMessage: string): boolean { private showRequestError(error: unknown, fallbackMessage: string): void {
if (!(error instanceof HttpErrorResponse)) { const payload =
this.toastService.danger(fallbackMessage); typeof error === 'object' && error !== null && 'error' in error
? (error as { error?: { message?: unknown } }).error
: undefined;
const message =
typeof payload?.message === 'string' && payload.message.trim()
? payload.message
: fallbackMessage;
return false; this.toastService.danger(message);
}
const response = error.error as ApiErrorResponse | null;
if (response?.code === 'purchase.expired' || this.hasExpiredPurchase()) {
this.navigationStarted = true;
this.stopQrPolling();
this.stopTransferPolling();
this.toastService.danger(
response?.code === 'purchase.expired' && response.message
? response.message
: 'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
);
void this.router.navigate(['/']);
return true;
}
const validationMessage = response?.errors
? Object.values(response.errors).flat().find(Boolean)
: undefined;
this.toastService.danger(validationMessage ?? response?.message ?? fallbackMessage);
return false;
}
private hasExpiredPurchase(): boolean {
const purchase = this.createdPurchase();
if (!purchase) {
return false;
}
if (purchase.status === 'expired') {
return true;
}
if (!purchase.expires_at) {
return false;
}
return Date.parse(purchase.expires_at) <= Date.now();
} }
} }

View File

@@ -9,17 +9,17 @@
@if ( @if (
allowModify() && allowModify() &&
(!readonly() || showModifyWhenReadonly()) && (!readonly() || showModifyWhenReadonly()) &&
requireEditingMode() && (requireEditingMode() || modifyAsAction()) &&
items().length > 0 items().length > 0
) { ) {
<button <button
class="btn btn-link p-0 border-0 cart-edit-btn" class="btn btn-link p-0 border-0 cart-edit-btn"
type="button" type="button"
[attr.aria-pressed]="editing()" [attr.aria-pressed]="modifyAsAction() ? null : editing()"
[disabled]="editingDisabled()" [disabled]="editingDisabled()"
(click)="toggleEditing()" (click)="toggleEditing()"
> >
{{ editing() ? 'Listo' : 'Modificar' }} {{ !modifyAsAction() && editing() ? 'Listo' : 'Modificar' }}
</button> </button>
} }

View File

@@ -367,6 +367,57 @@ describe('CartComponent', () => {
expect(editingChange).toHaveBeenLastCalledWith(false); expect(editingChange).toHaveBeenLastCalledWith(false);
}); });
it('emits Modificar as an action without toggling to Listo', async () => {
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
removeItem: vi.fn(),
},
},
{ provide: ModalService, useValue: {} },
{
provide: ToastService,
useValue: { success: vi.fn(), info: vi.fn(), danger: vi.fn() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [
{
cartItemId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1,
},
]);
fixture.componentRef.setInput('readonly', true);
fixture.componentRef.setInput('showModifyWhenReadonly', true);
fixture.componentRef.setInput('modifyAsAction', true);
const modify = vi.fn();
fixture.componentInstance.modify.subscribe(modify);
fixture.detectChanges();
const modifyButton = fixture.debugElement.query(By.css('.cart-edit-btn'));
expect(modifyButton.nativeElement.textContent.trim()).toBe('Modificar');
modifyButton.nativeElement.click();
fixture.detectChanges();
expect(modify).toHaveBeenCalledOnce();
expect(modifyButton.nativeElement.textContent.trim()).toBe('Modificar');
expect(fixture.componentInstance.editing()).toBe(false);
});
it('allows editing directly when the optional Modificar toggle is disabled', async () => { it('allows editing directly when the optional Modificar toggle is disabled', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [CartComponent], imports: [CartComponent],

View File

@@ -56,6 +56,7 @@ export class CartComponent {
readonly allowUpdateQuantity = input<boolean>(true); readonly allowUpdateQuantity = input<boolean>(true);
readonly allowModify = input<boolean>(true); readonly allowModify = input<boolean>(true);
readonly showModifyWhenReadonly = input<boolean>(false); readonly showModifyWhenReadonly = input<boolean>(false);
readonly modifyAsAction = input<boolean>(false);
readonly requireEditingMode = input<boolean>(false); readonly requireEditingMode = input<boolean>(false);
readonly allowUpdateVariant = input<boolean>(true); readonly allowUpdateVariant = input<boolean>(true);
readonly allowDelete = input<boolean>(true); readonly allowDelete = input<boolean>(true);
@@ -66,6 +67,7 @@ export class CartComponent {
readonly editing = model<boolean>(false); readonly editing = model<boolean>(false);
readonly closed = output<void>(); readonly closed = output<void>();
readonly modify = output<void>();
readonly itemQuantityChange = output<{ readonly itemQuantityChange = output<{
item: CartItemMock; item: CartItemMock;
index: number; index: number;
@@ -257,6 +259,11 @@ export class CartComponent {
return; return;
} }
if (this.modifyAsAction()) {
this.modify.emit();
return;
}
const editing = !this.editing(); const editing = !this.editing();
this.editing.set(editing); this.editing.set(editing);
} }