feat: enhance product detail page with improved layout, styling, and functionality for attributes and purchase actions

This commit is contained in:
2026-06-30 10:59:53 -03:00
parent 57a1e66d1a
commit 6cba0cf779
5 changed files with 814 additions and 15 deletions

View File

@@ -11,10 +11,145 @@
{{ error() }}
</div>
} @else if (product(); as prod) {
<div class="row">
<!-- Carousel takes ~33.3% width on md/lg screens -->
<div class="col-12 col-md-5">
<app-product-carousel [images]="carouselImages()" [discount]="20" />
<div class="row g-4 g-xl-5 align-items-start">
<div class="col-12 col-lg-5">
<div #carouselHost class="product-detail__carousel">
<app-product-carousel [images]="carouselImages()" />
</div>
</div>
<div class="col-12 col-lg-7">
<div class="product-detail__panel">
<section class="product-detail__section">
<h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1>
<div class="product-detail__price-group">
<span class="product-detail__price-current">
{{ getFormattedPrice(prod.precio) }}
</span>
@if (oldPrice(); as oldPrice) {
<span class="product-detail__price-previous">{{ oldPrice }}</span>
}
</div>
</section>
<div class="product-detail__divider"></div>
@if (colorAttribute() || sizeAttribute()) {
<section class="product-detail__section product-detail__section--attributes">
@if (colorAttribute(); as colorAttr) {
<div class="product-detail__attribute-row">
<span class="product-detail__attribute-label">Color:</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>
</div>
}
</section>
}
<div class="product-detail__divider"></div>
<section class="product-detail__section">
<div class="product-detail__purchase">
<div class="product-detail__quantity" aria-label="Selector de cantidad">
<button
type="button"
class="product-detail__quantity-button"
aria-label="Disminuir cantidad"
(click)="decreaseQuantity()"
>
-
</button>
<span class="product-detail__quantity-value">{{ quantity() }}</span>
<button
type="button"
class="product-detail__quantity-button"
aria-label="Aumentar cantidad"
(click)="increaseQuantity()"
>
+
</button>
</div>
<div class="product-detail__actions">
<app-button
class="product-detail__cta"
variant="secondary"
type="button"
>
Agregar al carrito
</app-button>
<app-button class="product-detail__cta" type="button">
Comprar
</app-button>
</div>
</div>
</section>
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--description">
<h2 class="product-detail__description-heading">DESCRIPCIÓN</h2>
<div
#descriptionBody
class="product-detail__description-body"
[class.product-detail__description-body--expanded]="descriptionExpanded()"
[style.max-height.px]="descriptionExpanded() ? descriptionMaxHeight() || null : null"
>
{{ prod.descripcion }}
</div>
@if (showDescriptionToggle()) {
<button
type="button"
class="product-detail__description-toggle"
(click)="toggleDescription()"
>
{{ descriptionExpanded() ? 'Mostrar menos' : 'Mostrar más' }}
</button>
}
</section>
</div>
</div>
</div>
} @else {
@@ -24,5 +159,3 @@
}
</div>
</section>

View File

