feat: implement product detail page with catalog service integration and image carousel support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
/* empty */
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user