Merge branch 'feature/restore-cart-on-modify' into develop
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user