feat: implement product detail page with attribute selector and quantity management
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
<div class="attribute-selector">
|
||||||
|
@for (attribute of attributes(); track attribute.id) {
|
||||||
|
<div class="attribute-selector__row">
|
||||||
|
<span class="attribute-selector__label">{{ attribute.nombre }}:</span>
|
||||||
|
|
||||||
|
<div class="attribute-selector__options">
|
||||||
|
@for (option of attribute.options; track option.id) {
|
||||||
|
@if (isColorOption(option)) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attribute-selector__swatch"
|
||||||
|
[class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)"
|
||||||
|
[style.background-color]="getOptionSwatchColor(option)"
|
||||||
|
[attr.aria-label]="option.label"
|
||||||
|
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
||||||
|
[title]="option.label"
|
||||||
|
(click)="selectAttributeOption(attribute, option)"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden">{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attribute-selector__text-option"
|
||||||
|
[class.attribute-selector__text-option--selected]="hasSelectedOption(attribute, option)"
|
||||||
|
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
||||||
|
(click)="selectAttributeOption(attribute, option)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
.attribute-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #A0A0A0;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__swatch {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
border-color: var(--tenant-primary);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(255, 255, 255, 1),
|
||||||
|
0 0 0 3px var(--tenant-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text-option {
|
||||||
|
border: 1px solid #dcdcdc;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #666666;
|
||||||
|
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
background: var(--tenant-primary);
|
||||||
|
border-color: var(--tenant-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
untracked
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ProductAttribute,
|
||||||
|
ProductAttributeOption,
|
||||||
|
ProductVariant,
|
||||||
|
ProductVariantMap
|
||||||
|
} from '../../../../core/services/catalog/catalog.interface';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-attribute-selector',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './product-attribute-selector.component.html',
|
||||||
|
styleUrl: './product-attribute-selector.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ProductAttributeSelectorComponent {
|
||||||
|
public attributes = input<ProductAttribute[]>([]);
|
||||||
|
public variantsMap = input<ProductVariantMap[]>([]);
|
||||||
|
public defaultVariant = input<ProductVariant | null>(null);
|
||||||
|
|
||||||
|
public variantChange = output<ProductVariantMap | null>();
|
||||||
|
|
||||||
|
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const attributes = this.attributes();
|
||||||
|
const defaultVariant = this.defaultVariant();
|
||||||
|
|
||||||
|
untracked(() => {
|
||||||
|
this.initializeSelections(attributes, defaultVariant);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const selections = this.selectedAttributeOptions();
|
||||||
|
const variantsMap = this.variantsMap();
|
||||||
|
const attributes = this.attributes();
|
||||||
|
|
||||||
|
untracked(() => {
|
||||||
|
this.emitMatchingVariant(selections, variantsMap, attributes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean {
|
||||||
|
return this.selectedAttributeOptions()[attribute.id] === option.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected selectAttributeOption(
|
||||||
|
attribute: ProductAttribute,
|
||||||
|
option: ProductAttributeOption
|
||||||
|
): void {
|
||||||
|
this.selectedAttributeOptions.update((current) => ({
|
||||||
|
...current,
|
||||||
|
[attribute.id]: option.id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isColorOption(option: ProductAttributeOption): boolean {
|
||||||
|
return this.findFirstHexValue(option.metadata) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getOptionSwatchColor(option: ProductAttributeOption): string {
|
||||||
|
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeSelections(attributes: ProductAttribute[], defaultVariant: ProductVariant | null): void {
|
||||||
|
const selections: Record<number, number> = {};
|
||||||
|
|
||||||
|
for (const attribute of attributes) {
|
||||||
|
const optionId = this.findDefaultOptionId(attribute, defaultVariant);
|
||||||
|
if (optionId !== null) {
|
||||||
|
selections[attribute.id] = optionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedAttributeOptions.set(selections);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findDefaultOptionId(
|
||||||
|
attribute: ProductAttribute,
|
||||||
|
variant: ProductVariant | null
|
||||||
|
): number | null {
|
||||||
|
if (!variant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions);
|
||||||
|
if (!defaultValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = this.normalizeText(defaultValue);
|
||||||
|
const matchByValue = attribute.options.find(
|
||||||
|
(option) => this.normalizeText(option.value) === normalizedValue
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchByValue) {
|
||||||
|
return matchByValue.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchByLabel = attribute.options.find(
|
||||||
|
(option) => this.normalizeText(option.label) === normalizedValue
|
||||||
|
);
|
||||||
|
|
||||||
|
return matchByLabel?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultVariantAttributeValue(
|
||||||
|
attribute: ProductAttribute,
|
||||||
|
variantAttributes: Record<string, string>
|
||||||
|
): string | null {
|
||||||
|
const normalizedCodigo = this.normalizeText(attribute.codigo);
|
||||||
|
const normalizedNombre = this.normalizeText(attribute.nombre);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(variantAttributes)) {
|
||||||
|
const normalizedKey = this.normalizeText(key);
|
||||||
|
|
||||||
|
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeText(value: string | null | undefined): string {
|
||||||
|
return (value ?? '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
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/);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const found = this.findFirstHexValue(item);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
for (const item of Object.values(value)) {
|
||||||
|
const found = this.findFirstHexValue(item);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitMatchingVariant(
|
||||||
|
selections: Record<number, number>,
|
||||||
|
variantsMap: ProductVariantMap[],
|
||||||
|
attributes: ProductAttribute[]
|
||||||
|
): void {
|
||||||
|
if (attributes.length === 0 || variantsMap.length === 0) {
|
||||||
|
this.variantChange.emit(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedValuesById: Record<number, string> = {};
|
||||||
|
let allSelected = true;
|
||||||
|
for (const attr of attributes) {
|
||||||
|
const selectedOptionId = selections[attr.id];
|
||||||
|
if (selectedOptionId === undefined) {
|
||||||
|
allSelected = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const option = attr.options.find(o => o.id === selectedOptionId);
|
||||||
|
if (option) {
|
||||||
|
selectedValuesById[attr.id] = this.normalizeText(option.value || option.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allSelected) {
|
||||||
|
this.variantChange.emit(null);
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.variantChange.emit(matchingVariant || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,40 +40,12 @@
|
|||||||
<div class="product-detail__divider"></div>
|
<div class="product-detail__divider"></div>
|
||||||
|
|
||||||
<section class="product-detail__section product-detail__section--attributes">
|
<section class="product-detail__section product-detail__section--attributes">
|
||||||
@for (attribute of renderableAttributes(); track attribute.id) {
|
<app-product-attribute-selector
|
||||||
<div class="product-detail__attribute-row">
|
[attributes]="renderableAttributes()"
|
||||||
<span class="product-detail__attribute-label">{{ attribute.nombre }}:</span>
|
[variantsMap]="prod.variants_map"
|
||||||
|
[defaultVariant]="prod.variant"
|
||||||
<div class="product-detail__attribute-options">
|
(variantChange)="onVariantChange($event)"
|
||||||
@for (option of attribute.options; track option.id) {
|
/>
|
||||||
@if (isColorOption(option)) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="product-detail__attribute-swatch"
|
|
||||||
[class.product-detail__attribute-swatch--selected]="hasSelectedOption(attribute, option)"
|
|
||||||
[style.background-color]="getOptionSwatchColor(option)"
|
|
||||||
[attr.aria-label]="option.label"
|
|
||||||
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
|
||||||
[title]="option.label"
|
|
||||||
(click)="selectAttributeOption(attribute, option)"
|
|
||||||
>
|
|
||||||
<span class="visually-hidden">{{ option.label }}</span>
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="product-detail__attribute-text-option"
|
|
||||||
[class.product-detail__attribute-text-option--selected]="hasSelectedOption(attribute, option)"
|
|
||||||
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
|
||||||
(click)="selectAttributeOption(attribute, option)"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,21 +54,6 @@
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__attribute-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.875rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__attribute-label {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: #A0A0A0;
|
|
||||||
min-width: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__attribute-options,
|
&__attribute-options,
|
||||||
&__actions {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -77,29 +62,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__attribute-swatch {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--selected {
|
|
||||||
border-color: var(--tenant-primary);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 2px rgba(255, 255, 255, 1),
|
|
||||||
0 0 0 3px var(--tenant-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__attribute-text-option,
|
|
||||||
&__quantity-button {
|
&__quantity-button {
|
||||||
border: 1px solid #dcdcdc;
|
border: 1px solid #dcdcdc;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -107,22 +69,6 @@
|
|||||||
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__attribute-text-option {
|
|
||||||
min-width: 34px;
|
|
||||||
min-height: 34px;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.35rem 0.6rem;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
&--selected {
|
|
||||||
background: var(--tenant-primary);
|
|
||||||
border-color: var(--tenant-primary);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__purchase {
|
&__purchase {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -20,15 +20,17 @@ import {
|
|||||||
ProductAttribute,
|
ProductAttribute,
|
||||||
ProductAttributeOption,
|
ProductAttributeOption,
|
||||||
ProductDetail,
|
ProductDetail,
|
||||||
ProductVariant
|
ProductVariant,
|
||||||
|
ProductVariantMap
|
||||||
} from '../../../../core/services/catalog/catalog.interface';
|
} from '../../../../core/services/catalog/catalog.interface';
|
||||||
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
|
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
|
||||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
|
import { ProductAttributeSelectorComponent } from '../../components/product-attribute-selector/product-attribute-selector.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-detail-page',
|
selector: 'app-product-detail-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterModule, ProductCarouselComponent, ButtonComponent],
|
imports: [CommonModule, RouterModule, ProductCarouselComponent, ButtonComponent, ProductAttributeSelectorComponent],
|
||||||
templateUrl: './product-detail-page.component.html',
|
templateUrl: './product-detail-page.component.html',
|
||||||
styleUrl: './product-detail-page.component.scss',
|
styleUrl: './product-detail-page.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -62,7 +64,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
protected readonly loading = signal(false);
|
protected readonly loading = signal(false);
|
||||||
protected readonly error = signal<string | null>(null);
|
protected readonly error = signal<string | null>(null);
|
||||||
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
|
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
|
||||||
protected readonly quantity = signal(1);
|
protected readonly quantity = signal(1);
|
||||||
protected readonly descriptionExpanded = signal(false);
|
protected readonly descriptionExpanded = signal(false);
|
||||||
protected readonly descriptionMaxHeight = signal(0);
|
protected readonly descriptionMaxHeight = signal(0);
|
||||||
@@ -124,7 +126,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
this.productSub = this.catalogService.getProducto(id, defaultVariantId).subscribe({
|
this.productSub = this.catalogService.getProducto(id, defaultVariantId).subscribe({
|
||||||
next: (prod) => {
|
next: (prod) => {
|
||||||
this.product.set(prod);
|
this.product.set(prod);
|
||||||
this.initializeSelections(prod);
|
|
||||||
this.quantity.set(1);
|
this.quantity.set(1);
|
||||||
this.descriptionExpanded.set(false);
|
this.descriptionExpanded.set(false);
|
||||||
this.descriptionHasOverflow.set(false);
|
this.descriptionHasOverflow.set(false);
|
||||||
@@ -151,22 +152,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
return `$${parts.join(',')}`;
|
return `$${parts.join(',')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean {
|
protected onVariantChange(variant: ProductVariantMap | null): void {
|
||||||
return this.selectedAttributeOptions()[attribute.id] === option.id;
|
this.selectedVariant.set(variant);
|
||||||
}
|
|
||||||
|
|
||||||
protected selectAttributeOption(
|
|
||||||
attribute: ProductAttribute,
|
|
||||||
option: ProductAttributeOption
|
|
||||||
): void {
|
|
||||||
this.selectedAttributeOptions.update((current) => ({
|
|
||||||
...current,
|
|
||||||
[attribute.id]: option.id
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected isColorOption(option: ProductAttributeOption): boolean {
|
|
||||||
return this.findFirstHexValue(option.metadata) !== null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected increaseQuantity(): void {
|
protected increaseQuantity(): void {
|
||||||
@@ -181,71 +168,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
this.descriptionExpanded.update((current) => !current);
|
this.descriptionExpanded.update((current) => !current);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getOptionSwatchColor(option: ProductAttributeOption): string {
|
|
||||||
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
|
|
||||||
}
|
|
||||||
|
|
||||||
private initializeSelections(product: ProductDetail): void {
|
|
||||||
const defaultVariant = product.variant;
|
|
||||||
const selections: Record<number, number> = {};
|
|
||||||
|
|
||||||
for (const attribute of product.attributes) {
|
|
||||||
const optionId = this.findDefaultOptionId(attribute, defaultVariant);
|
|
||||||
if (optionId !== null) {
|
|
||||||
selections[attribute.id] = optionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.selectedAttributeOptions.set(selections);
|
|
||||||
}
|
|
||||||
|
|
||||||
private findDefaultOptionId(
|
|
||||||
attribute: ProductAttribute,
|
|
||||||
variant: ProductVariant | null
|
|
||||||
): number | null {
|
|
||||||
if (!variant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions);
|
|
||||||
if (!defaultValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedValue = this.normalizeText(defaultValue);
|
|
||||||
const matchByValue = attribute.options.find(
|
|
||||||
(option) => this.normalizeText(option.value) === normalizedValue
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchByValue) {
|
|
||||||
return matchByValue.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchByLabel = attribute.options.find(
|
|
||||||
(option) => this.normalizeText(option.label) === normalizedValue
|
|
||||||
);
|
|
||||||
|
|
||||||
return matchByLabel?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDefaultVariantAttributeValue(
|
|
||||||
attribute: ProductAttribute,
|
|
||||||
variantAttributes: Record<string, string>
|
|
||||||
): string | null {
|
|
||||||
const normalizedCodigo = this.normalizeText(attribute.codigo);
|
|
||||||
const normalizedNombre = this.normalizeText(attribute.nombre);
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(variantAttributes)) {
|
|
||||||
const normalizedKey = this.normalizeText(key);
|
|
||||||
|
|
||||||
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseIntegerParam(value: string | null): number | null {
|
private parseIntegerParam(value: string | null): number | null {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
@@ -255,42 +177,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
return Number.isInteger(parsed) ? parsed : null;
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeText(value: string | null | undefined): string {
|
|
||||||
return (value ?? '')
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
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/);
|
|
||||||
return match ? match[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) {
|
|
||||||
const found = this.findFirstHexValue(item);
|
|
||||||
if (found) {
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
for (const item of Object.values(value)) {
|
|
||||||
const found = this.findFirstHexValue(item);
|
|
||||||
if (found) {
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bindCarouselResizeObserver(): void {
|
private bindCarouselResizeObserver(): void {
|
||||||
const previewElement = this.getCarouselPreviewElement();
|
const previewElement = this.getCarouselPreviewElement();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user