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 fd1e2b1..c06b543 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 @@ -23,6 +23,8 @@

{{ prod.nombre }}

+
+
{{ getFormattedPrice(prod.precio) }} @@ -34,48 +36,40 @@
-
+ @if (hasRenderableAttributes()) { +
- @if (colorAttribute() || sizeAttribute()) {
- @if (colorAttribute(); as colorAttr) { + @for (attribute of renderableAttributes(); track attribute.id) {
- Color: + {{ attribute.nombre }}: -
- @for (option of colorAttr.options; track option.id) { - - } -
-
- } - - @if (sizeAttribute(); as sizeAttr) { -
- Talle: - -
- @for (option of sizeAttr.options; track option.id) { - +
+ @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 4dc0a3a..d2f34f6 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 @@ -19,11 +19,15 @@ &__title { font-size: 28px; - font-weight: 325; + font-weight: 400; line-height: 1.35; color: #000000; } + &__title-price-divider { + border-top: 1px solid #e6e6e6; + } + &__price-group { display: flex; align-items: baseline; @@ -33,7 +37,7 @@ &__price-current { font-size: 42px; - font-weight: 425; + font-weight: 700; line-height: 1.1; color: var(--tenant-primary); } @@ -59,14 +63,13 @@ &__attribute-label { font-size: 15px; - font-weight: 400; + font-weight: 700; line-height: 1.2; - color: #666666; + color: #A0A0A0; min-width: 44px; } - &__color-options, - &__size-options, + &__attribute-options, &__actions { display: flex; align-items: center; @@ -74,7 +77,7 @@ flex-wrap: wrap; } - &__color-swatch { + &__attribute-swatch { width: 22px; height: 22px; border-radius: 50%; @@ -96,7 +99,7 @@ } } - &__size-option, + &__attribute-text-option, &__quantity-button { border: 1px solid #dcdcdc; background: #ffffff; @@ -104,13 +107,13 @@ transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease; } - &__size-option { + &__attribute-text-option { min-width: 34px; min-height: 34px; border-radius: 4px; padding: 0.35rem 0.6rem; - font-size: 15px; - font-weight: 400; + font-size: 16px; + font-weight: 700; line-height: 1; &--selected { @@ -130,28 +133,34 @@ &__quantity { display: inline-flex; align-items: center; - border: 1px solid #dcdcdc; - border-radius: 4px; + border: 1px solid #d8d8d8; + border-radius: 6px; overflow: hidden; - min-height: 42px; + min-height: 44px; + background: #f6f6f6; } &__quantity-button { - width: 34px; - height: 42px; + width: 25px; + height: 44px; padding: 0; - font-size: 18px; - font-weight: 400; + font-size: 20px; + font-weight: 700; line-height: 1; + color: #6f6f6f; + background: #f6f6f6; + border: 0; + + } &__quantity-value { - min-width: 40px; + min-width: 35px; text-align: center; - font-size: 15px; - font-weight: 400; + font-size: 16px; + font-weight: 700; line-height: 1; - color: #666666; + color: #6f6f6f; } &__actions { diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts index 2677943..6ac07c1 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts @@ -155,7 +155,7 @@ describe('ProductDetailPageComponent', () => { expect(carousel.images()).toEqual([]); }); - it('renders color and size selectors and preselects default variant options', async () => { + it('renders generic attributes and preselects default variant options', async () => { const detailProduct: ProductDetail = { ...mockProduct, attributes: [ @@ -185,23 +185,23 @@ describe('ProductDetailPageComponent', () => { }, { id: 2, - codigo: 'talle', - nombre: 'Talle', + codigo: 'material', + nombre: 'Material', is_required: true, metadata_schema: null, type: 'select', options: [ { id: 20, - value: 'S', - label: 'S', + value: 'mesh', + label: 'Mesh', sort_order: 1, metadata: null }, { id: 21, - value: 'M', - label: 'M', + value: 'cuero', + label: 'Cuero', sort_order: 2, metadata: null } @@ -213,7 +213,7 @@ describe('ProductDetailPageComponent', () => { images: ['https://example.com/variant1.png'], attributes: { color: 'beige', - talle: 'M' + material: 'Cuero' } } }; @@ -224,14 +224,29 @@ describe('ProductDetailPageComponent', () => { fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; - const colorOptions = element.querySelectorAll('.product-detail__color-swatch'); - const sizeOptions = element.querySelectorAll('.product-detail__size-option'); + const swatches = element.querySelectorAll('.product-detail__attribute-swatch'); + const textOptions = element.querySelectorAll('.product-detail__attribute-text-option'); + const labels = Array.from(element.querySelectorAll('.product-detail__attribute-label')).map((label) => + label.textContent?.trim() + ); - expect(colorOptions).toHaveLength(2); - expect(sizeOptions).toHaveLength(2); - expect(colorOptions[0].classList.contains('product-detail__color-swatch--selected')).toBe(true); - expect(sizeOptions[1].classList.contains('product-detail__size-option--selected')).toBe(true); - expect((colorOptions[0] as HTMLElement).style.backgroundColor).not.toBe(''); + expect(labels).toEqual(['Color:', 'Material:']); + expect(swatches).toHaveLength(2); + expect(textOptions).toHaveLength(2); + expect(swatches[0].classList.contains('product-detail__attribute-swatch--selected')).toBe(true); + expect(textOptions[1].classList.contains('product-detail__attribute-text-option--selected')).toBe(true); + expect((swatches[0] as HTMLElement).style.backgroundColor).not.toBe(''); + }); + + it('hides the attributes block and its extra divider when no attributes are present', async () => { + await configureTestingModule(); + const fixture = TestBed.createComponent(ProductDetailPageComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('.product-detail__section--attributes')).toBeNull(); + expect(element.querySelectorAll('.product-detail__divider')).toHaveLength(2); }); it('keeps quantity at a minimum of one and increments locally', async () => { 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 b32c6a0..3bed3d4 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 @@ -59,17 +59,16 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { }); protected readonly loading = signal(false); protected readonly error = signal(null); - protected readonly selectedColor = signal(null); - protected readonly selectedSize = signal(null); + protected readonly selectedAttributeOptions = signal>({}); protected readonly quantity = signal(1); protected readonly descriptionExpanded = signal(false); protected readonly descriptionMaxHeight = signal(0); protected readonly descriptionHasOverflow = signal(false); - protected readonly colorAttribute = computed(() => - this.findAttribute(this.product()?.attributes ?? [], ['color', 'colour']) + protected readonly renderableAttributes = computed(() => + (this.product()?.attributes ?? []).filter((attribute) => attribute.options.length > 0) ); - protected readonly sizeAttribute = computed(() => - this.findAttribute(this.product()?.attributes ?? [], ['talle', 'talla', 'size']) + protected readonly hasRenderableAttributes = computed( + () => this.renderableAttributes().length > 0 ); protected readonly oldPrice = computed(() => null); protected readonly showDescriptionToggle = computed( @@ -147,20 +146,22 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { return `$${parts.join(',')}`; } - protected hasColorSelection(optionId: number): boolean { - return this.selectedColor() === optionId; + protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean { + return this.selectedAttributeOptions()[attribute.id] === option.id; } - protected hasSizeSelection(optionId: number): boolean { - return this.selectedSize() === optionId; + protected selectAttributeOption( + attribute: ProductAttribute, + option: ProductAttributeOption + ): void { + this.selectedAttributeOptions.update((current) => ({ + ...current, + [attribute.id]: option.id + })); } - protected selectColor(optionId: number): void { - this.selectedColor.set(optionId); - } - - protected selectSize(optionId: number): void { - this.selectedSize.set(optionId); + protected isColorOption(option: ProductAttributeOption): boolean { + return this.findFirstHexValue(option.metadata) !== null; } protected increaseQuantity(): void { @@ -180,19 +181,24 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { } private initializeSelections(product: ProductDetail): void { - const colorAttribute = this.findAttribute(product.attributes, ['color', 'colour']); - const sizeAttribute = this.findAttribute(product.attributes, ['talle', 'talla', 'size']); const defaultVariant = product.default_variant; + const selections: Record = {}; - this.selectedColor.set(this.findDefaultOptionId(colorAttribute, defaultVariant)); - this.selectedSize.set(this.findDefaultOptionId(sizeAttribute, defaultVariant)); + 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 | undefined, + attribute: ProductAttribute, variant: ProductVariant | null ): number | null { - if (!attribute || !variant) { + if (!variant) { return null; } @@ -234,26 +240,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { return null; } - - private findAttribute( - attributes: ProductAttribute[], - aliases: string[] - ): ProductAttribute | undefined { - const normalizedAliases = aliases.map((alias) => this.normalizeText(alias)); - - const codeMatch = attributes.find((attribute) => - normalizedAliases.includes(this.normalizeText(attribute.codigo)) - ); - - if (codeMatch) { - return codeMatch; - } - - return attributes.find((attribute) => - normalizedAliases.includes(this.normalizeText(attribute.nombre)) - ); - } - private normalizeText(value: string | null | undefined): string { return (value ?? '') .normalize('NFD')