From 2d0664a7c7c23c1794974e0d79eb7bae80ba2948 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 19 Aug 2026 10:16:06 -0300 Subject: [PATCH] feat(cart): implement cart editing policy with granular permissions --- .../store-layout/store-layout.component.html | 6 +- .../store-layout.component.spec.ts | 76 ++++++++++++++++++- .../store-layout/store-layout.component.ts | 15 +++- src/app/core/services/cart/cart.interface.ts | 1 + src/app/core/services/tenant.interface.ts | 12 ++- .../checkout-page.component.html | 8 +- .../checkout-page.component.spec.ts | 33 +++++++- .../checkout-page/checkout-page.component.ts | 11 ++- .../cart-item/cart-item.component.html | 4 +- .../cart-item/cart-item.component.ts | 6 +- .../components/cart/cart.component.html | 20 ++++- .../components/cart/cart.component.spec.ts | 9 ++- .../shared/components/cart/cart.component.ts | 16 ++-- 13 files changed, 185 insertions(+), 32 deletions(-) diff --git a/src/app/core/layout/store-layout/store-layout.component.html b/src/app/core/layout/store-layout/store-layout.component.html index 204c82d..ecbdf2b 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -29,7 +29,11 @@ [subtotal]="cartSubtotal()" [discount]="cartDiscount()" [total]="cartTotal()" - [readonly]="!cartEditingEnabled()" + [readonly]="!canModifyCart()" + [allowModify]="canModifyCart()" + [allowUpdateQuantity]="canUpdateCartQuantity()" + [allowUpdateVariant]="canUpdateCartVariant()" + [allowDelete]="canDeleteCartItems()" [backgroundColor]="'#ffffff'" (closed)="isCartOpen.set(false)" > diff --git a/src/app/core/layout/store-layout/store-layout.component.spec.ts b/src/app/core/layout/store-layout/store-layout.component.spec.ts index 1b6758d..385f439 100644 --- a/src/app/core/layout/store-layout/store-layout.component.spec.ts +++ b/src/app/core/layout/store-layout/store-layout.component.spec.ts @@ -32,6 +32,13 @@ const tenant: Tenant = { footer_logo: 'https://example.com/footer.png', header_bg_image: 'https://example.com/header-background.png', footer_bg_image: 'https://example.com/footer-background.png', + cart_editing_policy: { + code: 'full', + allow_modify: true, + allow_delete: true, + allow_update_quantity: true, + allow_update_variant: true, + }, categories: [], menues: [ { @@ -647,7 +654,16 @@ describe('StoreLayoutComponent', () => { }); it('hides quantity selectors when the tenant disables cart editing', () => { - tenantState.set({ ...tenant, cart_editing_enabled: false }); + tenantState.set({ + ...tenant, + cart_editing_policy: { + code: 'disabled', + allow_modify: false, + allow_delete: false, + allow_update_quantity: false, + allow_update_variant: false, + }, + }); cartState.set({ id: 1, tenant_codigo: tenant.codigo, @@ -676,4 +692,62 @@ describe('StoreLayoutComponent', () => { expect(cartItem.componentInstance.readonly()).toBe(true); expect(cartItem.nativeElement.querySelector('app-quantity-selector')).toBeNull(); }); + + it('shows variant selectors only for the full cart editing policy', () => { + const variantCart: Cart = { + id: 1, + tenant_codigo: tenant.codigo, + status: 'active', + subtotal: '100.00', + items: [ + { + id: 1, + cantidad: 1, + precio_unitario: '100.00', + catalog_item_id: 1, + variant_id: 10, + nombre: 'Producto', + imagen: null, + variant: { id: 10, precio: '100.00', stock_tecnico: 5, values: { talle: 'M' } }, + variants: [ + { id: 10, precio: '100.00', stock_tecnico: 5, values: { talle: 'M' } }, + { id: 11, precio: '100.00', stock_tecnico: 5, values: { talle: 'L' } }, + ], + }, + ], + }; + tenantState.set({ + ...tenant, + cart_editing_policy: { + code: 'quantity_and_remove', + allow_modify: true, + allow_delete: true, + allow_update_quantity: true, + allow_update_variant: false, + }, + }); + cartState.set(variantCart); + + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + (fixture.componentInstance as any).isCartOpen.set(true); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-variant-selector')).toBeNull(); + expect(fixture.nativeElement.querySelector('app-quantity-selector')).not.toBeNull(); + + tenantState.set({ + ...tenant, + cart_editing_policy: { + code: 'full', + allow_modify: true, + allow_delete: true, + allow_update_quantity: true, + allow_update_variant: true, + }, + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-variant-selector')).not.toBeNull(); + }); }); diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 47d17e6..416fe04 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -36,8 +36,18 @@ export class StoreLayoutComponent implements OnInit { protected readonly isCartOpen = signal(false); protected readonly isCreatingPurchase = signal(false); protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true); - protected readonly cartEditingEnabled = computed( - () => this.tenant()?.cart_editing_enabled ?? true, + protected readonly cartEditingPolicy = computed(() => this.tenant()?.cart_editing_policy ?? null); + protected readonly canModifyCart = computed( + () => this.cartEditingPolicy()?.allow_modify ?? false, + ); + protected readonly canDeleteCartItems = computed( + () => this.cartEditingPolicy()?.allow_delete ?? false, + ); + protected readonly canUpdateCartQuantity = computed( + () => this.cartEditingPolicy()?.allow_update_quantity ?? false, + ); + protected readonly canUpdateCartVariant = computed( + () => this.cartEditingPolicy()?.allow_update_variant ?? false, ); protected readonly cartSubtotal = computed(() => { @@ -92,6 +102,7 @@ export class StoreLayoutComponent implements OnInit { attributes, quantity: item.cantidad, variantId: item.variant_id, + variants: item.variants, }; } diff --git a/src/app/core/services/cart/cart.interface.ts b/src/app/core/services/cart/cart.interface.ts index e93a658..787ab95 100644 --- a/src/app/core/services/cart/cart.interface.ts +++ b/src/app/core/services/cart/cart.interface.ts @@ -24,6 +24,7 @@ export interface CartItem { nombre: string | null; imagen: string | null; variant: CartItemVariant | null; + variants?: CartItemVariant[]; } export interface Cart { diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 1ee741c..68399a7 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -1,5 +1,15 @@ import { ApiResponse } from './api-response.interface'; +export type CartEditingPolicyCode = 'disabled' | 'quantity_and_remove' | 'full'; + +export interface CartEditingPolicy { + code: CartEditingPolicyCode; + allow_modify: boolean; + allow_delete: boolean; + allow_update_quantity: boolean; + allow_update_variant: boolean; +} + export interface BankAccount { id: number; tenant_code: string; @@ -127,7 +137,7 @@ export interface Tenant { display_categories?: boolean; display_seach_bar?: boolean; display_cart?: boolean; - cart_editing_enabled?: boolean; + cart_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 33a2c30..7ba1bd4 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 @@ -58,11 +58,13 @@ [subtotal]="cartSubtotal()" [discount]="cartDiscount()" [total]="cartTotal()" - [readonly]="!cartEditingEnabled()" - [allowEditing]=" + [readonly]="!canModifyCart()" + [allowModify]="canModifyCart()" + [allowUpdateQuantity]="canUpdateCartQuantity()" + [requireEditingMode]=" createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment' " - [allowRemove]="false" + [allowDelete]="false" [persistQuantityChanges]="false" [editing]="isEditingItems()" [editingDisabled]="isUpdatingItem() || isPreparingItemEdit()" 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 61cf098..8b99dc7 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 @@ -10,6 +10,7 @@ import { CartService } from '../../../../core/services/cart/cart.service'; import { CheckoutService } from '../../../../core/services/checkout.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { TenantService } from '../../../../core/services/tenant.service'; +import { CartEditingPolicy } from '../../../../core/services/tenant.interface'; import { CheckoutPageComponent } from './checkout-page.component'; describe('CheckoutPageComponent payment validation', () => { @@ -32,7 +33,12 @@ describe('CheckoutPageComponent payment validation', () => { let routerStub: { navigate: ReturnType }; let routeQueryParamMap: ReturnType; let authUserState: ReturnType; - let tenantState: ReturnType>; + let tenantState: ReturnType< + typeof signal<{ + codigo: string; + cart_editing_policy?: CartEditingPolicy; + }> + >; beforeAll(() => { try { @@ -87,7 +93,16 @@ describe('CheckoutPageComponent payment validation', () => { routerStub = { navigate: vi.fn() }; routeQueryParamMap = convertToParamMap({}); authUserState = signal(null); - tenantState = signal({ codigo: 'tenant-test' }); + tenantState = signal({ + codigo: 'tenant-test', + cart_editing_policy: { + code: 'full', + allow_modify: true, + allow_delete: true, + allow_update_quantity: true, + allow_update_variant: true, + }, + }); await TestBed.configureTestingModule({ imports: [CheckoutPageComponent], @@ -467,12 +482,22 @@ describe('CheckoutPageComponent payment validation', () => { }); it('does not allow item editing when the tenant disables cart editing', async () => { - tenantState.set({ codigo: 'tenant-test', cart_editing_enabled: false }); + tenantState.set({ + codigo: 'tenant-test', + cart_editing_policy: { + code: 'disabled', + allow_modify: false, + allow_delete: false, + allow_update_quantity: false, + allow_update_variant: false, + }, + }); const { component } = createComponent(); await component.onEditingItemsChange(true); - expect(component.cartEditingEnabled()).toBe(false); + expect(component.canUpdateCartQuantity()).toBe(false); + expect(component.canModifyCart()).toBe(false); expect(component.isEditingItems()).toBe(false); expect(checkoutServiceStub.prepareItemEditing).not.toHaveBeenCalled(); }); 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 59c6286..6adfaff 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 @@ -79,8 +79,11 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { protected readonly createdPurchase = signal(null); protected readonly isLoadingPurchase = signal(true); protected readonly checkoutStepIndex = signal(0); - protected readonly cartEditingEnabled = computed( - () => this.tenantService.tenant()?.cart_editing_enabled ?? true, + protected readonly canUpdateCartQuantity = computed( + () => this.tenantService.tenant()?.cart_editing_policy?.allow_update_quantity ?? false, + ); + protected readonly canModifyCart = computed( + () => this.tenantService.tenant()?.cart_editing_policy?.allow_modify ?? false, ); protected readonly cartSubtotal = computed(() => { @@ -181,7 +184,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { return; } - if (editing && !this.cartEditingEnabled()) { + if (editing && !this.canModifyCart()) { return; } @@ -231,7 +234,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { const itemId = event.item.cartItemId; if ( - !this.cartEditingEnabled() || + !this.canUpdateCartQuantity() || !tenant || !purchaseId || !itemId || diff --git a/src/app/shared/components/cart-item/cart-item.component.html b/src/app/shared/components/cart-item/cart-item.component.html index e934db3..14e9c02 100644 --- a/src/app/shared/components/cart-item/cart-item.component.html +++ b/src/app/shared/components/cart-item/cart-item.component.html @@ -26,7 +26,7 @@ - @if (hasVariantSelectors() && !quantityDisabled()) { + @if (hasVariantSelectors() && !variantDisabled()) { - @if (!quantityDisabled() && showRemove()) { + @if (!removeDisabled() && allowDelete()) { } diff --git a/src/app/shared/components/cart-item/cart-item.component.ts b/src/app/shared/components/cart-item/cart-item.component.ts index 72b2449..d23dda6 100644 --- a/src/app/shared/components/cart-item/cart-item.component.ts +++ b/src/app/shared/components/cart-item/cart-item.component.ts @@ -31,7 +31,9 @@ export class CartItemComponent { readonly quantity = input(1); readonly readonly = input(false); readonly quantityDisabled = input(false); - readonly showRemove = input(true); + readonly variantDisabled = input(false); + readonly removeDisabled = input(false); + readonly allowDelete = input(true); readonly quantityChange = output(); readonly remove = output(); @@ -61,7 +63,7 @@ export class CartItemComponent { } protected onVariantChange(variant: unknown): void { - if (!this.quantityDisabled() && typeof variant === 'number') { + if (!this.variantDisabled() && typeof variant === 'number') { this.variantChange.emit(variant); } } diff --git a/src/app/shared/components/cart/cart.component.html b/src/app/shared/components/cart/cart.component.html index 6249c7a..0f5da71 100644 --- a/src/app/shared/components/cart/cart.component.html +++ b/src/app/shared/components/cart/cart.component.html @@ -6,7 +6,7 @@

{{ title() }}

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