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