feat: refactor product detail page to support generic attributes and improve layout with enhanced styling
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user