feat: implement store home page with paginated product listing and error handling
This commit is contained in:
parent
5715977b25
commit
4bb69d1b06
|
|
@ -1,21 +1,45 @@
|
||||||
<section class="store-home">
|
<section class="py-5">
|
||||||
<div class="store-home__inner">
|
<div class="container-xl px-3 px-md-4">
|
||||||
<div class="store-home__hero">
|
<app-store-section title="Productos">
|
||||||
<span class="store-home__eyebrow">Store Layout</span>
|
<div class="d-grid gap-4">
|
||||||
<h1>Tienda en construccion</h1>
|
@if (error()) {
|
||||||
<p class="store-home__lead">
|
<p class="alert text-center mb-0 store-home__alert-error">{{ error() }}</p>
|
||||||
Esta pagina placeholder deja visible el shell del store mientras se conectan categorias,
|
} @else if (loading() && !productCards().length) {
|
||||||
catalogo y acciones reales.
|
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
|
||||||
</p>
|
} @else if (!productCards().length) {
|
||||||
</div>
|
<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">
|
@if (loading() && productCards().length) {
|
||||||
@for (highlight of highlights; track highlight) {
|
<p class="alert alert-light border text-center mb-0 py-2">Actualizando productos...</p>
|
||||||
<article class="store-home__card">
|
}
|
||||||
<span class="store-home__card-index" aria-hidden="true">0{{ $index + 1 }}</span>
|
|
||||||
<p>{{ highlight }}</p>
|
@if (totalPages() > 1) {
|
||||||
</article>
|
<div class="d-flex justify-content-center">
|
||||||
}
|
<app-paginator
|
||||||
</div>
|
[page]="currentPage()"
|
||||||
|
[totalPages]="totalPages()"
|
||||||
|
[disabled]="loading()"
|
||||||
|
(pageChange)="onPageChange($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</app-store-section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -2,95 +2,9 @@
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-home {
|
.store-home__alert-error {
|
||||||
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;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
border-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.18);
|
||||||
line-height: 1.6;
|
background-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.08);
|
||||||
color: #4a4a4a;
|
color: var(--tenant-danger, #dc3545);
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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({
|
@Component({
|
||||||
selector: 'app-store-home-page',
|
selector: 'app-store-home-page',
|
||||||
|
imports: [StoreSectionComponent, ProductCardComponent, PaginatorComponent],
|
||||||
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
|
||||||
})
|
})
|
||||||
export class StoreHomePageComponent {
|
export class StoreHomePageComponent implements OnInit, OnDestroy {
|
||||||
protected readonly highlights = [
|
private readonly catalogService = inject(CatalogService);
|
||||||
'Header y footer listos para conectar con datos reales.',
|
|
||||||
'Barra de categorias reservada para sumar dropdown despues.',
|
private activeRequestId = 0;
|
||||||
'Placeholder inicial para validar layout, espaciados y responsive.'
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue