Compare commits
2 Commits
efa0d48a40
...
993aa5823a
| Author | SHA1 | Date | |
|---|---|---|---|
| 993aa5823a | |||
| 6c00a3e2bb |
@@ -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),
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>([]);
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
|
<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">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>
|
||||||
@@ -39,4 +41,4 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</app-store-section>
|
</app-store-section>
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
Reference in New Issue
Block a user