feat: implement category dropdown and category items page with routing and styling
This commit is contained in:
@@ -23,6 +23,7 @@ const tenant: Tenant = {
|
||||
footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png',
|
||||
categories: [],
|
||||
};
|
||||
|
||||
function createTenantServiceStub(currentTenant: Tenant | null) {
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('AccountSidebar', () => {
|
||||
footer_bg_color: '#ffffff',
|
||||
header_logo: '',
|
||||
footer_logo: '',
|
||||
categories: [],
|
||||
menues: [
|
||||
{
|
||||
id: 1,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<section class="category-items" aria-labelledby="category-items-title">
|
||||
@if (error()) {
|
||||
<p class="alert text-center mb-0 category-items__alert-error">{{ error() }}</p>
|
||||
} @else if (loading()) {
|
||||
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
|
||||
} @else if (results(); as categoryResults) {
|
||||
<header class="category-items__header mb-5">
|
||||
<h1 id="category-items-title" class="category-items__title mb-0">
|
||||
{{ categoryResults.category.nombre }}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
@if (!categoryResults.data.length) {
|
||||
<p class="alert alert-light border text-center mb-0">
|
||||
No hay productos disponibles en esta categoría.
|
||||
</p>
|
||||
} @else {
|
||||
<app-product-list
|
||||
[layout]="categoryResults.layout"
|
||||
[groupLayout]="paginatedLayout"
|
||||
[items]="categoryResults"
|
||||
[loading]="loading()"
|
||||
(buy)="onBuyProduct($event)"
|
||||
(addToCart)="onAddToCart($event)"
|
||||
(pageChange)="onPageChange($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,19 @@
|
||||
.category-items__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 28px;
|
||||
background-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.category-items__title {
|
||||
color: #313131;
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-items__alert-error {
|
||||
color: var(--tenant-danger-color, var(--bs-danger));
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||
import { Tenant } from '../../../../core/services/tenant.interface';
|
||||
import { TenantService } from '../../../../core/services/tenant.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
import { ProductListComponent } from '../../../../shared/components/product-list/product-list.component';
|
||||
import { CategoryItemsPageComponent } from './category-items-page.component';
|
||||
|
||||
describe('CategoryItemsPageComponent', () => {
|
||||
const getCategoryItems = vi.fn();
|
||||
const tenant = signal({ codigo: 'shop' } as Tenant);
|
||||
|
||||
beforeEach(async () => {
|
||||
getCategoryItems.mockReset();
|
||||
getCategoryItems.mockReturnValue(
|
||||
of({
|
||||
category: {
|
||||
id: 7,
|
||||
nombre: 'Remeras',
|
||||
categoria_id: null,
|
||||
},
|
||||
layout: 'row',
|
||||
group_layout: 'simple_vertical',
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
nombre: 'Remera clásica',
|
||||
descripcion: 'Algodón',
|
||||
precio: '100.00',
|
||||
variants: [],
|
||||
},
|
||||
],
|
||||
links: {
|
||||
first: '/categories/7?page=1',
|
||||
last: '/categories/7?page=2',
|
||||
prev: null,
|
||||
next: '/categories/7?page=2',
|
||||
},
|
||||
meta: {
|
||||
current_page: 1,
|
||||
from: 1,
|
||||
last_page: 2,
|
||||
links: [],
|
||||
path: '/categories/7',
|
||||
per_page: 12,
|
||||
to: 1,
|
||||
total: 13,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CategoryItemsPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({ id: '7' })),
|
||||
queryParamMap: of(convertToParamMap({ page: '1' })),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CatalogService,
|
||||
useValue: { getCategoryItems },
|
||||
},
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: { tenant: tenant.asReadonly() },
|
||||
},
|
||||
{
|
||||
provide: CartService,
|
||||
useValue: { addItem: vi.fn() },
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: {
|
||||
success: vi.fn(),
|
||||
danger: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('loads the category and renders its products with the configured product layout', () => {
|
||||
const fixture = TestBed.createComponent(CategoryItemsPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getCategoryItems).toHaveBeenCalledWith(7, { page: 1 });
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Remeras');
|
||||
|
||||
const productList = fixture.debugElement.query(By.directive(ProductListComponent))
|
||||
.componentInstance as ProductListComponent;
|
||||
|
||||
expect(productList.layout()).toBe('row');
|
||||
expect(productList.groupLayout()).toBe('paginated');
|
||||
expect(Array.isArray(productList.items())).toBe(false);
|
||||
});
|
||||
|
||||
it('does not request the API when the category id is invalid', () => {
|
||||
TestBed.overrideProvider(ActivatedRoute, {
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({ id: 'invalid' })),
|
||||
queryParamMap: of(convertToParamMap({ page: '1' })),
|
||||
},
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(CategoryItemsPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getCategoryItems).not.toHaveBeenCalled();
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
|
||||
'La categoría solicitada no es válida.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
Injector,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { catchError, combineLatest, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||
import { CheckoutService } from '../../../../core/services/checkout.service';
|
||||
import {
|
||||
CatalogGroupLayout,
|
||||
CategoryItemsResponse,
|
||||
} from '../../../../core/services/catalog/catalog.interface';
|
||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||
import { TenantService } from '../../../../core/services/tenant.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
import {
|
||||
ProductListBuyEvent,
|
||||
ProductListCartEvent,
|
||||
ProductListComponent,
|
||||
} from '../../../../shared/components/product-list/product-list.component';
|
||||
|
||||
interface CategoryRouteState {
|
||||
categoryId: number;
|
||||
page: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-items-page',
|
||||
imports: [ProductListComponent],
|
||||
templateUrl: './category-items-page.component.html',
|
||||
styleUrl: './category-items-page.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CategoryItemsPageComponent {
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly catalogService = inject(CatalogService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly tenantService = inject(TenantService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
protected readonly results = signal<CategoryItemsResponse | null>(null);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly creatingDirectPurchase = signal(false);
|
||||
protected readonly paginatedLayout: CatalogGroupLayout = 'paginated';
|
||||
|
||||
constructor() {
|
||||
combineLatest([this.route.paramMap, this.route.queryParamMap])
|
||||
.pipe(
|
||||
map(
|
||||
([params, queryParams]): CategoryRouteState => ({
|
||||
categoryId: this.parsePositiveInteger(params.get('id')),
|
||||
page: this.parsePositiveInteger(queryParams.get('page'), 1),
|
||||
}),
|
||||
),
|
||||
distinctUntilChanged(
|
||||
(previous, current) =>
|
||||
previous.categoryId === current.categoryId && previous.page === current.page,
|
||||
),
|
||||
tap(() => {
|
||||
this.results.set(null);
|
||||
this.error.set(null);
|
||||
this.loading.set(true);
|
||||
}),
|
||||
switchMap(({ categoryId, page }) => {
|
||||
if (categoryId === 0) {
|
||||
this.error.set('La categoría solicitada no es válida.');
|
||||
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return this.catalogService.getCategoryItems(categoryId, { page }).pipe(
|
||||
catchError(() => {
|
||||
this.error.set('No pudimos cargar los productos de esta categoría.');
|
||||
|
||||
return of(null);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((results) => {
|
||||
this.results.set(results);
|
||||
this.loading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
protected onPageChange(page: number): void {
|
||||
void this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { page },
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
}
|
||||
|
||||
protected async onBuyProduct(event: ProductListBuyEvent): Promise<void> {
|
||||
if (!event.directPurchase) {
|
||||
await this.router.navigate(['/producto', event.product.id]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.creatingDirectPurchase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.injector.get(AuthService).user()) {
|
||||
await this.router.navigate(['/login'], {
|
||||
queryParams: { returnUrl: `/producto/${event.product.id}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tenant = this.tenantService.tenant();
|
||||
if (!tenant) {
|
||||
this.toastService.danger('No se pudo identificar la tienda.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.creatingDirectPurchase.set(true);
|
||||
try {
|
||||
const purchase = await this.injector.get(CheckoutService).startCheckout(tenant.codigo, {
|
||||
direct_item: {
|
||||
catalog_item_id: event.product.id,
|
||||
variant_id: event.variant ?? null,
|
||||
cantidad: event.quantity,
|
||||
},
|
||||
});
|
||||
await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } });
|
||||
} catch (error) {
|
||||
console.error('Failed to create direct purchase:', error);
|
||||
this.toastService.danger('No se pudo iniciar la compra directa.');
|
||||
} finally {
|
||||
this.creatingDirectPurchase.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected onAddToCart(event: ProductListCartEvent): void {
|
||||
this.cartService.addItem(event.product.id, event.variant ?? null, event.quantity).subscribe({
|
||||
next: (response) => {
|
||||
this.toastService.success(response.message || 'Producto agregado al carrito');
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
const message = error.error?.message || 'No se pudo agregar el producto al carrito.';
|
||||
this.toastService.danger(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private parsePositiveInteger(value: string | null, fallback = 0): number {
|
||||
const parsed = Number(value ?? fallback);
|
||||
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ describe('HelpSidebar', () => {
|
||||
footer_bg_color: '#ffffff',
|
||||
header_logo: '',
|
||||
footer_logo: '',
|
||||
categories: [],
|
||||
menues: [
|
||||
{
|
||||
id: 1,
|
||||
|
||||
@@ -18,6 +18,7 @@ const tenant: Tenant = {
|
||||
footer_bg_color: '#202020',
|
||||
header_logo: '',
|
||||
footer_logo: '',
|
||||
categories: [],
|
||||
menues: [
|
||||
{
|
||||
id: 1,
|
||||
|
||||
@@ -18,6 +18,7 @@ const tenant: Tenant = {
|
||||
footer_bg_color: '#202020',
|
||||
header_logo: '',
|
||||
footer_logo: '',
|
||||
categories: [],
|
||||
menues: [
|
||||
{
|
||||
id: 1,
|
||||
|
||||
@@ -102,6 +102,7 @@ function createTenant(mainCarouselImages?: string[]): Tenant {
|
||||
footer_bg_color: '#ffffff',
|
||||
header_logo: '/header.png',
|
||||
footer_logo: '/footer.png',
|
||||
categories: [],
|
||||
main_carousel_images: mainCarouselImages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +73,13 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/search-page/search-page.component').then((m) => m.SearchPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'categoria/:id',
|
||||
loadComponent: () =>
|
||||
import('./pages/category-items-page/category-items-page.component').then(
|
||||
(m) => m.CategoryItemsPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'producto/:id',
|
||||
canActivate: [hasMenuGuard('product.detail')],
|
||||
|
||||
Reference in New Issue
Block a user