feat(product-detail): enhance product variant handling with inventory policies and update UI components
This commit is contained in:
@@ -1,118 +1,119 @@
|
||||
<section class="product-detail">
|
||||
@if (loading()) {
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Cargando...</span>
|
||||
@if (loading()) {
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<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>
|
||||
} @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="product-detail__panel">
|
||||
<section class="product-detail__section">
|
||||
<h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1>
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="product-detail__panel">
|
||||
<section class="product-detail__section">
|
||||
<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">
|
||||
<span class="product-detail__price-current">
|
||||
{{ getFormattedPrice(prod.precio) }}
|
||||
</span>
|
||||
<div class="product-detail__price-group">
|
||||
<span class="product-detail__price-current">
|
||||
{{ getFormattedPrice(prod.precio) }}
|
||||
</span>
|
||||
|
||||
@if (oldPrice(); as oldPrice) {
|
||||
<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>
|
||||
@if (oldPrice(); as oldPrice) {
|
||||
<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>
|
||||
}
|
||||
|
||||
<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>
|
||||
} @else {
|
||||
<div class="alert alert-warning text-center mb-0" role="alert">
|
||||
No se encontró el producto especificado.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-warning text-center mb-0" role="alert">
|
||||
No se encontró el producto especificado.
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user