@@ -1,2 +1,221 @@
/* empty */
.product-detail {
color: #000000;
&__panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
&__section {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__divider {
border-top: 1px solid #e6e6e6;
}
&__title {
font-size: 28px;
font-weight: 325;
line-height: 1.35;
color: #000000;
}
&__price-group {
display: flex;
align-items: baseline;
gap: 0.875rem;
flex-wrap: wrap;
}
&__price-current {
font-size: 42px;
font-weight: 425;
line-height: 1.1;
color: var(--tenant-primary);
}
&__price-previous {
font-size: 19px;
font-weight: 325;
line-height: 1.2;
color: #a0a0a0;
text-decoration: line-through;
}
&__section--attributes {
gap: 1.25rem;
}
&__attribute-row {
display: flex;
align-items: center;
gap: 0.875rem;
flex-wrap: wrap;
}
&__attribute-label {
font-size: 15px;
font-weight: 400;
line-height: 1.2;
color: #666666;
min-width: 44px;
}
&__color-options,
&__size-options,
&__actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
&__color-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);
}
}
&__size-option,
&__quantity-button {
border: 1px solid #dcdcdc;
background: #ffffff;
color: #666666;
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
}
&__size-option {
min-width: 34px;
min-height: 34px;
border-radius: 4px;
padding: 0.35rem 0.6rem;
font-size: 15px;
font-weight: 400;
line-height: 1;
&--selected {
background: var(--tenant-primary);
border-color: var(--tenant-primary);
color: #ffffff;
}
}
&__purchase {
display: flex;
align-items: stretch;
gap: 0.875rem;
flex-wrap: wrap;
}
&__quantity {
display: inline-flex;
align-items: center;
border: 1px solid #dcdcdc;
border-radius: 4px;
overflow: hidden;
min-height: 42px;
}
&__quantity-button {
width: 34px;
height: 42px;
padding: 0;
font-size: 18px;
font-weight: 400;
line-height: 1;
}
&__quantity-value {
min-width: 40px;
text-align: center;
font-size: 15px;
font-weight: 400;
line-height: 1;
color: #666666;
}
&__actions {
flex: 1 1 0;
}
&__description-heading {
margin: 0;
font-size: 15px;
font-weight: 400;
line-height: 1.2;
letter-spacing: 0.04em;
color: #666666;
}
&__description-body {
font-size: 14px;
font-weight: 325;
line-height: 1.45;
color: #666666;
white-space: pre-line;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
&--expanded {
display: block;
-webkit-line-clamp: unset;
max-height: none;
overflow-y: auto;
padding-right: 0.35rem;
}
}
&__description-toggle {
align-self: center;
border: 0;
background: transparent;
padding: 0;
font-size: 14px;
font-weight: 400;
color: var(--tenant-primary);
}
}
app-button.product-detail__cta {
flex: 1 1 0;
min-width: 0;
}
@media (max-width: 991.98px) {
.product-detail {
&__panel {
gap: 1.25rem;
}
&__purchase {
flex-direction: column;
}
&__actions {
width: 100%;
flex-direction: column;
}
}
}

View File

