Compare commits

...

2 Commits

Author SHA1 Message Date
993aa5823a feat(product-detail): implement resolver for product details and update component logic for improved error handling
feat(product-carousel): enhance image loading with optimized attributes and improve layout consistency
style: refactor HTML structure for better readability and maintainability
test(product-detail): add unit tests for product detail resolver and component behavior
2026-07-08 16:12:35 -03:00
6c00a3e2bb feat(store-home): implement resolver for product data and enhance component logic for improved error handling and loading states 2026-07-08 16:04:11 -03:00
14 changed files with 670 additions and 333 deletions

View File

@@ -1,20 +1,36 @@
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 isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return (
request.method === 'GET' &&
/\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.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: isStoreCatalogRequest,
}),
),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])), provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideAppInitializer(authBootstrap), provideAppInitializer(authBootstrap),
provideAppInitializer(tenantBootstrap) provideAppInitializer(tenantBootstrap),
] ],
}; };

View File

@@ -3,11 +3,19 @@
<div class="product-carousel__main position-relative bg-light overflow-hidden"> <div class="product-carousel__main position-relative bg-light overflow-hidden">
<!-- Main Image --> <!-- Main Image -->
@if (images().length > 0) { @if (images().length > 0) {
<img [src]="images()[activeIndex()]" alt="Product active image" <img
class="product-carousel__main-image w-100 h-100 object-fit-cover" /> [ngSrc]="images()[activeIndex()]"
alt="Product active image"
fill
priority
sizes="(min-width: 992px) 42vw, 100vw"
class="product-carousel__main-image w-100 h-100 object-fit-cover"
/>
} @else { } @else {
<!-- Placeholder --> <!-- Placeholder -->
<div class="product-carousel__placeholder w-100 h-100 d-flex align-items-center justify-content-center text-muted"> <div
class="product-carousel__placeholder w-100 h-100 d-flex align-items-center justify-content-center text-muted"
>
<i class="fa-solid fa-image fa-3x"></i> <i class="fa-solid fa-image fa-3x"></i>
</div> </div>
} }
@@ -15,22 +23,29 @@
<!-- Discount Badge --> <!-- Discount Badge -->
@if (discount() && discount()! > 0) { @if (discount() && discount()! > 0) {
<span <span
class="product-carousel__discount-badge position-absolute top-0 end-0 bg-primary text-white px-3 py-2 fw-semibold"> class="product-carousel__discount-badge position-absolute top-0 end-0 bg-primary text-white px-3 py-2 fw-semibold"
>
-{{ discount() }}% -{{ discount() }}%
</span> </span>
} }
<!-- Navigation Arrows --> <!-- Navigation Arrows -->
@if (images().length > 1) { @if (images().length > 1) {
<button type="button" <button
type="button"
class="product-carousel__nav-btn product-carousel__nav-btn--prev position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center" class="product-carousel__nav-btn product-carousel__nav-btn--prev position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
(click)="prevImage()" aria-label="Previous image"> (click)="prevImage()"
aria-label="Previous image"
>
<i class="fa-solid fa-chevron-left"></i> <i class="fa-solid fa-chevron-left"></i>
</button> </button>
<button type="button" <button
type="button"
class="product-carousel__nav-btn product-carousel__nav-btn--next position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center" class="product-carousel__nav-btn product-carousel__nav-btn--next position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
(click)="nextImage()" aria-label="Next image"> (click)="nextImage()"
aria-label="Next image"
>
<i class="fa-solid fa-chevron-right"></i> <i class="fa-solid fa-chevron-right"></i>
</button> </button>
} }
@@ -40,9 +55,13 @@
@if (images().length > 1) { @if (images().length > 1) {
<div class="product-carousel__thumbnails"> <div class="product-carousel__thumbnails">
@for (image of images(); track image; let idx = $index) { @for (image of images(); track image; let idx = $index) {
<button type="button" class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light" <button
[class.product-carousel__thumbnail--active]="idx === activeIndex()" (click)="selectImage(idx)" type="button"
[attr.aria-label]="'Select image ' + (idx + 1)"> class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"
[class.product-carousel__thumbnail--active]="idx === activeIndex()"
(click)="selectImage(idx)"
[attr.aria-label]="'Select image ' + (idx + 1)"
>
<img [src]="image" alt="Product thumbnail" class="w-100 h-100 object-fit-cover" /> <img [src]="image" alt="Product thumbnail" class="w-100 h-100 object-fit-cover" />
</button> </button>
} }

View File

@@ -1,13 +1,13 @@
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule, NgOptimizedImage } from '@angular/common';
@Component({ @Component({
selector: 'app-product-carousel', selector: 'app-product-carousel',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, NgOptimizedImage],
templateUrl: './product-carousel.component.html', templateUrl: './product-carousel.component.html',
styleUrl: './product-carousel.component.scss', styleUrl: './product-carousel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ProductCarouselComponent { export class ProductCarouselComponent {
readonly images = input<string[]>([]); readonly images = input<string[]>([]);

View File

@@ -1,12 +1,8 @@
import { TestBed, getTestBed } from '@angular/core/testing'; import { TestBed, getTestBed } from '@angular/core/testing';
import { import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { BehaviorSubject, of, throwError } from 'rxjs'; import { BehaviorSubject, of, throwError } from 'rxjs';
import { convertToParamMap } from '@angular/router';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface'; import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
@@ -14,6 +10,10 @@ import { CatalogService } from '../../../../core/services/catalog/catalog.servic
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { ProductDetailPageComponent } from './product-detail-page.component'; import { ProductDetailPageComponent } from './product-detail-page.component';
import {
PRODUCT_DETAIL_ERROR_MESSAGE,
ProductDetailResolvedData,
} from './product-detail-page.resolver';
describe('ProductDetailPageComponent', () => { describe('ProductDetailPageComponent', () => {
const mockProduct: ProductDetail = { const mockProduct: ProductDetail = {
@@ -29,10 +29,10 @@ describe('ProductDetailPageComponent', () => {
images: ['https://example.com/image.png'], images: ['https://example.com/image.png'],
attributes: [], attributes: [],
variants_map: [], variants_map: [],
variant: null variant: null,
}; };
let paramMapSubject: BehaviorSubject<any>; let routeDataSubject: BehaviorSubject<{ productDetailData: ProductDetailResolvedData }>;
let catalogServiceStub: any; let catalogServiceStub: any;
let routerStub: any; let routerStub: any;
let cartServiceStub: any; let cartServiceStub: any;
@@ -40,10 +40,7 @@ describe('ProductDetailPageComponent', () => {
beforeAll(() => { beforeAll(() => {
try { try {
getTestBed().initTestEnvironment( getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
BrowserTestingModule,
platformBrowserTesting()
);
} catch { } catch {
// Test environment may already be initialized by another setup entrypoint. // Test environment may already be initialized by another setup entrypoint.
} }
@@ -51,20 +48,25 @@ describe('ProductDetailPageComponent', () => {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
paramMapSubject = new BehaviorSubject(convertToParamMap({ id: '1' })); routeDataSubject = new BehaviorSubject<{ productDetailData: ProductDetailResolvedData }>({
productDetailData: {
product: mockProduct,
error: null,
},
});
catalogServiceStub = { catalogServiceStub = {
getProducto: vi.fn().mockReturnValue(of(mockProduct)) getProducto: vi.fn(),
}; };
routerStub = { routerStub = {
navigate: vi.fn() navigate: vi.fn(),
}; };
cartServiceStub = { cartServiceStub = {
addItem: vi.fn().mockReturnValue(of({ message: 'Producto agregado al carrito' })) addItem: vi.fn().mockReturnValue(of({ message: 'Producto agregado al carrito' })),
}; };
toastServiceStub = { toastServiceStub = {
success: vi.fn(), success: vi.fn(),
danger: vi.fn(), danger: vi.fn(),
info: vi.fn() info: vi.fn(),
}; };
}); });
@@ -75,47 +77,69 @@ describe('ProductDetailPageComponent', () => {
{ {
provide: ActivatedRoute, provide: ActivatedRoute,
useValue: { useValue: {
paramMap: paramMapSubject.asObservable() data: routeDataSubject.asObservable(),
} },
}, },
{ {
provide: CatalogService, provide: CatalogService,
useValue: catalogServiceStub useValue: catalogServiceStub,
}, },
{ {
provide: Router, provide: Router,
useValue: routerStub useValue: routerStub,
}, },
{ {
provide: CartService, provide: CartService,
useValue: cartServiceStub useValue: cartServiceStub,
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: toastServiceStub useValue: toastServiceStub,
} },
] ],
}).compileComponents(); }).compileComponents();
} }
it('loads the product on init and embeds the carousel', async () => { function resolveProduct(product: ProductDetail): void {
routeDataSubject.next({
productDetailData: {
product,
error: null,
},
});
}
function resolveProductError(error: string): void {
routeDataSubject.next({
productDetailData: {
product: null,
error,
},
});
}
it('renders the resolved product and embeds the carousel', async () => {
await configureTestingModule(); await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1); expect(catalogServiceStub.getProducto).not.toHaveBeenCalled();
expect(element.querySelector('app-product-carousel')).not.toBeNull(); expect(element.querySelector('app-product-carousel')).not.toBeNull();
expect(element.querySelector('.product-detail__title')?.textContent).toContain('Auriculares Bluetooth'); expect(element.querySelector('.product-detail__title')?.textContent).toContain(
expect(element.querySelector('.product-detail__price-current')?.textContent).toContain('$24.999'); 'Auriculares Bluetooth',
);
expect(element.querySelector('.product-detail__price-current')?.textContent).toContain(
'$24.999',
);
expect(element.querySelector('.product-detail__price-previous')).toBeNull(); expect(element.querySelector('.product-detail__price-previous')).toBeNull();
expect(element.querySelector('.product-carousel__discount-badge')).toBeNull(); expect(element.querySelector('.product-carousel__discount-badge')).toBeNull();
}); });
it('shows error message if load fails', async () => { it('shows error message if the resolver cannot load the product', async () => {
catalogServiceStub.getProducto.mockReturnValue(throwError(() => new Error('load failed'))); resolveProductError(PRODUCT_DETAIL_ERROR_MESSAGE);
await configureTestingModule(); await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -125,7 +149,6 @@ describe('ProductDetailPageComponent', () => {
expect(element.textContent).toContain('No pudimos cargar los detalles del producto.'); expect(element.textContent).toContain('No pudimos cargar los detalles del producto.');
}); });
it('should use default variant images if present', async () => { it('should use default variant images if present', async () => {
const detailProduct: ProductDetail = { const detailProduct: ProductDetail = {
...mockProduct, ...mockProduct,
@@ -134,26 +157,29 @@ describe('ProductDetailPageComponent', () => {
id: 123, id: 123,
cantidad_maxima: 10, cantidad_maxima: 10,
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'], images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
definitions: {} definitions: {},
} },
}; };
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct)); resolveProduct(detailProduct);
await configureTestingModule(); await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges(); fixture.detectChanges();
const carousel = fixture.debugElement.query(By.css('app-product-carousel')).componentInstance; const carousel = fixture.debugElement.query(By.css('app-product-carousel')).componentInstance;
expect(carousel.images()).toEqual(['https://example.com/variant1.png', 'https://example.com/variant2.png']); expect(carousel.images()).toEqual([
'https://example.com/variant1.png',
'https://example.com/variant2.png',
]);
}); });
it('should fallback to product images if default variant images are not present', async () => { it('should fallback to product images if default variant images are not present', async () => {
const detailProduct: ProductDetail = { const detailProduct: ProductDetail = {
...mockProduct, ...mockProduct,
images: ['https://example.com/product.png'], images: ['https://example.com/product.png'],
variant: null variant: null,
}; };
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct)); resolveProduct(detailProduct);
await configureTestingModule(); await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
@@ -167,9 +193,9 @@ describe('ProductDetailPageComponent', () => {
const detailProduct: ProductDetail = { const detailProduct: ProductDetail = {
...mockProduct, ...mockProduct,
images: [], images: [],
variant: null variant: null,
}; };
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct)); resolveProduct(detailProduct);
await configureTestingModule(); await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
@@ -196,16 +222,16 @@ describe('ProductDetailPageComponent', () => {
value: 'beige', value: 'beige',
label: 'Beige', label: 'Beige',
sort_order: 1, sort_order: 1,
metadata: { hex: '#D8D1C7' } metadata: { hex: '#D8D1C7' },
}, },
{ {
id: 11, id: 11,
value: 'brown', value: 'brown',
label: 'Marrón', label: 'Marrón',
sort_order: 2, sort_order: 2,
metadata: { palette: { primary: '#7E6460' } } metadata: { palette: { primary: '#7E6460' } },
} },
] ],
}, },
{ {
id: 2, id: 2,
@@ -220,17 +246,17 @@ describe('ProductDetailPageComponent', () => {
value: 'mesh', value: 'mesh',
label: 'Mesh', label: 'Mesh',
sort_order: 1, sort_order: 1,
metadata: null metadata: null,
}, },
{ {
id: 21, id: 21,
value: 'cuero', value: 'cuero',
label: 'Cuero', label: 'Cuero',
sort_order: 2, sort_order: 2,
metadata: null metadata: null,
} },
] ],
} },
], ],
variant: { variant: {
id: 123, id: 123,
@@ -238,11 +264,11 @@ describe('ProductDetailPageComponent', () => {
images: ['https://example.com/variant1.png'], images: ['https://example.com/variant1.png'],
definitions: { definitions: {
color: 'beige', color: 'beige',
material: 'Cuero' material: 'Cuero',
} },
} },
}; };
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct)); resolveProduct(detailProduct);
await configureTestingModule(); await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
@@ -252,14 +278,16 @@ describe('ProductDetailPageComponent', () => {
const swatches = element.querySelectorAll('.attribute-selector__swatch'); const swatches = element.querySelectorAll('.attribute-selector__swatch');
const textOptions = element.querySelectorAll('.attribute-selector__text-option'); const textOptions = element.querySelectorAll('.attribute-selector__text-option');
const labels = Array.from(element.querySelectorAll('.attribute-selector__label')).map((label) => const labels = Array.from(element.querySelectorAll('.attribute-selector__label')).map((label) =>
label.textContent?.trim() label.textContent?.trim(),
); );
expect(labels).toEqual(['Color:', 'Material:']); expect(labels).toEqual(['Color:', 'Material:']);
expect(swatches).toHaveLength(2); expect(swatches).toHaveLength(2);
expect(textOptions).toHaveLength(2); expect(textOptions).toHaveLength(2);
expect(swatches[0].classList.contains('attribute-selector__swatch--selected')).toBe(true); expect(swatches[0].classList.contains('attribute-selector__swatch--selected')).toBe(true);
expect(textOptions[1].classList.contains('attribute-selector__text-option--selected')).toBe(true); expect(textOptions[1].classList.contains('attribute-selector__text-option--selected')).toBe(
true,
);
expect((swatches[0] as HTMLElement).style.backgroundColor).not.toBe(''); expect((swatches[0] as HTMLElement).style.backgroundColor).not.toBe('');
}); });
@@ -282,16 +310,16 @@ describe('ProductDetailPageComponent', () => {
fixture.componentInstance['selectedVariant'].set({ fixture.componentInstance['selectedVariant'].set({
variant_id: 1, variant_id: 1,
cantidad_maxima: 10, cantidad_maxima: 10,
attributes: {} attributes: {},
}); });
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const decreaseButton = element.querySelector( const decreaseButton = element.querySelector(
'[aria-label="Disminuir cantidad"]' '[aria-label="Disminuir cantidad"]',
) as HTMLButtonElement; ) as HTMLButtonElement;
const increaseButton = element.querySelector( const increaseButton = element.querySelector(
'[aria-label="Aumentar cantidad"]' '[aria-label="Aumentar cantidad"]',
) as HTMLButtonElement; ) as HTMLButtonElement;
decreaseButton.click(); decreaseButton.click();
@@ -316,7 +344,7 @@ describe('ProductDetailPageComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const toggleButton = element.querySelector( const toggleButton = element.querySelector(
'.product-detail__description-toggle' '.product-detail__description-toggle',
) as HTMLButtonElement; ) as HTMLButtonElement;
expect(toggleButton.textContent?.trim()).toBe('Mostrar más'); expect(toggleButton.textContent?.trim()).toBe('Mostrar más');
@@ -327,7 +355,7 @@ describe('ProductDetailPageComponent', () => {
expect( expect(
element element
.querySelector('.product-detail__description-body') .querySelector('.product-detail__description-body')
?.classList.contains('product-detail__description-body--expanded') ?.classList.contains('product-detail__description-body--expanded'),
).toBe(true); ).toBe(true);
expect(toggleButton.textContent?.trim()).toBe('Mostrar menos'); expect(toggleButton.textContent?.trim()).toBe('Mostrar menos');
}); });
@@ -338,12 +366,12 @@ describe('ProductDetailPageComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
const buttons = Array.from( const buttons = Array.from(
fixture.nativeElement.querySelectorAll('app-button button') fixture.nativeElement.querySelectorAll('app-button button'),
) as HTMLButtonElement[]; ) as HTMLButtonElement[];
expect(buttons.map((button) => button.textContent?.trim())).toEqual([ expect(buttons.map((button) => button.textContent?.trim())).toEqual([
'Agregar al carrito', 'Agregar al carrito',
'Comprar' 'Comprar',
]); ]);
buttons[0].click(); buttons[0].click();
@@ -359,17 +387,17 @@ describe('ProductDetailPageComponent', () => {
id: 123, id: 123,
cantidad_maxima: 5, cantidad_maxima: 5,
images: [], images: [],
definitions: {} definitions: {},
}, },
variants_map: [ variants_map: [
{ {
variant_id: 123, variant_id: 123,
cantidad_maxima: 5, cantidad_maxima: 5,
attributes: {} attributes: {},
} },
] ],
}; };
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct)); resolveProduct(detailProduct);
await configureTestingModule(); await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
@@ -382,7 +410,7 @@ describe('ProductDetailPageComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const addToCartButton = Array.from(element.querySelectorAll('app-button button')).find( const addToCartButton = Array.from(element.querySelectorAll('app-button button')).find(
(btn) => btn.textContent?.trim() === 'Agregar al carrito' (btn) => btn.textContent?.trim() === 'Agregar al carrito',
) as HTMLButtonElement; ) as HTMLButtonElement;
expect(addToCartButton).toBeDefined(); expect(addToCartButton).toBeDefined();
@@ -400,17 +428,17 @@ describe('ProductDetailPageComponent', () => {
id: 123, id: 123,
cantidad_maxima: 5, cantidad_maxima: 5,
images: [], images: [],
definitions: {} definitions: {},
}, },
variants_map: [ variants_map: [
{ {
variant_id: 123, variant_id: 123,
cantidad_maxima: 5, cantidad_maxima: 5,
attributes: {} attributes: {},
} },
] ],
}; };
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct)); resolveProduct(detailProduct);
cartServiceStub.addItem.mockReturnValue(throwError(() => new Error('Failed to add'))); cartServiceStub.addItem.mockReturnValue(throwError(() => new Error('Failed to add')));
await configureTestingModule(); await configureTestingModule();
@@ -422,13 +450,15 @@ describe('ProductDetailPageComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const addToCartButton = Array.from(element.querySelectorAll('app-button button')).find( const addToCartButton = Array.from(element.querySelectorAll('app-button button')).find(
(btn) => btn.textContent?.trim() === 'Agregar al carrito' (btn) => btn.textContent?.trim() === 'Agregar al carrito',
) as HTMLButtonElement; ) as HTMLButtonElement;
addToCartButton.click(); addToCartButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalled(); expect(cartServiceStub.addItem).toHaveBeenCalled();
expect(toastServiceStub.danger).toHaveBeenCalledWith('No se pudo agregar el producto al carrito.'); expect(toastServiceStub.danger).toHaveBeenCalledWith(
'No se pudo agregar el producto al carrito.',
);
}); });
}); });

