feat(store-home): implement resolver for product data and enhance component logic for improved error handling and loading states

This commit is contained in:
2026-07-08 16:04:11 -03:00
parent efa0d48a40
commit 6c00a3e2bb
8 changed files with 285 additions and 136 deletions

View File

@@ -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 { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser'; import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { authBootstrap } from './core/services/auth/auth-bootstrap'; import { authBootstrap } from './core/services/auth/auth-bootstrap';
import { authInterceptor } from './core/services/auth/auth.interceptor'; import { authInterceptor } from './core/services/auth/auth.interceptor';
import { tenantBootstrap } from './core/services/tenant-bootstrap'; import { tenantBootstrap } from './core/services/tenant-bootstrap';
function isStoreHomeProductsRequest(request: HttpRequest<unknown>): boolean {
return (
request.method === 'GET' &&
/\/api\/tenants\/[^/]+\/productos(?:\?|$)/.test(request.urlWithParams)
);
}
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideRouter(routes),
provideClientHydration(), provideClientHydration(
withHttpTransferCacheOptions({
includeRequestsWithAuthHeaders: true,
filter: isStoreHomeProductsRequest
})
),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])), provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideAppInitializer(authBootstrap), provideAppInitializer(authBootstrap),
provideAppInitializer(tenantBootstrap) provideAppInitializer(tenantBootstrap)

View File

@@ -1,42 +1,44 @@
<app-store-section title="Productos">
<app-store-section title="Productos"> <div class="d-grid gap-4">
<div class="d-grid gap-4"> @if (error()) {
@if (error()) { <p class="alert text-center mb-0 store-home__alert-error">{{ error() }}</p>
<p class="alert text-center mb-0 store-home__alert-error">{{ error() }}</p> } @else if (loading() && !productCards().length) {
} @else if (loading() && !productCards().length) { <p class="alert alert-light border text-center mb-0">Cargando productos...</p>
<p class="alert alert-light border text-center mb-0">Cargando productos...</p> } @else if (!productCards().length) {
} @else if (!productCards().length) { <p class="alert alert-light border text-center mb-0">
<p class="alert alert-light border text-center mb-0">No hay productos disponibles en este momento.</p> No hay productos disponibles en este momento.
} @else { </p>
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 g-4"> } @else {
@for (product of productCards(); track product.id) { <div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 g-4">
<div class="col"> @for (product of productCards(); track product.id; let index = $index) {
<app-product-card <div class="col">
[title]="product.title" <app-product-card
[originalPrice]="product.originalPrice" [title]="product.title"
[discount]="product.discount" [originalPrice]="product.originalPrice"
[transferPrice]="product.transferPrice" [discount]="product.discount"
[imageUrl]="product.imageUrl" [transferPrice]="product.transferPrice"
(buy)="onBuyProduct(product.id)" [imageUrl]="product.imageUrl"
/> [imagePriority]="index < priorityImageCount"
</div> (buy)="onBuyProduct(product.id)"
}
</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>
} }
</div> </div>
</app-store-section> }
@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>

View File