@@ -1,9 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { By } from '@angular/platform-browser';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { convertToParamMap } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
@@ -30,6 +34,17 @@ describe('ProductDetailPageComponent', () => {
let catalogServiceStub: any;
let routerStub: any;
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
beforeEach(() => {
vi.restoreAllMocks();
paramMapSubject = new BehaviorSubject(convertToParamMap({ id: '1' }));
@@ -70,8 +85,11 @@ describe('ProductDetailPageComponent', () => {
expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1);
// Check that ProductCarouselComponent is embedded
expect(element.querySelector('app-product-carousel')).not.toBeNull();
expect(element.querySelector('.product-detail__title')?.textContent).toContain('Auriculares Bluetooth');
expect(element.querySelector('.product-detail__price-current')?.textContent).toContain('$24.999');
expect(element.querySelector('.product-detail__price-previous')).toBeNull();
expect(element.querySelector('.product-carousel__discount-badge')).toBeNull();
});
it('shows error message if load fails', async () => {
@@ -136,5 +154,154 @@ describe('ProductDetailPageComponent', () => {
const carousel = fixture.debugElement.query(By.css('app-product-carousel')).componentInstance;
expect(carousel.images()).toEqual([]);
});
it('renders color and size selectors and preselects default variant options', async () => {
const detailProduct: ProductDetail = {
...mockProduct,
attributes: [
{
id: 1,
codigo: 'color',
nombre: 'Color',
is_required: true,
metadata_schema: null,
type: 'select',
options: [
{
id: 10,
value: 'beige',
label: 'Beige',
sort_order: 1,
metadata: { hex: '#D8D1C7' }
},
{
id: 11,
value: 'brown',
label: 'Marrón',
sort_order: 2,
metadata: { palette: { primary: '#7E6460' } }
}
]
},
{
id: 2,
codigo: 'talle',
nombre: 'Talle',
is_required: true,
metadata_schema: null,
type: 'select',
options: [
{
id: 20,
value: 'S',
label: 'S',
sort_order: 1,
metadata: null
},
{
id: 21,
value: 'M',
label: 'M',
sort_order: 2,
metadata: null
}
]
}
],
default_variant: {
variant_id: 123,
images: ['https://example.com/variant1.png'],
attributes: {
color: 'beige',
talle: 'M'
}
}
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
await configureTestingModule();
const fixture = TestBed.createComponent(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');
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('');
});
it('keeps quantity at a minimum of one and increments locally', async () => {
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const decreaseButton = element.querySelector(
'[aria-label="Disminuir cantidad"]'
) as HTMLButtonElement;
const increaseButton = element.querySelector(
'[aria-label="Aumentar cantidad"]'
) as HTMLButtonElement;
decreaseButton.click();
fixture.detectChanges();
expect(element.querySelector('.product-detail__quantity-value')?.textContent?.trim()).toBe('1');
increaseButton.click();
increaseButton.click();
fixture.detectChanges();
expect(element.querySelector('.product-detail__quantity-value')?.textContent?.trim()).toBe('3');
});
it('toggles the full description state when overflow is available', async () => {
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
(fixture.componentInstance as any).descriptionHasOverflow.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const toggleButton = element.querySelector(
'.product-detail__description-toggle'
) as HTMLButtonElement;
expect(toggleButton.textContent?.trim()).toBe('Mostrar más');
toggleButton.click();
fixture.detectChanges();
expect(
element
.querySelector('.product-detail__description-body')
?.classList.contains('product-detail__description-body--expanded')
).toBe(true);
expect(toggleButton.textContent?.trim()).toBe('Mostrar menos');
});
it('renders purchase actions without side effects', async () => {
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('app-button button')
) as HTMLButtonElement[];
expect(buttons.map((button) => button.textContent?.trim())).toEqual([
'Agregar al carrito',
'Comprar'
]);
buttons[0].click();
buttons[1].click();
expect(routerStub.navigate).not.toHaveBeenCalled();
});
});

View File

@@ -1,16 +1,33 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
OnInit,
computed,
effect,
inject,
signal,
viewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subscription } from 'rxjs';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
import {
ProductAttribute,
ProductAttributeOption,
ProductDetail,
ProductVariant
} from '../../../../core/services/catalog/catalog.interface';
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
@Component({
selector: 'app-product-detail-page',
standalone: true,
imports: [CommonModule, RouterModule, ProductCarouselComponent],
imports: [CommonModule, RouterModule, ProductCarouselComponent, ButtonComponent],
templateUrl: './product-detail-page.component.html',
styleUrl: './product-detail-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -19,9 +36,14 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly catalogService = inject(CatalogService);
private readonly carouselHost = viewChild<ElementRef<HTMLElement>>('carouselHost');
private readonly descriptionBody = viewChild<ElementRef<HTMLElement>>('descriptionBody');
private routeSub: Subscription | null = null;
private productSub: Subscription | null = null;
private carouselResizeObserver: ResizeObserver | null = null;
private observedCarouselPreview: HTMLElement | null = null;
private measurementTimer: ReturnType<typeof setTimeout> | null = null;
protected readonly product = signal<ProductDetail | null>(null);
protected readonly carouselImages = computed(() => {
@@ -37,6 +59,36 @@ 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 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 sizeAttribute = computed(() =>
this.findAttribute(this.product()?.attributes ?? [], ['talle', 'talla', 'size'])
);
protected readonly oldPrice = computed<string | null>(() => null);
protected readonly showDescriptionToggle = computed(
() => this.descriptionExpanded() || this.descriptionHasOverflow()
);
constructor() {
effect(() => {
this.carouselHost();
this.descriptionBody();
this.product();
this.descriptionExpanded();
queueMicrotask(() => {
this.bindCarouselResizeObserver();
this.refreshMeasurements();
});
});
}
ngOnInit(): void {
this.routeSub = this.route.paramMap.subscribe((params) => {
@@ -55,6 +107,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.routeSub?.unsubscribe();
this.productSub?.unsubscribe();
this.carouselResizeObserver?.disconnect();
this.clearMeasurementTimer();
}
private loadProduct(id: number): void {
@@ -66,6 +120,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.productSub = this.catalogService.getProducto(id).subscribe({
next: (prod) => {
this.product.set(prod);
this.initializeSelections(prod);
this.quantity.set(1);
this.descriptionExpanded.set(false);
this.descriptionHasOverflow.set(false);
this.loading.set(false);
},
error: () => {
@@ -88,4 +146,217 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `$${parts.join(',')}`;
}
protected hasColorSelection(optionId: number): boolean {
return this.selectedColor() === optionId;
}
protected hasSizeSelection(optionId: number): boolean {
return this.selectedSize() === optionId;
}
protected selectColor(optionId: number): void {
this.selectedColor.set(optionId);
}
protected selectSize(optionId: number): void {
this.selectedSize.set(optionId);
}
protected increaseQuantity(): void {
this.quantity.update((current) => current + 1);
}
protected decreaseQuantity(): void {
this.quantity.update((current) => Math.max(1, current - 1));
}
protected toggleDescription(): void {
this.descriptionExpanded.update((current) => !current);
}
protected getOptionSwatchColor(option: ProductAttributeOption): string {
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
}
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;
this.selectedColor.set(this.findDefaultOptionId(colorAttribute, defaultVariant));
this.selectedSize.set(this.findDefaultOptionId(sizeAttribute, defaultVariant));
}
private findDefaultOptionId(
attribute: ProductAttribute | undefined,
variant: ProductVariant | null
): number | null {
if (!attribute || !variant) {
return null;
}
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes);
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, string>
): 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 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')
.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();
if (!previewElement || previewElement === this.observedCarouselPreview) {
return;
}
this.carouselResizeObserver?.disconnect();
this.observedCarouselPreview = previewElement;
if (typeof ResizeObserver === 'undefined') {
this.updateDescriptionMaxHeight();
return;
}
this.carouselResizeObserver = new ResizeObserver(() => {
this.scheduleMeasurement();
});
this.carouselResizeObserver.observe(previewElement);
this.updateDescriptionMaxHeight();
}
private scheduleMeasurement(): void {
this.clearMeasurementTimer();
this.measurementTimer = setTimeout(() => {
this.measurementTimer = null;
this.refreshMeasurements();
}, 0);
}
private clearMeasurementTimer(): void {
if (this.measurementTimer) {
clearTimeout(this.measurementTimer);
this.measurementTimer = null;
}
}
private refreshMeasurements(): void {
this.updateDescriptionMaxHeight();
this.updateDescriptionOverflow();
}
private updateDescriptionMaxHeight(): void {
const previewElement = this.getCarouselPreviewElement();
const nextHeight = previewElement?.getBoundingClientRect().height ?? 0;
if (nextHeight > 0) {
this.descriptionMaxHeight.set(nextHeight);
}
}
private updateDescriptionOverflow(): void {
const descriptionElement = this.descriptionBody()?.nativeElement;
if (!descriptionElement || this.descriptionExpanded()) {
return;
}
this.descriptionHasOverflow.set(
descriptionElement.scrollHeight - descriptionElement.clientHeight > 1
);
}
private getCarouselPreviewElement(): HTMLElement | null {
return (
this.carouselHost()?.nativeElement.querySelector<HTMLElement>('.product-carousel__main') ??
null
);
}
}

View File

@@ -2,6 +2,11 @@
display: inline-block;
}
:host(.product-detail__cta) {
display: block;
width: 100%;
}
.btn {
min-height: 40px;
font-weight: 700;
@@ -9,6 +14,10 @@
min-width: 216px;
}
:host(.product-detail__cta) .btn {
width: 100%;
}
.btn-primary,
.btn-secondary,
.btn-danger {