View File

@@ -9,7 +9,7 @@ import {
inject, inject,
signal, signal,
viewChild, viewChild,
PLATFORM_ID PLATFORM_ID,
} from '@angular/core'; } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
@@ -20,16 +20,14 @@ import { CatalogService } from '../../../../core/services/catalog/catalog.servic
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { import {
ProductAttribute,
ProductAttributeOption,
ProductDetail, ProductDetail,
ProductVariant, ProductVariantMap,
ProductVariantMap
} from '../../../../core/services/catalog/catalog.interface'; } from '../../../../core/services/catalog/catalog.interface';
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component'; import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ProductAttributeSelectorComponent } from '../../components/product-attribute-selector/product-attribute-selector.component'; import { ProductAttributeSelectorComponent } from '../../components/product-attribute-selector/product-attribute-selector.component';
import { QuantitySelectorComponent } from '../../../../shared/components/quantity-selector/quantity-selector.component'; import { QuantitySelectorComponent } from '../../../../shared/components/quantity-selector/quantity-selector.component';
import { ProductDetailResolvedData } from './product-detail-page.resolver';
@Component({ @Component({
selector: 'app-product-detail-page', selector: 'app-product-detail-page',
@@ -40,11 +38,11 @@ import { QuantitySelectorComponent } from '../../../../shared/components/quantit
ProductCarouselComponent, ProductCarouselComponent,
ButtonComponent, ButtonComponent,
ProductAttributeSelectorComponent, ProductAttributeSelectorComponent,
QuantitySelectorComponent QuantitySelectorComponent,
], ],
templateUrl: './product-detail-page.component.html', templateUrl: './product-detail-page.component.html',
styleUrl: './product-detail-page.component.scss', styleUrl: './product-detail-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ProductDetailPageComponent implements OnInit, OnDestroy { export class ProductDetailPageComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
@@ -86,14 +84,14 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
protected readonly descriptionMaxHeight = signal(0); protected readonly descriptionMaxHeight = signal(0);
protected readonly descriptionHasOverflow = signal(false); protected readonly descriptionHasOverflow = signal(false);
protected readonly renderableAttributes = computed(() => protected readonly renderableAttributes = computed(() =>
(this.product()?.attributes ?? []).filter((attribute) => attribute.options.length > 0) (this.product()?.attributes ?? []).filter((attribute) => attribute.options.length > 0),
); );
protected readonly hasRenderableAttributes = computed( protected readonly hasRenderableAttributes = computed(
() => this.renderableAttributes().length > 0 () => this.renderableAttributes().length > 0,
); );
protected readonly oldPrice = computed<string | null>(() => null); protected readonly oldPrice = computed<string | null>(() => null);
protected readonly showDescriptionToggle = computed( protected readonly showDescriptionToggle = computed(
() => this.descriptionExpanded() || this.descriptionHasOverflow() () => this.descriptionExpanded() || this.descriptionHasOverflow(),
); );
constructor() { constructor() {
@@ -113,14 +111,12 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
this.routeSub = this.route.paramMap.subscribe((params) => { this.routeSub = this.route.data.subscribe((data) => {
const id = this.parseIntegerParam(params.get('id')); const resolvedData = data['productDetailData'] as ProductDetailResolvedData | undefined;
if (id === null) {
this.error.set('ID de producto inválido');
return;
}
this.loadProduct(id); if (resolvedData) {
this.applyResolvedData(resolvedData);
}
}); });
} }
@@ -131,38 +127,13 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.clearMeasurementTimer(); this.clearMeasurementTimer();
} }
private loadProduct(id: number): void {
this.loading.set(true);
this.error.set(null);
this.product.set(null);
this.productSub?.unsubscribe();
this.productSub = this.catalogService.getProducto(id).subscribe({
next: (prod) => {
this.product.set(prod);
const matchingVariant = prod.variants_map.find((v) => v.variant_id === prod.variant?.id) || null;
this.selectedVariant.set(matchingVariant);
this.quantity.set(1);
this.descriptionExpanded.set(false);
this.descriptionHasOverflow.set(false);
this.loading.set(false);
},
error: () => {
this.error.set('No pudimos cargar los detalles del producto.');
this.loading.set(false);
}
});
}
private loadProductVariant(productId: number, variantId: number): void { private loadProductVariant(productId: number, variantId: number): void {
this.variantLoading.set(true); this.variantLoading.set(true);
this.productSub?.unsubscribe(); this.productSub?.unsubscribe();
this.productSub = this.catalogService.getProducto(productId, variantId).subscribe({ this.productSub = this.catalogService.getProducto(productId, variantId).subscribe({
next: (prod) => { next: (prod) => {
this.product.set(prod); this.applyProduct(prod, false);
const matchingVariant = prod.variants_map.find((v) => v.variant_id === prod.variant?.id) || null;
this.selectedVariant.set(matchingVariant);
this.variantLoading.set(false); this.variantLoading.set(false);
}, },
error: (err: HttpErrorResponse) => { error: (err: HttpErrorResponse) => {
@@ -180,15 +151,44 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
}); });
return { return {
...currentProduct, ...currentProduct,
variants_map: updatedVariantsMap variants_map: updatedVariantsMap,
}; };
}); });
this.attributeSelector()?.reset(); this.attributeSelector()?.reset();
} },
}); });
} }
private applyResolvedData(resolvedData: ProductDetailResolvedData): void {
this.productSub?.unsubscribe();
this.loading.set(false);
this.variantLoading.set(false);
if (resolvedData.error) {
this.product.set(null);
this.error.set(resolvedData.error);
return;
}
if (resolvedData.product) {
this.applyProduct(resolvedData.product, true);
}
}
private applyProduct(prod: ProductDetail, resetQuantity: boolean): void {
this.product.set(prod);
const matchingVariant =
prod.variants_map.find((v) => v.variant_id === prod.variant?.id) || null;
this.selectedVariant.set(matchingVariant);
if (resetQuantity) {
this.quantity.set(1);
}
this.descriptionExpanded.set(false);
this.descriptionHasOverflow.set(false);
this.error.set(null);
}
protected goBack(): void { protected goBack(): void {
this.router.navigate(['/']); this.router.navigate(['/']);
} }
@@ -219,8 +219,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
} }
protected addToCart(): void { protected addToCart(): void {
const variant = this.selectedVariant(); const variant = this.selectedVariant();
if (!variant) { if (!variant) {
@@ -239,7 +237,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
const errorMessage = err.error?.message || 'No se pudo agregar el producto al carrito.'; const errorMessage = err.error?.message || 'No se pudo agregar el producto al carrito.';
this.toastService.danger(errorMessage); this.toastService.danger(errorMessage);
this.addingToCart.set(false); this.addingToCart.set(false);
} },
}); });
} }
@@ -247,15 +245,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.descriptionExpanded.update((current) => !current); this.descriptionExpanded.update((current) => !current);
} }
private parseIntegerParam(value: string | null): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : null;
}
private bindCarouselResizeObserver(): void { private bindCarouselResizeObserver(): void {
const previewElement = this.getCarouselPreviewElement(); const previewElement = this.getCarouselPreviewElement();
@@ -315,7 +304,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
this.descriptionHasOverflow.set( this.descriptionHasOverflow.set(
descriptionElement.scrollHeight - descriptionElement.clientHeight > 1 descriptionElement.scrollHeight - descriptionElement.clientHeight > 1,
); );
} }

