feat: refactor product detail page to support generic attributes and improve layout with enhanced styling

This commit is contained in:
2026-06-30 11:12:50 -03:00
parent 6cba0cf779
commit 8d9a828df0
4 changed files with 121 additions and 117 deletions

View File

@@ -23,6 +23,8 @@
<section class="product-detail__section"> <section class="product-detail__section">
<h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1> <h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1>
<div class="product-detail__title-price-divider"></div>
<div class="product-detail__price-group"> <div class="product-detail__price-group">
<span class="product-detail__price-current"> <span class="product-detail__price-current">
{{ getFormattedPrice(prod.precio) }} {{ getFormattedPrice(prod.precio) }}
@@ -34,48 +36,40 @@
</div> </div>
</section> </section>
<div class="product-detail__divider"></div> @if (hasRenderableAttributes()) {
<div class="product-detail__divider"></div>
@if (colorAttribute() || sizeAttribute()) {
<section class="product-detail__section product-detail__section--attributes"> <section class="product-detail__section product-detail__section--attributes">
@if (colorAttribute(); as colorAttr) { @for (attribute of renderableAttributes(); track attribute.id) {
<div class="product-detail__attribute-row"> <div class="product-detail__attribute-row">
<span class="product-detail__attribute-label">Color:</span> <span class="product-detail__attribute-label">{{ attribute.nombre }}:</span>
<div class="product-detail__color-options"> <div class="product-detail__attribute-options">
@for (option of colorAttr.options; track option.id) { @for (option of attribute.options; track option.id) {
<button @if (isColorOption(option)) {
type="button" <button
class="product-detail__color-swatch" type="button"
[class.product-detail__color-swatch--selected]="hasColorSelection(option.id)" class="product-detail__attribute-swatch"
[style.background-color]="getOptionSwatchColor(option)" [class.product-detail__attribute-swatch--selected]="hasSelectedOption(attribute, option)"
[attr.aria-label]="option.label" [style.background-color]="getOptionSwatchColor(option)"
[attr.aria-pressed]="hasColorSelection(option.id)" [attr.aria-label]="option.label"
[title]="option.label" [attr.aria-pressed]="hasSelectedOption(attribute, option)"
(click)="selectColor(option.id)" [title]="option.label"
> (click)="selectAttributeOption(attribute, option)"
<span class="visually-hidden">{{ option.label }}</span> >
</button> <span class="visually-hidden">{{ option.label }}</span>
} </button>
</div> } @else {
</div> <button
} type="button"
class="product-detail__attribute-text-option"
@if (sizeAttribute(); as sizeAttr) { [class.product-detail__attribute-text-option--selected]="hasSelectedOption(attribute, option)"
<div class="product-detail__attribute-row"> [attr.aria-pressed]="hasSelectedOption(attribute, option)"
<span class="product-detail__attribute-label">Talle:</span> (click)="selectAttributeOption(attribute, option)"
>
<div class="product-detail__size-options"> {{ option.label }}
@for (option of sizeAttr.options; track option.id) { </button>
<button }
type="button"
class="product-detail__size-option"
[class.product-detail__size-option--selected]="hasSizeSelection(option.id)"
[attr.aria-pressed]="hasSizeSelection(option.id)"
(click)="selectSize(option.id)"
>
{{ option.label }}
</button>
} }
</div> </div>
</div> </div>

View File

@@ -19,11 +19,15 @@
&__title { &__title {
font-size: 28px; font-size: 28px;
font-weight: 325; font-weight: 400;
line-height: 1.35; line-height: 1.35;
color: #000000; color: #000000;
} }
&__title-price-divider {
border-top: 1px solid #e6e6e6;
}
&__price-group { &__price-group {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -33,7 +37,7 @@
&__price-current { &__price-current {
font-size: 42px; font-size: 42px;
font-weight: 425; font-weight: 700;
line-height: 1.1; line-height: 1.1;
color: var(--tenant-primary); color: var(--tenant-primary);
} }
@@ -59,14 +63,13 @@
&__attribute-label { &__attribute-label {
font-size: 15px; font-size: 15px;
font-weight: 400; font-weight: 700;
line-height: 1.2; line-height: 1.2;
color: #666666; color: #A0A0A0;
min-width: 44px; min-width: 44px;
} }
&__color-options, &__attribute-options,
&__size-options,
&__actions { &__actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -74,7 +77,7 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
&__color-swatch { &__attribute-swatch {
width: 22px; width: 22px;
height: 22px; height: 22px;
border-radius: 50%; border-radius: 50%;
@@ -96,7 +99,7 @@
} }
} }
&__size-option, &__attribute-text-option,
&__quantity-button { &__quantity-button {
border: 1px solid #dcdcdc; border: 1px solid #dcdcdc;
background: #ffffff; background: #ffffff;
@@ -104,13 +107,13 @@
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;
} }
&__size-option { &__attribute-text-option {
min-width: 34px; min-width: 34px;
min-height: 34px; min-height: 34px;
border-radius: 4px; border-radius: 4px;
padding: 0.35rem 0.6rem; padding: 0.35rem 0.6rem;
font-size: 15px; font-size: 16px;
font-weight: 400; font-weight: 700;
line-height: 1; line-height: 1;
&--selected { &--selected {
@@ -130,28 +133,34 @@
&__quantity { &__quantity {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
border: 1px solid #dcdcdc; border: 1px solid #d8d8d8;
border-radius: 4px; border-radius: 6px;
overflow: hidden; overflow: hidden;
min-height: 42px; min-height: 44px;
background: #f6f6f6;
} }
&__quantity-button { &__quantity-button {
width: 34px; width: 25px;
height: 42px; height: 44px;
padding: 0; padding: 0;
font-size: 18px; font-size: 20px;
font-weight: 400; font-weight: 700;
line-height: 1; line-height: 1;
color: #6f6f6f;
background: #f6f6f6;
border: 0;
} }
&__quantity-value { &__quantity-value {
min-width: 40px; min-width: 35px;
text-align: center; text-align: center;
font-size: 15px; font-size: 16px;
font-weight: 400; font-weight: 700;
line-height: 1; line-height: 1;
color: #666666; color: #6f6f6f;
} }
&__actions { &__actions {

View File

@@ -155,7 +155,7 @@ describe('ProductDetailPageComponent', () => {
expect(carousel.images()).toEqual([]); 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 = { const detailProduct: ProductDetail = {
...mockProduct, ...mockProduct,
attributes: [ attributes: [
@@ -185,23 +185,23 @@ describe('ProductDetailPageComponent', () => {
}, },
{ {
id: 2, id: 2,
codigo: 'talle', codigo: 'material',
nombre: 'Talle', nombre: 'Material',
is_required: true, is_required: true,
metadata_schema: null, metadata_schema: null,
type: 'select', type: 'select',
options: [ options: [
{ {
id: 20, id: 20,
value: 'S', value: 'mesh',
label: 'S', label: 'Mesh',
sort_order: 1, sort_order: 1,
metadata: null metadata: null
}, },
{ {
id: 21, id: 21,
value: 'M', value: 'cuero',
label: 'M', label: 'Cuero',
sort_order: 2, sort_order: 2,
metadata: null metadata: null
} }
@@ -213,7 +213,7 @@ describe('ProductDetailPageComponent', () => {
images: ['https://example.com/variant1.png'], images: ['https://example.com/variant1.png'],
attributes: { attributes: {
color: 'beige', color: 'beige',
talle: 'M' material: 'Cuero'
} }
} }
}; };
@@ -224,14 +224,29 @@ describe('ProductDetailPageComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const colorOptions = element.querySelectorAll('.product-detail__color-swatch'); const swatches = element.querySelectorAll('.product-detail__attribute-swatch');
const sizeOptions = element.querySelectorAll('.product-detail__size-option'); 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(labels).toEqual(['Color:', 'Material:']);
expect(sizeOptions).toHaveLength(2); expect(swatches).toHaveLength(2);
expect(colorOptions[0].classList.contains('product-detail__color-swatch--selected')).toBe(true); expect(textOptions).toHaveLength(2);
expect(sizeOptions[1].classList.contains('product-detail__size-option--selected')).toBe(true); expect(swatches[0].classList.contains('product-detail__attribute-swatch--selected')).toBe(true);
expect((colorOptions[0] as HTMLElement).style.backgroundColor).not.toBe(''); 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 () => { it('keeps quantity at a minimum of one and increments locally', async () => {

View File

@@ -59,17 +59,16 @@ 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 selectedColor = signal<number | null>(null); protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
protected readonly selectedSize = signal<number | 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);
protected readonly descriptionHasOverflow = signal(false); protected readonly descriptionHasOverflow = signal(false);
protected readonly colorAttribute = computed(() => protected readonly renderableAttributes = computed(() =>
this.findAttribute(this.product()?.attributes ?? [], ['color', 'colour']) (this.product()?.attributes ?? []).filter((attribute) => attribute.options.length > 0)
); );
protected readonly sizeAttribute = computed(() => protected readonly hasRenderableAttributes = computed(
this.findAttribute(this.product()?.attributes ?? [], ['talle', 'talla', 'size']) () => this.renderableAttributes().length > 0
); );
protected readonly oldPrice = computed<string | null>(() => null); protected readonly oldPrice = computed<string | null>(() => null);
protected readonly showDescriptionToggle = computed( protected readonly showDescriptionToggle = computed(
@@ -147,20 +146,22 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
return `$${parts.join(',')}`; return `$${parts.join(',')}`;
} }
protected hasColorSelection(optionId: number): boolean { protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean {
return this.selectedColor() === optionId; return this.selectedAttributeOptions()[attribute.id] === option.id;
} }
protected hasSizeSelection(optionId: number): boolean { protected selectAttributeOption(
return this.selectedSize() === optionId; attribute: ProductAttribute,
option: ProductAttributeOption
): void {
this.selectedAttributeOptions.update((current) => ({
...current,
[attribute.id]: option.id
}));
} }
protected selectColor(optionId: number): void { protected isColorOption(option: ProductAttributeOption): boolean {
this.selectedColor.set(optionId); return this.findFirstHexValue(option.metadata) !== null;
}
protected selectSize(optionId: number): void {
this.selectedSize.set(optionId);
} }
protected increaseQuantity(): void { protected increaseQuantity(): void {
@@ -180,19 +181,24 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
private initializeSelections(product: ProductDetail): void { 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 defaultVariant = product.default_variant;
const selections: Record<number, number> = {};
this.selectedColor.set(this.findDefaultOptionId(colorAttribute, defaultVariant)); for (const attribute of product.attributes) {
this.selectedSize.set(this.findDefaultOptionId(sizeAttribute, defaultVariant)); const optionId = this.findDefaultOptionId(attribute, defaultVariant);
if (optionId !== null) {
selections[attribute.id] = optionId;
}
}
this.selectedAttributeOptions.set(selections);
} }
private findDefaultOptionId( private findDefaultOptionId(
attribute: ProductAttribute | undefined, attribute: ProductAttribute,
variant: ProductVariant | null variant: ProductVariant | null
): number | null { ): number | null {
if (!attribute || !variant) { if (!variant) {
return null; return null;
} }
@@ -234,26 +240,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
return null; 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 { private normalizeText(value: string | null | undefined): string {
return (value ?? '') return (value ?? '')
.normalize('NFD') .normalize('NFD')