feat(product-detail): enhance product variant handling with inventory policies and update UI components
This commit is contained in:
@@ -29,16 +29,22 @@ export interface ProductAttribute {
|
|||||||
options: ProductAttributeOption[];
|
options: ProductAttributeOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InventoryPolicy = 'tracked' | 'unlimited';
|
||||||
|
|
||||||
export interface ProductVariant {
|
export interface ProductVariant {
|
||||||
id: number;
|
id: number;
|
||||||
cantidad_maxima: number;
|
inventory_policy: InventoryPolicy;
|
||||||
|
cantidad_maxima: number | null;
|
||||||
|
cantidad_vendida: number;
|
||||||
definitions: Record<string, string>;
|
definitions: Record<string, string>;
|
||||||
images: string[];
|
images: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductVariantMap {
|
export interface ProductVariantMap {
|
||||||
variant_id: number;
|
variant_id: number;
|
||||||
cantidad_maxima: number;
|
inventory_policy: InventoryPolicy;
|
||||||
|
cantidad_maxima: number | null;
|
||||||
|
cantidad_vendida: number;
|
||||||
attributes: Record<string, string>;
|
attributes: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,4 +53,3 @@ export interface ProductDetail extends Product {
|
|||||||
variants_map: ProductVariantMap[];
|
variants_map: ProductVariantMap[];
|
||||||
variant: ProductVariant | null;
|
variant: ProductVariant | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,14 +6,14 @@ import {
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
untracked
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ProductAttribute,
|
ProductAttribute,
|
||||||
ProductAttributeOption,
|
ProductAttributeOption,
|
||||||
ProductVariant,
|
ProductVariant,
|
||||||
ProductVariantMap
|
ProductVariantMap,
|
||||||
} from '../../../../core/services/catalog/catalog.interface';
|
} from '../../../../core/services/catalog/catalog.interface';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './product-attribute-selector.component.html',
|
templateUrl: './product-attribute-selector.component.html',
|
||||||
styleUrl: './product-attribute-selector.component.scss',
|
styleUrl: './product-attribute-selector.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class ProductAttributeSelectorComponent {
|
export class ProductAttributeSelectorComponent {
|
||||||
public attributes = input<ProductAttribute[]>([]);
|
public attributes = input<ProductAttribute[]>([]);
|
||||||
@@ -44,28 +44,36 @@ export class ProductAttributeSelectorComponent {
|
|||||||
availability[attribute.id] = {};
|
availability[attribute.id] = {};
|
||||||
for (const option of attribute.options) {
|
for (const option of attribute.options) {
|
||||||
const optionNormalized = this.normalizeText(option.value || option.label);
|
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) {
|
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const otherAttr of attributes) {
|
for (const otherAttr of attributes) {
|
||||||
if (otherAttr.id === attribute.id) continue;
|
if (otherAttr.id === attribute.id) continue;
|
||||||
const selectedOptionId = selections[otherAttr.id];
|
const selectedOptionId = selections[otherAttr.id];
|
||||||
if (selectedOptionId !== undefined) {
|
if (selectedOptionId !== undefined) {
|
||||||
const selectedOption = otherAttr.options.find(o => o.id === selectedOptionId);
|
const selectedOption = otherAttr.options.find((o) => o.id === selectedOptionId);
|
||||||
if (selectedOption) {
|
if (selectedOption) {
|
||||||
const selectedNormalized = this.normalizeText(selectedOption.value || selectedOption.label);
|
const selectedNormalized = this.normalizeText(
|
||||||
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.attributes);
|
selectedOption.value || selectedOption.label,
|
||||||
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
|
);
|
||||||
return false;
|
const vAttrValue = this.getDefaultVariantAttributeValue(
|
||||||
}
|
otherAttr,
|
||||||
|
variant.attributes,
|
||||||
|
);
|
||||||
|
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -85,7 +93,7 @@ export class ProductAttributeSelectorComponent {
|
|||||||
effect(() => {
|
effect(() => {
|
||||||
const attributes = this.attributes();
|
const attributes = this.attributes();
|
||||||
const defaultVariant = this.defaultVariant();
|
const defaultVariant = this.defaultVariant();
|
||||||
|
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
this.initializeSelections(attributes, defaultVariant);
|
this.initializeSelections(attributes, defaultVariant);
|
||||||
});
|
});
|
||||||
@@ -95,24 +103,27 @@ export class ProductAttributeSelectorComponent {
|
|||||||
const selections = this.selectedAttributeOptions();
|
const selections = this.selectedAttributeOptions();
|
||||||
const variantsMap = this.variantsMap();
|
const variantsMap = this.variantsMap();
|
||||||
const attributes = this.attributes();
|
const attributes = this.attributes();
|
||||||
|
|
||||||
untracked(() => {
|
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;
|
return this.selectedAttributeOptions()[attribute.id] === option.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected selectAttributeOption(
|
protected selectAttributeOption(
|
||||||
attribute: ProductAttribute,
|
attribute: ProductAttribute,
|
||||||
option: ProductAttributeOption
|
option: ProductAttributeOption,
|
||||||
): void {
|
): void {
|
||||||
this.selectedAttributeOptions.update((current) => ({
|
this.selectedAttributeOptions.update((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[attribute.id]: option.id
|
[attribute.id]: option.id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +135,10 @@ export class ProductAttributeSelectorComponent {
|
|||||||
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
|
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> = {};
|
const selections: Record<number, number> = {};
|
||||||
|
|
||||||
for (const attribute of attributes) {
|
for (const attribute of attributes) {
|
||||||
@@ -139,7 +153,7 @@ export class ProductAttributeSelectorComponent {
|
|||||||
|
|
||||||
private findDefaultOptionId(
|
private findDefaultOptionId(
|
||||||
attribute: ProductAttribute,
|
attribute: ProductAttribute,
|
||||||
variant: ProductVariant | null
|
variant: ProductVariant | null,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!variant) {
|
if (!variant) {
|
||||||
return null;
|
return null;
|
||||||
@@ -152,7 +166,7 @@ export class ProductAttributeSelectorComponent {
|
|||||||
|
|
||||||
const normalizedValue = this.normalizeText(defaultValue);
|
const normalizedValue = this.normalizeText(defaultValue);
|
||||||
const matchByValue = attribute.options.find(
|
const matchByValue = attribute.options.find(
|
||||||
(option) => this.normalizeText(option.value) === normalizedValue
|
(option) => this.normalizeText(option.value) === normalizedValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchByValue) {
|
if (matchByValue) {
|
||||||
@@ -160,7 +174,7 @@ export class ProductAttributeSelectorComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const matchByLabel = attribute.options.find(
|
const matchByLabel = attribute.options.find(
|
||||||
(option) => this.normalizeText(option.label) === normalizedValue
|
(option) => this.normalizeText(option.label) === normalizedValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
return matchByLabel?.id ?? null;
|
return matchByLabel?.id ?? null;
|
||||||
@@ -168,7 +182,7 @@ export class ProductAttributeSelectorComponent {
|
|||||||
|
|
||||||
private getDefaultVariantAttributeValue(
|
private getDefaultVariantAttributeValue(
|
||||||
attribute: ProductAttribute,
|
attribute: ProductAttribute,
|
||||||
variantAttributes: Record<string, string>
|
variantAttributes: Record<string, string>,
|
||||||
): string | null {
|
): string | null {
|
||||||
const normalizedCodigo = this.normalizeText(attribute.codigo);
|
const normalizedCodigo = this.normalizeText(attribute.codigo);
|
||||||
const normalizedNombre = this.normalizeText(attribute.nombre);
|
const normalizedNombre = this.normalizeText(attribute.nombre);
|
||||||
@@ -192,6 +206,10 @@ export class ProductAttributeSelectorComponent {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isVariantAvailable(variant: ProductVariantMap): boolean {
|
||||||
|
return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private findFirstHexValue(value: unknown): string | null {
|
private findFirstHexValue(value: unknown): string | null {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
|
const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
|
||||||
@@ -223,7 +241,7 @@ export class ProductAttributeSelectorComponent {
|
|||||||
private emitMatchingVariant(
|
private emitMatchingVariant(
|
||||||
selections: Record<number, number>,
|
selections: Record<number, number>,
|
||||||
variantsMap: ProductVariantMap[],
|
variantsMap: ProductVariantMap[],
|
||||||
attributes: ProductAttribute[]
|
attributes: ProductAttribute[],
|
||||||
): void {
|
): void {
|
||||||
if (attributes.length === 0 || variantsMap.length === 0) {
|
if (attributes.length === 0 || variantsMap.length === 0) {
|
||||||
this.variantChange.emit(null);
|
this.variantChange.emit(null);
|
||||||
@@ -235,12 +253,12 @@ export class ProductAttributeSelectorComponent {
|
|||||||
for (const attr of attributes) {
|
for (const attr of attributes) {
|
||||||
const selectedOptionId = selections[attr.id];
|
const selectedOptionId = selections[attr.id];
|
||||||
if (selectedOptionId === undefined) {
|
if (selectedOptionId === undefined) {
|
||||||
allSelected = false;
|
allSelected = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const option = attr.options.find(o => o.id === selectedOptionId);
|
const option = attr.options.find((o) => o.id === selectedOptionId);
|
||||||
if (option) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchingVariant = variantsMap.find(vMap => {
|
const matchingVariant = variantsMap.find((vMap) => {
|
||||||
return attributes.every(attr => {
|
return attributes.every((attr) => {
|
||||||
const selectedValue = selectedValuesById[attr.id];
|
const selectedValue = selectedValuesById[attr.id];
|
||||||
const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes);
|
const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes);
|
||||||
if (!vMapValue) return false;
|
if (!vMapValue) return false;
|
||||||
return this.normalizeText(vMapValue) === selectedValue;
|
return this.normalizeText(vMapValue) === selectedValue;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +1,119 @@
|
|||||||
<section class="product-detail">
|
<section class="product-detail">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
<span class="visually-hidden">Cargando...</span>
|
<span class="visually-hidden">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if (error()) {
|
||||||
|
<div class="alert alert-danger text-center mb-0" role="alert">
|
||||||
|
{{ error() }}
|
||||||
|
</div>
|
||||||
|
} @else if (product(); as prod) {
|
||||||
|
<div class="row g-4 g-xl-5 align-items-start">
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<div #carouselHost class="product-detail__carousel">
|
||||||
|
<app-product-carousel [images]="carouselImages()" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else if (error()) {
|
|
||||||
<div class="alert alert-danger text-center mb-0" role="alert">
|
|
||||||
{{ error() }}
|
|
||||||
</div>
|
|
||||||
} @else if (product(); as prod) {
|
|
||||||
<div class="row g-4 g-xl-5 align-items-start">
|
|
||||||
<div class="col-12 col-lg-5">
|
|
||||||
<div #carouselHost class="product-detail__carousel">
|
|
||||||
<app-product-carousel [images]="carouselImages()" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-lg-7">
|
<div class="col-12 col-lg-7">
|
||||||
<div class="product-detail__panel">
|
<div class="product-detail__panel">
|
||||||
<section class="product-detail__section">
|
<section class="product-detail__section">
|
||||||
<h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1>
|
<h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1>
|
||||||
|
|
||||||
<div class="product-detail__title-price-divider"></div>
|
<div class="product-detail__title-price-divider"></div>
|
||||||
|
|
||||||
<div class="product-detail__price-group">
|
<div class="product-detail__price-group">
|
||||||
<span class="product-detail__price-current">
|
<span class="product-detail__price-current">
|
||||||
{{ getFormattedPrice(prod.precio) }}
|
{{ getFormattedPrice(prod.precio) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@if (oldPrice(); as oldPrice) {
|
@if (oldPrice(); as oldPrice) {
|
||||||
<span class="product-detail__price-previous">{{ oldPrice }}</span>
|
<span class="product-detail__price-previous">{{ oldPrice }}</span>
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@if (hasRenderableAttributes()) {
|
|
||||||
<div class="product-detail__divider"></div>
|
|
||||||
|
|
||||||
<section class="product-detail__section product-detail__section--attributes">
|
|
||||||
<app-product-attribute-selector
|
|
||||||
[attributes]="renderableAttributes()"
|
|
||||||
[variantsMap]="prod.variants_map"
|
|
||||||
[defaultVariant]="prod.variant"
|
|
||||||
(variantChange)="onVariantChange($event)"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="product-detail__divider"></div>
|
|
||||||
|
|
||||||
<section class="product-detail__section">
|
|
||||||
<div class="product-detail__purchase">
|
|
||||||
<app-quantity-selector
|
|
||||||
[(quantity)]="quantity"
|
|
||||||
[max]="selectedVariant()?.cantidad_maxima ?? 1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="product-detail__actions">
|
|
||||||
<app-button
|
|
||||||
class="product-detail__cta"
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
[disabled]="!selectedVariant() || variantLoading() || addingToCart()"
|
|
||||||
(click)="addToCart()"
|
|
||||||
>
|
|
||||||
@if (variantLoading() || addingToCart()) {
|
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
|
||||||
} @else {
|
|
||||||
Agregar al carrito
|
|
||||||
}
|
|
||||||
</app-button>
|
|
||||||
|
|
||||||
<app-button class="product-detail__cta" type="button" [disabled]="!selectedVariant() || variantLoading()">
|
|
||||||
@if (variantLoading()) {
|
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
|
||||||
} @else {
|
|
||||||
Comprar
|
|
||||||
}
|
|
||||||
</app-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="product-detail__divider"></div>
|
|
||||||
|
|
||||||
<section class="product-detail__section product-detail__section--description">
|
|
||||||
<h2 class="product-detail__description-heading">DESCRIPCIÓN</h2>
|
|
||||||
|
|
||||||
<div
|
|
||||||
#descriptionBody
|
|
||||||
class="product-detail__description-body"
|
|
||||||
[class.product-detail__description-body--expanded]="descriptionExpanded()"
|
|
||||||
[style.max-height.px]="descriptionExpanded() ? descriptionMaxHeight() || null : null"
|
|
||||||
>
|
|
||||||
{{ prod.descripcion }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (showDescriptionToggle()) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="product-detail__description-toggle"
|
|
||||||
(click)="toggleDescription()"
|
|
||||||
>
|
|
||||||
{{ descriptionExpanded() ? 'Mostrar menos' : 'Mostrar más' }}
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (hasRenderableAttributes()) {
|
||||||
|
<div class="product-detail__divider"></div>
|
||||||
|
|
||||||
|
<section class="product-detail__section product-detail__section--attributes">
|
||||||
|
<app-product-attribute-selector
|
||||||
|
[attributes]="renderableAttributes()"
|
||||||
|
[variantsMap]="prod.variants_map"
|
||||||
|
[defaultVariant]="prod.variant"
|
||||||
|
(variantChange)="onVariantChange($event)"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
<div class="product-detail__divider"></div>
|
||||||
|
|
||||||
|
<section class="product-detail__section">
|
||||||
|
<div class="product-detail__purchase">
|
||||||
|
<app-quantity-selector [(quantity)]="quantity" [max]="selectedVariantMax()" />
|
||||||
|
|
||||||
|
<div class="product-detail__actions">
|
||||||
|
<app-button
|
||||||
|
class="product-detail__cta"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!selectedVariantAvailable() || variantLoading() || addingToCart()"
|
||||||
|
(click)="addToCart()"
|
||||||
|
>
|
||||||
|
@if (variantLoading() || addingToCart()) {
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
|
} @else {
|
||||||
|
Agregar al carrito
|
||||||
|
}
|
||||||
|
</app-button>
|
||||||
|
|
||||||
|
<app-button
|
||||||
|
class="product-detail__cta"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!selectedVariantAvailable() || variantLoading()"
|
||||||
|
>
|
||||||
|
@if (variantLoading()) {
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
|
} @else {
|
||||||
|
Comprar
|
||||||
|
}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="product-detail__divider"></div>
|
||||||
|
|
||||||
|
<section class="product-detail__section product-detail__section--description">
|
||||||
|
<h2 class="product-detail__description-heading">DESCRIPCIÓN</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
#descriptionBody
|
||||||
|
class="product-detail__description-body"
|
||||||
|
[class.product-detail__description-body--expanded]="descriptionExpanded()"
|
||||||
|
[style.max-height.px]="descriptionExpanded() ? descriptionMaxHeight() || null : null"
|
||||||
|
>
|
||||||
|
{{ prod.descripcion }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showDescriptionToggle()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="product-detail__description-toggle"
|
||||||
|
(click)="toggleDescription()"
|
||||||
|
>
|
||||||
|
{{ descriptionExpanded() ? 'Mostrar menos' : 'Mostrar más' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
</div>
|
||||||
<div class="alert alert-warning text-center mb-0" role="alert">
|
} @else {
|
||||||
No se encontró el producto especificado.
|
<div class="alert alert-warning text-center mb-0" role="alert">
|
||||||
</div>
|
No se encontró el producto especificado.
|
||||||
}
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
images: ['https://example.com/product.png'],
|
images: ['https://example.com/product.png'],
|
||||||
variant: {
|
variant: {
|
||||||
id: 123,
|
id: 123,
|
||||||
|
inventory_policy: 'tracked',
|
||||||
cantidad_maxima: 10,
|
cantidad_maxima: 10,
|
||||||
|
cantidad_vendida: 0,
|
||||||
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
|
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
|
||||||
definitions: {},
|
definitions: {},
|
||||||
},
|
},
|
||||||
@@ -260,7 +262,9 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
],
|
],
|
||||||
variant: {
|
variant: {
|
||||||
id: 123,
|
id: 123,
|
||||||
|
inventory_policy: 'tracked',
|
||||||
cantidad_maxima: 10,
|
cantidad_maxima: 10,
|
||||||
|
cantidad_vendida: 0,
|
||||||
images: ['https://example.com/variant1.png'],
|
images: ['https://example.com/variant1.png'],
|
||||||
definitions: {
|
definitions: {
|
||||||
color: 'beige',
|
color: 'beige',
|
||||||
@@ -309,7 +313,9 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
|
|
||||||
fixture.componentInstance['selectedVariant'].set({
|
fixture.componentInstance['selectedVariant'].set({
|
||||||
variant_id: 1,
|
variant_id: 1,
|
||||||
|
inventory_policy: 'tracked',
|
||||||
cantidad_maxima: 10,
|
cantidad_maxima: 10,
|
||||||
|
cantidad_vendida: 0,
|
||||||
attributes: {},
|
attributes: {},
|
||||||
});
|
});
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -385,14 +391,18 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
...mockProduct,
|
...mockProduct,
|
||||||
variant: {
|
variant: {
|
||||||
id: 123,
|
id: 123,
|
||||||
|
inventory_policy: 'tracked',
|
||||||
cantidad_maxima: 5,
|
cantidad_maxima: 5,
|
||||||
|
cantidad_vendida: 0,
|
||||||
images: [],
|
images: [],
|
||||||
definitions: {},
|
definitions: {},
|
||||||
},
|
},
|
||||||
variants_map: [
|
variants_map: [
|
||||||
{
|
{
|
||||||
variant_id: 123,
|
variant_id: 123,
|
||||||
|
inventory_policy: 'tracked',
|
||||||
cantidad_maxima: 5,
|
cantidad_maxima: 5,
|
||||||
|
cantidad_vendida: 0,
|
||||||
attributes: {},
|
attributes: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -426,14 +436,18 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
...mockProduct,
|
...mockProduct,
|
||||||
variant: {
|
variant: {
|
||||||
id: 123,
|
id: 123,
|
||||||
|
inventory_policy: 'tracked',
|
||||||
cantidad_maxima: 5,
|
cantidad_maxima: 5,
|
||||||
|
cantidad_vendida: 0,
|
||||||
images: [],
|
images: [],
|
||||||
definitions: {},
|
definitions: {},
|
||||||
},
|
},
|
||||||
variants_map: [
|
variants_map: [
|
||||||
{
|
{
|
||||||
variant_id: 123,
|
variant_id: 123,
|
||||||
|
inventory_policy: 'tracked',
|
||||||
cantidad_maxima: 5,
|
cantidad_maxima: 5,
|
||||||
|
cantidad_vendida: 0,
|
||||||
attributes: {},
|
attributes: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -461,4 +475,71 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
'No se pudo agregar el producto al carrito.',
|
'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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
protected readonly error = signal<string | null>(null);
|
protected readonly error = signal<string | null>(null);
|
||||||
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
|
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
|
||||||
protected readonly quantity = signal(1);
|
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 descriptionExpanded = signal(false);
|
||||||
protected readonly descriptionMaxHeight = signal(0);
|
protected readonly descriptionMaxHeight = signal(0);
|
||||||
protected readonly descriptionHasOverflow = signal(false);
|
protected readonly descriptionHasOverflow = signal(false);
|
||||||
@@ -143,15 +151,9 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.product.update((currentProduct) => {
|
this.product.update((currentProduct) => {
|
||||||
if (!currentProduct) return null;
|
if (!currentProduct) return null;
|
||||||
const updatedVariantsMap = currentProduct.variants_map.map((v) => {
|
|
||||||
if (v.variant_id === variantId) {
|
|
||||||
return { ...v, cantidad_maxima: 0 };
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
...currentProduct,
|
...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);
|
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));
|
this.quantity.set(Math.max(1, variant.cantidad_maxima));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +223,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
protected addToCart(): void {
|
protected addToCart(): void {
|
||||||
const variant = this.selectedVariant();
|
const variant = this.selectedVariant();
|
||||||
if (!variant) {
|
if (!variant || !this.isVariantAvailable(variant)) {
|
||||||
this.toastService.danger('Por favor, selecciona una variante.');
|
this.toastService.danger('Por favor, selecciona una variante.');
|
||||||
return;
|
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 {
|
protected toggleDescription(): void {
|
||||||
this.descriptionExpanded.update((current) => !current);
|
this.descriptionExpanded.update((current) => !current);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="quantity-selector__button border-0 p-0 fw-bold lh-1"
|
class="quantity-selector__button border-0 p-0 fw-bold lh-1"
|
||||||
@@ -9,13 +13,16 @@
|
|||||||
-
|
-
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="quantity-selector__button border-0 p-0 fw-bold lh-1"
|
class="quantity-selector__button border-0 p-0 fw-bold lh-1"
|
||||||
aria-label="Aumentar cantidad"
|
aria-label="Aumentar cantidad"
|
||||||
[disabled]="quantity() >= max()"
|
[disabled]="atMaximum()"
|
||||||
(click)="onIncrease()"
|
(click)="onIncrease()"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-quantity-selector',
|
selector: 'app-quantity-selector',
|
||||||
@@ -6,14 +6,19 @@ import { ChangeDetectionStrategy, Component, input, model, output } from '@angul
|
|||||||
imports: [],
|
imports: [],
|
||||||
templateUrl: './quantity-selector.component.html',
|
templateUrl: './quantity-selector.component.html',
|
||||||
styleUrl: './quantity-selector.component.scss',
|
styleUrl: './quantity-selector.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class QuantitySelectorComponent {
|
export class QuantitySelectorComponent {
|
||||||
readonly quantity = model<number>(1);
|
readonly quantity = model<number>(1);
|
||||||
readonly min = input<number>(1);
|
readonly min = input<number>(1);
|
||||||
readonly max = input<number>(100);
|
readonly max = input<number | null>(100);
|
||||||
readonly size = input<'small' | 'medium'>('medium');
|
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 increase = output<void>();
|
||||||
readonly decrease = output<void>();
|
readonly decrease = output<void>();
|
||||||
|
|
||||||
@@ -25,7 +30,7 @@ export class QuantitySelectorComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected onIncrease(): void {
|
protected onIncrease(): void {
|
||||||
if (this.quantity() < this.max()) {
|
if (!this.atMaximum()) {
|
||||||
this.quantity.set(this.quantity() + 1);
|
this.quantity.set(this.quantity() + 1);
|
||||||
this.increase.emit();
|
this.increase.emit();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user