Merge branch 'feature/restore-cart-on-modify' into develop
This commit is contained in:
@@ -5,12 +5,7 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="checkout-page">
|
||||
<div
|
||||
class="checkout-page__stepper-col"
|
||||
[class.checkout-page__stepper-col--editing]="isEditingItems()"
|
||||
[attr.aria-hidden]="isEditingItems()"
|
||||
[attr.inert]="isEditingItems() ? '' : null"
|
||||
>
|
||||
<div class="checkout-page__stepper-col">
|
||||
<app-stepper #stepper [initialStepIndex]="checkoutStepIndex()">
|
||||
<app-step label="Datos" [isValid]="isStep1Valid()">
|
||||
<app-checkout-data-step
|
||||
@@ -45,12 +40,6 @@
|
||||
</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 class="checkout-page__cart-col">
|
||||
<app-cart
|
||||
title="COMPRA"
|
||||
@@ -58,23 +47,13 @@
|
||||
[subtotal]="cartSubtotal()"
|
||||
[discount]="cartDiscount()"
|
||||
[total]="cartTotal()"
|
||||
[readonly]="!canModifyCart()"
|
||||
[readonly]="true"
|
||||
[allowModify]="true"
|
||||
[showModifyWhenReadonly]="true"
|
||||
[allowUpdateQuantity]="canUpdateCartQuantity()"
|
||||
[allowUpdateVariant]="canUpdateCartVariant()"
|
||||
[requireEditingMode]="true"
|
||||
[allowDelete]="canDeleteCartItems()"
|
||||
[persistQuantityChanges]="false"
|
||||
[persistVariantChanges]="false"
|
||||
[persistDeleteChanges]="false"
|
||||
[editing]="isEditingItems()"
|
||||
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
|
||||
[modifyAsAction]="true"
|
||||
[editingDisabled]="isRestoringCart()"
|
||||
backgroundColor="transparent"
|
||||
(editingChange)="onEditingItemsChange($event)"
|
||||
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
|
||||
(itemVariantChange)="onPurchaseItemVariantChange($event)"
|
||||
(itemRemove)="onPurchaseItemRemove($event)"
|
||||
(modify)="onModifyPurchase()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.checkout-page__stepper-col,
|
||||
.checkout-page__editing-notice {
|
||||
.checkout-page__stepper-col {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
@@ -21,27 +20,6 @@
|
||||
min-width: 0;
|
||||
border-radius: 4px;
|
||||
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 {
|
||||
|
||||
@@ -10,9 +10,11 @@ import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||
import { CheckoutService } from '../../../../core/services/checkout.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 { ToastService } from '../../../../core/services/toast.service';
|
||||
import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
import { CheckoutPageComponent } from './checkout-page.component';
|
||||
|
||||
describe('CheckoutPageComponent payment validation', () => {
|
||||
@@ -22,7 +24,6 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||
updateItemQuantity: ReturnType<typeof vi.fn>;
|
||||
updateItemVariant: ReturnType<typeof vi.fn>;
|
||||
removeItem: ReturnType<typeof vi.fn>;
|
||||
prepareItemEditing: ReturnType<typeof vi.fn>;
|
||||
cancelPurchase: ReturnType<typeof vi.fn>;
|
||||
generatePaymentIntent: 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 toastServiceStub: { danger: ReturnType<typeof vi.fn> };
|
||||
let globalLoadingServiceStub: {
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
|
||||
let authUserState: ReturnType<typeof signal>;
|
||||
let tenantState: ReturnType<
|
||||
@@ -68,7 +73,6 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||
updateItemQuantity: vi.fn(),
|
||||
updateItemVariant: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
prepareItemEditing: vi.fn(),
|
||||
cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }),
|
||||
generatePaymentIntent: vi.fn().mockResolvedValue({
|
||||
qr_data: { qr_code: 'qr-value' },
|
||||
@@ -101,6 +105,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||
});
|
||||
routerStub = { navigate: vi.fn() };
|
||||
toastServiceStub = { danger: vi.fn() };
|
||||
globalLoadingServiceStub = { start: vi.fn(), stop: vi.fn() };
|
||||
routeQueryParamMap = convertToParamMap({});
|
||||
authUserState = signal(null);
|
||||
tenantState = signal({
|
||||
@@ -122,6 +127,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||
{ provide: CartService, useValue: cartServiceStub },
|
||||
{ provide: TenantService, useValue: { tenant: tenantState } },
|
||||
{ provide: AuthService, useValue: { user: authUserState } },
|
||||
{ provide: GlobalLoadingService, useValue: globalLoadingServiceStub },
|
||||
{ provide: ToastService, useValue: toastServiceStub },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
@@ -477,83 +483,30 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
|
||||
});
|
||||
|
||||
it('updates a purchase item while editing and refreshes checkout totals', async () => {
|
||||
const updatedPurchase = {
|
||||
id: 25,
|
||||
items: [],
|
||||
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,
|
||||
it('shows the API error in a toast when restoring the cart fails', async () => {
|
||||
const message = 'La compra venció. Iniciá una nueva compra.';
|
||||
checkoutServiceStub.cancelPurchase.mockRejectedValue({
|
||||
error: { code: 'purchase.expired', message },
|
||||
});
|
||||
const { component } = createComponent();
|
||||
|
||||
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith(
|
||||
'tenant-test',
|
||||
25,
|
||||
91,
|
||||
3,
|
||||
null,
|
||||
'purchase',
|
||||
await component.onModifyPurchase();
|
||||
|
||||
expect(toastServiceStub.danger).toHaveBeenCalledWith(message);
|
||||
expect(routerStub.navigate).not.toHaveBeenCalled();
|
||||
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
|
||||
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
|
||||
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();
|
||||
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({
|
||||
id: 25,
|
||||
status: 'created',
|
||||
@@ -577,23 +530,28 @@ describe('CheckoutPageComponent payment validation', () => {
|
||||
subtotal: '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(modifyButton?.textContent?.trim()).toBe('Modificar');
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector('app-quantity-selector'),
|
||||
).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(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
|
||||
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
|
||||
expect(globalLoadingServiceStub.stop).not.toHaveBeenCalled();
|
||||
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
|
||||
expect(component.createdPurchaseId()).toBeNull();
|
||||
expect(component.createdPurchase()).toBeNull();
|
||||
expect(routerStub.navigate).toHaveBeenCalledWith(['/'], {
|
||||
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 () => {
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
} from '../../../../core/services/checkout.service';
|
||||
import { AuthService } from '../../../../core/services/auth/auth.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 { BankAccount } from '../../../../core/services/tenant.interface';
|
||||
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
|
||||
import { StepComponent } from '../../../../shared/components/stepper/step.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 authService = inject(AuthService);
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly globalLoadingService = inject(GlobalLoadingService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly qrPollingIntervalMs = 5_000;
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly qrPollingMaxAttempts = 9;
|
||||
@@ -93,19 +95,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
|
||||
protected readonly isLoadingPurchase = signal(true);
|
||||
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(() => {
|
||||
const purchase = this.createdPurchase();
|
||||
return purchase ? parseFloat(purchase.subtotal) : 0;
|
||||
@@ -134,9 +123,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
protected readonly transferDni = signal<string>('');
|
||||
|
||||
protected readonly isUpdatingPurchase = signal(false);
|
||||
protected readonly isEditingItems = signal(false);
|
||||
protected readonly isUpdatingItem = signal(false);
|
||||
protected readonly isPreparingItemEdit = signal(false);
|
||||
protected readonly isRestoringCart = signal(false);
|
||||
protected readonly createdPurchaseId = signal<number | null>(null);
|
||||
protected readonly isGeneratingIntent = signal(false);
|
||||
protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent());
|
||||
@@ -202,187 +189,39 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
protected async onEditingItemsChange(editing: boolean): Promise<void> {
|
||||
if (this.isUpdatingItem() || this.isPreparingItemEdit()) {
|
||||
protected async onModifyPurchase(): Promise<void> {
|
||||
if (this.isRestoringCart()) {
|
||||
return;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
if (editing && !this.canModifyCart()) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
this.isEditingItems.set(false);
|
||||
|
||||
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);
|
||||
this.isRestoringCart.set(true);
|
||||
this.globalLoadingService.start();
|
||||
try {
|
||||
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
|
||||
this.createdPurchase.set(purchase);
|
||||
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
|
||||
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) {
|
||||
this.handleCheckoutError(error, 'No se pudo preparar la compra para editarla.');
|
||||
this.isEditingItems.set(false);
|
||||
console.error('Failed to restore cart for editing:', error);
|
||||
this.showRequestError(error, 'No se pudo recuperar el carrito para modificar la compra.');
|
||||
} 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> {
|
||||
if (this.form.invalid || this.isUpdatingPurchase() || this.isEditingItems()) return;
|
||||
if (this.form.invalid || this.isUpdatingPurchase()) return;
|
||||
|
||||
const tenant = this.tenantService.tenant();
|
||||
const purchaseId = this.createdPurchaseId();
|
||||
@@ -405,7 +244,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
// Auto trigger intent for default option
|
||||
void this.selectPaymentMethod(this.selectedPaymentMethod());
|
||||
} 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 {
|
||||
this.isUpdatingPurchase.set(false);
|
||||
}
|
||||
@@ -431,13 +271,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
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 true;
|
||||
}
|
||||
|
||||
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
|
||||
if (this.navigationStarted || this.isEditingItems()) {
|
||||
if (this.navigationStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -481,17 +322,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
this.startQrPolling();
|
||||
}
|
||||
} 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 {
|
||||
this.isGeneratingIntent.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async generateTransferIntent(dni: string): Promise<void> {
|
||||
if (this.isEditingItems()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const purchaseId = this.createdPurchaseId();
|
||||
const tenant = this.tenantService.tenant();
|
||||
|
||||
@@ -518,7 +356,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
} 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 {
|
||||
this.isGeneratingIntent.set(false);
|
||||
}
|
||||
@@ -549,7 +388,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
!purchaseId ||
|
||||
!tenant ||
|
||||
this.navigationStarted ||
|
||||
this.isEditingItems() ||
|
||||
this.transferValidationStatus() === 'checking'
|
||||
) {
|
||||
return;
|
||||
@@ -571,16 +409,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
if (purchase.status === 'paid') {
|
||||
this.navigateToPurchaseStatus(purchaseId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleTransferPoll(runId);
|
||||
} catch (error) {
|
||||
const expired = this.handleCheckoutError(
|
||||
error,
|
||||
'No se pudo enviar la compra a revisi\u00f3n.',
|
||||
);
|
||||
console.error('Failed to submit transfer payment for review:', error);
|
||||
this.showRequestError(error, 'No se pudo enviar el pago para su validación.');
|
||||
|
||||
if (!expired && runId === this.transferPollingRunId) {
|
||||
this.transferValidationStatus.set('error');
|
||||
@@ -811,57 +643,21 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load purchase:', error);
|
||||
this.showRequestError(error, 'No se pudo cargar la compra.');
|
||||
void this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCheckoutError(error: unknown, fallbackMessage: string): boolean {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
this.toastService.danger(fallbackMessage);
|
||||
private showRequestError(error: unknown, fallbackMessage: string): void {
|
||||
const payload =
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
this.toastService.danger(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
@if (
|
||||
allowModify() &&
|
||||
(!readonly() || showModifyWhenReadonly()) &&
|
||||
requireEditingMode() &&
|
||||
(requireEditingMode() || modifyAsAction()) &&
|
||||
items().length > 0
|
||||
) {
|
||||
<button
|
||||
class="btn btn-link p-0 border-0 cart-edit-btn"
|
||||
type="button"
|
||||
[attr.aria-pressed]="editing()"
|
||||
[attr.aria-pressed]="modifyAsAction() ? null : editing()"
|
||||
[disabled]="editingDisabled()"
|
||||
(click)="toggleEditing()"
|
||||
>
|
||||
{{ editing() ? 'Listo' : 'Modificar' }}
|
||||
{{ !modifyAsAction() && editing() ? 'Listo' : 'Modificar' }}
|
||||
</button>
|
||||
}
|
||||
|
||||
|
||||
@@ -367,6 +367,57 @@ describe('CartComponent', () => {
|
||||
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 () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CartComponent],
|
||||
|
||||
@@ -56,6 +56,7 @@ export class CartComponent {
|
||||
readonly allowUpdateQuantity = input<boolean>(true);
|
||||
readonly allowModify = input<boolean>(true);
|
||||
readonly showModifyWhenReadonly = input<boolean>(false);
|
||||
readonly modifyAsAction = input<boolean>(false);
|
||||
readonly requireEditingMode = input<boolean>(false);
|
||||
readonly allowUpdateVariant = input<boolean>(true);
|
||||
readonly allowDelete = input<boolean>(true);
|
||||
@@ -66,6 +67,7 @@ export class CartComponent {
|
||||
readonly editing = model<boolean>(false);
|
||||
|
||||
readonly closed = output<void>();
|
||||
readonly modify = output<void>();
|
||||
readonly itemQuantityChange = output<{
|
||||
item: CartItemMock;
|
||||
index: number;
|
||||
@@ -257,6 +259,11 @@ export class CartComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.modifyAsAction()) {
|
||||
this.modify.emit();
|
||||
return;
|
||||
}
|
||||
|
||||
const editing = !this.editing();
|
||||
this.editing.set(editing);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user