@@ -1,16 +1,21 @@
import { TestBed } from '@angular/core/testing'; 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface'; import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
import { Product } from '../../../../core/services/catalog/catalog.interface'; import { Product } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { StoreHomePageComponent } from './store-home-page.component'; import { StoreHomePageComponent } from './store-home-page.component';
import {
STORE_HOME_PRODUCTS_ERROR_MESSAGE,
StoreHomeProductsResolvedData,
} from './store-home-page.resolver';
function createPaginatedResponse( function createPaginatedResponse(
products: Product[], products: Product[],
currentPage: number, currentPage: number,
lastPage: number lastPage: number,
): ApiPaginatedResponse<Product[]> { ): ApiPaginatedResponse<Product[]> {
return { return {
data: products, data: products,
@@ -22,14 +27,34 @@ function createPaginatedResponse(
path: '/productos', path: '/productos',
per_page: 12, per_page: 12,
to: products.length || null, to: products.length || null,
total: products.length total: products.length,
}, },
links: { links: {
first: '/productos?page=1', first: '/productos?page=1',
last: `/productos?page=${lastPage}`, last: `/productos?page=${lastPage}`,
prev: currentPage > 1 ? `/productos?page=${currentPage - 1}` : null, 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<Product[]>,
): 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', nombre: 'Auriculares Bluetooth',
descripcion: 'Auriculares bluetooth de prueba', descripcion: 'Auriculares bluetooth de prueba',
precio: '24999', precio: '24999',
category: 'Tecnología', category: 'Tecnologia',
brand: null, brand: null,
images: [] images: ['/catalog/auriculares.jpg'],
}, },
{ {
id: 2, id: 2,
@@ -53,31 +78,32 @@ describe('StoreHomePageComponent', () => {
brand_id: null, brand_id: null,
slug: 'teclado-mecanico', slug: 'teclado-mecanico',
nombre: 'Teclado Mecanico', nombre: 'Teclado Mecanico',
descripcion: 'Teclado mecánico de prueba', descripcion: 'Teclado mecanico de prueba',
precio: '18999', precio: '18999',
category: 'Tecnología', category: 'Tecnologia',
brand: null, brand: null,
images: [] images: [],
} },
]; ];
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); 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 = { const catalogServiceStub = {
getProductos: vi.fn().mockReturnValue(of(createPaginatedResponse(pageOneProducts, 1, 3))) getProductos: vi.fn(),
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreHomePageComponent], imports: [StoreHomePageComponent],
providers: [ providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
{ {
provide: CatalogService, provide: CatalogService,
useValue: catalogServiceStub useValue: catalogServiceStub,
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent); const fixture = TestBed.createComponent(StoreHomePageComponent);
@@ -85,51 +111,52 @@ describe('StoreHomePageComponent', () => {
const element = fixture.nativeElement as HTMLElement; 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.querySelector('.store-section__title')?.textContent?.trim()).toBe('Productos');
expect(element.querySelectorAll('app-product-card')).toHaveLength(2); expect(element.querySelectorAll('app-product-card')).toHaveLength(2);
expect(element.textContent).toContain('Auriculares Bluetooth'); expect(element.textContent).toContain('Auriculares Bluetooth');
expect(element.textContent).toContain('Teclado Mecanico'); 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 () => { it('requests the next page when the paginator emits a page change', async () => {
const catalogServiceStub = { const catalogServiceStub = {
getProductos: vi getProductos: vi.fn().mockReturnValueOnce(
.fn() of(
.mockReturnValueOnce(of(createPaginatedResponse(pageOneProducts, 1, 3))) createPaginatedResponse(
.mockReturnValueOnce( [
of( {
createPaginatedResponse( id: 3,
[ category_id: 12,
{ brand_id: null,
id: 3, slug: 'mouse-gamer',
category_id: 12, nombre: 'Mouse Gamer',
brand_id: null, descripcion: 'Mouse gamer de prueba',
slug: 'mouse-gamer', precio: '15999',
nombre: 'Mouse Gamer', category: 'Tecnologia',
descripcion: 'Mouse gamer de prueba', brand: null,
precio: '15999', images: [],
category: 'Tecnología', },
brand: null, ],
images: [] 2,
} 3,
], ),
2, ),
3 ),
)
)
)
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreHomePageComponent], imports: [StoreHomePageComponent],
providers: [ providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
{ {
provide: CatalogService, provide: CatalogService,
useValue: catalogServiceStub useValue: catalogServiceStub,
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent); const fixture = TestBed.createComponent(StoreHomePageComponent);
@@ -139,29 +166,29 @@ describe('StoreHomePageComponent', () => {
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click(); (element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
fixture.detectChanges(); fixture.detectChanges();
expect(catalogServiceStub.getProductos).toHaveBeenNthCalledWith(1, { page: 1 }); expect(catalogServiceStub.getProductos).toHaveBeenCalledTimes(1);
expect(catalogServiceStub.getProductos).toHaveBeenNthCalledWith(2, { page: 2 }); expect(catalogServiceStub.getProductos).toHaveBeenCalledWith({ page: 2 });
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('2/3'); expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
'2/3',
);
expect(element.textContent).toContain('Mouse Gamer'); expect(element.textContent).toContain('Mouse Gamer');
}); });
it('disables the paginator while a new page request is in flight', async () => { it('disables the paginator while a new page request is in flight', async () => {
const nextPageSubject = new Subject<ApiPaginatedResponse<Product[]>>(); const nextPageSubject = new Subject<ApiPaginatedResponse<Product[]>>();
const catalogServiceStub = { const catalogServiceStub = {
getProductos: vi getProductos: vi.fn().mockReturnValueOnce(nextPageSubject.asObservable()),
.fn()
.mockReturnValueOnce(of(createPaginatedResponse(pageOneProducts, 1, 3)))
.mockReturnValueOnce(nextPageSubject.asObservable())
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreHomePageComponent], imports: [StoreHomePageComponent],
providers: [ providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
{ {
provide: CatalogService, provide: CatalogService,
useValue: catalogServiceStub useValue: catalogServiceStub,
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent); const fixture = TestBed.createComponent(StoreHomePageComponent);
@@ -173,8 +200,8 @@ describe('StoreHomePageComponent', () => {
expect( expect(
Array.from(element.querySelectorAll('app-paginator button')).every( Array.from(element.querySelectorAll('app-paginator button')).every(
(button) => (button as HTMLButtonElement).disabled (button) => (button as HTMLButtonElement).disabled,
) ),
).toBe(true); ).toBe(true);
expect(element.textContent).toContain('Actualizando productos...'); expect(element.textContent).toContain('Actualizando productos...');
@@ -184,24 +211,25 @@ describe('StoreHomePageComponent', () => {
expect( expect(
Array.from(element.querySelectorAll('app-paginator button')).some( Array.from(element.querySelectorAll('app-paginator button')).some(
(button) => !(button as HTMLButtonElement).disabled (button) => !(button as HTMLButtonElement).disabled,
) ),
).toBe(true); ).toBe(true);
}); });
it('shows an empty-state message and hides the paginator when there are no products', async () => { it('shows an empty-state message and hides the paginator when there are no products', async () => {
const catalogServiceStub = { const catalogServiceStub = {
getProductos: vi.fn().mockReturnValue(of(createPaginatedResponse([], 1, 1))) getProductos: vi.fn(),
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreHomePageComponent], imports: [StoreHomePageComponent],
providers: [ providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse([], 1, 1))),
{ {
provide: CatalogService, provide: CatalogService,
useValue: catalogServiceStub useValue: catalogServiceStub,
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent); const fixture = TestBed.createComponent(StoreHomePageComponent);
@@ -211,21 +239,26 @@ describe('StoreHomePageComponent', () => {
expect(element.textContent).toContain('No hay productos disponibles en este momento.'); expect(element.textContent).toContain('No hay productos disponibles en este momento.');
expect(element.querySelector('app-paginator')).toBeNull(); 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 = { const catalogServiceStub = {
getProductos: vi.fn().mockReturnValue(throwError(() => new Error('boom'))) getProductos: vi.fn(),
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreHomePageComponent], imports: [StoreHomePageComponent],
providers: [ providers: [
provideActivatedRoute({
response: null,
error: STORE_HOME_PRODUCTS_ERROR_MESSAGE,
}),
{ {
provide: CatalogService, provide: CatalogService,
useValue: catalogServiceStub useValue: catalogServiceStub,
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent); 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.textContent).toContain('No pudimos cargar los productos en este momento.');
expect(element.querySelectorAll('app-product-card')).toHaveLength(0); expect(element.querySelectorAll('app-product-card')).toHaveLength(0);
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,12 +1,25 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core'; import {
import { Router } from '@angular/router'; ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { Product } from '../../../../core/services/catalog/catalog.interface'; import { Product } from '../../../../core/services/catalog/catalog.interface';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component'; import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component'; import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.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 { interface StoreHomeProductCardViewModel {
id: number; id: number;
@@ -22,14 +35,16 @@ interface StoreHomeProductCardViewModel {
imports: [StoreSectionComponent, ProductCardComponent, PaginatorComponent], 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 changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class StoreHomePageComponent implements OnInit, OnDestroy { export class StoreHomePageComponent implements OnInit, OnDestroy {
private readonly catalogService = inject(CatalogService); private readonly catalogService = inject(CatalogService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private activeRequestId = 0; private activeRequestId = 0;
private productsRequestSubscription: Subscription | null = null; private productsRequestSubscription: Subscription | null = null;
protected readonly priorityImageCount = 4;
protected readonly currentPage = signal(1); protected readonly currentPage = signal(1);
protected readonly products = signal<Product[]>([]); protected readonly products = signal<Product[]>([]);
@@ -44,11 +59,21 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
originalPrice: this.parseProductPrice(product.precio), originalPrice: this.parseProductPrice(product.precio),
discount: null, discount: null,
transferPrice: null, transferPrice: null,
imageUrl: product.images?.[0] ?? null imageUrl: product.images?.[0] ?? null,
})) })),
); );
ngOnInit(): void { ngOnInit(): void {
const resolvedData = this.route.snapshot.data['productsData'] as
| StoreHomeProductsResolvedData
| undefined;
if (resolvedData) {
this.applyResolvedData(resolvedData);
return;
}
this.loadProducts(1); this.loadProducts(1);
} }
@@ -82,9 +107,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
return; return;
} }
this.products.set(response.data ?? []); this.applyProductsResponse(response);
this.currentPage.set(response.meta.current_page);
this.totalPages.set(response.meta.last_page);
}, },
error: () => { error: () => {
if (requestId !== this.activeRequestId) { if (requestId !== this.activeRequestId) {
@@ -94,7 +117,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
this.products.set([]); this.products.set([]);
this.totalPages.set(0); this.totalPages.set(0);
this.loading.set(false); this.loading.set(false);
this.error.set('No pudimos cargar los productos en este momento.'); this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE);
}, },
complete: () => { complete: () => {
if (requestId !== this.activeRequestId) { if (requestId !== this.activeRequestId) {
@@ -102,10 +125,33 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
} }
this.loading.set(false); 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<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 { private parseProductPrice(price: string): number {
const parsedPrice = Number(price); const parsedPrice = Number(price);

View File

@@ -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<Product[]> | null;
error: string | null;
}
export const storeHomeProductsResolver: ResolveFn<StoreHomeProductsResolvedData> = () => {
return inject(CatalogService)
.getProductos({ page: 1 })
.pipe(
map(
(response): StoreHomeProductsResolvedData => ({
response,
error: null,
}),
),
catchError(() =>
of({
response: null,
error: STORE_HOME_PRODUCTS_ERROR_MESSAGE,
}),
),
);
};

View File

@@ -6,6 +6,7 @@ import { authGuard, guestOnlyGuard } from '../../core/services/auth/auth.guards'
import { LoginPageComponent } from './pages/login-page/login-page.component'; import { LoginPageComponent } from './pages/login-page/login-page.component';
import { RegisterPageComponent } from './pages/register-page/register-page.component'; import { RegisterPageComponent } from './pages/register-page/register-page.component';
import { StoreHomePageComponent } from './pages/store-home-page/store-home-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 = [ export const routes: Routes = [
{ {
@@ -14,7 +15,10 @@ export const routes: Routes = [
children: [ children: [
{ {
path: '', path: '',
component: StoreHomePageComponent component: StoreHomePageComponent,
resolve: {
productsData: storeHomeProductsResolver
}
}, },
{ {
path: 'login', path: 'login',

View File

@@ -1,20 +1,32 @@
<div class="product-card border rounded shadow-sm bg-white overflow-hidden d-flex flex-column h-100"> <div
class="product-card border rounded shadow-sm bg-white overflow-hidden d-flex flex-column h-100"
>
<!-- Image section --> <!-- Image section -->
<div class="product-card__image-container position-relative bg-light"> <div class="product-card__image-container position-relative bg-light">
@if (imageUrl()) { @if (imageUrl(); as imageSrc) {
<img [src]="imageUrl()" [alt]="title()" class="product-card__image w-100 h-100 object-fit-cover" /> <img
[ngSrc]="imageSrc"
[alt]="title()"
[priority]="imagePriority()"
fill
sizes="(min-width: 1200px) 25vw, (min-width: 768px) 50vw, 100vw"
class="product-card__image w-100 h-100 object-fit-cover"
/>
} @else { } @else {
<div <div
class="product-card__image-placeholder w-100 h-100 d-flex align-items-center justify-content-center bg-light text-muted"> class="product-card__image-placeholder w-100 h-100 d-flex align-items-center justify-content-center bg-light text-muted"
<i class="fa-solid fa-image fa-2x"></i> >
</div> <i class="fa-solid fa-image fa-2x"></i>
</div>
} }
<!-- Discount badge in top right --> <!-- Discount badge in top right -->
@if (discount() && discount()! > 0) { @if (discount() && discount()! > 0) {
<span class="product-card__discount-badge position-absolute top-0 end-0 bg-primary text-white px-2 py-1"> <span
-{{ discount() }}% class="product-card__discount-badge position-absolute top-0 end-0 bg-primary text-white px-2 py-1"
</span> >
-{{ discount() }}%
</span>
} }
</div> </div>
@@ -26,11 +38,15 @@
<!-- Prices Area --> <!-- Prices Area -->
<div class="product-card__prices d-flex align-items-center justify-content-center gap-2"> <div class="product-card__prices d-flex align-items-center justify-content-center gap-2">
<!-- Discounted (computado) Price --> <!-- Discounted (computado) Price -->
<span class="product-card__price-discounted text-primary">{{ formattedDiscountedPrice() }}</span> <span class="product-card__price-discounted text-primary">{{
formattedDiscountedPrice()
}}</span>
<!-- Original strikethrough Price (only if discount exists) --> <!-- Original strikethrough Price (only if discount exists) -->
@if (discount() && discount()! > 0) { @if (discount() && discount()! > 0) {
<span class="product-card__price-original text-decoration-line-through">{{ formattedOriginalPrice() }}</span> <span class="product-card__price-original text-decoration-line-through">{{
formattedOriginalPrice()
}}</span>
} }
</div> </div>
@@ -47,4 +63,4 @@
</app-button> </app-button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,13 +1,14 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { ButtonComponent } from '../button/button.component'; import { ButtonComponent } from '../button/button.component';
@Component({ @Component({
selector: 'app-product-card', selector: 'app-product-card',
standalone: true, standalone: true,
imports: [ButtonComponent], imports: [ButtonComponent, NgOptimizedImage],
templateUrl: './product-card.component.html', templateUrl: './product-card.component.html',
styleUrl: './product-card.component.scss', styleUrl: './product-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ProductCardComponent { export class ProductCardComponent {
// Configurable inputs // Configurable inputs
@@ -17,6 +18,7 @@ export class ProductCardComponent {
readonly discount = input<number | null>(null); readonly discount = input<number | null>(null);
readonly transferPrice = input<number | null>(null); readonly transferPrice = input<number | null>(null);
readonly buttonText = input<string>('Comprar'); readonly buttonText = input<string>('Comprar');
readonly imagePriority = input<boolean>(false);
// Interactive events // Interactive events
readonly buy = output<void>(); readonly buy = output<void>();