From 0c0d2b559dd853e05c5b0e1f41012ade086cdc0a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 20 Jul 2026 16:46:17 -0300 Subject: [PATCH] feat(catalog): refactor product and variant interfaces for improved structure and clarity --- src/app/core/services/cart/cart.interface.ts | 6 +- .../core/services/cart/cart.service.spec.ts | 10 +- src/app/core/services/cart/cart.service.ts | 8 +- .../services/catalog/catalog.interface.ts | 38 ++-- .../core/services/catalog/catalog.service.ts | 6 +- ...oduct-attribute-selector.component.spec.ts | 30 ++- .../product-attribute-selector.component.ts | 53 +++--- .../product-detail-page.component.html | 5 +- .../product-detail-page.component.spec.ts | 175 +++++++++--------- .../product-detail-page.component.ts | 54 +++--- .../product-detail-page.resolver.spec.ts | 22 ++- .../product-detail-page.resolver.ts | 6 +- 12 files changed, 216 insertions(+), 197 deletions(-) diff --git a/src/app/core/services/cart/cart.interface.ts b/src/app/core/services/cart/cart.interface.ts index aa854b1..c774367 100644 --- a/src/app/core/services/cart/cart.interface.ts +++ b/src/app/core/services/cart/cart.interface.ts @@ -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; } diff --git a/src/app/core/services/cart/cart.service.spec.ts b/src/app/core/services/cart/cart.service.spec.ts index a81c661..0fc9653 100644 --- a/src/app/core/services/cart/cart.service.spec.ts +++ b/src/app/core/services/cart/cart.service.spec.ts @@ -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); diff --git a/src/app/core/services/cart/cart.service.ts b/src/app/core/services/cart/cart.service.ts index 7a1d891..94e5e0b 100644 --- a/src/app/core/services/cart/cart.service.ts +++ b/src/app/core/services/cart/cart.service.ts @@ -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> { this.isUpdatingState.set(true); return this.http .post< ApiResponse - >(`${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); diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 7c86f79..11693be 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -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; + stock_tecnico: number | null; + values: Record; +} + +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; -} - -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'; diff --git a/src/app/core/services/catalog/catalog.service.ts b/src/app/core/services/catalog/catalog.service.ts index e25975f..394de08 100644 --- a/src/app/core/services/catalog/catalog.service.ts +++ b/src/app/core/services/catalog/catalog.service.ts @@ -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 { + getCatalogItem(id: number, variantId?: number): Observable { let params = new HttpParams(); if (variantId) { params = params.set('variant_id', variantId); } return this.http - .get>(`${this.tenantApiUrl}/productos/${id}`, { params }) + .get>(`${this.tenantApiUrl}/catalog-items/${id}`, { params }) .pipe(map((response) => response.data)); } diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.spec.ts b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.spec.ts index a94e359..3c1cdf7 100644 --- a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.spec.ts +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.spec.ts @@ -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(); diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts index 3b0c121..98a341d 100644 --- a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts @@ -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([]); - public variantsMap = input([]); - public defaultVariant = input(null); + public variants = input([]); + public selectedVariant = input(null); + public inventoryPolicy = input.required(); - public variantChange = output(); + public variantChange = output(); protected readonly selectedAttributeOptions = signal>({}); protected readonly availableOptions = computed(() => { const selections = this.selectedAttributeOptions(); - const variants = this.variantsMap(); + const variants = this.variants(); const attributes = this.attributes(); const availability: Record> = {}; @@ -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 = {}; @@ -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, - 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; }); }); diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html index a73a124..8ee711b 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html @@ -41,8 +41,9 @@
diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts index 97b8915..d9cb4be 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts @@ -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(); diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts index 765c26d..0335823 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts @@ -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 | null = null; - protected readonly product = signal(null); + protected readonly product = signal(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(null); - protected readonly selectedVariant = signal(null); + protected readonly selectedVariant = signal(null); protected readonly quantity = signal(1); protected readonly selectedVariantMax = computed(() => { + 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 { diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.spec.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.spec.ts index 1da5ff9..d378c5f 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.spec.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.spec.ts @@ -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 }; + let catalogServiceStub: { getCatalogItem: ReturnType }; 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), diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.ts index bfb4083..5cf1709 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.ts @@ -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 = ( } return inject(CatalogService) - .getProducto(productId) + .getCatalogItem(productId) .pipe( map( (product): ProductDetailResolvedData => ({