feat: implement store home page with paginated product listing and error handling

This commit is contained in:
ncoronel 2026-06-29 17:01:19 -03:00
parent 5715977b25
commit 4bb69d1b06
4 changed files with 377 additions and 116 deletions

View File

@ -1,21 +1,45 @@
<section class="store-home">
<div class="store-home__inner">
<div class="store-home__hero">
<span class="store-home__eyebrow">Store Layout</span>
<h1>Tienda en construccion</h1>
<p class="store-home__lead">
Esta pagina placeholder deja visible el shell del store mientras se conectan categorias,
catalogo y acciones reales.
</p>
</div>
<section class="py-5">
<div class="container-xl px-3 px-md-4">
<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) {
<div class="col">
<app-product-card
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
[imageUrl]="product.imageUrl"
(buy)="onBuyProduct(product.id)"
/>
</div>
}
</div>
}
<div class="store-home__highlights">
@for (highlight of highlights; track highlight) {
<article class="store-home__card">
<span class="store-home__card-index" aria-hidden="true">0{{ $index + 1 }}</span>
<p>{{ highlight }}</p>
</article>
}
</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>
</div>
</section>

View File

@ -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);
}

View File

@ -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<Product[]> {
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<ApiPaginatedResponse<Product[]>>();
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);
});
});

View File

@ -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<Product[]>([]);
protected readonly totalPages = signal(0);
protected readonly loading = signal(false);
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: 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;
}
}