From e4195a8567027731cd56cc569ae53dc9e7429fe9 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 15 Jul 2026 14:33:39 -0300 Subject: [PATCH] feat(product-detail): enhance product variant handling with inventory policies and update UI components --- .../services/catalog/catalog.interface.ts | 11 +- ...oduct-attribute-selector.component.spec.ts | 75 ++++++ .../product-attribute-selector.component.ts | 100 ++++---- .../product-detail-page.component.html | 213 +++++++++--------- .../product-detail-page.component.spec.ts | 81 +++++++ .../product-detail-page.component.ts | 24 +- .../quantity-selector.component.html | 13 +- .../quantity-selector.component.spec.ts | 55 +++++ .../quantity-selector.component.ts | 13 +- 9 files changed, 419 insertions(+), 166 deletions(-) create mode 100644 src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.spec.ts create mode 100644 src/app/shared/components/quantity-selector/quantity-selector.component.spec.ts diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index bf65854..39007a7 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -29,16 +29,22 @@ export interface ProductAttribute { options: ProductAttributeOption[]; } +export type InventoryPolicy = 'tracked' | 'unlimited'; + export interface ProductVariant { id: number; - cantidad_maxima: number; + inventory_policy: InventoryPolicy; + cantidad_maxima: number | null; + cantidad_vendida: number; definitions: Record; images: string[]; } export interface ProductVariantMap { variant_id: number; - cantidad_maxima: number; + inventory_policy: InventoryPolicy; + cantidad_maxima: number | null; + cantidad_vendida: number; attributes: Record; } @@ -47,4 +53,3 @@ export interface ProductDetail extends Product { variants_map: ProductVariantMap[]; variant: ProductVariant | null; } - diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.spec.ts b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.spec.ts new file mode 100644 index 0000000..a94e359 --- /dev/null +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { ProductAttribute } from '../../../../core/services/catalog/catalog.interface'; +import { ProductAttributeSelectorComponent } from './product-attribute-selector.component'; + +describe('ProductAttributeSelectorComponent', () => { + const sizeAttribute: ProductAttribute = { + id: 1, + codigo: 'size', + nombre: 'Size', + is_required: true, + metadata_schema: null, + type: 'select', + options: [ + { id: 10, value: 'S', label: 'S', sort_order: 0, metadata: null }, + { id: 20, value: 'M', label: 'M', sort_order: 1, metadata: null }, + ], + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProductAttributeSelectorComponent], + }).compileComponents(); + }); + + it('keeps an unlimited option available when maximum quantity is null', () => { + const fixture = TestBed.createComponent(ProductAttributeSelectorComponent); + fixture.componentRef.setInput('attributes', [sizeAttribute]); + fixture.componentRef.setInput('variantsMap', [ + { + variant_id: 1, + inventory_policy: 'unlimited', + cantidad_maxima: null, + cantidad_vendida: 0, + attributes: { size: 'S' }, + }, + ]); + fixture.detectChanges(); + + const buttons = Array.from( + fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'), + ) as HTMLButtonElement[]; + expect(buttons[0].disabled).toBe(false); + expect(buttons[1].disabled).toBe(true); + }); + + it('disables tracked options without available stock', () => { + const fixture = TestBed.createComponent(ProductAttributeSelectorComponent); + fixture.componentRef.setInput('attributes', [sizeAttribute]); + fixture.componentRef.setInput('variantsMap', [ + { + variant_id: 1, + inventory_policy: 'tracked', + cantidad_maxima: 0, + cantidad_vendida: 0, + attributes: { size: 'S' }, + }, + { + variant_id: 2, + inventory_policy: 'tracked', + cantidad_maxima: 2, + cantidad_vendida: 0, + attributes: { size: 'M' }, + }, + ]); + fixture.detectChanges(); + + const buttons = Array.from( + fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'), + ) as HTMLButtonElement[]; + expect(buttons[0].disabled).toBe(true); + expect(buttons[1].disabled).toBe(false); + }); +}); diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts index 7497908..3b0c121 100644 --- a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts @@ -6,14 +6,14 @@ import { input, output, signal, - untracked + untracked, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ProductAttribute, ProductAttributeOption, ProductVariant, - ProductVariantMap + ProductVariantMap, } from '../../../../core/services/catalog/catalog.interface'; @Component({ @@ -22,7 +22,7 @@ import { imports: [CommonModule], templateUrl: './product-attribute-selector.component.html', styleUrl: './product-attribute-selector.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProductAttributeSelectorComponent { public attributes = input([]); @@ -44,28 +44,36 @@ export class ProductAttributeSelectorComponent { availability[attribute.id] = {}; for (const option of attribute.options) { const optionNormalized = this.normalizeText(option.value || option.label); - - const isAvailable = variants.some(variant => { - if (variant.cantidad_maxima <= 0) return false; - const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes); + const isAvailable = variants.some((variant) => { + if (!this.isVariantAvailable(variant)) return false; + + const variantAttrValue = this.getDefaultVariantAttributeValue( + attribute, + variant.attributes, + ); if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) { - return false; + return false; } for (const otherAttr of attributes) { - if (otherAttr.id === attribute.id) continue; - const selectedOptionId = selections[otherAttr.id]; - if (selectedOptionId !== undefined) { - const selectedOption = otherAttr.options.find(o => o.id === selectedOptionId); - if (selectedOption) { - const selectedNormalized = this.normalizeText(selectedOption.value || selectedOption.label); - const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.attributes); - if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) { - return false; - } + if (otherAttr.id === attribute.id) continue; + const selectedOptionId = selections[otherAttr.id]; + if (selectedOptionId !== undefined) { + const selectedOption = otherAttr.options.find((o) => o.id === selectedOptionId); + if (selectedOption) { + const selectedNormalized = this.normalizeText( + selectedOption.value || selectedOption.label, + ); + const vAttrValue = this.getDefaultVariantAttributeValue( + otherAttr, + variant.attributes, + ); + if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) { + return false; } - } + } + } } return true; }); @@ -85,7 +93,7 @@ export class ProductAttributeSelectorComponent { effect(() => { const attributes = this.attributes(); const defaultVariant = this.defaultVariant(); - + untracked(() => { this.initializeSelections(attributes, defaultVariant); }); @@ -95,24 +103,27 @@ export class ProductAttributeSelectorComponent { const selections = this.selectedAttributeOptions(); const variantsMap = this.variantsMap(); const attributes = this.attributes(); - + untracked(() => { - this.emitMatchingVariant(selections, variantsMap, attributes); + this.emitMatchingVariant(selections, variantsMap, attributes); }); }); } - protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean { + protected hasSelectedOption( + attribute: ProductAttribute, + option: ProductAttributeOption, + ): boolean { return this.selectedAttributeOptions()[attribute.id] === option.id; } protected selectAttributeOption( attribute: ProductAttribute, - option: ProductAttributeOption + option: ProductAttributeOption, ): void { this.selectedAttributeOptions.update((current) => ({ ...current, - [attribute.id]: option.id + [attribute.id]: option.id, })); } @@ -124,7 +135,10 @@ export class ProductAttributeSelectorComponent { return this.findFirstHexValue(option.metadata) ?? '#D9D9D9'; } - private initializeSelections(attributes: ProductAttribute[], defaultVariant: ProductVariant | null): void { + private initializeSelections( + attributes: ProductAttribute[], + defaultVariant: ProductVariant | null, + ): void { const selections: Record = {}; for (const attribute of attributes) { @@ -139,7 +153,7 @@ export class ProductAttributeSelectorComponent { private findDefaultOptionId( attribute: ProductAttribute, - variant: ProductVariant | null + variant: ProductVariant | null, ): number | null { if (!variant) { return null; @@ -152,7 +166,7 @@ export class ProductAttributeSelectorComponent { const normalizedValue = this.normalizeText(defaultValue); const matchByValue = attribute.options.find( - (option) => this.normalizeText(option.value) === normalizedValue + (option) => this.normalizeText(option.value) === normalizedValue, ); if (matchByValue) { @@ -160,7 +174,7 @@ export class ProductAttributeSelectorComponent { } const matchByLabel = attribute.options.find( - (option) => this.normalizeText(option.label) === normalizedValue + (option) => this.normalizeText(option.label) === normalizedValue, ); return matchByLabel?.id ?? null; @@ -168,7 +182,7 @@ export class ProductAttributeSelectorComponent { private getDefaultVariantAttributeValue( attribute: ProductAttribute, - variantAttributes: Record + variantAttributes: Record, ): string | null { const normalizedCodigo = this.normalizeText(attribute.codigo); const normalizedNombre = this.normalizeText(attribute.nombre); @@ -192,6 +206,10 @@ export class ProductAttributeSelectorComponent { .toLowerCase(); } + private isVariantAvailable(variant: ProductVariantMap): boolean { + return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0; + } + private findFirstHexValue(value: unknown): string | null { if (typeof value === 'string') { const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/); @@ -223,7 +241,7 @@ export class ProductAttributeSelectorComponent { private emitMatchingVariant( selections: Record, variantsMap: ProductVariantMap[], - attributes: ProductAttribute[] + attributes: ProductAttribute[], ): void { if (attributes.length === 0 || variantsMap.length === 0) { this.variantChange.emit(null); @@ -235,12 +253,12 @@ export class ProductAttributeSelectorComponent { for (const attr of attributes) { const selectedOptionId = selections[attr.id]; if (selectedOptionId === undefined) { - allSelected = false; - break; + allSelected = false; + break; } - const option = attr.options.find(o => o.id === selectedOptionId); + const option = attr.options.find((o) => o.id === selectedOptionId); if (option) { - selectedValuesById[attr.id] = this.normalizeText(option.value || option.label); + selectedValuesById[attr.id] = this.normalizeText(option.value || option.label); } } @@ -249,12 +267,12 @@ export class ProductAttributeSelectorComponent { return; } - const matchingVariant = variantsMap.find(vMap => { - return attributes.every(attr => { - const selectedValue = selectedValuesById[attr.id]; - const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes); - if (!vMapValue) return false; - return this.normalizeText(vMapValue) === selectedValue; + const matchingVariant = variantsMap.find((vMap) => { + return attributes.every((attr) => { + const selectedValue = selectedValuesById[attr.id]; + const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes); + if (!vMapValue) return false; + return this.normalizeText(vMapValue) === selectedValue; }); }); diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html index 75d6205..a73a124 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html @@ -1,118 +1,119 @@
- @if (loading()) { -
-
- Cargando... + @if (loading()) { +
+
+ Cargando... +
+
+ } @else if (error()) { + + } @else if (product(); as prod) { +
+
+
- } @else if (error()) { - - } @else if (product(); as prod) { -
-
- -
-
-
-
-

{{ prod.nombre }}

+
+
+
+

{{ prod.nombre }}

-
+
-
- - {{ getFormattedPrice(prod.precio) }} - +
+ + {{ getFormattedPrice(prod.precio) }} + - @if (oldPrice(); as oldPrice) { - {{ oldPrice }} - } -
-
- - @if (hasRenderableAttributes()) { -
- -
- -
- } - -
- -
-
- - -
- - @if (variantLoading() || addingToCart()) { -
- } @else { - Agregar al carrito - } -
- - - @if (variantLoading()) { -
- } @else { - Comprar - } -
-
-
-
- -
- -
-

DESCRIPCIÓN

- -
- {{ prod.descripcion }} -
- - @if (showDescriptionToggle()) { - + @if (oldPrice(); as oldPrice) { + {{ oldPrice }} } +
+
+ + @if (hasRenderableAttributes()) { +
+ +
+
-
+ } + +
+ +
+
+ + +
+ + @if (variantLoading() || addingToCart()) { +
+ } @else { + Agregar al carrito + } +
+ + + @if (variantLoading()) { +
+ } @else { + Comprar + } +
+
+
+
+ +
+ +
+

DESCRIPCIÓN

+ +
+ {{ prod.descripcion }} +
+ + @if (showDescriptionToggle()) { + + } +
- } @else { - - } +
+ } @else { + + }
diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts index 39392b9..f19800a 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts @@ -155,7 +155,9 @@ describe('ProductDetailPageComponent', () => { images: ['https://example.com/product.png'], variant: { id: 123, + inventory_policy: 'tracked', cantidad_maxima: 10, + cantidad_vendida: 0, images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'], definitions: {}, }, @@ -260,7 +262,9 @@ describe('ProductDetailPageComponent', () => { ], variant: { id: 123, + inventory_policy: 'tracked', cantidad_maxima: 10, + cantidad_vendida: 0, images: ['https://example.com/variant1.png'], definitions: { color: 'beige', @@ -309,7 +313,9 @@ describe('ProductDetailPageComponent', () => { fixture.componentInstance['selectedVariant'].set({ variant_id: 1, + inventory_policy: 'tracked', cantidad_maxima: 10, + cantidad_vendida: 0, attributes: {}, }); fixture.detectChanges(); @@ -385,14 +391,18 @@ describe('ProductDetailPageComponent', () => { ...mockProduct, variant: { id: 123, + inventory_policy: 'tracked', cantidad_maxima: 5, + cantidad_vendida: 0, images: [], definitions: {}, }, variants_map: [ { variant_id: 123, + inventory_policy: 'tracked', cantidad_maxima: 5, + cantidad_vendida: 0, attributes: {}, }, ], @@ -426,14 +436,18 @@ describe('ProductDetailPageComponent', () => { ...mockProduct, variant: { id: 123, + inventory_policy: 'tracked', cantidad_maxima: 5, + cantidad_vendida: 0, images: [], definitions: {}, }, variants_map: [ { variant_id: 123, + inventory_policy: 'tracked', cantidad_maxima: 5, + cantidad_vendida: 0, attributes: {}, }, ], @@ -461,4 +475,71 @@ describe('ProductDetailPageComponent', () => { 'No se pudo agregar el producto al carrito.', ); }); + + it('allows unlimited variants to increase quantity without a maximum', async () => { + const unlimitedVariant = { + variant_id: 321, + inventory_policy: 'unlimited' as const, + cantidad_maxima: null, + cantidad_vendida: 10, + attributes: {}, + }; + resolveProduct({ + ...mockProduct, + variant: { + id: 321, + inventory_policy: 'unlimited', + cantidad_maxima: null, + cantidad_vendida: 10, + images: [], + definitions: {}, + }, + variants_map: [unlimitedVariant], + }); + + await configureTestingModule(); + const fixture = TestBed.createComponent(ProductDetailPageComponent); + fixture.detectChanges(); + + const increaseButton = fixture.nativeElement.querySelector( + 'app-quantity-selector button:last-child', + ) as HTMLButtonElement; + expect(increaseButton.disabled).toBe(false); + increaseButton.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance['quantity']()).toBe(2); + }); + + it('disables purchase actions for tracked variants without stock', async () => { + const trackedVariant = { + variant_id: 654, + inventory_policy: 'tracked' as const, + cantidad_maxima: 0, + cantidad_vendida: 5, + attributes: {}, + }; + resolveProduct({ + ...mockProduct, + variant: { + id: 654, + inventory_policy: 'tracked', + cantidad_maxima: 0, + cantidad_vendida: 5, + images: [], + definitions: {}, + }, + variants_map: [trackedVariant], + }); + + await configureTestingModule(); + const fixture = TestBed.createComponent(ProductDetailPageComponent); + fixture.detectChanges(); + + const purchaseButtons = Array.from( + fixture.nativeElement.querySelectorAll('app-button button'), + ) as HTMLButtonElement[]; + expect(purchaseButtons).toHaveLength(2); + expect(purchaseButtons.every((button) => button.disabled)).toBe(true); + }); }); diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts index 51b4ad1..3ef8624 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts @@ -80,6 +80,14 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { protected readonly error = signal(null); protected readonly selectedVariant = signal(null); protected readonly quantity = signal(1); + protected readonly selectedVariantMax = computed(() => { + const variant = this.selectedVariant(); + return variant ? variant.cantidad_maxima : 1; + }); + protected readonly selectedVariantAvailable = computed(() => { + const variant = this.selectedVariant(); + return variant !== null && this.isVariantAvailable(variant); + }); protected readonly descriptionExpanded = signal(false); protected readonly descriptionMaxHeight = signal(0); protected readonly descriptionHasOverflow = signal(false); @@ -143,15 +151,9 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { this.product.update((currentProduct) => { if (!currentProduct) return null; - const updatedVariantsMap = currentProduct.variants_map.map((v) => { - if (v.variant_id === variantId) { - return { ...v, cantidad_maxima: 0 }; - } - return v; - }); return { ...currentProduct, - variants_map: updatedVariantsMap, + variants_map: currentProduct.variants_map.filter((v) => v.variant_id !== variantId), }; }); @@ -209,7 +211,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { } this.selectedVariant.set(variant); - if (variant && this.quantity() > variant.cantidad_maxima) { + if (variant && variant.cantidad_maxima !== null && this.quantity() > variant.cantidad_maxima) { this.quantity.set(Math.max(1, variant.cantidad_maxima)); } @@ -221,7 +223,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { protected addToCart(): void { const variant = this.selectedVariant(); - if (!variant) { + if (!variant || !this.isVariantAvailable(variant)) { this.toastService.danger('Por favor, selecciona una variante.'); return; } @@ -241,6 +243,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { }); } + private isVariantAvailable(variant: ProductVariantMap): boolean { + return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0; + } + protected toggleDescription(): void { this.descriptionExpanded.update((current) => !current); } diff --git a/src/app/shared/components/quantity-selector/quantity-selector.component.html b/src/app/shared/components/quantity-selector/quantity-selector.component.html index 2d834e6..f5aeabe 100644 --- a/src/app/shared/components/quantity-selector/quantity-selector.component.html +++ b/src/app/shared/components/quantity-selector/quantity-selector.component.html @@ -1,4 +1,8 @@ -
+