feat(catalog): refactor product and variant interfaces for improved structure and clarity

This commit is contained in:
2026-07-20 16:46:17 -03:00
parent a233a93d16
commit 0c0d2b559d
12 changed files with 216 additions and 197 deletions

View File

@@ -3,14 +3,12 @@ export interface CartItemProduct {
imagen: string | null; imagen: string | null;
} }
export type BuyableType = 'variant' | 'bundle';
export interface CartItem { export interface CartItem {
id: number; id: number;
cantidad: number; cantidad: number;
precio_unitario: string; precio_unitario: string;
buyable_type: BuyableType; catalog_item_id: number;
buyable_id: number; variant_id: number | null;
product: CartItemProduct | null; product: CartItemProduct | null;
} }

View File

@@ -21,8 +21,8 @@ describe('CartService', () => {
id: 1, id: 1,
cantidad: 2, cantidad: 2,
precio_unitario: '10.00', precio_unitario: '10.00',
buyable_type: 'variant', catalog_item_id: 5,
buyable_id: 10, variant_id: 10,
product: { product: {
nombre: 'Test Product (Size: M)', nombre: 'Test Product (Size: M)',
imagen: null, imagen: null,
@@ -88,7 +88,7 @@ describe('CartService', () => {
}); });
it('should add item and update signal', () => { it('should add item and update signal', () => {
service.addItem('variant', 10, 2).subscribe((res) => { service.addItem(5, 10, 2).subscribe((res) => {
expect(res.data).toEqual(mockCart); expect(res.data).toEqual(mockCart);
expect(service.cart()).toEqual(mockCart); expect(service.cart()).toEqual(mockCart);
}); });
@@ -96,8 +96,8 @@ describe('CartService', () => {
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items'); const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items');
expect(req.request.method).toBe('POST'); expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
buyable_type: 'variant', catalog_item_id: 5,
buyable_id: 10, variant_id: 10,
cantidad: 2, cantidad: 2,
}); });
expect(req.request.withCredentials).toBe(true); expect(req.request.withCredentials).toBe(true);

View File

@@ -4,7 +4,7 @@ import { catchError, map, Observable, tap } from 'rxjs';
import { ApiResponse } from '../api-response.interface'; import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { BuyableType, Cart } from './cart.interface'; import { Cart } from './cart.interface';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -45,15 +45,15 @@ export class CartService {
} }
addItem( addItem(
buyableType: BuyableType, catalogItemId: number,
buyableId: number, variantId: number | null,
cantidad: number, cantidad: number,
): Observable<ApiResponse<Cart>> { ): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.post< .post<
ApiResponse<Cart> ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items`, { buyable_type: buyableType, buyable_id: buyableId, cantidad }, { withCredentials: true }) >(`${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { withCredentials: true })
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);

View File

@@ -33,27 +33,35 @@ export interface ProductAttribute {
export type InventoryPolicy = 'tracked' | 'unlimited'; export type InventoryPolicy = 'tracked' | 'unlimited';
export interface ProductVariant { export interface CatalogItemVariant {
id: number; id: number;
inventory_policy: InventoryPolicy; stock_tecnico: number | null;
cantidad_maxima: number | null; values: Record<string, string>;
cantidad_vendida: number; }
definitions: Record<string, string>;
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
images: string[]; images: string[];
} }
export interface ProductVariantMap { export interface CatalogItemDetail {
variant_id: number; id: number;
category_id: number | null;
brand_id: number | null;
slug: string;
nombre: string;
descripcion: string | null;
precio: string;
category: string | null;
brand: string | null;
inventory_policy: InventoryPolicy; inventory_policy: InventoryPolicy;
cantidad_maxima: number | null; has_tickets: boolean;
cantidad_vendida: number; minimum_use_date: string | null;
attributes: Record<string, string>; maximum_use_date: string | null;
}
export interface ProductDetail extends Product {
attributes: ProductAttribute[]; attributes: ProductAttribute[];
variants_map: ProductVariantMap[]; variants: CatalogItemVariant[];
variant: ProductVariant | null; selected_variant?: SelectedCatalogItemVariant;
stock_tecnico?: number | null;
images?: string[];
} }
export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart'; export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';

View File

@@ -9,8 +9,8 @@ import { TenantService } from '../tenant.service';
import { import {
CatalogFeaturedGroup, CatalogFeaturedGroup,
CatalogFeaturedItem, CatalogFeaturedItem,
CatalogItemDetail,
Product, Product,
ProductDetail,
} from './catalog.interface'; } from './catalog.interface';
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[]; type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
@@ -46,14 +46,14 @@ export class CatalogService {
); );
} }
getProducto(id: number, variantId?: number): Observable<ProductDetail> { getCatalogItem(id: number, variantId?: number): Observable<CatalogItemDetail> {
let params = new HttpParams(); let params = new HttpParams();
if (variantId) { if (variantId) {
params = params.set('variant_id', variantId); params = params.set('variant_id', variantId);
} }
return this.http return this.http
.get<ApiResponse<ProductDetail>>(`${this.tenantApiUrl}/productos/${id}`, { params }) .get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, { params })
.pipe(map((response) => response.data)); .pipe(map((response) => response.data));
} }