View File

@@ -0,0 +1,91 @@
import { TestBed } from '@angular/core/testing';
import { convertToParamMap } from '@angular/router';
import { firstValueFrom, Observable, of, throwError } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import {
PRODUCT_DETAIL_ERROR_MESSAGE,
PRODUCT_DETAIL_INVALID_ID_MESSAGE,
ProductDetailResolvedData,
productDetailResolver,
} from './product-detail-page.resolver';
describe('productDetailResolver', () => {
const product: ProductDetail = {
id: 1,
category_id: 10,
brand_id: null,
slug: 'auriculares-bluetooth',
nombre: 'Auriculares Bluetooth',
descripcion: 'Auriculares bluetooth de prueba',
precio: '24999',
category: 'Tecnologia',
brand: null,
images: [],
attributes: [],
variants_map: [],
variant: null,
};
let catalogServiceStub: { getProducto: ReturnType<typeof vi.fn> };
beforeEach(() => {
catalogServiceStub = {
getProducto: vi.fn().mockReturnValue(of(product)),
};
TestBed.configureTestingModule({
providers: [
{
provide: CatalogService,
useValue: catalogServiceStub,
},
],
});
});
it('resolves the product detail for the route id', async () => {
const result = TestBed.runInInjectionContext(() =>
productDetailResolver(createRouteSnapshot('1'), {} as never),
);
await expect(firstValueFrom(result as Observable<ProductDetailResolvedData>)).resolves.toEqual({
product,
error: null,
});
expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1);
});
it('returns an error state for invalid ids', async () => {
const result = TestBed.runInInjectionContext(() =>
productDetailResolver(createRouteSnapshot('abc'), {} as never),
);
await expect(firstValueFrom(result as Observable<ProductDetailResolvedData>)).resolves.toEqual({
product: null,
error: PRODUCT_DETAIL_INVALID_ID_MESSAGE,
});
expect(catalogServiceStub.getProducto).not.toHaveBeenCalled();
});
it('returns an error state when the request fails', async () => {
catalogServiceStub.getProducto.mockReturnValue(throwError(() => new Error('boom')));
const result = TestBed.runInInjectionContext(() =>
productDetailResolver(createRouteSnapshot('1'), {} as never),
);
await expect(firstValueFrom(result as Observable<ProductDetailResolvedData>)).resolves.toEqual({
product: null,
error: PRODUCT_DETAIL_ERROR_MESSAGE,
});
});
});
function createRouteSnapshot(id: string) {
return {
paramMap: convertToParamMap({ id }),
} as never;
}

