feat: implement product attribute selector component and integrate into product detail page
This commit is contained in:
@@ -10,10 +10,12 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="attribute-selector__swatch"
|
class="attribute-selector__swatch"
|
||||||
[class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)"
|
[class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)"
|
||||||
|
[class.attribute-selector__swatch--disabled]="!availableOptions()[attribute.id][option.id]"
|
||||||
[style.background-color]="getOptionSwatchColor(option)"
|
[style.background-color]="getOptionSwatchColor(option)"
|
||||||
[attr.aria-label]="option.label"
|
[attr.aria-label]="option.label"
|
||||||
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
||||||
[title]="option.label"
|
[title]="option.label"
|
||||||
|
[disabled]="!availableOptions()[attribute.id][option.id]"
|
||||||
(click)="selectAttributeOption(attribute, option)"
|
(click)="selectAttributeOption(attribute, option)"
|
||||||
>
|
>
|
||||||
<span class="visually-hidden">{{ option.label }}</span>
|
<span class="visually-hidden">{{ option.label }}</span>
|
||||||
@@ -23,7 +25,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="attribute-selector__text-option"
|
class="attribute-selector__text-option"
|
||||||
[class.attribute-selector__text-option--selected]="hasSelectedOption(attribute, option)"
|
[class.attribute-selector__text-option--selected]="hasSelectedOption(attribute, option)"
|
||||||
|
[class.attribute-selector__text-option--disabled]="!availableOptions()[attribute.id][option.id]"
|
||||||
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
||||||
|
[disabled]="!availableOptions()[attribute.id][option.id]"
|
||||||
(click)="selectAttributeOption(attribute, option)"
|
(click)="selectAttributeOption(attribute, option)"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
0 0 0 2px rgba(255, 255, 255, 1),
|
0 0 0 2px rgba(255, 255, 255, 1),
|
||||||
0 0 0 3px var(--tenant-primary);
|
0 0 0 3px var(--tenant-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__text-option {
|
&__text-option {
|
||||||
@@ -65,5 +71,13 @@
|
|||||||
border-color: var(--tenant-primary);
|
border-color: var(--tenant-primary);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
color: #b0b0b0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
effect,
|
effect,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
@@ -32,6 +33,50 @@ export class ProductAttributeSelectorComponent {
|
|||||||
|
|
||||||
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
|
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
|
||||||
|
|
||||||
|
protected readonly availableOptions = computed(() => {
|
||||||
|
const selections = this.selectedAttributeOptions();
|
||||||
|
const variants = this.variantsMap();
|
||||||
|
const attributes = this.attributes();
|
||||||
|
|
||||||
|
const availability: Record<number, Record<number, boolean>> = {};
|
||||||
|
|
||||||
|
for (const attribute of attributes) {
|
||||||
|
availability[attribute.id] = {};
|
||||||
|
for (const option of attribute.options) {
|
||||||
|
const optionNormalized = this.normalizeText(option.value || option.label);
|
||||||
|
|
||||||
|
const isAvailable = variants.some(variant => {
|
||||||
|
if (variant.stock <= 0) return false;
|
||||||
|
|
||||||
|
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes);
|
||||||
|
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
availability[attribute.id][option.id] = isAvailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability;
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const attributes = this.attributes();
|
const attributes = this.attributes();
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="product-detail__quantity-button"
|
class="product-detail__quantity-button"
|
||||||
aria-label="Aumentar cantidad"
|
aria-label="Aumentar cantidad"
|
||||||
|
[disabled]="quantity() >= (selectedVariant()?.stock ?? 1)"
|
||||||
(click)="increaseQuantity()"
|
(click)="increaseQuantity()"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
@@ -80,11 +81,12 @@
|
|||||||
class="product-detail__cta"
|
class="product-detail__cta"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
|
[disabled]="!selectedVariant()"
|
||||||
>
|
>
|
||||||
Agregar al carrito
|
Agregar al carrito
|
||||||
</app-button>
|
</app-button>
|
||||||
|
|
||||||
<app-button class="product-detail__cta" type="button">
|
<app-button class="product-detail__cta" type="button" [disabled]="!selectedVariant()">
|
||||||
Comprar
|
Comprar
|
||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -154,10 +154,14 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
protected onVariantChange(variant: ProductVariantMap | null): void {
|
protected onVariantChange(variant: ProductVariantMap | null): void {
|
||||||
this.selectedVariant.set(variant);
|
this.selectedVariant.set(variant);
|
||||||
|
if (variant && this.quantity() > variant.stock) {
|
||||||
|
this.quantity.set(Math.max(1, variant.stock));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected increaseQuantity(): void {
|
protected increaseQuantity(): void {
|
||||||
this.quantity.update((current) => current + 1);
|
const currentStock = this.selectedVariant()?.stock ?? 1;
|
||||||
|
this.quantity.update((current) => current < currentStock ? current + 1 : current);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected decreaseQuantity(): void {
|
protected decreaseQuantity(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user