View File

@@ -27,13 +27,12 @@ describe('ProductAttributeSelectorComponent', () => {
it('keeps an unlimited option available when maximum quantity is null', () => { it('keeps an unlimited option available when maximum quantity is null', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent); const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]); fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('variantsMap', [ fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{ {
variant_id: 1, id: 1,
inventory_policy: 'unlimited', stock_tecnico: null,
cantidad_maxima: null, values: { size: 'S' },
cantidad_vendida: 0,
attributes: { size: 'S' },
}, },
]); ]);
fixture.detectChanges(); fixture.detectChanges();
@@ -48,20 +47,17 @@ describe('ProductAttributeSelectorComponent', () => {
it('disables tracked options without available stock', () => { it('disables tracked options without available stock', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent); const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]); fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('variantsMap', [ fixture.componentRef.setInput('inventoryPolicy', 'tracked');
fixture.componentRef.setInput('variants', [
{ {
variant_id: 1, id: 1,
inventory_policy: 'tracked', stock_tecnico: 0,
cantidad_maxima: 0, values: { size: 'S' },
cantidad_vendida: 0,
attributes: { size: 'S' },
}, },
{ {
variant_id: 2, id: 2,
inventory_policy: 'tracked', stock_tecnico: 2,
cantidad_maxima: 2, values: { size: 'M' },
cantidad_vendida: 0,
attributes: { size: 'M' },
}, },
]); ]);
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -10,10 +10,10 @@ import {
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
CatalogItemVariant,
InventoryPolicy,
ProductAttribute, ProductAttribute,
ProductAttributeOption, ProductAttributeOption,
ProductVariant,
ProductVariantMap,
} from '../../../../core/services/catalog/catalog.interface'; } from '../../../../core/services/catalog/catalog.interface';
@Component({ @Component({
@@ -26,16 +26,17 @@ import {
}) })
export class ProductAttributeSelectorComponent { export class ProductAttributeSelectorComponent {
public attributes = input<ProductAttribute[]>([]); public attributes = input<ProductAttribute[]>([]);
public variantsMap = input<ProductVariantMap[]>([]); public variants = input<CatalogItemVariant[]>([]);
public defaultVariant = input<ProductVariant | null>(null); public selectedVariant = input<CatalogItemVariant | null>(null);
public inventoryPolicy = input.required<InventoryPolicy>();
public variantChange = output<ProductVariantMap | null>(); public variantChange = output<CatalogItemVariant | null>();
protected readonly selectedAttributeOptions = signal<Record<number, number>>({}); protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
protected readonly availableOptions = computed(() => { protected readonly availableOptions = computed(() => {
const selections = this.selectedAttributeOptions(); const selections = this.selectedAttributeOptions();
const variants = this.variantsMap(); const variants = this.variants();
const attributes = this.attributes(); const attributes = this.attributes();
const availability: Record<number, Record<number, boolean>> = {}; const availability: Record<number, Record<number, boolean>> = {};
@@ -48,10 +49,7 @@ export class ProductAttributeSelectorComponent {
const isAvailable = variants.some((variant) => { const isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false; if (!this.isVariantAvailable(variant)) return false;
const variantAttrValue = this.getDefaultVariantAttributeValue( const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
attribute,
variant.attributes,
);
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) { if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
return false; return false;
} }
@@ -65,10 +63,7 @@ export class ProductAttributeSelectorComponent {
const selectedNormalized = this.normalizeText( const selectedNormalized = this.normalizeText(
selectedOption.value || selectedOption.label, selectedOption.value || selectedOption.label,
); );
const vAttrValue = this.getDefaultVariantAttributeValue( const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.values);
otherAttr,
variant.attributes,
);
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) { if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
return false; return false;
} }
@@ -86,13 +81,13 @@ export class ProductAttributeSelectorComponent {
}); });
public reset(): void { public reset(): void {
this.initializeSelections(this.attributes(), this.defaultVariant()); this.initializeSelections(this.attributes(), this.selectedVariant());
} }
constructor() { constructor() {
effect(() => { effect(() => {
const attributes = this.attributes(); const attributes = this.attributes();
const defaultVariant = this.defaultVariant(); const defaultVariant = this.selectedVariant();
untracked(() => { untracked(() => {
this.initializeSelections(attributes, defaultVariant); this.initializeSelections(attributes, defaultVariant);
@@ -101,11 +96,11 @@ export class ProductAttributeSelectorComponent {
effect(() => { effect(() => {
const selections = this.selectedAttributeOptions(); const selections = this.selectedAttributeOptions();
const variantsMap = this.variantsMap(); const variants = this.variants();
const attributes = this.attributes(); const attributes = this.attributes();
untracked(() => { untracked(() => {
this.emitMatchingVariant(selections, variantsMap, attributes); this.emitMatchingVariant(selections, variants, attributes);
}); });
}); });
} }
@@ -137,7 +132,7 @@ export class ProductAttributeSelectorComponent {
private initializeSelections( private initializeSelections(
attributes: ProductAttribute[], attributes: ProductAttribute[],
defaultVariant: ProductVariant | null, defaultVariant: CatalogItemVariant | null,
): void { ): void {
const selections: Record<number, number> = {}; const selections: Record<number, number> = {};
@@ -153,13 +148,13 @@ export class ProductAttributeSelectorComponent {
private findDefaultOptionId( private findDefaultOptionId(
attribute: ProductAttribute, attribute: ProductAttribute,
variant: ProductVariant | null, variant: CatalogItemVariant | null,
): number | null { ): number | null {
if (!variant) { if (!variant) {
return null; return null;
} }
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions); const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
if (!defaultValue) { if (!defaultValue) {
return null; return null;
} }
@@ -206,8 +201,8 @@ export class ProductAttributeSelectorComponent {
.toLowerCase(); .toLowerCase();
} }
private isVariantAvailable(variant: ProductVariantMap): boolean { private isVariantAvailable(variant: CatalogItemVariant): boolean {
return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0; return this.inventoryPolicy() === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
} }
private findFirstHexValue(value: unknown): string | null { private findFirstHexValue(value: unknown): string | null {
@@ -240,10 +235,10 @@ export class ProductAttributeSelectorComponent {
private emitMatchingVariant( private emitMatchingVariant(
selections: Record<number, number>, selections: Record<number, number>,
variantsMap: ProductVariantMap[], variants: CatalogItemVariant[],
attributes: ProductAttribute[], attributes: ProductAttribute[],
): void { ): void {
if (attributes.length === 0 || variantsMap.length === 0) { if (attributes.length === 0 || variants.length === 0) {
this.variantChange.emit(null); this.variantChange.emit(null);
return; return;
} }
@@ -267,12 +262,12 @@ export class ProductAttributeSelectorComponent {
return; return;
} }
const matchingVariant = variantsMap.find((vMap) => { const matchingVariant = variants.find((variant) => {
return attributes.every((attr) => { return attributes.every((attr) => {
const selectedValue = selectedValuesById[attr.id]; const selectedValue = selectedValuesById[attr.id];
const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes); const variantValue = this.getDefaultVariantAttributeValue(attr, variant.values);
if (!vMapValue) return false; if (!variantValue) return false;
return this.normalizeText(vMapValue) === selectedValue; return this.normalizeText(variantValue) === selectedValue;
}); });
}); });

View File

@@ -41,8 +41,9 @@
<section class="product-detail__section product-detail__section--attributes"> <section class="product-detail__section product-detail__section--attributes">
<app-product-attribute-selector <app-product-attribute-selector
[attributes]="renderableAttributes()" [attributes]="renderableAttributes()"
[variantsMap]="prod.variants_map" [variants]="prod.variants"
[defaultVariant]="prod.variant" [selectedVariant]="prod.selected_variant ?? null"
[inventoryPolicy]="prod.inventory_policy"
(variantChange)="onVariantChange($event)" (variantChange)="onVariantChange($event)"
/> />
</section> </section>

View File

@@ -5,7 +5,7 @@ import { By } from '@angular/platform-browser';
import { BehaviorSubject, of, throwError } from 'rxjs'; import { BehaviorSubject, of, throwError } from 'rxjs';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface'; import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
@@ -16,7 +16,7 @@ import {
} from './product-detail-page.resolver'; } from './product-detail-page.resolver';
describe('ProductDetailPageComponent', () => { describe('ProductDetailPageComponent', () => {
const mockProduct: ProductDetail = { const mockProduct: CatalogItemDetail = {
id: 1, id: 1,
category_id: 10, category_id: 10,
brand_id: null, brand_id: null,
@@ -27,9 +27,13 @@ describe('ProductDetailPageComponent', () => {
category: 'Tecnología', category: 'Tecnología',
brand: 'Sony', brand: 'Sony',
images: ['https://example.com/image.png'], images: ['https://example.com/image.png'],
inventory_policy: 'tracked',
has_tickets: false,
minimum_use_date: null,
maximum_use_date: null,
stock_tecnico: 10,
attributes: [], attributes: [],
variants_map: [], variants: [],
variant: null,
}; };
let routeDataSubject: BehaviorSubject<{ productDetailData: ProductDetailResolvedData }>; let routeDataSubject: BehaviorSubject<{ productDetailData: ProductDetailResolvedData }>;
@@ -55,7 +59,7 @@ describe('ProductDetailPageComponent', () => {
}, },
}); });
catalogServiceStub = { catalogServiceStub = {
getProducto: vi.fn(), getCatalogItem: vi.fn(),
}; };
routerStub = { routerStub = {
navigate: vi.fn(), navigate: vi.fn(),
@@ -100,7 +104,7 @@ describe('ProductDetailPageComponent', () => {
}).compileComponents(); }).compileComponents();
} }
function resolveProduct(product: ProductDetail): void { function resolveProduct(product: CatalogItemDetail): void {
routeDataSubject.next({ routeDataSubject.next({
productDetailData: { productDetailData: {
product, product,
@@ -125,7 +129,7 @@ describe('ProductDetailPageComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
expect(catalogServiceStub.getProducto).not.toHaveBeenCalled(); expect(catalogServiceStub.getCatalogItem).not.toHaveBeenCalled();
expect(element.querySelector('app-product-carousel')).not.toBeNull(); expect(element.querySelector('app-product-carousel')).not.toBeNull();
expect(element.querySelector('.product-detail__title')?.textContent).toContain( expect(element.querySelector('.product-detail__title')?.textContent).toContain(
@@ -150,16 +154,15 @@ describe('ProductDetailPageComponent', () => {
}); });
it('should use default variant images if present', async () => { it('should use default variant images if present', async () => {
const detailProduct: ProductDetail = { const detailProduct: CatalogItemDetail = {
...mockProduct, ...mockProduct,
images: ['https://example.com/product.png'], images: ['https://example.com/product.png'],
variant: { variants: [{ id: 123, stock_tecnico: 10, values: {} }],
selected_variant: {
id: 123, id: 123,
inventory_policy: 'tracked', stock_tecnico: 10,
cantidad_maxima: 10,
cantidad_vendida: 0,
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'], images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
definitions: {}, values: {},
}, },
}; };
resolveProduct(detailProduct); resolveProduct(detailProduct);
@@ -176,10 +179,9 @@ describe('ProductDetailPageComponent', () => {
}); });
it('should fallback to product images if default variant images are not present', async () => { it('should fallback to product images if default variant images are not present', async () => {
const detailProduct: ProductDetail = { const detailProduct: CatalogItemDetail = {
...mockProduct, ...mockProduct,
images: ['https://example.com/product.png'], images: ['https://example.com/product.png'],
variant: null,
}; };
resolveProduct(detailProduct); resolveProduct(detailProduct);
@@ -192,10 +194,9 @@ describe('ProductDetailPageComponent', () => {
}); });
it('should fallback to empty array if no images are present at all', async () => { it('should fallback to empty array if no images are present at all', async () => {
const detailProduct: ProductDetail = { const detailProduct: CatalogItemDetail = {
...mockProduct, ...mockProduct,
images: [], images: [],
variant: null,
}; };
resolveProduct(detailProduct); resolveProduct(detailProduct);
@@ -208,7 +209,7 @@ describe('ProductDetailPageComponent', () => {
}); });
it('renders generic attributes and preselects default variant options', async () => { it('renders generic attributes and preselects default variant options', async () => {
const detailProduct: ProductDetail = { const detailProduct: CatalogItemDetail = {
...mockProduct, ...mockProduct,
attributes: [ attributes: [
{ {
@@ -260,13 +261,12 @@ describe('ProductDetailPageComponent', () => {
], ],
}, },
], ],
variant: { variants: [{ id: 123, stock_tecnico: 10, values: { color: 'beige', material: 'Cuero' } }],
selected_variant: {
id: 123, id: 123,
inventory_policy: 'tracked', stock_tecnico: 10,
cantidad_maxima: 10,
cantidad_vendida: 0,
images: ['https://example.com/variant1.png'], images: ['https://example.com/variant1.png'],
definitions: { values: {
color: 'beige', color: 'beige',
material: 'Cuero', material: 'Cuero',
}, },
@@ -312,11 +312,9 @@ describe('ProductDetailPageComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
fixture.componentInstance['selectedVariant'].set({ fixture.componentInstance['selectedVariant'].set({
variant_id: 1, id: 1,
inventory_policy: 'tracked', stock_tecnico: 10,
cantidad_maxima: 10, values: {},
cantidad_vendida: 0,
attributes: {},
}); });
fixture.detectChanges(); fixture.detectChanges();
@@ -387,25 +385,21 @@ describe('ProductDetailPageComponent', () => {
}); });
it('calls CartService.addItem when variant is selected and Add to Cart is clicked', async () => { it('calls CartService.addItem when variant is selected and Add to Cart is clicked', async () => {
const detailProduct: ProductDetail = { const detailProduct: CatalogItemDetail = {
...mockProduct, ...mockProduct,
variant: { variants: [
id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 5,
cantidad_vendida: 0,
images: [],
definitions: {},
},
variants_map: [
{ {
variant_id: 123, id: 123,
inventory_policy: 'tracked', stock_tecnico: 5,
cantidad_maxima: 5, values: {},
cantidad_vendida: 0,
attributes: {},
}, },
], ],
selected_variant: {
id: 123,
stock_tecnico: 5,
images: [],
values: {},
},
}; };
resolveProduct(detailProduct); resolveProduct(detailProduct);
@@ -414,7 +408,7 @@ describe('ProductDetailPageComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
// Select the variant // Select the variant
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]); fixture.componentInstance['selectedVariant'].set(detailProduct.variants[0]);
fixture.componentInstance['quantity'].set(3); fixture.componentInstance['quantity'].set(3);
fixture.detectChanges(); fixture.detectChanges();
@@ -427,30 +421,50 @@ describe('ProductDetailPageComponent', () => {
addToCartButton.click(); addToCartButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalledWith('variant', 123, 3); expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, 123, 3);
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito'); expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito');
}); });
it('shows error toast when CartService.addItem fails', async () => { it('adds a catalog item without variants to the cart', async () => {
const detailProduct: ProductDetail = { resolveProduct({
...mockProduct, ...mockProduct,
variant: { variants: [],
id: 123, stock_tecnico: 4,
inventory_policy: 'tracked', });
cantidad_maxima: 5,
cantidad_vendida: 0, await configureTestingModule();
images: [], const fixture = TestBed.createComponent(ProductDetailPageComponent);
definitions: {}, fixture.detectChanges();
},
variants_map: [ const buttons = Array.from(
fixture.nativeElement.querySelectorAll('app-button button'),
) as HTMLButtonElement[];
const addToCartButton = buttons.find(
(button) => button.textContent?.trim() === 'Agregar al carrito',
) as HTMLButtonElement;
addToCartButton.click();
fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, null, 1);
});
it('shows error toast when CartService.addItem fails', async () => {
const detailProduct: CatalogItemDetail = {
...mockProduct,
variants: [
{ {
variant_id: 123, id: 123,
inventory_policy: 'tracked', stock_tecnico: 5,
cantidad_maxima: 5, values: {},
cantidad_vendida: 0,
attributes: {},
}, },
], ],
selected_variant: {
id: 123,
stock_tecnico: 5,
images: [],
values: {},
},
}; };
resolveProduct(detailProduct); resolveProduct(detailProduct);
cartServiceStub.addItem.mockReturnValue(throwError(() => new Error('Failed to add'))); cartServiceStub.addItem.mockReturnValue(throwError(() => new Error('Failed to add')));
@@ -459,7 +473,7 @@ describe('ProductDetailPageComponent', () => {
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges(); fixture.detectChanges();
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]); fixture.componentInstance['selectedVariant'].set(detailProduct.variants[0]);
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
@@ -478,23 +492,20 @@ describe('ProductDetailPageComponent', () => {
it('allows unlimited variants to increase quantity without a maximum', async () => { it('allows unlimited variants to increase quantity without a maximum', async () => {
const unlimitedVariant = { const unlimitedVariant = {
variant_id: 321, id: 321,
inventory_policy: 'unlimited' as const, stock_tecnico: null,
cantidad_maxima: null, values: {},
cantidad_vendida: 10,
attributes: {},
}; };
resolveProduct({ resolveProduct({
...mockProduct, ...mockProduct,
variant: { inventory_policy: 'unlimited',
selected_variant: {
id: 321, id: 321,
inventory_policy: 'unlimited', stock_tecnico: null,
cantidad_maxima: null,
cantidad_vendida: 10,
images: [], images: [],
definitions: {}, values: {},
}, },
variants_map: [unlimitedVariant], variants: [unlimitedVariant],
}); });
await configureTestingModule(); await configureTestingModule();
@@ -513,23 +524,19 @@ describe('ProductDetailPageComponent', () => {
it('disables purchase actions for tracked variants without stock', async () => { it('disables purchase actions for tracked variants without stock', async () => {
const trackedVariant = { const trackedVariant = {
variant_id: 654, id: 654,
inventory_policy: 'tracked' as const, stock_tecnico: 0,
cantidad_maxima: 0, values: {},
cantidad_vendida: 5,
attributes: {},
}; };
resolveProduct({ resolveProduct({
...mockProduct, ...mockProduct,
variant: { selected_variant: {
id: 654, id: 654,
inventory_policy: 'tracked', stock_tecnico: 0,
cantidad_maxima: 0,
cantidad_vendida: 5,
images: [], images: [],
definitions: {}, values: {},
}, },
variants_map: [trackedVariant], variants: [trackedVariant],
}); });
await configureTestingModule(); await configureTestingModule();

View File

@@ -20,8 +20,8 @@ import { CatalogService } from '../../../../core/services/catalog/catalog.servic
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { import {
ProductDetail, CatalogItemDetail,
ProductVariantMap, CatalogItemVariant,
} from '../../../../core/services/catalog/catalog.interface'; } from '../../../../core/services/catalog/catalog.interface';
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component'; import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
@@ -62,12 +62,12 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
private observedCarouselPreview: HTMLElement | null = null; private observedCarouselPreview: HTMLElement | null = null;
private measurementTimer: ReturnType<typeof setTimeout> | null = null; private measurementTimer: ReturnType<typeof setTimeout> | null = null;
protected readonly product = signal<ProductDetail | null>(null); protected readonly product = signal<CatalogItemDetail | null>(null);
protected readonly carouselImages = computed(() => { protected readonly carouselImages = computed(() => {
const prod = this.product(); const prod = this.product();
if (!prod) return []; if (!prod) return [];
if (prod.variant?.images && prod.variant.images.length > 0) { if (prod.selected_variant?.images && prod.selected_variant.images.length > 0) {
return prod.variant.images; return prod.selected_variant.images;
} }
if (prod.images && prod.images.length > 0) { if (prod.images && prod.images.length > 0) {
return prod.images; return prod.images;
@@ -78,15 +78,24 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
protected readonly variantLoading = signal(false); protected readonly variantLoading = signal(false);
protected readonly addingToCart = signal(false); protected readonly addingToCart = signal(false);
protected readonly error = signal<string | null>(null); protected readonly error = signal<string | null>(null);
protected readonly selectedVariant = signal<ProductVariantMap | null>(null); protected readonly selectedVariant = signal<CatalogItemVariant | null>(null);
protected readonly quantity = signal(1); protected readonly quantity = signal(1);
protected readonly selectedVariantMax = computed<number | null>(() => { protected readonly selectedVariantMax = computed<number | null>(() => {
const prod = this.product();
const variant = this.selectedVariant(); const variant = this.selectedVariant();
return variant ? variant.cantidad_maxima : 1; if (variant) return variant.stock_tecnico;
if (prod && prod.variants.length === 0) return prod.stock_tecnico ?? null;
return 0;
}); });
protected readonly selectedVariantAvailable = computed(() => { protected readonly selectedVariantAvailable = computed(() => {
const prod = this.product();
if (!prod) return false;
const variant = this.selectedVariant(); const variant = this.selectedVariant();
return variant !== null && this.isVariantAvailable(variant); if (variant) return this.isVariantAvailable(variant, prod);
if (prod.variants.length > 0) return false;
return prod.inventory_policy === 'unlimited' || (prod.stock_tecnico ?? 0) > 0;
}); });
protected readonly descriptionExpanded = signal(false); protected readonly descriptionExpanded = signal(false);
protected readonly descriptionMaxHeight = signal(0); protected readonly descriptionMaxHeight = signal(0);
@@ -139,7 +148,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.variantLoading.set(true); this.variantLoading.set(true);
this.productSub?.unsubscribe(); this.productSub?.unsubscribe();
this.productSub = this.catalogService.getProducto(productId, variantId).subscribe({ this.productSub = this.catalogService.getCatalogItem(productId, variantId).subscribe({
next: (prod) => { next: (prod) => {
this.applyProduct(prod, false); this.applyProduct(prod, false);
this.variantLoading.set(false); this.variantLoading.set(false);
@@ -153,7 +162,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
if (!currentProduct) return null; if (!currentProduct) return null;
return { return {
...currentProduct, ...currentProduct,
variants_map: currentProduct.variants_map.filter((v) => v.variant_id !== variantId), variants: currentProduct.variants.filter((variant) => variant.id !== variantId),
}; };
}); });
@@ -178,10 +187,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
} }
private applyProduct(prod: ProductDetail, resetQuantity: boolean): void { private applyProduct(prod: CatalogItemDetail, resetQuantity: boolean): void {
this.product.set(prod); this.product.set(prod);
const matchingVariant = const matchingVariant =
prod.variants_map.find((v) => v.variant_id === prod.variant?.id) || null; prod.variants.find((variant) => variant.id === prod.selected_variant?.id) || null;
this.selectedVariant.set(matchingVariant); this.selectedVariant.set(matchingVariant);
if (resetQuantity) { if (resetQuantity) {
this.quantity.set(1); this.quantity.set(1);
@@ -205,31 +214,32 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
return `$${parts.join(',')}`; return `$${parts.join(',')}`;
} }
protected onVariantChange(variant: ProductVariantMap | null): void { protected onVariantChange(variant: CatalogItemVariant | null): void {
if (this.selectedVariant()?.variant_id === variant?.variant_id) { if (this.selectedVariant()?.id === variant?.id) {
return; return;
} }
this.selectedVariant.set(variant); this.selectedVariant.set(variant);
if (variant && variant.cantidad_maxima !== null && this.quantity() > variant.cantidad_maxima) { if (variant && variant.stock_tecnico !== null && this.quantity() > variant.stock_tecnico) {
this.quantity.set(Math.max(1, variant.cantidad_maxima)); this.quantity.set(Math.max(1, variant.stock_tecnico));
} }
const currentProduct = this.product(); const currentProduct = this.product();
if (variant && currentProduct && currentProduct.variant?.id !== variant.variant_id) { if (variant && currentProduct && currentProduct.selected_variant?.id !== variant.id) {
this.loadProductVariant(currentProduct.id, variant.variant_id); this.loadProductVariant(currentProduct.id, variant.id);
} }
} }
protected addToCart(): void { protected addToCart(): void {
const currentProduct = this.product();
const variant = this.selectedVariant(); const variant = this.selectedVariant();
if (!variant || !this.isVariantAvailable(variant)) { if (!currentProduct || !this.selectedVariantAvailable()) {
this.toastService.danger('Por favor, selecciona una variante.'); this.toastService.danger('Por favor, selecciona una variante.');
return; return;
} }
this.addingToCart.set(true); this.addingToCart.set(true);
this.cartService.addItem('variant', variant.variant_id, this.quantity()).subscribe({ this.cartService.addItem(currentProduct.id, variant?.id ?? null, this.quantity()).subscribe({
next: (res) => { next: (res) => {
const msg = res.message || 'Producto agregado al carrito'; const msg = res.message || 'Producto agregado al carrito';
this.toastService.success(msg); this.toastService.success(msg);
@@ -243,8 +253,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
}); });
} }
private isVariantAvailable(variant: ProductVariantMap): boolean { private isVariantAvailable(variant: CatalogItemVariant, product: CatalogItemDetail): boolean {
return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0; return product.inventory_policy === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
} }
protected toggleDescription(): void { protected toggleDescription(): void {

View File

@@ -3,7 +3,7 @@ import { convertToParamMap } from '@angular/router';
import { firstValueFrom, Observable, of, throwError } from 'rxjs'; import { firstValueFrom, Observable, of, throwError } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface'; import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { import {
PRODUCT_DETAIL_ERROR_MESSAGE, PRODUCT_DETAIL_ERROR_MESSAGE,
@@ -13,7 +13,7 @@ import {
} from './product-detail-page.resolver'; } from './product-detail-page.resolver';
describe('productDetailResolver', () => { describe('productDetailResolver', () => {
const product: ProductDetail = { const product: CatalogItemDetail = {
id: 1, id: 1,
category_id: 10, category_id: 10,
brand_id: null, brand_id: null,
@@ -24,16 +24,20 @@ describe('productDetailResolver', () => {
category: 'Tecnologia', category: 'Tecnologia',
brand: null, brand: null,
images: [], images: [],
inventory_policy: 'tracked',
has_tickets: false,
minimum_use_date: null,
maximum_use_date: null,
stock_tecnico: 0,
attributes: [], attributes: [],
variants_map: [], variants: [],
variant: null,
}; };
let catalogServiceStub: { getProducto: ReturnType<typeof vi.fn> }; let catalogServiceStub: { getCatalogItem: ReturnType<typeof vi.fn> };
beforeEach(() => { beforeEach(() => {
catalogServiceStub = { catalogServiceStub = {
getProducto: vi.fn().mockReturnValue(of(product)), getCatalogItem: vi.fn().mockReturnValue(of(product)),
}; };
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -55,7 +59,7 @@ describe('productDetailResolver', () => {
product, product,
error: null, error: null,
}); });
expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1); expect(catalogServiceStub.getCatalogItem).toHaveBeenCalledWith(1);
}); });
it('returns an error state for invalid ids', async () => { it('returns an error state for invalid ids', async () => {
@@ -67,11 +71,11 @@ describe('productDetailResolver', () => {
product: null, product: null,
error: PRODUCT_DETAIL_INVALID_ID_MESSAGE, error: PRODUCT_DETAIL_INVALID_ID_MESSAGE,
}); });
expect(catalogServiceStub.getProducto).not.toHaveBeenCalled(); expect(catalogServiceStub.getCatalogItem).not.toHaveBeenCalled();
}); });
it('returns an error state when the request fails', async () => { it('returns an error state when the request fails', async () => {
catalogServiceStub.getProducto.mockReturnValue(throwError(() => new Error('boom'))); catalogServiceStub.getCatalogItem.mockReturnValue(throwError(() => new Error('boom')));
const result = TestBed.runInInjectionContext(() => const result = TestBed.runInInjectionContext(() =>
productDetailResolver(createRouteSnapshot('1'), {} as never), productDetailResolver(createRouteSnapshot('1'), {} as never),

View File

@@ -2,14 +2,14 @@ import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router';
import { catchError, map, of } from 'rxjs'; import { catchError, map, of } from 'rxjs';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface'; import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
export const PRODUCT_DETAIL_ERROR_MESSAGE = 'No pudimos cargar los detalles del producto.'; export const PRODUCT_DETAIL_ERROR_MESSAGE = 'No pudimos cargar los detalles del producto.';
export const PRODUCT_DETAIL_INVALID_ID_MESSAGE = 'ID de producto invalido'; export const PRODUCT_DETAIL_INVALID_ID_MESSAGE = 'ID de producto invalido';
export interface ProductDetailResolvedData { export interface ProductDetailResolvedData {
product: ProductDetail | null; product: CatalogItemDetail | null;
error: string | null; error: string | null;
} }
@@ -26,7 +26,7 @@ export const productDetailResolver: ResolveFn<ProductDetailResolvedData> = (
} }
return inject(CatalogService) return inject(CatalogService)
.getProducto(productId) .getCatalogItem(productId)
.pipe( .pipe(
map( map(
(product): ProductDetailResolvedData => ({ (product): ProductDetailResolvedData => ({