diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index f97eea4..5e61a0d 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -3,6 +3,7 @@ import { firstValueFrom } from 'rxjs'; import { environment } from '../../../environments/environment'; import { BaseApiService } from './base-api.service'; +import { CartItemVariant } from './cart/cart.interface'; export interface UpdatePurchaseCustomerPayload { dni: string; @@ -73,6 +74,7 @@ export interface PurchaseDetailItemResponse { line_total: string; source_catalog_item_id: number | null; source_variant_id: number | null; + variants?: CartItemVariant[]; item_details: { nombre: string; descripcion: string | null; @@ -95,7 +97,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse { telefono: string | null; nombre_apellido: string | null; email: string | null; - items_source: 'purchase'; + items_source: 'purchase' | 'cart'; items: PurchaseDetailItemResponse[]; tickets_count?: number; has_generated_tickets?: boolean; @@ -174,7 +176,20 @@ export class CheckoutService extends BaseApiService { purchaseId: number, itemId: number, quantity: number, + cartId: number | null = null, + itemsSource: 'purchase' | 'cart' = 'purchase', ): Promise { + if (itemsSource === 'cart' && cartId !== null) { + await firstValueFrom( + this.http.patch( + `${environment.url}tenants/${tenantCode}/checkout-carts/${cartId}/items/${itemId}`, + { cantidad: quantity }, + ), + ); + + return this.getPurchase(tenantCode, purchaseId); + } + const response = await firstValueFrom( this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`, @@ -190,6 +205,51 @@ export class CheckoutService extends BaseApiService { return purchase; } + async updateItemVariant( + tenantCode: string, + purchaseId: number, + itemId: number, + variantId: number, + quantity: number, + cartId: number | null = null, + itemsSource: 'purchase' | 'cart' = 'purchase', + ): Promise { + if (itemsSource === 'cart' && cartId !== null) { + await firstValueFrom( + this.http.patch( + `${environment.url}tenants/${tenantCode}/checkout-carts/${cartId}/items/${itemId}`, + { cantidad: quantity, variant_id: variantId }, + ), + ); + } else { + await firstValueFrom( + this.http.patch( + `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`, + { quantity, variant_id: variantId }, + ), + ); + } + + return this.getPurchase(tenantCode, purchaseId); + } + + async removeItem( + tenantCode: string, + purchaseId: number, + itemId: number, + cartId: number | null = null, + itemsSource: 'purchase' | 'cart' = 'purchase', + ): Promise { + const url = + itemsSource === 'cart' && cartId !== null + ? `${environment.url}tenants/${tenantCode}/checkout-carts/${cartId}/items/${itemId}` + : `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`; + + await firstValueFrom(this.http.delete(url)); + + return this.getPurchase(tenantCode, purchaseId); + } + async prepareItemEditing( tenantCode: string, purchaseId: number, diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 68399a7..21c1c11 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -138,6 +138,7 @@ export interface Tenant { display_seach_bar?: boolean; display_cart?: boolean; cart_editing_policy?: CartEditingPolicy; + checkout_editing_policy?: CartEditingPolicy; display_cart_item_images?: boolean; social_media?: SocialMedia[]; menues?: Menu[]; diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.html b/src/app/features/store/pages/checkout-page/checkout-page.component.html index 7ba1bd4..8fb9344 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.html +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.html @@ -59,18 +59,22 @@ [discount]="cartDiscount()" [total]="cartTotal()" [readonly]="!canModifyCart()" - [allowModify]="canModifyCart()" + [allowModify]="true" + [showModifyWhenReadonly]="true" [allowUpdateQuantity]="canUpdateCartQuantity()" - [requireEditingMode]=" - createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment' - " - [allowDelete]="false" + [allowUpdateVariant]="canUpdateCartVariant()" + [requireEditingMode]="true" + [allowDelete]="canDeleteCartItems()" [persistQuantityChanges]="false" + [persistVariantChanges]="false" + [persistDeleteChanges]="false" [editing]="isEditingItems()" [editingDisabled]="isUpdatingItem() || isPreparingItemEdit()" backgroundColor="transparent" (editingChange)="onEditingItemsChange($event)" (itemQuantityChange)="onPurchaseItemQuantityChange($event)" + (itemVariantChange)="onPurchaseItemVariantChange($event)" + (itemRemove)="onPurchaseItemRemove($event)" /> diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts index 8b99dc7..5743880 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts @@ -18,6 +18,8 @@ describe('CheckoutPageComponent payment validation', () => { startCheckout: ReturnType; updateCustomerData: ReturnType; updateItemQuantity: ReturnType; + updateItemVariant: ReturnType; + removeItem: ReturnType; prepareItemEditing: ReturnType; cancelPurchase: ReturnType; generatePaymentIntent: ReturnType; @@ -36,7 +38,7 @@ describe('CheckoutPageComponent payment validation', () => { let tenantState: ReturnType< typeof signal<{ codigo: string; - cart_editing_policy?: CartEditingPolicy; + checkout_editing_policy?: CartEditingPolicy; }> >; @@ -60,6 +62,8 @@ describe('CheckoutPageComponent payment validation', () => { }), updateCustomerData: vi.fn(), updateItemQuantity: vi.fn(), + updateItemVariant: vi.fn(), + removeItem: vi.fn(), prepareItemEditing: vi.fn(), cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }), generatePaymentIntent: vi.fn().mockResolvedValue({ @@ -95,7 +99,7 @@ describe('CheckoutPageComponent payment validation', () => { authUserState = signal(null); tenantState = signal({ codigo: 'tenant-test', - cart_editing_policy: { + checkout_editing_policy: { code: 'full', allow_modify: true, allow_delete: true, @@ -448,7 +452,14 @@ describe('CheckoutPageComponent payment validation', () => { quantity: 3, }); - expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith('tenant-test', 25, 91, 3); + expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith( + 'tenant-test', + 25, + 91, + 3, + null, + 'purchase', + ); expect(component.createdPurchase()).toBe(updatedPurchase); expect(component.isUpdatingItem()).toBe(false); }); @@ -481,10 +492,10 @@ describe('CheckoutPageComponent payment validation', () => { expect(component.isEditingItems()).toBe(false); }); - it('does not allow item editing when the tenant disables cart editing', async () => { + it('shows Modificar and returns to the home cart when checkout editing is disabled', async () => { tenantState.set({ codigo: 'tenant-test', - cart_editing_policy: { + checkout_editing_policy: { code: 'disabled', allow_modify: false, allow_delete: false, @@ -492,7 +503,37 @@ describe('CheckoutPageComponent payment validation', () => { allow_update_variant: false, }, }); - const { component } = createComponent(); + const { fixture, component } = createComponent(); + component.createdPurchase.set({ + id: 25, + status: 'created', + items: [ + { + id: 91, + quantity: 2, + unit_price: '100.00', + line_total: '200.00', + source_catalog_item_id: 8, + source_variant_id: null, + item_details: { + nombre: 'Remera', + descripcion: null, + slug: 'remera', + imagen: null, + attributes: [], + }, + }, + ], + subtotal: '200.00', + total: '200.00', + }); + fixture.detectChanges(); + + 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); @@ -500,6 +541,9 @@ describe('CheckoutPageComponent payment validation', () => { expect(component.canModifyCart()).toBe(false); expect(component.isEditingItems()).toBe(false); expect(checkoutServiceStub.prepareItemEditing).not.toHaveBeenCalled(); + expect(routerStub.navigate).toHaveBeenCalledWith(['/'], { + queryParams: { openCart: true }, + }); }); it('updates customer data on the existing purchase before payment', async () => { diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.ts index 6adfaff..01336b6 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.ts @@ -80,10 +80,16 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { protected readonly isLoadingPurchase = signal(true); protected readonly checkoutStepIndex = signal(0); protected readonly canUpdateCartQuantity = computed( - () => this.tenantService.tenant()?.cart_editing_policy?.allow_update_quantity ?? false, + () => 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()?.cart_editing_policy?.allow_modify ?? false, + () => this.tenantService.tenant()?.checkout_editing_policy?.allow_modify ?? false, ); protected readonly cartSubtotal = computed(() => { @@ -176,6 +182,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { value: attribute.value === null ? '' : String(attribute.value), })), quantity: item.quantity, + variantId: item.source_variant_id, + variants: item.variants, }; } @@ -185,6 +193,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } if (editing && !this.canModifyCart()) { + await this.router.navigate(['/'], { + queryParams: { openCart: true }, + }); return; } @@ -251,6 +262,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { purchaseId, itemId, event.quantity, + this.createdPurchase()?.cart_id ?? null, + this.createdPurchase()?.items_source ?? 'purchase', ); this.createdPurchase.set(purchase); } catch (error) { @@ -260,6 +273,79 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } } + protected async onPurchaseItemVariantChange(event: { + item: CartItemMock; + variantId: number; + }): Promise { + 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) { + console.error('Failed to update purchase item variant:', error); + } finally { + this.isUpdatingItem.set(false); + } + } + + protected async onPurchaseItemRemove(event: { item: CartItemMock }): Promise { + 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) { + console.error('Failed to remove purchase item:', error); + } finally { + this.isUpdatingItem.set(false); + } + } + protected async onStep1Continue(): Promise { if (this.form.invalid || this.isUpdatingPurchase() || this.isEditingItems()) return; diff --git a/src/app/shared/components/cart/cart.component.html b/src/app/shared/components/cart/cart.component.html index 83c5d74..715f1de 100644 --- a/src/app/shared/components/cart/cart.component.html +++ b/src/app/shared/components/cart/cart.component.html @@ -6,7 +6,12 @@

{{ title() }}

- @if (!readonly() && allowModify() && requireEditingMode() && items().length > 0) { + @if ( + allowModify() && + (!readonly() || showModifyWhenReadonly()) && + requireEditingMode() && + items().length > 0 + ) {