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">
<h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1>
<div class="product-detail__title-price-divider"></div>
<div class="product-detail__price-group">
<span class="product-detail__price-current">
{{ getFormattedPrice(prod.precio) }}
@@ -34,48 +36,40 @@
</div>
</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">
@if (colorAttribute(); as colorAttr) {
@for (attribute of renderableAttributes(); track attribute.id) {
<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">
@for (option of colorAttr.options; track option.id) {
<button
type="button"
class="product-detail__color-swatch"
[class.product-detail__color-swatch--selected]="hasColorSelection(option.id)"
[style.background-color]="getOptionSwatchColor(option)"
[attr.aria-label]="option.label"
[attr.aria-pressed]="hasColorSelection(option.id)"
[title]="option.label"
(click)="selectColor(option.id)"
>
<span class="visually-hidden">{{ option.label }}</span>
</button>
}
</div>
</div>
}
@if (sizeAttribute(); as sizeAttr) {
<div class="product-detail__attribute-row">
<span class="product-detail__attribute-label">Talle:</span>
<div class="product-detail__size-options">
@for (option of sizeAttr.options; track option.id) {
<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 class="product-detail__attribute-options">
@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>

View File

@@ -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 {

View File

@@ -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 () => {

View File

@@ -59,17 +59,16 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
});
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly selectedColor = signal<number | null>(null);
protected readonly selectedSize = signal<number | null>(null);
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
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<string | null>(() => 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<number, number> = {};
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')