From 4bb69d1b06702cc9b2973e5044a5d3179b7d3d31 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 29 Jun 2026 17:01:19 -0300 Subject: [PATCH] feat: implement store home page with paginated product listing and error handling --- .../store-home-page.component.html | 60 +++-- .../store-home-page.component.scss | 94 +------- .../store-home-page.component.spec.ts | 227 ++++++++++++++++++ .../store-home-page.component.ts | 112 ++++++++- 4 files changed, 377 insertions(+), 116 deletions(-) create mode 100644 src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts 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 759ca67..7d556cc 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,21 +1,45 @@ -
-
-
- Store Layout -

Tienda en construccion

-

- Esta pagina placeholder deja visible el shell del store mientras se conectan categorias, - catalogo y acciones reales. -

-
+
+
+ +
+ @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) { +
+ +
+ } +
+ } -
- @for (highlight of highlights; track highlight) { -
- -

{{ highlight }}

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

Actualizando productos...

+ } + + @if (totalPages() > 1) { +
+ +
+ } +
+
diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.scss b/src/app/features/store/pages/store-home-page/store-home-page.component.scss index 8953f81..fc76535 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.scss +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.scss @@ -2,95 +2,9 @@ display: block; } -.store-home { - padding: 3rem 0 4rem; -} - -.store-home__inner { - width: min(100% - 3rem, 75rem); - margin: 0 auto; - display: grid; - gap: 1.5rem; -} - -.store-home__hero { - padding: clamp(2rem, 4vw, 3rem); - border: 1px solid #e4e4e4; - border-radius: 1.5rem; - background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%); - box-shadow: 0 1.2rem 2.2rem rgba(17, 17, 17, 0.06); -} - -.store-home__eyebrow { - display: inline-block; - margin-bottom: 0.75rem; - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #666666; -} - -h1 { - margin-bottom: 0.75rem; - font-size: clamp(2rem, 4vw, 3.25rem); - font-weight: 700; - letter-spacing: -0.05em; -} - -.store-home__lead { - max-width: 42rem; +.store-home__alert-error { margin: 0; - font-size: 1rem; - line-height: 1.6; - color: #4a4a4a; -} - -.store-home__highlights { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1rem; -} - -.store-home__card { - display: grid; - gap: 0.9rem; - padding: 1.5rem; - border: 1px solid #e4e4e4; - border-radius: 1.25rem; - background-color: #ffffff; - box-shadow: 0 0.75rem 1.5rem rgba(17, 17, 17, 0.04); -} - -.store-home__card-index { - font-size: 0.85rem; - font-weight: 700; - letter-spacing: 0.08em; - color: #707070; -} - -.store-home__card p { - margin: 0; - line-height: 1.6; - color: #333333; -} - -@media (max-width: 991.98px) { - .store-home__highlights { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 767.98px) { - .store-home { - padding: 2rem 0 3rem; - } - - .store-home__inner { - width: min(100% - 1.5rem, 75rem); - } - - .store-home__highlights { - grid-template-columns: minmax(0, 1fr); - } + border-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.18); + background-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.08); + color: var(--tenant-danger, #dc3545); } 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 new file mode 100644 index 0000000..ffe3e59 --- /dev/null +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts @@ -0,0 +1,227 @@ +import { TestBed } from '@angular/core/testing'; +import { Subject, of, throwError } 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'; + +function createPaginatedResponse( + products: Product[], + currentPage: number, + lastPage: number +): ApiPaginatedResponse { + return { + data: products, + meta: { + current_page: currentPage, + from: products.length ? 1 : null, + last_page: lastPage, + links: [], + path: '/productos', + per_page: 12, + to: products.length || null, + 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 + } + }; +} + +describe('StoreHomePageComponent', () => { + const pageOneProducts: Product[] = [ + { + id: 1, + tenant_codigo: 'test', + categoria_id: 10, + slug: 'auriculares-bluetooth', + nombre: 'Auriculares Bluetooth', + precio: '24999' + }, + { + id: 2, + tenant_codigo: 'test', + categoria_id: 11, + slug: 'teclado-mecanico', + nombre: 'Teclado Mecanico', + precio: '18999' + } + ]; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('loads page 1 on init, renders the products title and the fetched product cards', async () => { + const catalogServiceStub = { + getProductos: vi.fn().mockReturnValue(of(createPaginatedResponse(pageOneProducts, 1, 3))) + }; + + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + { + provide: CatalogService, + useValue: catalogServiceStub + } + ] + }).compileComponents(); + + const fixture = TestBed.createComponent(StoreHomePageComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + + expect(catalogServiceStub.getProductos).toHaveBeenCalledWith({ page: 1 }); + 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'); + }); + + 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, + tenant_codigo: 'test', + categoria_id: 12, + slug: 'mouse-gamer', + nombre: 'Mouse Gamer', + precio: '15999' + } + ], + 2, + 3 + ) + ) + ) + }; + + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + { + 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).toHaveBeenNthCalledWith(1, { page: 1 }); + expect(catalogServiceStub.getProductos).toHaveBeenNthCalledWith(2, { 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()) + }; + + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + { + 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( + Array.from(element.querySelectorAll('app-paginator button')).every( + (button) => (button as HTMLButtonElement).disabled + ) + ).toBe(true); + expect(element.textContent).toContain('Actualizando productos...'); + + nextPageSubject.next(createPaginatedResponse(pageOneProducts, 2, 3)); + nextPageSubject.complete(); + fixture.detectChanges(); + + expect( + Array.from(element.querySelectorAll('app-paginator button')).some( + (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))) + }; + + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + { + 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(); + }); + + it('shows an error message when the catalog request fails', async () => { + const catalogServiceStub = { + getProductos: vi.fn().mockReturnValue(throwError(() => new Error('boom'))) + }; + + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + { + 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-card')).toHaveLength(0); + }); +}); 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 b708858..f097236 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,14 +1,110 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core'; +import { Subscription } from 'rxjs'; + +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'; + +interface StoreHomeProductCardViewModel { + id: number; + title: string; + originalPrice: number; + discount: null; + transferPrice: null; + imageUrl: null; +} @Component({ selector: 'app-store-home-page', + imports: [StoreSectionComponent, ProductCardComponent, PaginatorComponent], templateUrl: './store-home-page.component.html', - styleUrl: './store-home-page.component.scss' + styleUrl: './store-home-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush }) -export class StoreHomePageComponent { - protected readonly highlights = [ - 'Header y footer listos para conectar con datos reales.', - 'Barra de categorias reservada para sumar dropdown despues.', - 'Placeholder inicial para validar layout, espaciados y responsive.' - ]; +export class StoreHomePageComponent implements OnInit, OnDestroy { + private readonly catalogService = inject(CatalogService); + + private activeRequestId = 0; + private productsRequestSubscription: Subscription | null = null; + + protected readonly currentPage = signal(1); + protected readonly products = signal([]); + protected readonly totalPages = signal(0); + protected readonly loading = signal(false); + protected readonly error = signal(null); + + protected readonly productCards = computed(() => + this.products().map((product) => ({ + id: product.id, + title: product.nombre, + originalPrice: this.parseProductPrice(product.precio), + discount: null, + transferPrice: null, + imageUrl: null + })) + ); + + ngOnInit(): void { + this.loadProducts(1); + } + + ngOnDestroy(): void { + this.productsRequestSubscription?.unsubscribe(); + } + + protected onPageChange(page: number): void { + if (page === this.currentPage() || page < 1) { + return; + } + + this.loadProducts(page); + } + + protected onBuyProduct(_productId: number): void {} + + 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.products.set(response.data ?? []); + this.currentPage.set(response.meta.current_page); + this.totalPages.set(response.meta.last_page); + }, + error: () => { + if (requestId !== this.activeRequestId) { + return; + } + + this.products.set([]); + this.totalPages.set(0); + this.loading.set(false); + this.error.set('No pudimos cargar los productos en este momento.'); + }, + complete: () => { + if (requestId !== this.activeRequestId) { + return; + } + + this.loading.set(false); + } + }); + } + + private parseProductPrice(price: string): number { + const parsedPrice = Number(price); + + return Number.isFinite(parsedPrice) ? parsedPrice : 0; + } }