diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html new file mode 100644 index 0000000..96f43aa --- /dev/null +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html @@ -0,0 +1,36 @@ +
+ @for (attribute of attributes(); track attribute.id) { +
+ {{ attribute.nombre }}: + +
+ @for (option of attribute.options; track option.id) { + @if (isColorOption(option)) { + + } @else { + + } + } +
+
+ } +
diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.scss b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.scss new file mode 100644 index 0000000..4d4a831 --- /dev/null +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.scss @@ -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; + } + } +} diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts new file mode 100644 index 0000000..d4f6bde --- /dev/null +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts @@ -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([]); + public variantsMap = input([]); + public defaultVariant = input(null); + + public variantChange = output(); + + protected readonly selectedAttributeOptions = signal>({}); + + 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 = {}; + + 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 | 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, + variantsMap: ProductVariantMap[], + attributes: ProductAttribute[] + ): void { + if (attributes.length === 0 || variantsMap.length === 0) { + this.variantChange.emit(null); + return; + } + + const selectedValuesById: Record = {}; + 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); + } +} diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html index c06b543..a919b5d 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html @@ -40,40 +40,12 @@
- @for (attribute of renderableAttributes(); track attribute.id) { -
- {{ attribute.nombre }}: - -
- @for (option of attribute.options; track option.id) { - @if (isColorOption(option)) { - - } @else { - - } - } -
-
- } +
} diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.scss b/src/app/features/store/pages/product-detail-page/product-detail-page.component.scss index d2f34f6..2ccce13 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.scss +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.scss @@ -54,21 +54,6 @@ 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, &__actions { display: flex; @@ -77,29 +62,6 @@ 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 { border: 1px solid #dcdcdc; background: #ffffff; @@ -107,22 +69,6 @@ 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 { display: flex; align-items: stretch; diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts index dcd296c..d4fdde9 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts @@ -20,15 +20,17 @@ import { ProductAttribute, ProductAttributeOption, ProductDetail, - ProductVariant + ProductVariant, + ProductVariantMap } from '../../../../core/services/catalog/catalog.interface'; import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component'; +import { ProductAttributeSelectorComponent } from '../../components/product-attribute-selector/product-attribute-selector.component'; @Component({ selector: 'app-product-detail-page', standalone: true, - imports: [CommonModule, RouterModule, ProductCarouselComponent, ButtonComponent], + imports: [CommonModule, RouterModule, ProductCarouselComponent, ButtonComponent, ProductAttributeSelectorComponent], templateUrl: './product-detail-page.component.html', styleUrl: './product-detail-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush @@ -62,7 +64,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { }); protected readonly loading = signal(false); protected readonly error = signal(null); - protected readonly selectedAttributeOptions = signal>({}); + protected readonly selectedVariant = signal(null); protected readonly quantity = signal(1); protected readonly descriptionExpanded = signal(false); protected readonly descriptionMaxHeight = signal(0); @@ -124,7 +126,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { this.productSub = this.catalogService.getProducto(id, defaultVariantId).subscribe({ next: (prod) => { this.product.set(prod); - this.initializeSelections(prod); this.quantity.set(1); this.descriptionExpanded.set(false); this.descriptionHasOverflow.set(false); @@ -151,22 +152,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { return `$${parts.join(',')}`; } - 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 onVariantChange(variant: ProductVariantMap | null): void { + this.selectedVariant.set(variant); } protected increaseQuantity(): void { @@ -181,71 +168,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { 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 = {}; - - 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 | 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 { if (!value) { return null; @@ -255,42 +177,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { 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 { const previewElement = this.getCarouselPreviewElement();