feat(store-home-page): refactor to use catalog structure and update product loading logic
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number;
|
category_id: number;
|
||||||
@@ -53,3 +55,29 @@ export interface ProductDetail extends Product {
|
|||||||
variants_map: ProductVariantMap[];
|
variants_map: ProductVariantMap[];
|
||||||
variant: ProductVariant | null;
|
variant: ProductVariant | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';
|
||||||
|
|
||||||
|
export interface CatalogFeaturedItemVariant {
|
||||||
|
id: number;
|
||||||
|
stock_tecnico: number | null;
|
||||||
|
values: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogFeaturedItem {
|
||||||
|
id: number;
|
||||||
|
nombre: string;
|
||||||
|
descripcion?: string | null;
|
||||||
|
precio: number | string;
|
||||||
|
image?: string | null;
|
||||||
|
stock_tecnico?: number | null;
|
||||||
|
variants?: CatalogFeaturedItemVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogFeaturedGroup {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
layout: CatalogProductLayout;
|
||||||
|
group_order: number;
|
||||||
|
items: ApiPaginatedResponse<CatalogFeaturedItem[]>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ import { ApiPaginationQueryParams } from '../api-pagination-query-params.interfa
|
|||||||
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
|
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
|
||||||
import { ApiResponse } from '../api-response.interface';
|
import { ApiResponse } from '../api-response.interface';
|
||||||
import { TenantService } from '../tenant.service';
|
import { TenantService } from '../tenant.service';
|
||||||
import { Product, ProductDetail } from './catalog.interface';
|
import {
|
||||||
|
CatalogFeaturedGroup,
|
||||||
|
CatalogFeaturedItem,
|
||||||
|
Product,
|
||||||
|
ProductDetail,
|
||||||
|
} from './catalog.interface';
|
||||||
|
|
||||||
type HttpParamValue =
|
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| readonly (string | number | boolean)[];
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class CatalogService {
|
export class CatalogService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
@@ -25,13 +26,23 @@ export class CatalogService {
|
|||||||
return this.tenantService.getTenantApiUrl();
|
return this.tenantService.getTenantApiUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
|
||||||
|
return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
|
||||||
|
params: this.buildHttpParams(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getProductos(
|
getCatalog(): Observable<CatalogFeaturedGroup[]> {
|
||||||
params?: ApiPaginationQueryParams
|
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
|
||||||
): Observable<ApiPaginatedResponse<Product[]>> {
|
}
|
||||||
return this.http.get<ApiPaginatedResponse<Product[]>>(
|
|
||||||
`${this.tenantApiUrl}/productos`,
|
getFeaturedGroupItems(
|
||||||
{ params: this.buildHttpParams(params) }
|
featuredGroupId: number,
|
||||||
|
params?: ApiPaginationQueryParams,
|
||||||
|
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
|
||||||
|
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
|
||||||
|
`${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`,
|
||||||
|
{ params: this.buildHttpParams(params) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +70,7 @@ export class CatalogService {
|
|||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{}
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
return new HttpParams({ fromObject });
|
return new HttpParams({ fromObject });
|
||||||
|
|||||||
@@ -5,47 +5,24 @@
|
|||||||
></app-hero-banner>
|
></app-hero-banner>
|
||||||
}
|
}
|
||||||
|
|
||||||
<app-store-section title="Productos">
|
@if (error()) {
|
||||||
<div class="d-grid gap-4">
|
<p class="alert text-center mb-0 store-home__alert-error">{{ error() }}</p>
|
||||||
@if (error()) {
|
} @else if (loading()) {
|
||||||
<p class="alert text-center mb-0 store-home__alert-error">{{ error() }}</p>
|
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
|
||||||
} @else if (loading() && !productCards().length) {
|
} @else if (!catalog().length) {
|
||||||
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
|
<p class="alert alert-light border text-center mb-0">
|
||||||
} @else if (!productCards().length) {
|
No hay productos disponibles en este momento.
|
||||||
<p class="alert alert-light border text-center mb-0">
|
</p>
|
||||||
No hay productos disponibles en este momento.
|
} @else {
|
||||||
</p>
|
@for (group of catalog(); track group.id) {
|
||||||
} @else {
|
<app-store-section [title]="group.title">
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 g-4">
|
<app-product-list
|
||||||
@for (product of productCards(); track product.id; let index = $index) {
|
[layout]="group.layout"
|
||||||
<div class="col">
|
[items]="group.items"
|
||||||
<app-product-column-with-image
|
[loading]="isGroupLoading(group.id)"
|
||||||
[title]="product.title"
|
(buy)="onBuyProduct($event)"
|
||||||
[originalPrice]="product.originalPrice"
|
(pageChange)="onPageChange(group.id, $event)"
|
||||||
[discount]="product.discount"
|
/>
|
||||||
[transferPrice]="product.transferPrice"
|
</app-store-section>
|
||||||
[imageUrl]="product.imageUrl"
|
}
|
||||||
[imagePriority]="index < priorityImageCount"
|
}
|
||||||
(buy)="onBuyProduct(product.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading() && productCards().length) {
|
|
||||||
<p class="alert alert-light border text-center mb-0 py-2">Actualizando productos...</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (totalPages() > 1) {
|
|
||||||
<div class="d-flex justify-content-center">
|
|
||||||
<app-paginator
|
|
||||||
[page]="currentPage()"
|
|
||||||
[totalPages]="totalPages()"
|
|
||||||
[disabled]="loading()"
|
|
||||||
(pageChange)="onPageChange($event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</app-store-section>
|
|
||||||
|
|||||||
@@ -4,196 +4,157 @@ import { Subject, of } from 'rxjs';
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
|
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
|
||||||
import { Product } from '../../../../core/services/catalog/catalog.interface';
|
import {
|
||||||
|
CatalogFeaturedGroup,
|
||||||
|
CatalogFeaturedItem,
|
||||||
|
} from '../../../../core/services/catalog/catalog.interface';
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
import { StoreHomePageComponent } from './store-home-page.component';
|
import { StoreHomePageComponent } from './store-home-page.component';
|
||||||
import {
|
import {
|
||||||
STORE_HOME_PRODUCTS_ERROR_MESSAGE,
|
STORE_HOME_PRODUCTS_ERROR_MESSAGE,
|
||||||
StoreHomeProductsResolvedData,
|
StoreHomeCatalogResolvedData,
|
||||||
} from './store-home-page.resolver';
|
} from './store-home-page.resolver';
|
||||||
|
|
||||||
function createPaginatedResponse(
|
function createItemsPage(
|
||||||
products: Product[],
|
items: CatalogFeaturedItem[],
|
||||||
currentPage: number,
|
currentPage = 1,
|
||||||
lastPage: number,
|
lastPage = 1,
|
||||||
): ApiPaginatedResponse<Product[]> {
|
): ApiPaginatedResponse<CatalogFeaturedItem[]> {
|
||||||
return {
|
return {
|
||||||
data: products,
|
data: items,
|
||||||
meta: {
|
meta: {
|
||||||
current_page: currentPage,
|
current_page: currentPage,
|
||||||
from: products.length ? 1 : null,
|
from: items.length ? 1 : null,
|
||||||
last_page: lastPage,
|
last_page: lastPage,
|
||||||
links: [],
|
links: [],
|
||||||
path: '/productos',
|
path: '/catalog/featured-groups/1/items',
|
||||||
per_page: 12,
|
per_page: 12,
|
||||||
to: products.length || null,
|
to: items.length || null,
|
||||||
total: products.length,
|
total: items.length,
|
||||||
},
|
},
|
||||||
links: {
|
links: {
|
||||||
first: '/productos?page=1',
|
first: '/catalog/featured-groups/1/items?page=1',
|
||||||
last: `/productos?page=${lastPage}`,
|
last: `/catalog/featured-groups/1/items?page=${lastPage}`,
|
||||||
prev: currentPage > 1 ? `/productos?page=${currentPage - 1}` : null,
|
prev: currentPage > 1 ? `/catalog/featured-groups/1/items?page=${currentPage - 1}` : null,
|
||||||
next: currentPage < lastPage ? `/productos?page=${currentPage + 1}` : null,
|
next:
|
||||||
|
currentPage < lastPage ? `/catalog/featured-groups/1/items?page=${currentPage + 1}` : null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createResolvedData(
|
function createCatalog(
|
||||||
response: ApiPaginatedResponse<Product[]>,
|
items = pageOneItems,
|
||||||
): StoreHomeProductsResolvedData {
|
currentPage = 1,
|
||||||
return {
|
lastPage = 2,
|
||||||
response,
|
): CatalogFeaturedGroup[] {
|
||||||
error: null,
|
return [
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function provideActivatedRoute(productsData: StoreHomeProductsResolvedData) {
|
|
||||||
return {
|
|
||||||
provide: ActivatedRoute,
|
|
||||||
useValue: {
|
|
||||||
snapshot: {
|
|
||||||
data: { productsData },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('StoreHomePageComponent', () => {
|
|
||||||
const pageOneProducts: Product[] = [
|
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 7,
|
||||||
category_id: 10,
|
title: 'Destacados',
|
||||||
brand_id: null,
|
layout: 'column_with_image',
|
||||||
slug: 'auriculares-bluetooth',
|
group_order: 0,
|
||||||
nombre: 'Auriculares Bluetooth',
|
items: createItemsPage(items, currentPage, lastPage),
|
||||||
descripcion: 'Auriculares bluetooth de prueba',
|
|
||||||
precio: '24999',
|
|
||||||
category: 'Tecnologia',
|
|
||||||
brand: null,
|
|
||||||
images: ['/catalog/auriculares.jpg'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
category_id: 11,
|
|
||||||
brand_id: null,
|
|
||||||
slug: 'teclado-mecanico',
|
|
||||||
nombre: 'Teclado Mecanico',
|
|
||||||
descripcion: 'Teclado mecanico de prueba',
|
|
||||||
precio: '18999',
|
|
||||||
category: 'Tecnologia',
|
|
||||||
brand: null,
|
|
||||||
images: [],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
function provideActivatedRoute(catalogData: StoreHomeCatalogResolvedData) {
|
||||||
vi.restoreAllMocks();
|
return {
|
||||||
});
|
provide: ActivatedRoute,
|
||||||
|
useValue: { snapshot: { data: { catalogData } } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
it('renders the products resolved by the route before component init', async () => {
|
const pageOneItems: CatalogFeaturedItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Auriculares Bluetooth',
|
||||||
|
precio: '24999.00',
|
||||||
|
image: '/catalog/auriculares.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nombre: 'Teclado Mecanico',
|
||||||
|
precio: '18999.00',
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('StoreHomePageComponent', () => {
|
||||||
|
beforeEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('renders the CatalogNew groups resolved by the route', async () => {
|
||||||
const catalogServiceStub = {
|
const catalogServiceStub = {
|
||||||
getProductos: vi.fn(),
|
getCatalog: vi.fn(),
|
||||||
|
getFeaturedGroupItems: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [StoreHomePageComponent],
|
imports: [StoreHomePageComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
|
provideActivatedRoute({ response: createCatalog(), error: null }),
|
||||||
{
|
{ provide: CatalogService, useValue: catalogServiceStub },
|
||||||
provide: CatalogService,
|
|
||||||
useValue: catalogServiceStub,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(StoreHomePageComponent);
|
const fixture = TestBed.createComponent(StoreHomePageComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
|
expect(catalogServiceStub.getCatalog).not.toHaveBeenCalled();
|
||||||
expect(element.querySelector('.store-section__title')?.textContent?.trim()).toBe('Productos');
|
expect(element.querySelector('.store-section__title')?.textContent?.trim()).toBe('Destacados');
|
||||||
expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2);
|
expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2);
|
||||||
expect(element.textContent).toContain('Auriculares Bluetooth');
|
expect(element.textContent).toContain('Auriculares Bluetooth');
|
||||||
expect(element.textContent).toContain('Teclado Mecanico');
|
|
||||||
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
|
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
|
||||||
'1/3',
|
'1/2',
|
||||||
);
|
);
|
||||||
expect(element.querySelector('img')?.getAttribute('fetchpriority')).toBe('high');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requests the next page when the paginator emits a page change', async () => {
|
it('requests another page for the selected featured group', async () => {
|
||||||
|
const pageTwoItems = [{ id: 3, nombre: 'Mouse Gamer', precio: '15999.00', image: null }];
|
||||||
const catalogServiceStub = {
|
const catalogServiceStub = {
|
||||||
getProductos: vi.fn().mockReturnValueOnce(
|
getCatalog: vi.fn(),
|
||||||
of(
|
getFeaturedGroupItems: vi.fn().mockReturnValue(of(createItemsPage(pageTwoItems, 2, 2))),
|
||||||
createPaginatedResponse(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
category_id: 12,
|
|
||||||
brand_id: null,
|
|
||||||
slug: 'mouse-gamer',
|
|
||||||
nombre: 'Mouse Gamer',
|
|
||||||
descripcion: 'Mouse gamer de prueba',
|
|
||||||
precio: '15999',
|
|
||||||
category: 'Tecnologia',
|
|
||||||
brand: null,
|
|
||||||
images: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [StoreHomePageComponent],
|
imports: [StoreHomePageComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
|
provideActivatedRoute({ response: createCatalog(), error: null }),
|
||||||
{
|
{ provide: CatalogService, useValue: catalogServiceStub },
|
||||||
provide: CatalogService,
|
|
||||||
useValue: catalogServiceStub,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(StoreHomePageComponent);
|
const fixture = TestBed.createComponent(StoreHomePageComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
|
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(catalogServiceStub.getProductos).toHaveBeenCalledTimes(1);
|
expect(catalogServiceStub.getFeaturedGroupItems).toHaveBeenCalledWith(7, { page: 2 });
|
||||||
expect(catalogServiceStub.getProductos).toHaveBeenCalledWith({ page: 2 });
|
|
||||||
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
|
|
||||||
'2/3',
|
|
||||||
);
|
|
||||||
expect(element.textContent).toContain('Mouse Gamer');
|
expect(element.textContent).toContain('Mouse Gamer');
|
||||||
|
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
|
||||||
|
'2/2',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables the paginator while a new page request is in flight', async () => {
|
it('disables only the group paginator while its page request is in flight', async () => {
|
||||||
const nextPageSubject = new Subject<ApiPaginatedResponse<Product[]>>();
|
const nextPage = new Subject<ApiPaginatedResponse<CatalogFeaturedItem[]>>();
|
||||||
const catalogServiceStub = {
|
const catalogServiceStub = {
|
||||||
getProductos: vi.fn().mockReturnValueOnce(nextPageSubject.asObservable()),
|
getCatalog: vi.fn(),
|
||||||
|
getFeaturedGroupItems: vi.fn().mockReturnValue(nextPage.asObservable()),
|
||||||
};
|
};
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [StoreHomePageComponent],
|
imports: [StoreHomePageComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
|
provideActivatedRoute({ response: createCatalog(), error: null }),
|
||||||
{
|
{ provide: CatalogService, useValue: catalogServiceStub },
|
||||||
provide: CatalogService,
|
|
||||||
useValue: catalogServiceStub,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(StoreHomePageComponent);
|
const fixture = TestBed.createComponent(StoreHomePageComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
|
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -203,10 +164,9 @@ describe('StoreHomePageComponent', () => {
|
|||||||
(button) => (button as HTMLButtonElement).disabled,
|
(button) => (button as HTMLButtonElement).disabled,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(element.textContent).toContain('Actualizando productos...');
|
|
||||||
|
|
||||||
nextPageSubject.next(createPaginatedResponse(pageOneProducts, 2, 3));
|
nextPage.next(createItemsPage(pageOneItems, 2, 2));
|
||||||
nextPageSubject.complete();
|
nextPage.complete();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -216,58 +176,41 @@ describe('StoreHomePageComponent', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows an empty-state message and hides the paginator when there are no products', async () => {
|
it('shows an empty state when CatalogNew returns no groups', async () => {
|
||||||
const catalogServiceStub = {
|
const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() };
|
||||||
getProductos: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [StoreHomePageComponent],
|
imports: [StoreHomePageComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideActivatedRoute(createResolvedData(createPaginatedResponse([], 1, 1))),
|
provideActivatedRoute({ response: [], error: null }),
|
||||||
{
|
{ provide: CatalogService, useValue: catalogServiceStub },
|
||||||
provide: CatalogService,
|
|
||||||
useValue: catalogServiceStub,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(StoreHomePageComponent);
|
const fixture = TestBed.createComponent(StoreHomePageComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
|
||||||
|
'No hay productos disponibles en este momento.',
|
||||||
expect(element.textContent).toContain('No hay productos disponibles en este momento.');
|
);
|
||||||
expect(element.querySelector('app-paginator')).toBeNull();
|
|
||||||
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows an error message when the route resolver cannot load the catalog', async () => {
|
it('shows an error when the catalog resolver fails', async () => {
|
||||||
const catalogServiceStub = {
|
const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() };
|
||||||
getProductos: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [StoreHomePageComponent],
|
imports: [StoreHomePageComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideActivatedRoute({
|
provideActivatedRoute({ response: null, error: STORE_HOME_PRODUCTS_ERROR_MESSAGE }),
|
||||||
response: null,
|
{ provide: CatalogService, useValue: catalogServiceStub },
|
||||||
error: STORE_HOME_PRODUCTS_ERROR_MESSAGE,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
provide: CatalogService,
|
|
||||||
useValue: catalogServiceStub,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(StoreHomePageComponent);
|
const fixture = TestBed.createComponent(StoreHomePageComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
|
||||||
|
'No pudimos cargar los productos en este momento.',
|
||||||
expect(element.textContent).toContain('No pudimos cargar los productos en este momento.');
|
);
|
||||||
expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(0);
|
|
||||||
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,43 +3,29 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
computed,
|
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
|
import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.interface';
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
import { Product } from '../../../../core/services/catalog/catalog.interface';
|
|
||||||
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component';
|
|
||||||
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
|
|
||||||
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
|
||||||
import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component';
|
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
import {
|
||||||
|
ProductListComponent,
|
||||||
|
ProductListItem,
|
||||||
|
} from '../../../../shared/components/product-list/product-list.component';
|
||||||
|
import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component';
|
||||||
|
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
||||||
import {
|
import {
|
||||||
STORE_HOME_PRODUCTS_ERROR_MESSAGE,
|
STORE_HOME_PRODUCTS_ERROR_MESSAGE,
|
||||||
StoreHomeProductsResolvedData,
|
StoreHomeCatalogResolvedData,
|
||||||
} from './store-home-page.resolver';
|
} from './store-home-page.resolver';
|
||||||
|
|
||||||
interface StoreHomeProductCardViewModel {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
originalPrice: number;
|
|
||||||
discount: null;
|
|
||||||
transferPrice: null;
|
|
||||||
imageUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-store-home-page',
|
selector: 'app-store-home-page',
|
||||||
imports: [
|
imports: [StoreSectionComponent, ProductListComponent, HeroBannerComponent],
|
||||||
StoreSectionComponent,
|
|
||||||
ProductColumnWithImageComponent,
|
|
||||||
PaginatorComponent,
|
|
||||||
HeroBannerComponent,
|
|
||||||
],
|
|
||||||
templateUrl: './store-home-page.component.html',
|
templateUrl: './store-home-page.component.html',
|
||||||
styleUrl: './store-home-page.component.scss',
|
styleUrl: './store-home-page.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -51,120 +37,101 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
|
|||||||
private readonly tenantService = inject(TenantService);
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
protected readonly tenant = this.tenantService.tenant;
|
protected readonly tenant = this.tenantService.tenant;
|
||||||
|
protected readonly catalog = signal<CatalogFeaturedGroup[]>([]);
|
||||||
private activeRequestId = 0;
|
|
||||||
private productsRequestSubscription: Subscription | null = null;
|
|
||||||
protected readonly priorityImageCount = 4;
|
|
||||||
|
|
||||||
protected readonly currentPage = signal(1);
|
|
||||||
protected readonly products = signal<Product[]>([]);
|
|
||||||
protected readonly totalPages = signal(0);
|
|
||||||
protected readonly loading = signal(false);
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly loadingGroupIds = signal<ReadonlySet<number>>(new Set());
|
||||||
protected readonly error = signal<string | null>(null);
|
protected readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
protected readonly productCards = computed<StoreHomeProductCardViewModel[]>(() =>
|
private catalogRequestSubscription: Subscription | null = null;
|
||||||
this.products().map((product) => ({
|
private readonly groupRequestSubscriptions = new Map<number, Subscription>();
|
||||||
id: product.id,
|
|
||||||
title: product.nombre,
|
|
||||||
originalPrice: this.parseProductPrice(product.precio),
|
|
||||||
discount: null,
|
|
||||||
transferPrice: null,
|
|
||||||
imageUrl: product.images?.[0] ?? null,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const resolvedData = this.route.snapshot.data['productsData'] as
|
const resolvedData = this.route.snapshot.data['catalogData'] as
|
||||||
| StoreHomeProductsResolvedData
|
| StoreHomeCatalogResolvedData
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
if (resolvedData) {
|
if (resolvedData) {
|
||||||
this.applyResolvedData(resolvedData);
|
this.applyResolvedData(resolvedData);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loadProducts(1);
|
this.loadCatalog();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.productsRequestSubscription?.unsubscribe();
|
this.catalogRequestSubscription?.unsubscribe();
|
||||||
|
this.groupRequestSubscriptions.forEach((subscription) => subscription.unsubscribe());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onPageChange(page: number): void {
|
protected isGroupLoading(groupId: number): boolean {
|
||||||
if (page === this.currentPage() || page < 1) {
|
return this.loadingGroupIds().has(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onPageChange(groupId: number, page: number): void {
|
||||||
|
const group = this.catalog().find((candidate) => candidate.id === groupId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!group ||
|
||||||
|
page < 1 ||
|
||||||
|
page === group.items.meta.current_page ||
|
||||||
|
this.isGroupLoading(groupId)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loadProducts(page);
|
this.setGroupLoading(groupId, true);
|
||||||
}
|
this.groupRequestSubscriptions.get(groupId)?.unsubscribe();
|
||||||
|
|
||||||
protected onBuyProduct(productId: number): void {
|
const subscription = this.catalogService.getFeaturedGroupItems(groupId, { page }).subscribe({
|
||||||
this.router.navigate(['/producto', productId]);
|
next: (items) => {
|
||||||
}
|
this.catalog.update((groups) =>
|
||||||
|
groups.map((candidate) =>
|
||||||
private loadProducts(page: number): void {
|
candidate.id === groupId ? { ...candidate, items } : candidate,
|
||||||
this.activeRequestId += 1;
|
),
|
||||||
const requestId = this.activeRequestId;
|
);
|
||||||
|
this.error.set(null);
|
||||||
this.productsRequestSubscription?.unsubscribe();
|
|
||||||
this.loading.set(true);
|
|
||||||
this.error.set(null);
|
|
||||||
|
|
||||||
this.productsRequestSubscription = this.catalogService.getProductos({ page }).subscribe({
|
|
||||||
next: (response) => {
|
|
||||||
if (requestId !== this.activeRequestId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.applyProductsResponse(response);
|
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
if (requestId !== this.activeRequestId) {
|
this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE);
|
||||||
return;
|
this.setGroupLoading(groupId, false);
|
||||||
}
|
},
|
||||||
|
complete: () => this.setGroupLoading(groupId, false),
|
||||||
|
});
|
||||||
|
|
||||||
this.products.set([]);
|
this.groupRequestSubscriptions.set(groupId, subscription);
|
||||||
this.totalPages.set(0);
|
}
|
||||||
|
|
||||||
|
protected onBuyProduct(product: ProductListItem): void {
|
||||||
|
this.router.navigate(['/producto', product.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCatalog(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
this.catalogRequestSubscription?.unsubscribe();
|
||||||
|
|
||||||
|
this.catalogRequestSubscription = this.catalogService.getCatalog().subscribe({
|
||||||
|
next: (catalog) => this.catalog.set(catalog),
|
||||||
|
error: () => {
|
||||||
|
this.catalog.set([]);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE);
|
this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE);
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => this.loading.set(false),
|
||||||
if (requestId !== this.activeRequestId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loading.set(false);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyResolvedData(resolvedData: StoreHomeProductsResolvedData): void {
|
private applyResolvedData(resolvedData: StoreHomeCatalogResolvedData): void {
|
||||||
if (resolvedData.error) {
|
this.catalog.set(resolvedData.response ?? []);
|
||||||
this.products.set([]);
|
this.loading.set(false);
|
||||||
this.totalPages.set(0);
|
this.error.set(resolvedData.error);
|
||||||
this.loading.set(false);
|
|
||||||
this.error.set(resolvedData.error);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvedData.response) {
|
|
||||||
this.applyProductsResponse(resolvedData.response);
|
|
||||||
this.loading.set(false);
|
|
||||||
this.error.set(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyProductsResponse(response: ApiPaginatedResponse<Product[]>): void {
|
private setGroupLoading(groupId: number, loading: boolean): void {
|
||||||
this.products.set(response.data ?? []);
|
this.loadingGroupIds.update((current) => {
|
||||||
this.currentPage.set(response.meta.current_page);
|
const updated = new Set(current);
|
||||||
this.totalPages.set(response.meta.last_page);
|
loading ? updated.add(groupId) : updated.delete(groupId);
|
||||||
}
|
return updated;
|
||||||
|
});
|
||||||
private parseProductPrice(price: string): number {
|
|
||||||
const parsedPrice = Number(price);
|
|
||||||
|
|
||||||
return Number.isFinite(parsedPrice) ? parsedPrice : 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,22 @@ import { inject } from '@angular/core';
|
|||||||
import { ResolveFn } from '@angular/router';
|
import { ResolveFn } from '@angular/router';
|
||||||
import { catchError, map, of } from 'rxjs';
|
import { catchError, map, of } from 'rxjs';
|
||||||
|
|
||||||
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
|
import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.interface';
|
||||||
import { Product } from '../../../../core/services/catalog/catalog.interface';
|
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
|
|
||||||
export const STORE_HOME_PRODUCTS_ERROR_MESSAGE = 'No pudimos cargar los productos en este momento.';
|
export const STORE_HOME_PRODUCTS_ERROR_MESSAGE = 'No pudimos cargar los productos en este momento.';
|
||||||
|
|
||||||
export interface StoreHomeProductsResolvedData {
|
export interface StoreHomeCatalogResolvedData {
|
||||||
response: ApiPaginatedResponse<Product[]> | null;
|
response: CatalogFeaturedGroup[] | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storeHomeProductsResolver: ResolveFn<StoreHomeProductsResolvedData> = () => {
|
export const storeHomeCatalogResolver: ResolveFn<StoreHomeCatalogResolvedData> = () => {
|
||||||
return inject(CatalogService)
|
return inject(CatalogService)
|
||||||
.getProductos({ page: 1 })
|
.getCatalog()
|
||||||
.pipe(
|
.pipe(
|
||||||
map(
|
map(
|
||||||
(response): StoreHomeProductsResolvedData => ({
|
(response): StoreHomeCatalogResolvedData => ({
|
||||||
response,
|
response,
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { hasMenuGuard } from '../../core/guards/menu.guard';
|
|||||||
import { productDetailResolver } from './pages/product-detail-page/product-detail-page.resolver';
|
import { productDetailResolver } from './pages/product-detail-page/product-detail-page.resolver';
|
||||||
import { RegisterPageComponent } from './pages/register-page/register-page.component';
|
import { RegisterPageComponent } from './pages/register-page/register-page.component';
|
||||||
import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
|
import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
|
||||||
import { storeHomeProductsResolver } from './pages/store-home-page/store-home-page.resolver';
|
import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -20,7 +20,7 @@ export const routes: Routes = [
|
|||||||
component: StoreHomePageComponent,
|
component: StoreHomePageComponent,
|
||||||
canActivate: [hasMenuGuard('index')],
|
canActivate: [hasMenuGuard('index')],
|
||||||
resolve: {
|
resolve: {
|
||||||
productsData: storeHomeProductsResolver,
|
catalogData: storeHomeCatalogResolver,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||||
|
|
||||||
import { ApiPaginatedResponse } from '../../../core/services/api-paginated-response.interface';
|
import { ApiPaginatedResponse } from '../../../core/services/api-paginated-response.interface';
|
||||||
|
import {
|
||||||
|
CatalogFeaturedItem,
|
||||||
|
CatalogFeaturedItemVariant,
|
||||||
|
CatalogProductLayout,
|
||||||
|
} from '../../../core/services/catalog/catalog.interface';
|
||||||
import { PaginatorComponent } from '../paginator/paginator.component';
|
import { PaginatorComponent } from '../paginator/paginator.component';
|
||||||
import { ProductColumnWithImageComponent } from '../product-column-with-image/product-column-with-image.component';
|
import { ProductColumnWithImageComponent } from '../product-column-with-image/product-column-with-image.component';
|
||||||
import {
|
import {
|
||||||
@@ -9,23 +14,9 @@ import {
|
|||||||
} from '../product-row-card/product-row-card.component';
|
} from '../product-row-card/product-row-card.component';
|
||||||
import { ProductVerticalWithCartCardComponent } from '../product-vertical-with-cart-card/product-vertical-with-cart-card.component';
|
import { ProductVerticalWithCartCardComponent } from '../product-vertical-with-cart-card/product-vertical-with-cart-card.component';
|
||||||
|
|
||||||
export type ProductListLayout = 'row' | 'column_with_image' | 'column_with_cart';
|
export type ProductListLayout = CatalogProductLayout;
|
||||||
|
export type ProductListVariant = CatalogFeaturedItemVariant;
|
||||||
export interface ProductListVariant {
|
export type ProductListItem = CatalogFeaturedItem;
|
||||||
id: number;
|
|
||||||
stock_tecnico: number | null;
|
|
||||||
values: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductListItem {
|
|
||||||
id: number;
|
|
||||||
nombre: string;
|
|
||||||
descripcion?: string | null;
|
|
||||||
precio: number | string;
|
|
||||||
image?: string | null;
|
|
||||||
stock_tecnico?: number | null;
|
|
||||||
variants?: ProductListVariant[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductListCartEvent {
|
export interface ProductListCartEvent {
|
||||||
product: ProductListItem;
|
product: ProductListItem;
|
||||||
|
|||||||
Reference in New Issue
Block a user