diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html index a29ca38..fd1e2b1 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html @@ -11,10 +11,145 @@ {{ error() }} } @else if (product(); as prod) { -
- -
- +
+
+ +
+ +
+
+
+

{{ prod.nombre }}

+ +
+ + {{ getFormattedPrice(prod.precio) }} + + + @if (oldPrice(); as oldPrice) { + {{ oldPrice }} + } +
+
+ +
+ + @if (colorAttribute() || sizeAttribute()) { +
+ @if (colorAttribute(); as colorAttr) { +
+ Color: + +
+ @for (option of colorAttr.options; track option.id) { + + } +
+
+ } + + @if (sizeAttribute(); as sizeAttr) { +
+ Talle: + +
+ @for (option of sizeAttr.options; track option.id) { + + } +
+
+ } +
+ } + +
+ +
+
+
+ + + {{ quantity() }} + + +
+ +
+ + Agregar al carrito + + + + Comprar + +
+
+
+ +
+ +
+

DESCRIPCIÓN

+ +
+ {{ prod.descripcion }} +
+ + @if (showDescriptionToggle()) { + + } +
+
} @else { @@ -24,5 +159,3 @@ }
- - diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.scss b/src/app/features/store/pages/product-detail-page/product-detail-page.component.scss index ef37f26..4dc0a3a 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.scss +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.scss @@ -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; + } + } +} diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts index 0e05a83..2677943 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts @@ -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' })); @@ -69,9 +84,12 @@ describe('ProductDetailPageComponent', () => { const element = fixture.nativeElement as HTMLElement; 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(); + }); +}); diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts index ae51147..b32c6a0 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts @@ -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>('carouselHost'); + private readonly descriptionBody = viewChild>('descriptionBody'); private routeSub: Subscription | null = null; private productSub: Subscription | null = null; + private carouselResizeObserver: ResizeObserver | null = null; + private observedCarouselPreview: HTMLElement | null = null; + private measurementTimer: ReturnType | null = null; protected readonly product = signal(null); protected readonly carouselImages = computed(() => { @@ -37,6 +59,36 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { }); protected readonly loading = signal(false); protected readonly error = signal(null); + protected readonly selectedColor = signal(null); + protected readonly selectedSize = signal(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(() => 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 | 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('.product-carousel__main') ?? + null + ); + } } diff --git a/src/app/shared/components/button/button.component.scss b/src/app/shared/components/button/button.component.scss index b226c71..5019740 100644 --- a/src/app/shared/components/button/button.component.scss +++ b/src/app/shared/components/button/button.component.scss @@ -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 {