feat(product-detail): enhance product variant handling with inventory policies and update UI components

This commit is contained in:
2026-07-15 14:33:39 -03:00
parent 25085f6f85
commit e4195a8567
9 changed files with 419 additions and 166 deletions

View File

@@ -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<string, string>;
images: string[];
}
export interface ProductVariantMap {
variant_id: number;
cantidad_maxima: number;
inventory_policy: InventoryPolicy;
cantidad_maxima: number | null;
cantidad_vendida: number;
attributes: Record<string, string>;
}
@@ -47,4 +53,3 @@ export interface ProductDetail extends Product {
variants_map: ProductVariantMap[];
variant: ProductVariant | null;
}

View File

@@ -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);
});
});

View File

@@ -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<ProductAttribute[]>([]);
@@ -45,10 +45,13 @@ export class ProductAttributeSelectorComponent {
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 isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false;
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes);
const variantAttrValue = this.getDefaultVariantAttributeValue(
attribute,
variant.attributes,
);
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
return false;
}
@@ -57,10 +60,15 @@ export class ProductAttributeSelectorComponent {
if (otherAttr.id === attribute.id) continue;
const selectedOptionId = selections[otherAttr.id];
if (selectedOptionId !== undefined) {
const selectedOption = otherAttr.options.find(o => o.id === selectedOptionId);
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);
const selectedNormalized = this.normalizeText(
selectedOption.value || selectedOption.label,
);
const vAttrValue = this.getDefaultVariantAttributeValue(
otherAttr,
variant.attributes,
);
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
return false;
}
@@ -102,17 +110,20 @@ export class ProductAttributeSelectorComponent {
});
}
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<number, number> = {};
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<string, string>
variantAttributes: Record<string, string>,
): 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<number, number>,
variantsMap: ProductVariantMap[],
attributes: ProductAttribute[]
attributes: ProductAttribute[],
): void {
if (attributes.length === 0 || variantsMap.length === 0) {
this.variantChange.emit(null);
@@ -238,7 +256,7 @@ export class ProductAttributeSelectorComponent {
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);
}
@@ -249,8 +267,8 @@ export class ProductAttributeSelectorComponent {
return;
}
const matchingVariant = variantsMap.find(vMap => {
return attributes.every(attr => {
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;

View File

@@ -52,17 +52,14 @@
<section class="product-detail__section">
<div class="product-detail__purchase">
<app-quantity-selector
[(quantity)]="quantity"
[max]="selectedVariant()?.cantidad_maxima ?? 1"
/>
<app-quantity-selector [(quantity)]="quantity" [max]="selectedVariantMax()" />
<div class="product-detail__actions">
<app-button
class="product-detail__cta"
variant="secondary"
type="button"
[disabled]="!selectedVariant() || variantLoading() || addingToCart()"
[disabled]="!selectedVariantAvailable() || variantLoading() || addingToCart()"
(click)="addToCart()"
>
@if (variantLoading() || addingToCart()) {
@@ -72,7 +69,11 @@
}
</app-button>
<app-button class="product-detail__cta" type="button" [disabled]="!selectedVariant() || variantLoading()">
<app-button
class="product-detail__cta"
type="button"
[disabled]="!selectedVariantAvailable() || variantLoading()"
>
@if (variantLoading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {

View File

@@ -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);
});
});

View File

@@ -80,6 +80,14 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
protected readonly error = signal<string | null>(null);
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
protected readonly quantity = signal(1);
protected readonly selectedVariantMax = computed<number | null>(() => {
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);
}

View File

@@ -1,4 +1,8 @@
<div class="quantity-selector d-inline-flex align-items-center overflow-hidden rounded" [class.quantity-selector--small]="size() === 'small'" aria-label="Selector de cantidad">
<div
class="quantity-selector d-inline-flex align-items-center overflow-hidden rounded"
[class.quantity-selector--small]="size() === 'small'"
aria-label="Selector de cantidad"
>
<button
type="button"
class="quantity-selector__button border-0 p-0 fw-bold lh-1"
@@ -9,13 +13,16 @@
-
</button>
<span class="quantity-selector__value flex-grow-1 min-w-0 text-center fw-bold lh-1 user-select-none">{{ quantity() }}</span>
<span
class="quantity-selector__value flex-grow-1 min-w-0 text-center fw-bold lh-1 user-select-none"
>{{ quantity() }}</span
>
<button
type="button"
class="quantity-selector__button border-0 p-0 fw-bold lh-1"
aria-label="Aumentar cantidad"
[disabled]="quantity() >= max()"
[disabled]="atMaximum()"
(click)="onIncrease()"
>
+

View File

@@ -0,0 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { QuantitySelectorComponent } from './quantity-selector.component';
describe('QuantitySelectorComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [QuantitySelectorComponent],
}).compileComponents();
});
it('stops increasing when it reaches a numeric maximum', () => {
const fixture = TestBed.createComponent(QuantitySelectorComponent);
fixture.componentRef.setInput('max', 2);
fixture.detectChanges();
const increaseButton = fixture.nativeElement.querySelector(
'button:last-child',
) as HTMLButtonElement;
increaseButton.click();
fixture.detectChanges();
expect(fixture.componentInstance.quantity()).toBe(2);
expect(increaseButton.disabled).toBe(true);
});
it('keeps increasing when maximum is null', () => {
const fixture = TestBed.createComponent(QuantitySelectorComponent);
fixture.componentRef.setInput('max', null);
fixture.detectChanges();
const increaseButton = fixture.nativeElement.querySelector(
'button:last-child',
) as HTMLButtonElement;
increaseButton.click();
increaseButton.click();
fixture.detectChanges();
expect(fixture.componentInstance.quantity()).toBe(3);
expect(increaseButton.disabled).toBe(false);
});
it('does not decrease below the configured minimum', () => {
const fixture = TestBed.createComponent(QuantitySelectorComponent);
fixture.detectChanges();
const decreaseButton = fixture.nativeElement.querySelector(
'button:first-child',
) as HTMLButtonElement;
expect(decreaseButton.disabled).toBe(true);
expect(fixture.componentInstance.quantity()).toBe(1);
});
});

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
@Component({
selector: 'app-quantity-selector',
@@ -6,14 +6,19 @@ import { ChangeDetectionStrategy, Component, input, model, output } from '@angul
imports: [],
templateUrl: './quantity-selector.component.html',
styleUrl: './quantity-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuantitySelectorComponent {
readonly quantity = model<number>(1);
readonly min = input<number>(1);
readonly max = input<number>(100);
readonly max = input<number | null>(100);
readonly size = input<'small' | 'medium'>('medium');
protected readonly atMaximum = computed(() => {
const max = this.max();
return max !== null && this.quantity() >= max;
});
readonly increase = output<void>();
readonly decrease = output<void>();
@@ -25,7 +30,7 @@ export class QuantitySelectorComponent {
}
protected onIncrease(): void {
if (this.quantity() < this.max()) {
if (!this.atMaximum()) {
this.quantity.set(this.quantity() + 1);
this.increase.emit();
}