From 6c00a3e2bb7f946b07a79c69e17720e6ab0dc7ee Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 8 Jul 2026 16:04:11 -0300 Subject: [PATCH] feat(store-home): implement resolver for product data and enhance component logic for improved error handling and loading states --- src/app/app.config.ts | 18 +- .../store-home-page.component.html | 78 ++++---- .../store-home-page.component.spec.ts | 172 +++++++++++------- .../store-home-page.component.ts | 66 ++++++- .../store-home-page.resolver.ts | 33 ++++ src/app/features/store/store.routes.ts | 6 +- .../product-card/product-card.component.html | 42 +++-- .../product-card/product-card.component.ts | 6 +- 8 files changed, 285 insertions(+), 136 deletions(-) create mode 100644 src/app/features/store/pages/store-home-page/store-home-page.resolver.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 4c0d369..9b6bf20 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,18 +1,30 @@ -import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; +import { HttpRequest, provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; -import { provideClientHydration } from '@angular/platform-browser'; +import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser'; import { routes } from './app.routes'; import { authBootstrap } from './core/services/auth/auth-bootstrap'; import { authInterceptor } from './core/services/auth/auth.interceptor'; import { tenantBootstrap } from './core/services/tenant-bootstrap'; +function isStoreHomeProductsRequest(request: HttpRequest): boolean { + return ( + request.method === 'GET' && + /\/api\/tenants\/[^/]+\/productos(?:\?|$)/.test(request.urlWithParams) + ); +} + export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), - provideClientHydration(), + provideClientHydration( + withHttpTransferCacheOptions({ + includeRequestsWithAuthHeaders: true, + filter: isStoreHomeProductsRequest + }) + ), provideHttpClient(withFetch(), withInterceptors([authInterceptor])), provideAppInitializer(authBootstrap), provideAppInitializer(tenantBootstrap) diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.html b/src/app/features/store/pages/store-home-page/store-home-page.component.html index 318fbf9..b95482e 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.html +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.html @@ -1,42 +1,44 @@ - - -
- @if (error()) { -

{{ error() }}

- } @else if (loading() && !productCards().length) { -

Cargando productos...

- } @else if (!productCards().length) { -

No hay productos disponibles en este momento.

- } @else { -
- @for (product of productCards(); track product.id) { -
- -
- } -
- } - - @if (loading() && productCards().length) { -

Actualizando productos...

- } - - @if (totalPages() > 1) { -
- +
+ @if (error()) { +

{{ error() }}

+ } @else if (loading() && !productCards().length) { +

Cargando productos...

+ } @else if (!productCards().length) { +

+ No hay productos disponibles en este momento. +

+ } @else { +
+ @for (product of productCards(); track product.id; let index = $index) { +
+
}
- + } + + @if (loading() && productCards().length) { +

Actualizando productos...

+ } + + @if (totalPages() > 1) { +
+ +
+ } +
+ diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts index 6d7bb2b..66d56f1 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts @@ -1,16 +1,21 @@ import { TestBed } from '@angular/core/testing'; -import { Subject, of, throwError } from 'rxjs'; +import { ActivatedRoute } from '@angular/router'; +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 { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { StoreHomePageComponent } from './store-home-page.component'; +import { + STORE_HOME_PRODUCTS_ERROR_MESSAGE, + StoreHomeProductsResolvedData, +} from './store-home-page.resolver'; function createPaginatedResponse( products: Product[], currentPage: number, - lastPage: number + lastPage: number, ): ApiPaginatedResponse { return { data: products, @@ -22,14 +27,34 @@ function createPaginatedResponse( path: '/productos', per_page: 12, to: products.length || null, - total: products.length + total: products.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 - } + next: currentPage < lastPage ? `/productos?page=${currentPage + 1}` : null, + }, + }; +} + +function createResolvedData( + response: ApiPaginatedResponse, +): StoreHomeProductsResolvedData { + return { + response, + error: null, + }; +} + +function provideActivatedRoute(productsData: StoreHomeProductsResolvedData) { + return { + provide: ActivatedRoute, + useValue: { + snapshot: { + data: { productsData }, + }, + }, }; } @@ -43,9 +68,9 @@ describe('StoreHomePageComponent', () => { nombre: 'Auriculares Bluetooth', descripcion: 'Auriculares bluetooth de prueba', precio: '24999', - category: 'Tecnología', + category: 'Tecnologia', brand: null, - images: [] + images: ['/catalog/auriculares.jpg'], }, { id: 2, @@ -53,31 +78,32 @@ describe('StoreHomePageComponent', () => { brand_id: null, slug: 'teclado-mecanico', nombre: 'Teclado Mecanico', - descripcion: 'Teclado mecánico de prueba', + descripcion: 'Teclado mecanico de prueba', precio: '18999', - category: 'Tecnología', + category: 'Tecnologia', brand: null, - images: [] - } + images: [], + }, ]; beforeEach(() => { vi.restoreAllMocks(); }); - it('loads page 1 on init, renders the products title and the fetched product cards', async () => { + it('renders the products resolved by the route before component init', async () => { const catalogServiceStub = { - getProductos: vi.fn().mockReturnValue(of(createPaginatedResponse(pageOneProducts, 1, 3))) + getProductos: vi.fn(), }; await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], providers: [ + provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))), { provide: CatalogService, - useValue: catalogServiceStub - } - ] + useValue: catalogServiceStub, + }, + ], }).compileComponents(); const fixture = TestBed.createComponent(StoreHomePageComponent); @@ -85,51 +111,52 @@ describe('StoreHomePageComponent', () => { const element = fixture.nativeElement as HTMLElement; - expect(catalogServiceStub.getProductos).toHaveBeenCalledWith({ page: 1 }); + expect(catalogServiceStub.getProductos).not.toHaveBeenCalled(); expect(element.querySelector('.store-section__title')?.textContent?.trim()).toBe('Productos'); expect(element.querySelectorAll('app-product-card')).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'); + expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe( + '1/3', + ); + expect(element.querySelector('img')?.getAttribute('fetchpriority')).toBe('high'); }); it('requests the next page when the paginator emits a page change', async () => { const catalogServiceStub = { - getProductos: vi - .fn() - .mockReturnValueOnce(of(createPaginatedResponse(pageOneProducts, 1, 3))) - .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: 'Tecnología', - brand: null, - images: [] - } - ], - 2, - 3 - ) - ) - ) + 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, + ), + ), + ), }; await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], providers: [ + provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))), { provide: CatalogService, - useValue: catalogServiceStub - } - ] + useValue: catalogServiceStub, + }, + ], }).compileComponents(); const fixture = TestBed.createComponent(StoreHomePageComponent); @@ -139,29 +166,29 @@ describe('StoreHomePageComponent', () => { (element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click(); fixture.detectChanges(); - expect(catalogServiceStub.getProductos).toHaveBeenNthCalledWith(1, { page: 1 }); - expect(catalogServiceStub.getProductos).toHaveBeenNthCalledWith(2, { page: 2 }); - expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('2/3'); + expect(catalogServiceStub.getProductos).toHaveBeenCalledTimes(1); + expect(catalogServiceStub.getProductos).toHaveBeenCalledWith({ page: 2 }); + expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe( + '2/3', + ); expect(element.textContent).toContain('Mouse Gamer'); }); it('disables the paginator while a new page request is in flight', async () => { const nextPageSubject = new Subject>(); const catalogServiceStub = { - getProductos: vi - .fn() - .mockReturnValueOnce(of(createPaginatedResponse(pageOneProducts, 1, 3))) - .mockReturnValueOnce(nextPageSubject.asObservable()) + getProductos: vi.fn().mockReturnValueOnce(nextPageSubject.asObservable()), }; await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], providers: [ + provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))), { provide: CatalogService, - useValue: catalogServiceStub - } - ] + useValue: catalogServiceStub, + }, + ], }).compileComponents(); const fixture = TestBed.createComponent(StoreHomePageComponent); @@ -173,8 +200,8 @@ describe('StoreHomePageComponent', () => { expect( Array.from(element.querySelectorAll('app-paginator button')).every( - (button) => (button as HTMLButtonElement).disabled - ) + (button) => (button as HTMLButtonElement).disabled, + ), ).toBe(true); expect(element.textContent).toContain('Actualizando productos...'); @@ -184,24 +211,25 @@ describe('StoreHomePageComponent', () => { expect( Array.from(element.querySelectorAll('app-paginator button')).some( - (button) => !(button as HTMLButtonElement).disabled - ) + (button) => !(button as HTMLButtonElement).disabled, + ), ).toBe(true); }); it('shows an empty-state message and hides the paginator when there are no products', async () => { const catalogServiceStub = { - getProductos: vi.fn().mockReturnValue(of(createPaginatedResponse([], 1, 1))) + getProductos: vi.fn(), }; await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], providers: [ + provideActivatedRoute(createResolvedData(createPaginatedResponse([], 1, 1))), { provide: CatalogService, - useValue: catalogServiceStub - } - ] + useValue: catalogServiceStub, + }, + ], }).compileComponents(); const fixture = TestBed.createComponent(StoreHomePageComponent); @@ -211,21 +239,26 @@ describe('StoreHomePageComponent', () => { 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 catalog request fails', async () => { + it('shows an error message when the route resolver cannot load the catalog', async () => { const catalogServiceStub = { - getProductos: vi.fn().mockReturnValue(throwError(() => new Error('boom'))) + getProductos: vi.fn(), }; await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], providers: [ + provideActivatedRoute({ + response: null, + error: STORE_HOME_PRODUCTS_ERROR_MESSAGE, + }), { provide: CatalogService, - useValue: catalogServiceStub - } - ] + useValue: catalogServiceStub, + }, + ], }).compileComponents(); const fixture = TestBed.createComponent(StoreHomePageComponent); @@ -235,5 +268,6 @@ describe('StoreHomePageComponent', () => { expect(element.textContent).toContain('No pudimos cargar los productos en este momento.'); expect(element.querySelectorAll('app-product-card')).toHaveLength(0); + expect(catalogServiceStub.getProductos).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.ts index 7fc4d66..68a672b 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.ts @@ -1,12 +1,25 @@ -import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core'; -import { Router } from '@angular/router'; +import { + ChangeDetectionStrategy, + 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 { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { Product } from '../../../../core/services/catalog/catalog.interface'; import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component'; import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component'; import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; +import { + STORE_HOME_PRODUCTS_ERROR_MESSAGE, + StoreHomeProductsResolvedData, +} from './store-home-page.resolver'; interface StoreHomeProductCardViewModel { id: number; @@ -22,14 +35,16 @@ interface StoreHomeProductCardViewModel { imports: [StoreSectionComponent, ProductCardComponent, PaginatorComponent], templateUrl: './store-home-page.component.html', styleUrl: './store-home-page.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class StoreHomePageComponent implements OnInit, OnDestroy { private readonly catalogService = inject(CatalogService); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private activeRequestId = 0; private productsRequestSubscription: Subscription | null = null; + protected readonly priorityImageCount = 4; protected readonly currentPage = signal(1); protected readonly products = signal([]); @@ -44,11 +59,21 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { originalPrice: this.parseProductPrice(product.precio), discount: null, transferPrice: null, - imageUrl: product.images?.[0] ?? null - })) + imageUrl: product.images?.[0] ?? null, + })), ); ngOnInit(): void { + const resolvedData = this.route.snapshot.data['productsData'] as + | StoreHomeProductsResolvedData + | undefined; + + if (resolvedData) { + this.applyResolvedData(resolvedData); + + return; + } + this.loadProducts(1); } @@ -82,9 +107,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { return; } - this.products.set(response.data ?? []); - this.currentPage.set(response.meta.current_page); - this.totalPages.set(response.meta.last_page); + this.applyProductsResponse(response); }, error: () => { if (requestId !== this.activeRequestId) { @@ -94,7 +117,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { this.products.set([]); this.totalPages.set(0); this.loading.set(false); - this.error.set('No pudimos cargar los productos en este momento.'); + this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE); }, complete: () => { if (requestId !== this.activeRequestId) { @@ -102,10 +125,33 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { } 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 applyProductsResponse(response: ApiPaginatedResponse): 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); diff --git a/src/app/features/store/pages/store-home-page/store-home-page.resolver.ts b/src/app/features/store/pages/store-home-page/store-home-page.resolver.ts new file mode 100644 index 0000000..0d3e524 --- /dev/null +++ b/src/app/features/store/pages/store-home-page/store-home-page.resolver.ts @@ -0,0 +1,33 @@ +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 { 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 | null; + error: string | null; +} + +export const storeHomeProductsResolver: ResolveFn = () => { + return inject(CatalogService) + .getProductos({ page: 1 }) + .pipe( + map( + (response): StoreHomeProductsResolvedData => ({ + response, + error: null, + }), + ), + catchError(() => + of({ + response: null, + error: STORE_HOME_PRODUCTS_ERROR_MESSAGE, + }), + ), + ); +}; diff --git a/src/app/features/store/store.routes.ts b/src/app/features/store/store.routes.ts index 027e776..5219a71 100644 --- a/src/app/features/store/store.routes.ts +++ b/src/app/features/store/store.routes.ts @@ -6,6 +6,7 @@ import { authGuard, guestOnlyGuard } from '../../core/services/auth/auth.guards' import { LoginPageComponent } from './pages/login-page/login-page.component'; 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'; export const routes: Routes = [ { @@ -14,7 +15,10 @@ export const routes: Routes = [ children: [ { path: '', - component: StoreHomePageComponent + component: StoreHomePageComponent, + resolve: { + productsData: storeHomeProductsResolver + } }, { path: 'login', diff --git a/src/app/shared/components/product-card/product-card.component.html b/src/app/shared/components/product-card/product-card.component.html index 58fefac..b03647f 100644 --- a/src/app/shared/components/product-card/product-card.component.html +++ b/src/app/shared/components/product-card/product-card.component.html @@ -1,20 +1,32 @@ -
+
- @if (imageUrl()) { - + @if (imageUrl(); as imageSrc) { + } @else { -
- -
+
+ +
} @if (discount() && discount()! > 0) { - - -{{ discount() }}% - + + -{{ discount() }}% + }
@@ -26,11 +38,15 @@
- {{ formattedDiscountedPrice() }} + {{ + formattedDiscountedPrice() + }} @if (discount() && discount()! > 0) { - {{ formattedOriginalPrice() }} + {{ + formattedOriginalPrice() + }} }
@@ -47,4 +63,4 @@
-
\ No newline at end of file +
diff --git a/src/app/shared/components/product-card/product-card.component.ts b/src/app/shared/components/product-card/product-card.component.ts index 8459ca0..78c8eff 100644 --- a/src/app/shared/components/product-card/product-card.component.ts +++ b/src/app/shared/components/product-card/product-card.component.ts @@ -1,13 +1,14 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { NgOptimizedImage } from '@angular/common'; import { ButtonComponent } from '../button/button.component'; @Component({ selector: 'app-product-card', standalone: true, - imports: [ButtonComponent], + imports: [ButtonComponent, NgOptimizedImage], templateUrl: './product-card.component.html', styleUrl: './product-card.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProductCardComponent { // Configurable inputs @@ -17,6 +18,7 @@ export class ProductCardComponent { readonly discount = input(null); readonly transferPrice = input(null); readonly buttonText = input('Comprar'); + readonly imagePriority = input(false); // Interactive events readonly buy = output();