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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 => ({