feat: implement product detail page with catalog service integration and image carousel support

This commit is contained in:
2026-06-30 10:04:34 -03:00
parent bf2ede4bc1
commit cc16a53fac
8 changed files with 115 additions and 15 deletions

View File

@@ -11,3 +11,33 @@ export interface Product {
brand: string | null;
images: string[];
}
export interface ProductAttributeOption {
id: number;
value: string;
label: string;
sort_order: number;
metadata: Record<string, any> | null;
}
export interface ProductAttribute {
id: number;
codigo: string;
nombre: string;
is_required: boolean;
metadata_schema: Record<string, any> | null;
type: string;
options: ProductAttributeOption[];
}
export interface ProductVariant {
variant_id: number;
images: string[];
attributes: Record<string, string>;
}
export interface ProductDetail extends Product {
attributes: ProductAttribute[];
default_variant: ProductVariant | null;
}

View File

@@ -6,7 +6,7 @@ import { ApiPaginationQueryParams } from '../api-pagination-query-params.interfa
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service';
import { Product } from './catalog.interface';
import { Product, ProductDetail } from './catalog.interface';
type HttpParamValue =
| string
@@ -35,9 +35,9 @@ export class CatalogService {
);
}
getProducto(id: number): Observable<Product> {
getProducto(id: number): Observable<ProductDetail> {
return this.http
.get<ApiResponse<Product>>(`${this.tenantApiUrl}/productos/${id}`)
.get<ApiResponse<ProductDetail>>(`${this.tenantApiUrl}/productos/${id}`)
.pipe(map((response) => response.data));
}

View File

@@ -6,7 +6,7 @@
<img
[src]="images()[activeIndex()]"
alt="Product active image"
class="product-carousel__main-image w-100 h-100 object-fit-contain"
class="product-carousel__main-image w-100 h-100 object-fit-cover"
/>
} @else {
<!-- Placeholder -->
@@ -55,7 +55,7 @@
(click)="selectImage(idx)"
[attr.aria-label]="'Select image ' + (idx + 1)"
>
<img [src]="image" alt="Product thumbnail" class="w-100 h-100 object-fit-contain" />
<img [src]="image" alt="Product thumbnail" class="w-100 h-100 object-fit-cover" />
</button>
}
</div>

View File

@@ -12,7 +12,7 @@
}
&__main-image {
padding: 1rem;
padding: 0;
transition: transform 0.3s ease;
}
@@ -65,7 +65,7 @@
aspect-ratio: 1 / 1;
background-color: #e0e0e0 !important; /* Matches mock layout */
border-radius: 4px;
padding: 0.25rem;
padding: 0;
transition: opacity 0.2s ease, border-color 0.2s ease;
border: 3px solid transparent !important;
cursor: pointer;
@@ -73,7 +73,7 @@
img {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
}
&:hover {

View File

@@ -14,7 +14,7 @@
<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" />
<app-product-carousel [images]="carouselImages()" [discount]="20" />
</div>
</div>
} @else {
@@ -24,3 +24,5 @@
}
</div>
</section>

View File

@@ -1,15 +1,16 @@
import { TestBed } from '@angular/core/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 { Product } from '../../../../core/services/catalog/catalog.interface';
import { ProductDetail } 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 = {
const mockProduct: ProductDetail = {
id: 1,
tenant_codigo: 'test',
category_id: 10,
@@ -20,7 +21,9 @@ describe('ProductDetailPageComponent', () => {
precio: '24999',
category: 'Tecnología',
brand: 'Sony',
images: ['https://example.com/image.png']
images: ['https://example.com/image.png'],
attributes: [],
default_variant: null
};
let paramMapSubject: BehaviorSubject<any>;
@@ -81,4 +84,57 @@ describe('ProductDetailPageComponent', () => {
expect(element.textContent).toContain('No pudimos cargar los detalles del producto.');
});
it('should use default variant images if present', async () => {
const detailProduct: ProductDetail = {
...mockProduct,
images: ['https://example.com/product.png'],
default_variant: {
variant_id: 123,
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
attributes: {}
}
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const carousel = fixture.debugElement.query(By.css('app-product-carousel')).componentInstance;
expect(carousel.images()).toEqual(['https://example.com/variant1.png', 'https://example.com/variant2.png']);
});
it('should fallback to product images if default variant images are not present', async () => {
const detailProduct: ProductDetail = {
...mockProduct,
images: ['https://example.com/product.png'],
default_variant: null
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const carousel = fixture.debugElement.query(By.css('app-product-carousel')).componentInstance;
expect(carousel.images()).toEqual(['https://example.com/product.png']);
});
it('should fallback to empty array if no images are present at all', async () => {
const detailProduct: ProductDetail = {
...mockProduct,
images: [],
default_variant: null
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const carousel = fixture.debugElement.query(By.css('app-product-carousel')).componentInstance;
expect(carousel.images()).toEqual([]);
});
});

View File

@@ -1,10 +1,10 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, 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 { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
@Component({
@@ -23,7 +23,18 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
private routeSub: Subscription | null = null;
private productSub: Subscription | null = null;
protected readonly product = signal<Product | null>(null);
protected readonly product = signal<ProductDetail | null>(null);
protected readonly carouselImages = computed(() => {
const prod = this.product();
if (!prod) return [];
if (prod.default_variant?.images && prod.default_variant.images.length > 0) {
return prod.default_variant.images;
}
if (prod.images && prod.images.length > 0) {
return prod.images;
}
return [];
});
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);