feat: enhance product detail page with improved layout, styling, and functionality for attributes and purchase actions
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user