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();