feat: implement product detail page and image carousel component
This commit is contained in:
@@ -1 +1,26 @@
|
||||
<p>product-detail-page works!</p>
|
||||
<section class="product-detail py-5">
|
||||
<div class="container-xl px-3 px-md-4">
|
||||
@if (loading()) {
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Cargando...</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="alert alert-danger text-center mb-0" role="alert">
|
||||
{{ 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-4">
|
||||
<app-product-carousel [images]="prod.images" [discount]="20" />
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-warning text-center mb-0" role="alert">
|
||||
No se encontró el producto especificado.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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<any>;
|
||||
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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Product | null>(null);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(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(',')}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user