From bf2ede4bc11936bb4d9936eb9353a1ae0a1a6166 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 30 Jun 2026 09:56:53 -0300 Subject: [PATCH] feat: implement product detail page and image carousel component --- .../product-carousel.component.html | 63 +++++++++++++ .../product-carousel.component.scss | 88 ++++++++++++++++++ .../product-carousel.component.spec.ts | 90 +++++++++++++++++++ .../product-carousel.component.ts | 38 ++++++++ .../product-detail-page.component.html | 27 +++++- .../product-detail-page.component.spec.ts | 82 +++++++++++++++-- .../product-detail-page.component.ts | 75 +++++++++++++++- 7 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 src/app/features/store/components/product-carousel/product-carousel.component.html create mode 100644 src/app/features/store/components/product-carousel/product-carousel.component.scss create mode 100644 src/app/features/store/components/product-carousel/product-carousel.component.spec.ts create mode 100644 src/app/features/store/components/product-carousel/product-carousel.component.ts diff --git a/src/app/features/store/components/product-carousel/product-carousel.component.html b/src/app/features/store/components/product-carousel/product-carousel.component.html new file mode 100644 index 0000000..8b187b7 --- /dev/null +++ b/src/app/features/store/components/product-carousel/product-carousel.component.html @@ -0,0 +1,63 @@ + diff --git a/src/app/features/store/components/product-carousel/product-carousel.component.scss b/src/app/features/store/components/product-carousel/product-carousel.component.scss new file mode 100644 index 0000000..9d06cea --- /dev/null +++ b/src/app/features/store/components/product-carousel/product-carousel.component.scss @@ -0,0 +1,88 @@ +.product-carousel { + width: 100%; + + &__main { + aspect-ratio: 1 / 1; + width: 100%; + background-color: #e0e0e0 !important; /* Matches grey placeholder in mock */ + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + } + + &__main-image { + padding: 1rem; + transition: transform 0.3s ease; + } + + &__placeholder { + height: 100%; + background-color: #e0e0e0; + } + + &__discount-badge { + top: 0 !important; + right: 0 !important; + background-color: var(--tenant-primary, #6376F3) !important; + border-bottom-left-radius: 4px; + font-size: 0.9rem; + letter-spacing: 0.5px; + padding: 0.5rem 1rem !important; + } + + &__nav-btn { + width: 36px; + height: 36px; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.2) !important; + color: #ffffff !important; + font-size: 0.9rem; + transition: background-color 0.2s ease, transform 0.2s ease; + z-index: 10; + + &:hover { + background-color: rgba(0, 0, 0, 0.4) !important; + transform: translateY(-50%) scale(1.05); + } + + &--prev { + left: 16px !important; + } + + &--next { + right: 16px !important; + } + } + + &__thumbnails { + margin-top: 1rem; + } + + &__thumbnail { + width: 80px; + height: 80px; + aspect-ratio: 1 / 1; + background-color: #e0e0e0 !important; /* Matches mock layout */ + border-radius: 4px; + padding: 0.25rem; + transition: opacity 0.2s ease, border-color 0.2s ease; + border: 3px solid transparent !important; + cursor: pointer; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + &:hover { + opacity: 0.9; + } + + &--active { + border-color: var(--tenant-primary, #6376F3) !important; + opacity: 1; + } + } +} diff --git a/src/app/features/store/components/product-carousel/product-carousel.component.spec.ts b/src/app/features/store/components/product-carousel/product-carousel.component.spec.ts new file mode 100644 index 0000000..a232b24 --- /dev/null +++ b/src/app/features/store/components/product-carousel/product-carousel.component.spec.ts @@ -0,0 +1,90 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { ProductCarouselComponent } from './product-carousel.component'; + +describe('ProductCarouselComponent', () => { + const mockImages = [ + 'https://example.com/img1.png', + 'https://example.com/img2.png', + 'https://example.com/img3.png' + ]; + + beforeEach(() => { + TestBed.resetTestingModule(); + }); + + async function createComponent(images: string[] = [], discount: number | null = null) { + await TestBed.configureTestingModule({ + imports: [ProductCarouselComponent] + }).compileComponents(); + + const fixture = TestBed.createComponent(ProductCarouselComponent); + const component = fixture.componentInstance; + + // Set signals input values + fixture.componentRef.setInput('images', images); + fixture.componentRef.setInput('discount', discount); + + fixture.detectChanges(); + + return { fixture, component }; + } + + it('should create and start at activeIndex 0', async () => { + const { component } = await createComponent(mockImages); + expect(component).toBeTruthy(); + expect(component.activeIndex()).toBe(0); + }); + + it('should cycle through images on nextImage', async () => { + const { component } = await createComponent(mockImages); + expect(component.activeIndex()).toBe(0); + + component.nextImage(); + expect(component.activeIndex()).toBe(1); + + component.nextImage(); + expect(component.activeIndex()).toBe(2); + + component.nextImage(); // Should wrap around to 0 + expect(component.activeIndex()).toBe(0); + }); + + it('should cycle backwards on prevImage', async () => { + const { component } = await createComponent(mockImages); + expect(component.activeIndex()).toBe(0); + + component.prevImage(); // Should wrap around to end (2) + expect(component.activeIndex()).toBe(2); + + component.prevImage(); + expect(component.activeIndex()).toBe(1); + }); + + it('should set activeIndex on selectImage', async () => { + const { component } = await createComponent(mockImages); + expect(component.activeIndex()).toBe(0); + + component.selectImage(2); + expect(component.activeIndex()).toBe(2); + + // If out of bounds, should not change + component.selectImage(5); + expect(component.activeIndex()).toBe(2); + }); + + it('should render discount badge if provided', async () => { + const { fixture } = await createComponent(mockImages, 20); + const element = fixture.nativeElement as HTMLElement; + const badge = element.querySelector('.product-carousel__discount-badge'); + expect(badge).not.toBeNull(); + expect(badge?.textContent?.trim()).toBe('-20%'); + }); + + it('should not render discount badge if not provided', async () => { + const { fixture } = await createComponent(mockImages); + const element = fixture.nativeElement as HTMLElement; + const badge = element.querySelector('.product-carousel__discount-badge'); + expect(badge).toBeNull(); + }); +}); diff --git a/src/app/features/store/components/product-carousel/product-carousel.component.ts b/src/app/features/store/components/product-carousel/product-carousel.component.ts new file mode 100644 index 0000000..4d54bc2 --- /dev/null +++ b/src/app/features/store/components/product-carousel/product-carousel.component.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-product-carousel', + standalone: true, + imports: [CommonModule], + templateUrl: './product-carousel.component.html', + styleUrl: './product-carousel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ProductCarouselComponent { + readonly images = input([]); + readonly discount = input(null); + + readonly activeIndex = signal(0); + + nextImage(): void { + const total = this.images().length; + if (total > 0) { + this.activeIndex.set((this.activeIndex() + 1) % total); + } + } + + prevImage(): void { + const total = this.images().length; + if (total > 0) { + this.activeIndex.set((this.activeIndex() - 1 + total) % total); + } + } + + selectImage(index: number): void { + const total = this.images().length; + if (index >= 0 && index < total) { + this.activeIndex.set(index); + } + } +} 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 b57e35b..1e87558 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 @@ -1 +1,26 @@ -

product-detail-page works!

+
+
+ @if (loading()) { +
+
+ Cargando... +
+
+ } @else if (error()) { + + } @else if (product(); as prod) { +
+ +
+ +
+
+ } @else { + + } +
+
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 73b7f89..4a2e571 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,18 +1,84 @@ import { TestBed } from '@angular/core/testing'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { convertToParamMap } from '@angular/router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Product } from '../../../../core/services/catalog/catalog.interface'; +import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { ProductDetailPageComponent } from './product-detail-page.component'; describe('ProductDetailPageComponent', () => { + const mockProduct: Product = { + id: 1, + tenant_codigo: 'test', + category_id: 10, + brand_id: null, + slug: 'auriculares-bluetooth', + nombre: 'Auriculares Bluetooth', + descripcion: 'Auriculares bluetooth de prueba', + precio: '24999', + category: 'Tecnología', + brand: 'Sony', + images: ['https://example.com/image.png'] + }; + + let paramMapSubject: BehaviorSubject; + let catalogServiceStub: any; + let routerStub: any; + beforeEach(() => { - TestBed.resetTestingModule(); + vi.restoreAllMocks(); + paramMapSubject = new BehaviorSubject(convertToParamMap({ id: '1' })); + catalogServiceStub = { + getProducto: vi.fn().mockReturnValue(of(mockProduct)) + }; + routerStub = { + navigate: vi.fn() + }; }); - it('should create', () => { - TestBed.configureTestingModule({ - imports: [ProductDetailPageComponent] - }); + async function configureTestingModule() { + await TestBed.configureTestingModule({ + imports: [ProductDetailPageComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { paramMap: paramMapSubject.asObservable() } + }, + { + provide: CatalogService, + useValue: catalogServiceStub + }, + { + provide: Router, + useValue: routerStub + } + ] + }).compileComponents(); + } + + it('loads the product on init and embeds the carousel', async () => { + await configureTestingModule(); const fixture = TestBed.createComponent(ProductDetailPageComponent); - const component = fixture.componentInstance; - expect(component).toBeTruthy(); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + + expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1); + + // Check that ProductCarouselComponent is embedded + expect(element.querySelector('app-product-carousel')).not.toBeNull(); + }); + + it('shows error message if load fails', async () => { + catalogServiceStub.getProducto.mockReturnValue(throwError(() => new Error('load failed'))); + await configureTestingModule(); + const fixture = TestBed.createComponent(ProductDetailPageComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + + expect(element.textContent).toContain('No pudimos cargar los detalles del producto.'); }); }); 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 184af64..6652124 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,11 +1,80 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } 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 { Product } from '../../../../core/services/catalog/catalog.interface'; +import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component'; @Component({ selector: 'app-product-detail-page', standalone: true, - imports: [], + imports: [CommonModule, RouterModule, ProductCarouselComponent], templateUrl: './product-detail-page.component.html', styleUrl: './product-detail-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) -export class ProductDetailPageComponent {} +export class ProductDetailPageComponent implements OnInit, OnDestroy { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly catalogService = inject(CatalogService); + + private routeSub: Subscription | null = null; + private productSub: Subscription | null = null; + + protected readonly product = signal(null); + protected readonly loading = signal(false); + protected readonly error = signal(null); + + ngOnInit(): void { + this.routeSub = this.route.paramMap.subscribe((params) => { + const idParam = params.get('id'); + if (idParam) { + const id = Number(idParam); + if (Number.isInteger(id)) { + this.loadProduct(id); + } else { + this.error.set('ID de producto inválido'); + } + } + }); + } + + ngOnDestroy(): void { + this.routeSub?.unsubscribe(); + this.productSub?.unsubscribe(); + } + + private loadProduct(id: number): void { + this.loading.set(true); + this.error.set(null); + this.product.set(null); + + this.productSub?.unsubscribe(); + this.productSub = this.catalogService.getProducto(id).subscribe({ + next: (prod) => { + this.product.set(prod); + this.loading.set(false); + }, + error: () => { + this.error.set('No pudimos cargar los detalles del producto.'); + this.loading.set(false); + } + }); + } + + protected goBack(): void { + this.router.navigate(['/']); + } + + protected getFormattedPrice(priceStr: string | undefined): string { + if (!priceStr) return '$0'; + const parsed = Number(priceStr); + const value = Number.isFinite(parsed) ? parsed : 0; + const rounded = Math.round(value); + const parts = rounded.toString().split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.'); + return `$${parts.join(',')}`; + } +}