feat: implement product detail page and image carousel component

This commit is contained in:
2026-06-30 09:56:53 -03:00
parent 79d4de6c04
commit bf2ede4bc1
7 changed files with 451 additions and 12 deletions

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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();
});
});

View File

@@ -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);
}
}
}