Compare commits
2 Commits
57a1e66d1a
...
8d9a828df0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d9a828df0 | |||
| 6cba0cf779 |
@@ -11,10 +11,139 @@
|
|||||||
{{ error() }}
|
{{ error() }}
|
||||||
</div>
|
</div>
|
||||||
} @else if (product(); as prod) {
|
} @else if (product(); as prod) {
|
||||||
<div class="row">
|
<div class="row g-4 g-xl-5 align-items-start">
|
||||||
<!-- Carousel takes ~33.3% width on md/lg screens -->
|
<div class="col-12 col-lg-5">
|
||||||
<div class="col-12 col-md-5">
|
<div #carouselHost class="product-detail__carousel">
|
||||||
<app-product-carousel [images]="carouselImages()" [discount]="20" />
|
<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__title-price-divider"></div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
@if (hasRenderableAttributes()) {
|
||||||
|
<div class="product-detail__divider"></div>
|
||||||
|
|
||||||
|
<section class="product-detail__section product-detail__section--attributes">
|
||||||
|
@for (attribute of renderableAttributes(); track attribute.id) {
|
||||||
|
<div class="product-detail__attribute-row">
|
||||||
|
<span class="product-detail__attribute-label">{{ attribute.nombre }}:</span>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -24,5 +153,3 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,230 @@
|
|||||||
/* 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: 400;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-price-divider {
|
||||||
|
border-top: 1px solid #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.875rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price-current {
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 700;
|
||||||
|
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: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #A0A0A0;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__attribute-options,
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__attribute-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__attribute-text-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__attribute-text-option {
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
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 #d8d8d8;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 44px;
|
||||||
|
background: #f6f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quantity-button {
|
||||||
|
width: 25px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: #6f6f6f;
|
||||||
|
background: #f6f6f6;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quantity-value {
|
||||||
|
min-width: 35px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: #6f6f6f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { BehaviorSubject, of, throwError } from 'rxjs';
|
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||||
import { convertToParamMap } from '@angular/router';
|
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 { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
@@ -30,6 +34,17 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
let catalogServiceStub: any;
|
let catalogServiceStub: any;
|
||||||
let routerStub: any;
|
let routerStub: any;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
try {
|
||||||
|
getTestBed().initTestEnvironment(
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting()
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Test environment may already be initialized by another setup entrypoint.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
paramMapSubject = new BehaviorSubject(convertToParamMap({ id: '1' }));
|
paramMapSubject = new BehaviorSubject(convertToParamMap({ id: '1' }));
|
||||||
@@ -70,8 +85,11 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
|
|
||||||
expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1);
|
expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
// Check that ProductCarouselComponent is embedded
|
|
||||||
expect(element.querySelector('app-product-carousel')).not.toBeNull();
|
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 () => {
|
it('shows error message if load fails', async () => {
|
||||||
@@ -136,5 +154,169 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
const carousel = fixture.debugElement.query(By.css('app-product-carousel')).componentInstance;
|
const carousel = fixture.debugElement.query(By.css('app-product-carousel')).componentInstance;
|
||||||
expect(carousel.images()).toEqual([]);
|
expect(carousel.images()).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
it('renders generic attributes 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: 'material',
|
||||||
|
nombre: 'Material',
|
||||||
|
is_required: true,
|
||||||
|
metadata_schema: null,
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: 20,
|
||||||
|
value: 'mesh',
|
||||||
|
label: 'Mesh',
|
||||||
|
sort_order: 1,
|
||||||
|
metadata: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 21,
|
||||||
|
value: 'cuero',
|
||||||
|
label: 'Cuero',
|
||||||
|
sort_order: 2,
|
||||||
|
metadata: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
default_variant: {
|
||||||
|
variant_id: 123,
|
||||||
|
images: ['https://example.com/variant1.png'],
|
||||||
|
attributes: {
|
||||||
|
color: 'beige',
|
||||||
|
material: 'Cuero'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
|
||||||
|
|
||||||
|
await configureTestingModule();
|
||||||
|
const fixture = TestBed.createComponent(ProductDetailPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
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(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 () => {
|
||||||
|
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 { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
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 { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
|
||||||
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-detail-page',
|
selector: 'app-product-detail-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterModule, ProductCarouselComponent],
|
imports: [CommonModule, RouterModule, ProductCarouselComponent, ButtonComponent],
|
||||||
templateUrl: './product-detail-page.component.html',
|
templateUrl: './product-detail-page.component.html',
|
||||||
styleUrl: './product-detail-page.component.scss',
|
styleUrl: './product-detail-page.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -19,9 +36,14 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly catalogService = inject(CatalogService);
|
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 routeSub: Subscription | null = null;
|
||||||
private productSub: 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 product = signal<ProductDetail | null>(null);
|
||||||
protected readonly carouselImages = computed(() => {
|
protected readonly carouselImages = computed(() => {
|
||||||
@@ -37,6 +59,35 @@ 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 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 renderableAttributes = computed(() =>
|
||||||
|
(this.product()?.attributes ?? []).filter((attribute) => attribute.options.length > 0)
|
||||||
|
);
|
||||||
|
protected readonly hasRenderableAttributes = computed(
|
||||||
|
() => this.renderableAttributes().length > 0
|
||||||
|
);
|
||||||
|
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 {
|
ngOnInit(): void {
|
||||||
this.routeSub = this.route.paramMap.subscribe((params) => {
|
this.routeSub = this.route.paramMap.subscribe((params) => {
|
||||||
@@ -55,6 +106,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.routeSub?.unsubscribe();
|
this.routeSub?.unsubscribe();
|
||||||
this.productSub?.unsubscribe();
|
this.productSub?.unsubscribe();
|
||||||
|
this.carouselResizeObserver?.disconnect();
|
||||||
|
this.clearMeasurementTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadProduct(id: number): void {
|
private loadProduct(id: number): void {
|
||||||
@@ -66,6 +119,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
this.productSub = this.catalogService.getProducto(id).subscribe({
|
this.productSub = this.catalogService.getProducto(id).subscribe({
|
||||||
next: (prod) => {
|
next: (prod) => {
|
||||||
this.product.set(prod);
|
this.product.set(prod);
|
||||||
|
this.initializeSelections(prod);
|
||||||
|
this.quantity.set(1);
|
||||||
|
this.descriptionExpanded.set(false);
|
||||||
|
this.descriptionHasOverflow.set(false);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -88,4 +145,204 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
||||||
return `$${parts.join(',')}`;
|
return `$${parts.join(',')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean {
|
||||||
|
return this.selectedAttributeOptions()[attribute.id] === option.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected selectAttributeOption(
|
||||||
|
attribute: ProductAttribute,
|
||||||
|
option: ProductAttributeOption
|
||||||
|
): void {
|
||||||
|
this.selectedAttributeOptions.update((current) => ({
|
||||||
|
...current,
|
||||||
|
[attribute.id]: option.id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isColorOption(option: ProductAttributeOption): boolean {
|
||||||
|
return this.findFirstHexValue(option.metadata) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 defaultVariant = product.default_variant;
|
||||||
|
const selections: Record<number, number> = {};
|
||||||
|
|
||||||
|
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,
|
||||||
|
variant: ProductVariant | null
|
||||||
|
): number | null {
|
||||||
|
if (!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 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;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host(.product-detail__cta) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -9,6 +14,10 @@
|
|||||||
min-width: 216px;
|
min-width: 216px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host(.product-detail__cta) .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary,
|
.btn-primary,
|
||||||
.btn-secondary,
|
.btn-secondary,
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
|
|||||||
Reference in New Issue
Block a user