feat: implement product detail page and image carousel component
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
<div class="product-carousel d-flex flex-column gap-3">
|
||||||
|
<!-- Active/Main Image Area -->
|
||||||
|
<div class="product-carousel__main position-relative bg-light rounded overflow-hidden">
|
||||||
|
<!-- Main Image -->
|
||||||
|
@if (images().length > 0) {
|
||||||
|
<img
|
||||||
|
[src]="images()[activeIndex()]"
|
||||||
|
alt="Product active image"
|
||||||
|
class="product-carousel__main-image w-100 h-100 object-fit-contain"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<!-- Placeholder -->
|
||||||
|
<div class="product-carousel__placeholder w-100 h-100 d-flex align-items-center justify-content-center text-muted">
|
||||||
|
<i class="fa-solid fa-image fa-3x"></i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Discount Badge -->
|
||||||
|
@if (discount() && discount()! > 0) {
|
||||||
|
<span class="product-carousel__discount-badge position-absolute top-0 end-0 bg-primary text-white px-3 py-2 fw-semibold">
|
||||||
|
-{{ discount() }}%
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Navigation Arrows -->
|
||||||
|
@if (images().length > 1) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="product-carousel__nav-btn product-carousel__nav-btn--prev position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
|
||||||
|
(click)="prevImage()"
|
||||||
|
aria-label="Previous image"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="product-carousel__nav-btn product-carousel__nav-btn--next position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
|
||||||
|
(click)="nextImage()"
|
||||||
|
aria-label="Next image"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Thumbnails Row -->
|
||||||
|
@if (images().length > 1) {
|
||||||
|
<div class="product-carousel__thumbnails d-flex gap-3">
|
||||||
|
@for (image of images(); track image; let idx = $index) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="product-carousel__thumbnail border-0 p-0 rounded overflow-hidden bg-light"
|
||||||
|
[class.product-carousel__thumbnail--active]="idx === activeIndex()"
|
||||||
|
(click)="selectImage(idx)"
|
||||||
|
[attr.aria-label]="'Select image ' + (idx + 1)"
|
||||||
|
>
|
||||||
|
<img [src]="image" alt="Product thumbnail" class="w-100 h-100 object-fit-contain" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string[]>([]);
|
||||||
|
readonly discount = input<number | null>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { 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';
|
import { ProductDetailPageComponent } from './product-detail-page.component';
|
||||||
|
|
||||||
describe('ProductDetailPageComponent', () => {
|
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(() => {
|
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', () => {
|
async function configureTestingModule() {
|
||||||
TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [ProductDetailPageComponent]
|
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 fixture = TestBed.createComponent(ProductDetailPageComponent);
|
||||||
const component = fixture.componentInstance;
|
fixture.detectChanges();
|
||||||
expect(component).toBeTruthy();
|
|
||||||
|
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({
|
@Component({
|
||||||
selector: 'app-product-detail-page',
|
selector: 'app-product-detail-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [CommonModule, RouterModule, ProductCarouselComponent],
|
||||||
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
|
||||||
})
|
})
|
||||||
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