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