feat(catalog): refactor product and variant interfaces for improved structure and clarity
This commit is contained in:
@@ -3,14 +3,12 @@ export interface CartItemProduct {
|
||||
imagen: string | null;
|
||||
}
|
||||
|
||||
export type BuyableType = 'variant' | 'bundle';
|
||||
|
||||
export interface CartItem {
|
||||
id: number;
|
||||
cantidad: number;
|
||||
precio_unitario: string;
|
||||
buyable_type: BuyableType;
|
||||
buyable_id: number;
|
||||
catalog_item_id: number;
|
||||
variant_id: number | null;
|
||||
product: CartItemProduct | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ describe('CartService', () => {
|
||||
id: 1,
|
||||
cantidad: 2,
|
||||
precio_unitario: '10.00',
|
||||
buyable_type: 'variant',
|
||||
buyable_id: 10,
|
||||
catalog_item_id: 5,
|
||||
variant_id: 10,
|
||||
product: {
|
||||
nombre: 'Test Product (Size: M)',
|
||||
imagen: null,
|
||||
@@ -88,7 +88,7 @@ describe('CartService', () => {
|
||||
});
|
||||
|
||||
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(service.cart()).toEqual(mockCart);
|
||||
});
|
||||
@@ -96,8 +96,8 @@ describe('CartService', () => {
|
||||
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({
|
||||
buyable_type: 'variant',
|
||||
buyable_id: 10,
|
||||
catalog_item_id: 5,
|
||||
variant_id: 10,
|
||||
cantidad: 2,
|
||||
});
|
||||
expect(req.request.withCredentials).toBe(true);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { catchError, map, Observable, tap } from 'rxjs';
|
||||
|
||||
import { ApiResponse } from '../api-response.interface';
|
||||
import { TenantService } from '../tenant.service';
|
||||
import { BuyableType, Cart } from './cart.interface';
|
||||
import { Cart } from './cart.interface';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -45,15 +45,15 @@ export class CartService {
|
||||
}
|
||||
|
||||
addItem(
|
||||
buyableType: BuyableType,
|
||||
buyableId: number,
|
||||
catalogItemId: number,
|
||||
variantId: number | null,
|
||||
cantidad: number,
|
||||
): Observable<ApiResponse<Cart>> {
|
||||
this.isUpdatingState.set(true);
|
||||
return this.http
|
||||
.post<
|
||||
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(
|
||||
tap((response) => {
|
||||
this.cartState.set(response.data);
|
||||
|
||||
@@ -33,27 +33,35 @@ export interface ProductAttribute {
|
||||
|
||||
export type InventoryPolicy = 'tracked' | 'unlimited';
|
||||
|
||||
export interface ProductVariant {
|
||||
export interface CatalogItemVariant {
|
||||
id: number;
|
||||
inventory_policy: InventoryPolicy;
|
||||
cantidad_maxima: number | null;
|
||||
cantidad_vendida: number;
|
||||
definitions: Record<string, string>;
|
||||
stock_tecnico: number | null;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
|
||||
images: string[];
|
||||
}
|
||||
|
||||
export interface ProductVariantMap {
|
||||
variant_id: number;
|
||||
export interface CatalogItemDetail {
|
||||
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;
|
||||
cantidad_maxima: number | null;
|
||||
cantidad_vendida: number;
|
||||
attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProductDetail extends Product {
|
||||
has_tickets: boolean;
|
||||
minimum_use_date: string | null;
|
||||
maximum_use_date: string | null;
|
||||
attributes: ProductAttribute[];
|
||||
variants_map: ProductVariantMap[];
|
||||
variant: ProductVariant | null;
|
||||
variants: CatalogItemVariant[];
|
||||
selected_variant?: SelectedCatalogItemVariant;
|
||||
stock_tecnico?: number | null;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';
|
||||
|
||||
@@ -9,8 +9,8 @@ import { TenantService } from '../tenant.service';
|
||||
import {
|
||||
CatalogFeaturedGroup,
|
||||
CatalogFeaturedItem,
|
||||
CatalogItemDetail,
|
||||
Product,
|
||||
ProductDetail,
|
||||
} from './catalog.interface';
|
||||
|
||||
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();
|
||||
if (variantId) {
|
||||
params = params.set('variant_id', variantId);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,13 +27,12 @@ describe('ProductAttributeSelectorComponent', () => {
|
||||
it('keeps an unlimited option available when maximum quantity is null', () => {
|
||||
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
||||
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
||||
fixture.componentRef.setInput('variantsMap', [
|
||||
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
|
||||
fixture.componentRef.setInput('variants', [
|
||||
{
|
||||
variant_id: 1,
|
||||
inventory_policy: 'unlimited',
|
||||
cantidad_maxima: null,
|
||||
cantidad_vendida: 0,
|
||||
attributes: { size: 'S' },
|
||||
id: 1,
|
||||
stock_tecnico: null,
|
||||
values: { size: 'S' },
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
@@ -48,20 +47,17 @@ describe('ProductAttributeSelectorComponent', () => {
|
||||
it('disables tracked options without available stock', () => {
|
||||
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
||||
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
||||
fixture.componentRef.setInput('variantsMap', [
|
||||
fixture.componentRef.setInput('inventoryPolicy', 'tracked');
|
||||
fixture.componentRef.setInput('variants', [
|
||||
{
|
||||
variant_id: 1,
|
||||
inventory_policy: 'tracked',
|
||||
cantidad_maxima: 0,
|
||||
cantidad_vendida: 0,
|
||||
attributes: { size: 'S' },
|
||||
id: 1,
|
||||
stock_tecnico: 0,
|
||||
values: { size: 'S' },
|
||||
},
|
||||
{
|
||||
variant_id: 2,
|
||||
inventory_policy: 'tracked',
|
||||
cantidad_maxima: 2,
|
||||
cantidad_vendida: 0,
|
||||
attributes: { size: 'M' },
|
||||
id: 2,
|
||||
stock_tecnico: 2,
|
||||
values: { size: 'M' },
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
CatalogItemVariant,
|
||||
InventoryPolicy,
|
||||
ProductAttribute,
|
||||
ProductAttributeOption,
|
||||
ProductVariant,
|
||||
ProductVariantMap,
|
||||
} from '../../../../core/services/catalog/catalog.interface';
|
||||
|
||||
@Component({
|
||||
@@ -26,16 +26,17 @@ import {
|
||||
})
|
||||
export class ProductAttributeSelectorComponent {
|
||||
public attributes = input<ProductAttribute[]>([]);
|
||||
public variantsMap = input<ProductVariantMap[]>([]);
|
||||
public defaultVariant = input<ProductVariant | null>(null);
|
||||
public variants = input<CatalogItemVariant[]>([]);
|
||||
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 availableOptions = computed(() => {
|
||||
const selections = this.selectedAttributeOptions();
|
||||
const variants = this.variantsMap();
|
||||
const variants = this.variants();
|
||||
const attributes = this.attributes();
|
||||
|
||||
const availability: Record<number, Record<number, boolean>> = {};
|
||||
@@ -48,10 +49,7 @@ export class ProductAttributeSelectorComponent {
|
||||
const isAvailable = variants.some((variant) => {
|
||||
if (!this.isVariantAvailable(variant)) return false;
|
||||
|
||||
const variantAttrValue = this.getDefaultVariantAttributeValue(
|
||||
attribute,
|
||||
variant.attributes,
|
||||
);
|
||||
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
|
||||
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
|
||||
return false;
|
||||
}
|
||||
@@ -65,10 +63,7 @@ export class ProductAttributeSelectorComponent {
|
||||
const selectedNormalized = this.normalizeText(
|
||||
selectedOption.value || selectedOption.label,
|
||||
);
|
||||
const vAttrValue = this.getDefaultVariantAttributeValue(
|
||||
otherAttr,
|
||||
variant.attributes,
|
||||
);
|
||||
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.values);
|
||||
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
|
||||
return false;
|
||||
}
|
||||
@@ -86,13 +81,13 @@ export class ProductAttributeSelectorComponent {
|
||||
});
|
||||
|
||||
public reset(): void {
|
||||
this.initializeSelections(this.attributes(), this.defaultVariant());
|
||||
this.initializeSelections(this.attributes(), this.selectedVariant());
|
||||
}
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const attributes = this.attributes();
|
||||
const defaultVariant = this.defaultVariant();
|
||||
const defaultVariant = this.selectedVariant();
|
||||
|
||||
untracked(() => {
|
||||
this.initializeSelections(attributes, defaultVariant);
|
||||
@@ -101,11 +96,11 @@ export class ProductAttributeSelectorComponent {
|
||||
|
||||
effect(() => {
|
||||
const selections = this.selectedAttributeOptions();
|
||||
const variantsMap = this.variantsMap();
|
||||
const variants = this.variants();
|
||||
const attributes = this.attributes();
|
||||
|
||||
untracked(() => {
|
||||
this.emitMatchingVariant(selections, variantsMap, attributes);
|
||||
this.emitMatchingVariant(selections, variants, attributes);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -137,7 +132,7 @@ export class ProductAttributeSelectorComponent {
|
||||
|
||||
private initializeSelections(
|
||||
attributes: ProductAttribute[],
|
||||
defaultVariant: ProductVariant | null,
|
||||
defaultVariant: CatalogItemVariant | null,
|
||||
): void {
|
||||
const selections: Record<number, number> = {};
|
||||
|
||||
@@ -153,13 +148,13 @@ export class ProductAttributeSelectorComponent {
|
||||
|
||||
private findDefaultOptionId(
|
||||
attribute: ProductAttribute,
|
||||
variant: ProductVariant | null,
|
||||
variant: CatalogItemVariant | null,
|
||||
): number | null {
|
||||
if (!variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions);
|
||||
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
|
||||
if (!defaultValue) {
|
||||
return null;
|
||||
}
|
||||
@@ -206,8 +201,8 @@ export class ProductAttributeSelectorComponent {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
private isVariantAvailable(variant: ProductVariantMap): boolean {
|
||||
return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0;
|
||||
private isVariantAvailable(variant: CatalogItemVariant): boolean {
|
||||
return this.inventoryPolicy() === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
|
||||
}
|
||||
|
||||
private findFirstHexValue(value: unknown): string | null {
|
||||
@@ -240,10 +235,10 @@ export class ProductAttributeSelectorComponent {
|
||||
|
||||
private emitMatchingVariant(
|
||||
selections: Record<number, number>,
|
||||
variantsMap: ProductVariantMap[],
|
||||
variants: CatalogItemVariant[],
|
||||
attributes: ProductAttribute[],
|
||||
): void {
|
||||
if (attributes.length === 0 || variantsMap.length === 0) {
|
||||
if (attributes.length === 0 || variants.length === 0) {
|
||||
this.variantChange.emit(null);
|
||||
return;
|
||||
}
|
||||
@@ -267,12 +262,12 @@ export class ProductAttributeSelectorComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingVariant = variantsMap.find((vMap) => {
|
||||
const matchingVariant = variants.find((variant) => {
|
||||
return attributes.every((attr) => {
|
||||
const selectedValue = selectedValuesById[attr.id];
|
||||
const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes);
|
||||
if (!vMapValue) return false;
|
||||
return this.normalizeText(vMapValue) === selectedValue;
|
||||
const variantValue = this.getDefaultVariantAttributeValue(attr, variant.values);
|
||||
if (!variantValue) return false;
|
||||
return this.normalizeText(variantValue) === selectedValue;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@
|
||||
<section class="product-detail__section product-detail__section--attributes">
|
||||
<app-product-attribute-selector
|
||||
[attributes]="renderableAttributes()"
|
||||
[variantsMap]="prod.variants_map"
|
||||
[defaultVariant]="prod.variant"
|
||||
[variants]="prod.variants"
|
||||
[selectedVariant]="prod.selected_variant ?? null"
|
||||
[inventoryPolicy]="prod.inventory_policy"
|
||||
(variantChange)="onVariantChange($event)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { By } from '@angular/platform-browser';
|
||||
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||
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 { CartService } from '../../../../core/services/cart/cart.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from './product-detail-page.resolver';
|
||||
|
||||
describe('ProductDetailPageComponent', () => {
|
||||
const mockProduct: ProductDetail = {
|
||||
const mockProduct: CatalogItemDetail = {
|
||||
id: 1,
|
||||
category_id: 10,
|
||||
brand_id: null,
|
||||
@@ -27,9 +27,13 @@ describe('ProductDetailPageComponent', () => {
|
||||
category: 'Tecnología',
|
||||
brand: 'Sony',
|
||||
images: ['https://example.com/image.png'],
|
||||
inventory_policy: 'tracked',
|
||||
has_tickets: false,
|
||||
minimum_use_date: null,
|
||||
maximum_use_date: null,
|
||||
stock_tecnico: 10,
|
||||
attributes: [],
|
||||
variants_map: [],
|
||||
variant: null,
|
||||
variants: [],
|
||||
};
|
||||
|
||||
let routeDataSubject: BehaviorSubject<{ productDetailData: ProductDetailResolvedData }>;
|
||||
@@ -55,7 +59,7 @@ describe('ProductDetailPageComponent', () => {
|
||||
},
|
||||
});
|
||||
catalogServiceStub = {
|
||||
getProducto: vi.fn(),
|
||||
getCatalogItem: vi.fn(),
|
||||
};
|
||||
routerStub = {
|
||||
navigate: vi.fn(),
|
||||
@@ -100,7 +104,7 @@ describe('ProductDetailPageComponent', () => {
|
||||
}).compileComponents();
|
||||
}
|
||||
|
||||
function resolveProduct(product: ProductDetail): void {
|
||||
function resolveProduct(product: CatalogItemDetail): void {
|
||||
routeDataSubject.next({
|
||||
productDetailData: {
|
||||
product,
|
||||
@@ -125,7 +129,7 @@ describe('ProductDetailPageComponent', () => {
|
||||
|
||||
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('.product-detail__title')?.textContent).toContain(
|
||||
@@ -150,16 +154,15 @@ describe('ProductDetailPageComponent', () => {
|
||||
});
|
||||
|
||||
it('should use default variant images if present', async () => {
|
||||
const detailProduct: ProductDetail = {
|
||||
const detailProduct: CatalogItemDetail = {
|
||||
...mockProduct,
|
||||
images: ['https://example.com/product.png'],
|
||||
variant: {
|
||||
variants: [{ id: 123, stock_tecnico: 10, values: {} }],
|
||||
selected_variant: {
|
||||
id: 123,
|
||||
inventory_policy: 'tracked',
|
||||
cantidad_maxima: 10,
|
||||
cantidad_vendida: 0,
|
||||
stock_tecnico: 10,
|
||||
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
|
||||
definitions: {},
|
||||
values: {},
|
||||
},
|
||||
};
|
||||
resolveProduct(detailProduct);
|
||||
@@ -176,10 +179,9 @@ describe('ProductDetailPageComponent', () => {
|
||||
});
|
||||
|
||||
it('should fallback to product images if default variant images are not present', async () => {
|
||||
const detailProduct: ProductDetail = {
|
||||
const detailProduct: CatalogItemDetail = {
|
||||
...mockProduct,
|
||||
images: ['https://example.com/product.png'],
|
||||
variant: null,
|
||||
};
|
||||
resolveProduct(detailProduct);
|
||||
|
||||
@@ -192,10 +194,9 @@ describe('ProductDetailPageComponent', () => {
|
||||
});
|
||||
|
||||
it('should fallback to empty array if no images are present at all', async () => {
|
||||
const detailProduct: ProductDetail = {
|
||||
const detailProduct: CatalogItemDetail = {
|
||||
...mockProduct,
|
||||
images: [],
|
||||
variant: null,
|
||||
};
|
||||
resolveProduct(detailProduct);
|
||||
|
||||
@@ -208,7 +209,7 @@ describe('ProductDetailPageComponent', () => {
|
||||
});
|
||||
|
||||
it('renders generic attributes and preselects default variant options', async () => {
|
||||
const detailProduct: ProductDetail = {
|
||||
const detailProduct: CatalogItemDetail = {
|
||||
...mockProduct,
|
||||
attributes: [
|
||||
{
|
||||
@@ -260,13 +261,12 @@ describe('ProductDetailPageComponent', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
variant: {
|
||||
variants: [{ id: 123, stock_tecnico: 10, values: { color: 'beige', material: 'Cuero' } }],
|
||||
selected_variant: {
|
||||
id: 123,
|
||||
inventory_policy: 'tracked',
|
||||
cantidad_maxima: 10,
|
||||
cantidad_vendida: 0,
|
||||
stock_tecnico: 10,
|
||||
images: ['https://example.com/variant1.png'],
|
||||
definitions: {
|
||||
values: {
|
||||
color: 'beige',
|
||||
material: 'Cuero',
|
||||
},
|
||||
@@ -312,11 +312,9 @@ describe('ProductDetailPageComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance['selectedVariant'].set({
|
||||
variant_id: 1,
|
||||
inventory_policy: 'tracked',
|
||||
cantidad_maxima: 10,
|
||||
cantidad_vendida: 0,
|
||||
attributes: {},
|
||||
id: 1,
|
||||
stock_tecnico: 10,
|
||||
values: {},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -387,25 +385,21 @@ describe('ProductDetailPageComponent', () => {
|
||||
});
|
||||
|
||||
it('calls CartService.addItem when variant is selected and Add to Cart is clicked', async () => {
|
||||
const detailProduct: ProductDetail = {
|
||||
const detailProduct: CatalogItemDetail = {
|
||||
...mockProduct,
|
||||
variant: {
|
||||
id: 123,
|
||||
inventory_policy: 'tracked',
|
||||
cantidad_maxima: 5,
|
||||
cantidad_vendida: 0,
|
||||
images: [],
|
||||
definitions: {},
|
||||
},
|
||||
variants_map: [
|
||||
variants: [
|
||||
{
|
||||
variant_id: 123,
|
||||
inventory_policy: 'tracked',
|
||||
cantidad_maxima: 5,
|
||||
cantidad_vendida: 0,
|
||||
attributes: {},
|
||||
id: 123,
|
||||
stock_tecnico: 5,
|
||||
values: {},
|
||||
},
|
||||
],
|
||||
selected_variant: {
|
||||
id: 123,
|
||||
stock_tecnico: 5,
|
||||
images: [],
|
||||
values: {},
|
||||
},
|
||||
};
|
||||
resolveProduct(detailProduct);
|
||||
|
||||
@@ -414,7 +408,7 @@ describe('ProductDetailPageComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
// Select the variant
|
||||
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]);
|
||||
fixture.componentInstance['selectedVariant'].set(detailProduct.variants[0]);
|
||||
fixture.componentInstance['quantity'].set(3);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -427,30 +421,50 @@ describe('ProductDetailPageComponent', () => {
|
||||
addToCartButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cartServiceStub.addItem).toHaveBeenCalledWith('variant', 123, 3);
|
||||
expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, 123, 3);
|
||||
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito');
|
||||
});
|
||||
|
||||
it('shows error toast when CartService.addItem fails', async () => {
|
||||
const detailProduct: ProductDetail = {
|
||||
it('adds a catalog item without variants to the cart', async () => {
|
||||
resolveProduct({
|
||||
...mockProduct,
|
||||
variant: {
|
||||
id: 123,
|
||||
inventory_policy: 'tracked',
|
||||
cantidad_maxima: 5,
|
||||
cantidad_vendida: 0,
|
||||
images: [],
|
||||
definitions: {},
|
||||
},
|
||||
variants_map: [
|
||||
variants: [],
|
||||
stock_tecnico: 4,
|
||||
});
|
||||
|
||||
await configureTestingModule();
|
||||
const fixture = TestBed.createComponent(ProductDetailPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
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,
|
||||
inventory_policy: 'tracked',
|
||||
cantidad_maxima: 5,
|
||||
cantidad_vendida: 0,
|
||||
attributes: {},
|
||||
id: 123,
|
||||
stock_tecnico: 5,
|
||||
values: {},
|
||||
},
|
||||
],
|
||||
selected_variant: {
|
||||
id: 123,
|
||||
stock_tecnico: 5,
|
||||
images: [],
|
||||
values: {},
|
||||
},
|
||||
};
|
||||
resolveProduct(detailProduct);
|
||||
cartServiceStub.addItem.mockReturnValue(throwError(() => new Error('Failed to add')));
|
||||
@@ -459,7 +473,7 @@ describe('ProductDetailPageComponent', () => {
|
||||
const fixture = TestBed.createComponent(ProductDetailPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]);
|
||||
fixture.componentInstance['selectedVariant'].set(detailProduct.variants[0]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
@@ -478,23 +492,20 @@ describe('ProductDetailPageComponent', () => {
|
||||
|
||||
it('allows unlimited variants to increase quantity without a maximum', async () => {
|
||||
const unlimitedVariant = {
|
||||
variant_id: 321,
|
||||
inventory_policy: 'unlimited' as const,
|
||||
cantidad_maxima: null,
|
||||
cantidad_vendida: 10,
|
||||
attributes: {},
|
||||
id: 321,
|
||||
stock_tecnico: null,
|
||||
values: {},
|
||||
};
|
||||
resolveProduct({
|
||||
...mockProduct,
|
||||
variant: {
|
||||
inventory_policy: 'unlimited',
|
||||
selected_variant: {
|
||||
id: 321,
|
||||
inventory_policy: 'unlimited',
|
||||
cantidad_maxima: null,
|
||||
cantidad_vendida: 10,
|
||||
stock_tecnico: null,
|
||||
images: [],
|
||||
definitions: {},
|
||||
values: {},
|
||||
},
|
||||
variants_map: [unlimitedVariant],
|
||||
variants: [unlimitedVariant],
|
||||
});
|
||||
|
||||
await configureTestingModule();
|
||||
@@ -513,23 +524,19 @@ describe('ProductDetailPageComponent', () => {
|
||||
|
||||
it('disables purchase actions for tracked variants without stock', async () => {
|
||||
const trackedVariant = {
|
||||
variant_id: 654,
|
||||
inventory_policy: 'tracked' as const,
|
||||
cantidad_maxima: 0,
|
||||
cantidad_vendida: 5,
|
||||
attributes: {},
|
||||
id: 654,
|
||||
stock_tecnico: 0,
|
||||
values: {},
|
||||
};
|
||||
resolveProduct({
|
||||
...mockProduct,
|
||||
variant: {
|
||||
selected_variant: {
|
||||
id: 654,
|
||||
inventory_policy: 'tracked',
|
||||
cantidad_maxima: 0,
|
||||
cantidad_vendida: 5,
|
||||
stock_tecnico: 0,
|
||||
images: [],
|
||||
definitions: {},
|
||||
values: {},
|
||||
},
|
||||
variants_map: [trackedVariant],
|
||||
variants: [trackedVariant],
|
||||
});
|
||||
|
||||
await configureTestingModule();
|
||||
|
||||
@@ -20,8 +20,8 @@ import { CatalogService } from '../../../../core/services/catalog/catalog.servic
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||
import {
|
||||
ProductDetail,
|
||||
ProductVariantMap,
|
||||
CatalogItemDetail,
|
||||
CatalogItemVariant,
|
||||
} from '../../../../core/services/catalog/catalog.interface';
|
||||
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.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 measurementTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
protected readonly product = signal<ProductDetail | null>(null);
|
||||
protected readonly product = signal<CatalogItemDetail | null>(null);
|
||||
protected readonly carouselImages = computed(() => {
|
||||
const prod = this.product();
|
||||
if (!prod) return [];
|
||||
if (prod.variant?.images && prod.variant.images.length > 0) {
|
||||
return prod.variant.images;
|
||||
if (prod.selected_variant?.images && prod.selected_variant.images.length > 0) {
|
||||
return prod.selected_variant.images;
|
||||
}
|
||||
if (prod.images && prod.images.length > 0) {
|
||||
return prod.images;
|
||||
@@ -78,15 +78,24 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
||||
protected readonly variantLoading = signal(false);
|
||||
protected readonly addingToCart = signal(false);
|
||||
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 selectedVariantMax = computed<number | null>(() => {
|
||||
const prod = this.product();
|
||||
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(() => {
|
||||
const prod = this.product();
|
||||
if (!prod) return false;
|
||||
|
||||
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 descriptionMaxHeight = signal(0);
|
||||
@@ -139,7 +148,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
||||
this.variantLoading.set(true);
|
||||
|
||||
this.productSub?.unsubscribe();
|
||||
this.productSub = this.catalogService.getProducto(productId, variantId).subscribe({
|
||||
this.productSub = this.catalogService.getCatalogItem(productId, variantId).subscribe({
|
||||
next: (prod) => {
|
||||
this.applyProduct(prod, false);
|
||||
this.variantLoading.set(false);
|
||||
@@ -153,7 +162,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
||||
if (!currentProduct) return null;
|
||||
return {
|
||||
...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);
|
||||
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);
|
||||
if (resetQuantity) {
|
||||
this.quantity.set(1);
|
||||
@@ -205,31 +214,32 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
||||
return `$${parts.join(',')}`;
|
||||
}
|
||||
|
||||
protected onVariantChange(variant: ProductVariantMap | null): void {
|
||||
if (this.selectedVariant()?.variant_id === variant?.variant_id) {
|
||||
protected onVariantChange(variant: CatalogItemVariant | null): void {
|
||||
if (this.selectedVariant()?.id === variant?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedVariant.set(variant);
|
||||
if (variant && variant.cantidad_maxima !== null && this.quantity() > variant.cantidad_maxima) {
|
||||
this.quantity.set(Math.max(1, variant.cantidad_maxima));
|
||||
if (variant && variant.stock_tecnico !== null && this.quantity() > variant.stock_tecnico) {
|
||||
this.quantity.set(Math.max(1, variant.stock_tecnico));
|
||||
}
|
||||
|
||||
const currentProduct = this.product();
|
||||
if (variant && currentProduct && currentProduct.variant?.id !== variant.variant_id) {
|
||||
this.loadProductVariant(currentProduct.id, variant.variant_id);
|
||||
if (variant && currentProduct && currentProduct.selected_variant?.id !== variant.id) {
|
||||
this.loadProductVariant(currentProduct.id, variant.id);
|
||||
}
|
||||
}
|
||||
|
||||
protected addToCart(): void {
|
||||
const currentProduct = this.product();
|
||||
const variant = this.selectedVariant();
|
||||
if (!variant || !this.isVariantAvailable(variant)) {
|
||||
if (!currentProduct || !this.selectedVariantAvailable()) {
|
||||
this.toastService.danger('Por favor, selecciona una variante.');
|
||||
return;
|
||||
}
|
||||
|
||||
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) => {
|
||||
const msg = res.message || 'Producto agregado al carrito';
|
||||
this.toastService.success(msg);
|
||||
@@ -243,8 +253,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private isVariantAvailable(variant: ProductVariantMap): boolean {
|
||||
return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0;
|
||||
private isVariantAvailable(variant: CatalogItemVariant, product: CatalogItemDetail): boolean {
|
||||
return product.inventory_policy === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
|
||||
}
|
||||
|
||||
protected toggleDescription(): void {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { convertToParamMap } from '@angular/router';
|
||||
import { firstValueFrom, Observable, of, throwError } from 'rxjs';
|
||||
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 {
|
||||
PRODUCT_DETAIL_ERROR_MESSAGE,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './product-detail-page.resolver';
|
||||
|
||||
describe('productDetailResolver', () => {
|
||||
const product: ProductDetail = {
|
||||
const product: CatalogItemDetail = {
|
||||
id: 1,
|
||||
category_id: 10,
|
||||
brand_id: null,
|
||||
@@ -24,16 +24,20 @@ describe('productDetailResolver', () => {
|
||||
category: 'Tecnologia',
|
||||
brand: null,
|
||||
images: [],
|
||||
inventory_policy: 'tracked',
|
||||
has_tickets: false,
|
||||
minimum_use_date: null,
|
||||
maximum_use_date: null,
|
||||
stock_tecnico: 0,
|
||||
attributes: [],
|
||||
variants_map: [],
|
||||
variant: null,
|
||||
variants: [],
|
||||
};
|
||||
|
||||
let catalogServiceStub: { getProducto: ReturnType<typeof vi.fn> };
|
||||
let catalogServiceStub: { getCatalogItem: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
catalogServiceStub = {
|
||||
getProducto: vi.fn().mockReturnValue(of(product)),
|
||||
getCatalogItem: vi.fn().mockReturnValue(of(product)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -55,7 +59,7 @@ describe('productDetailResolver', () => {
|
||||
product,
|
||||
error: null,
|
||||
});
|
||||
expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1);
|
||||
expect(catalogServiceStub.getCatalogItem).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('returns an error state for invalid ids', async () => {
|
||||
@@ -67,11 +71,11 @@ describe('productDetailResolver', () => {
|
||||
product: null,
|
||||
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 () => {
|
||||
catalogServiceStub.getProducto.mockReturnValue(throwError(() => new Error('boom')));
|
||||
catalogServiceStub.getCatalogItem.mockReturnValue(throwError(() => new Error('boom')));
|
||||
|
||||
const result = TestBed.runInInjectionContext(() =>
|
||||
productDetailResolver(createRouteSnapshot('1'), {} as never),
|
||||
|
||||
@@ -2,14 +2,14 @@ import { inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router';
|
||||
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';
|
||||
|
||||
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 interface ProductDetailResolvedData {
|
||||
product: ProductDetail | null;
|
||||
product: CatalogItemDetail | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const productDetailResolver: ResolveFn<ProductDetailResolvedData> = (
|
||||
}
|
||||
|
||||
return inject(CatalogService)
|
||||
.getProducto(productId)
|
||||
.getCatalogItem(productId)
|
||||
.pipe(
|
||||
map(
|
||||
(product): ProductDetailResolvedData => ({
|
||||
|
||||
Reference in New Issue
Block a user