View File

@@ -0,0 +1,53 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
export const PRODUCT_DETAIL_ERROR_MESSAGE = 'No pudimos cargar los detalles del producto.';
export const PRODUCT_DETAIL_INVALID_ID_MESSAGE = 'ID de producto invalido';
export interface ProductDetailResolvedData {
product: ProductDetail | null;
error: string | null;
}
export const productDetailResolver: ResolveFn<ProductDetailResolvedData> = (
route: ActivatedRouteSnapshot,
) => {
const productId = parseIntegerParam(route.paramMap.get('id'));
if (productId === null) {
return of({
product: null,
error: PRODUCT_DETAIL_INVALID_ID_MESSAGE,
});
}
return inject(CatalogService)
.getProducto(productId)
.pipe(
map(
(product): ProductDetailResolvedData => ({
product,
error: null,
}),
),
catchError(() =>
of({
product: null,
error: PRODUCT_DETAIL_ERROR_MESSAGE,
}),
),
);
};
function parseIntegerParam(value: string | null): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : null;
}

View File

@@ -1,4 +1,3 @@
<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()) {
@@ -6,10 +5,12 @@
} @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">No hay productos disponibles en este momento.</p> <p class="alert alert-light border text-center mb-0">
No hay productos disponibles en este momento.
</p>
} @else { } @else {
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 g-4"> <div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 g-4">
@for (product of productCards(); track product.id) { @for (product of productCards(); track product.id; let index = $index) {
<div class="col"> <div class="col">
<app-product-card <app-product-card
[title]="product.title" [title]="product.title"
@@ -17,6 +18,7 @@
[discount]="product.discount" [discount]="product.discount"
[transferPrice]="product.transferPrice" [transferPrice]="product.transferPrice"
[imageUrl]="product.imageUrl" [imageUrl]="product.imageUrl"
[imagePriority]="index < priorityImageCount"
(buy)="onBuyProduct(product.id)" (buy)="onBuyProduct(product.id)"
/> />
</div> </div>

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,20 +111,20 @@ 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()
.mockReturnValueOnce(of(createPaginatedResponse(pageOneProducts, 1, 3)))
.mockReturnValueOnce(
of( of(
createPaginatedResponse( createPaginatedResponse(
[ [
@@ -110,26 +136,27 @@ describe('StoreHomePageComponent', () => {
nombre: 'Mouse Gamer', nombre: 'Mouse Gamer',
descripcion: 'Mouse gamer de prueba', descripcion: 'Mouse gamer de prueba',
precio: '15999', precio: '15999',
category: 'Tecnología', category: 'Tecnologia',
brand: null, brand: null,
images: [] images: [],
} },
], ],
2, 2,
3 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

@@ -4,8 +4,10 @@ import { SimpleLayoutComponent } from '../../core/layout/simple-layout/simple-la
import { StoreLayoutComponent } from '../../core/layout/store-layout/store-layout.component'; import { StoreLayoutComponent } from '../../core/layout/store-layout/store-layout.component';
import { authGuard, guestOnlyGuard } from '../../core/services/auth/auth.guards'; 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 { productDetailResolver } from './pages/product-detail-page/product-detail-page.resolver';
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 +16,10 @@ export const routes: Routes = [
children: [ children: [
{ {
path: '', path: '',
component: StoreHomePageComponent component: StoreHomePageComponent,
resolve: {
productsData: storeHomeProductsResolver,
},
}, },
{ {
path: 'login', path: 'login',
@@ -23,9 +28,9 @@ export const routes: Routes = [
children: [ children: [
{ {
path: '', path: '',
component: LoginPageComponent component: LoginPageComponent,
} },
] ],
}, },
{ {
path: 'register', path: 'register',
@@ -34,24 +39,27 @@ export const routes: Routes = [
children: [ children: [
{ {
path: '', path: '',
component: RegisterPageComponent component: RegisterPageComponent,
} },
] ],
}, },
{ {
path: 'producto/:id', path: 'producto/:id',
resolve: {
productDetailData: productDetailResolver,
},
loadComponent: () => loadComponent: () =>
import('./pages/product-detail-page/product-detail-page.component').then( import('./pages/product-detail-page/product-detail-page.component').then(
(m) => m.ProductDetailPageComponent (m) => m.ProductDetailPageComponent,
) ),
}, },
{ {
path: 'checkout', path: 'checkout',
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => loadComponent: () =>
import('./pages/checkout-page/checkout-page.component').then( import('./pages/checkout-page/checkout-page.component').then(
(m) => m.CheckoutPageComponent (m) => m.CheckoutPageComponent,
) ),
}, },
{ {
path: 'checkout/status', path: 'checkout/status',
@@ -61,47 +69,45 @@ export const routes: Routes = [
path: ':id', path: ':id',
loadComponent: () => loadComponent: () =>
import('./pages/purchase-status-page/purchase-status-page.component').then( import('./pages/purchase-status-page/purchase-status-page.component').then(
(m) => m.PurchaseStatusPageComponent (m) => m.PurchaseStatusPageComponent,
) ),
} },
] ],
}, },
{ {
path: 'mi-cuenta', path: 'mi-cuenta',
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => loadComponent: () =>
import('./pages/account-page/account-layout/account-layout').then( import('./pages/account-page/account-layout/account-layout').then((m) => m.AccountLayout),
(m) => m.AccountLayout
),
children: [ children: [
{ {
path: 'datos-personales', path: 'datos-personales',
loadComponent: () => loadComponent: () =>
import('./pages/account-page/pages/profile-page/profile-page').then( import('./pages/account-page/pages/profile-page/profile-page').then(
(m) => m.ProfilePage (m) => m.ProfilePage,
) ),
}, },
{ {
path: 'compras', path: 'compras',
loadComponent: () => loadComponent: () =>
import('./pages/account-page/pages/purchases-page/purchases-page').then( import('./pages/account-page/pages/purchases-page/purchases-page').then(
(m) => m.PurchasesPage (m) => m.PurchasesPage,
) ),
}, },
{ {
path: 'compras/:id', path: 'compras/:id',
loadComponent: () => loadComponent: () =>
import('./pages/account-page/pages/purchase-detail-page/purchase-detail-page').then( import('./pages/account-page/pages/purchase-detail-page/purchase-detail-page').then(
(m) => m.PurchaseDetailPage (m) => m.PurchaseDetailPage,
) ),
}, },
{ {
path: '', path: '',
redirectTo: 'datos-personales', redirectTo: 'datos-personales',
pathMatch: 'full' pathMatch: 'full',
} },
] ],
} },
] ],
} },
]; ];

View File

@@ -1,18 +1,30 @@
<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> <i class="fa-solid fa-image fa-2x"></i>
</div> </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
class="product-card__discount-badge position-absolute top-0 end-0 bg-primary text-white px-2 py-1"
>
-{{ discount() }}% -{{ discount() }}%
</span> </span>
} }
@@ -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>

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>();