diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts index 9b21c14..d533b12 100644 --- a/src/app/app.routes.spec.ts +++ b/src/app/app.routes.spec.ts @@ -23,7 +23,8 @@ const tenant: Tenant = { header_bg_color: '#313131', footer_bg_color: '#313131', header_logo: 'https://example.com/header.png', - footer_logo: 'https://example.com/footer.png' + footer_logo: 'https://example.com/footer.png', + categories: [] }; function createTenantServiceStub( diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 200a98c..b1a504b 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -24,7 +24,8 @@ const tenant: Tenant = { header_bg_color: '#313131', footer_bg_color: '#313131', header_logo: 'https://example.com/header.png', - footer_logo: 'https://example.com/footer.png' + footer_logo: 'https://example.com/footer.png', + categories: [] }; function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) { diff --git a/src/app/core/layout/store-layout/store-header/category-dropdown/category-dropdown.component.html b/src/app/core/layout/store-layout/store-header/category-dropdown/category-dropdown.component.html new file mode 100644 index 0000000..a45e588 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/category-dropdown/category-dropdown.component.html @@ -0,0 +1,39 @@ +@if (open() && categories().length) { + +} diff --git a/src/app/core/layout/store-layout/store-header/category-dropdown/category-dropdown.component.scss b/src/app/core/layout/store-layout/store-header/category-dropdown/category-dropdown.component.scss new file mode 100644 index 0000000..e61127c --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/category-dropdown/category-dropdown.component.scss @@ -0,0 +1,47 @@ +.category-dropdown { + min-width: 240px; + background-color: #ffffff; + border: 0; + box-shadow: 0 0 25px rgba(0, 0, 0, 0.05); +} + +.category-dropdown .dropdown-item, +.category-dropdown .dropdown-item:active { + display: flex; + align-items: center; + min-height: 2rem; + font-size: 14px; + font-weight: 700; + line-height: 1.25; + color: #8a8a8a; + background-color: #ffffff; +} + +.category-dropdown .dropdown-item:hover, +.category-dropdown .dropdown-item:focus, +.category-dropdown .dropdown-item.active { + color: var(--tenant-primary, var(--bs-primary)); + background-color: #ffffff; +} + +.dropdown-submenu { + position: relative; +} + +.category-dropdown__submenu { + top: -0.5rem; + left: calc(100% - 0.1rem); + min-width: 240px; + border: 0; + box-shadow: 0 0 25px rgba(0, 0, 0, 0.05); +} + +@media (max-width: 575.98px) { + .category-dropdown__submenu { + position: static; + margin: 0; + border: 0; + border-radius: 0; + box-shadow: none; + } +} diff --git a/src/app/core/layout/store-layout/store-header/category-dropdown/category-dropdown.component.ts b/src/app/core/layout/store-layout/store-header/category-dropdown/category-dropdown.component.ts new file mode 100644 index 0000000..779887f --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/category-dropdown/category-dropdown.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core'; + +import { Category } from '../../../../services/tenant.interface'; + +@Component({ + selector: 'app-category-dropdown', + standalone: true, + templateUrl: './category-dropdown.component.html', + styleUrl: './category-dropdown.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CategoryDropdownComponent { + readonly categories = input([]); + readonly open = input(false); + readonly categorySelect = output(); + + protected readonly activeCategory = signal(null); + protected readonly subcategories = computed(() => this.activeCategory()?.subcategories ?? []); + + constructor() { + effect(() => { + const categories = this.categories(); + const activeCategory = this.activeCategory(); + + if (!this.open()) { + this.activeCategory.set(null); + return; + } + + if (activeCategory && !categories.some((category) => category.id === activeCategory.id)) { + this.activeCategory.set(null); + } + }); + } + + protected selectCategory(category: Category): void { + if (category.subcategories.length) { + this.activeCategory.update((activeCategory) => + activeCategory?.id === category.id ? null : category, + ); + return; + } + + this.categorySelect.emit(category); + } +} diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.html b/src/app/core/layout/store-layout/store-header/store-header.component.html index d178acd..5131259 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.html +++ b/src/app/core/layout/store-layout/store-header/store-header.component.html @@ -99,16 +99,28 @@ -
+
- +
+ + + +
diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.scss b/src/app/core/layout/store-layout/store-header/store-header.component.scss index ec482a2..f6b264b 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.scss +++ b/src/app/core/layout/store-layout/store-header/store-header.component.scss @@ -111,6 +111,19 @@ color: #8a8a8a; } +.store-layout__category-menu { + position: relative; + display: inline-flex; + flex-direction: column; +} + +.store-layout__category-menu app-category-dropdown { + position: absolute; + z-index: 1050; + top: 100%; + left: 0; +} + @keyframes store-header-logo-skeleton { from { background-position: 200% 0; diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.ts b/src/app/core/layout/store-layout/store-header/store-header.component.ts index 12f6b13..c87f52f 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.ts +++ b/src/app/core/layout/store-layout/store-header/store-header.component.ts @@ -7,6 +7,8 @@ import { UserDropdownComponent } from './user-dropdown/user-dropdown.component'; import { Menu } from '../../../services/tenant.interface'; import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ReactiveFormsModule, FormControl } from '@angular/forms'; +import { Category } from '../../../services/tenant.interface'; +import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component'; @Component({ selector: 'app-store-header', @@ -17,6 +19,7 @@ import { ReactiveFormsModule, FormControl } from '@angular/forms'; RouterLink, ReactiveFormsModule, UserDropdownComponent, + CategoryDropdownComponent, ], templateUrl: './store-header.component.html', styleUrl: './store-header.component.scss', @@ -30,32 +33,37 @@ export class StoreHeaderComponent { readonly isAuthenticated = input(false); readonly accountNavigationMenus = input([]); readonly ticketsMenu = input(null); + readonly categories = input([]); readonly cartClick = output(); readonly ticketsClick = output(); readonly loginClick = output(); readonly logoutClick = output(); readonly searchSubmit = output(); + readonly categorySelect = output(); protected readonly isUserDropdownOpen = signal(false); + protected readonly isCategoryDropdownOpen = signal(false); protected readonly minSearchLength = 3; protected readonly showSearchError = signal(false); protected readonly searchControl = new FormControl('', { nonNullable: true }); @HostListener('document:click', ['$event']) protected onDocumentClick(event: MouseEvent): void { - if (!this.isUserDropdownOpen()) { + if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen()) { return; } const target = event.target; if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) { this.isUserDropdownOpen.set(false); + this.isCategoryDropdownOpen.set(false); } } @HostListener('document:keydown.escape') protected onEscapeKey(): void { this.isUserDropdownOpen.set(false); + this.isCategoryDropdownOpen.set(false); } protected toggleUserDropdown(): void { @@ -94,4 +102,13 @@ export class StoreHeaderComponent { protected clearSearchError(): void { this.showSearchError.set(false); } + + protected toggleCategoryDropdown(): void { + this.isCategoryDropdownOpen.update((isOpen) => !isOpen); + } + + protected onCategorySelect(category: Category): void { + this.isCategoryDropdownOpen.set(false); + this.categorySelect.emit(category); + } } diff --git a/src/app/core/layout/store-layout/store-layout.component.html b/src/app/core/layout/store-layout/store-layout.component.html index 242eda6..828df31 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -6,11 +6,13 @@ [isAuthenticated]="isAuthenticated()" [accountNavigationMenus]="accountNavigationMenus()" [ticketsMenu]="ticketsMenu() ?? null" + [categories]="tenant()?.categories ?? []" (cartClick)="isCartOpen.set(!isCartOpen())" (ticketsClick)="onTicketsClick()" (loginClick)="onLoginClick()" (logoutClick)="onLogoutClick()" (searchSubmit)="onSearch($event)" + (categorySelect)="onCategorySelect($event)" /> @if (isCartOpen()) { diff --git a/src/app/core/layout/store-layout/store-layout.component.spec.ts b/src/app/core/layout/store-layout/store-layout.component.spec.ts index 45ffffc..ddcf321 100644 --- a/src/app/core/layout/store-layout/store-layout.component.spec.ts +++ b/src/app/core/layout/store-layout/store-layout.component.spec.ts @@ -30,6 +30,7 @@ const tenant: Tenant = { footer_bg_color: '#313131', header_logo: 'https://example.com/header.png', footer_logo: 'https://example.com/footer.png', + categories: [], menues: [ { id: 4, diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 914b9a7..8336100 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -11,6 +11,7 @@ import { AuthService } from '../../services/auth/auth.service'; import { findMenu } from '../../services/menu.utils'; import { CheckoutService } from '../../services/checkout.service'; import { ToastService } from '../../services/toast.service'; +import { Category } from '../../services/tenant.interface'; @Component({ selector: 'app-store-layout', @@ -125,6 +126,12 @@ export class StoreLayoutComponent implements OnInit { }); } + protected onCategorySelect(category: Category): void { + void this.router.navigate(['/categoria', category.id], { + queryParams: { page: 1 }, + }); + } + protected onTicketsClick(): void { const route = this.ticketsMenu()?.route; diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 8676b93..56b9835 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -88,7 +88,8 @@ export interface CatalogFeaturedItem { } export type CatalogFeaturedItems = - ApiPaginatedResponse | CatalogFeaturedItem[]; + | ApiPaginatedResponse + | CatalogFeaturedItem[]; export interface CatalogFeaturedGroup { id: number; @@ -98,3 +99,15 @@ export interface CatalogFeaturedGroup { group_order: number; items: CatalogFeaturedItems; } + +export interface CatalogCategory { + id: number; + nombre: string; + categoria_id: number | null; +} + +export interface CategoryItemsResponse extends ApiPaginatedResponse { + category: CatalogCategory; + layout: CatalogProductLayout; + group_layout: CatalogGroupLayout; +} diff --git a/src/app/core/services/catalog/catalog.service.ts b/src/app/core/services/catalog/catalog.service.ts index 329dbed..58aebf8 100644 --- a/src/app/core/services/catalog/catalog.service.ts +++ b/src/app/core/services/catalog/catalog.service.ts @@ -11,6 +11,7 @@ import { CatalogFeaturedItem, CatalogFeaturedItems, CatalogItemDetail, + CategoryItemsResponse, Product, } from './catalog.interface'; @@ -50,6 +51,15 @@ export class CatalogService { ); } + getCategoryItems( + categoryId: number, + params?: ApiPaginationQueryParams, + ): Observable { + return this.http.get(`${this.tenantApiUrl}/categories/${categoryId}`, { + params: this.buildHttpParams(params), + }); + } + getFeaturedGroupItems( featuredGroupId: number, params?: ApiPaginationQueryParams, diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index a64e9b7..51a87cf 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -41,6 +41,12 @@ export interface SocialMedia { url: string; } +export interface Category { + id: number; + nombre: string; + subcategories: Category[]; +} + export interface Tenant { id: number; codigo: string; @@ -64,6 +70,7 @@ export interface Tenant { main_carousel_images?: string[]; social_media?: SocialMedia[]; menues?: Menu[]; + categories: Category[]; } export type TenantBootstrapResponse = ApiResponse; diff --git a/src/app/core/services/tenant.service.spec.ts b/src/app/core/services/tenant.service.spec.ts index 04e7d87..26fa838 100644 --- a/src/app/core/services/tenant.service.spec.ts +++ b/src/app/core/services/tenant.service.spec.ts @@ -20,7 +20,20 @@ const tenant: Tenant = { header_bg_color: '#313131', footer_bg_color: '#313131', header_logo: 'https://example.com/header.png', - footer_logo: 'https://example.com/footer.png' + footer_logo: 'https://example.com/footer.png', + categories: [ + { + id: 1, + nombre: 'Remeras', + subcategories: [ + { + id: 2, + nombre: 'Manga corta', + subcategories: [] + } + ] + } + ] }; const tenantResponse: TenantBootstrapResponse = { diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts index 8f3fcea..ae141d8 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts @@ -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) { diff --git a/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.spec.ts b/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.spec.ts index 556900b..72b27d2 100644 --- a/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.spec.ts +++ b/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.spec.ts @@ -22,6 +22,7 @@ describe('AccountSidebar', () => { footer_bg_color: '#ffffff', header_logo: '', footer_logo: '', + categories: [], menues: [ { id: 1, diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.html b/src/app/features/store/pages/category-items-page/category-items-page.component.html new file mode 100644 index 0000000..bf39754 --- /dev/null +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.html @@ -0,0 +1,29 @@ +
+ @if (error()) { +

{{ error() }}

+ } @else if (loading()) { +

Cargando productos...

+ } @else if (results(); as categoryResults) { +
+

+ {{ categoryResults.category.nombre }} +

+
+ + @if (!categoryResults.data.length) { +

+ No hay productos disponibles en esta categoría. +

+ } @else { + + } + } +
diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.scss b/src/app/features/store/pages/category-items-page/category-items-page.component.scss new file mode 100644 index 0000000..f756dd7 --- /dev/null +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.scss @@ -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)); +} diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts b/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts new file mode 100644 index 0000000..d389b35 --- /dev/null +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts @@ -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.', + ); + }); +}); diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.ts b/src/app/features/store/pages/category-items-page/category-items-page.component.ts new file mode 100644 index 0000000..784ee1a --- /dev/null +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.ts @@ -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(null); + protected readonly loading = signal(true); + protected readonly error = signal(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 { + 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; + } +} diff --git a/src/app/features/store/pages/help-page/components/help-sidebar/help-sidebar.spec.ts b/src/app/features/store/pages/help-page/components/help-sidebar/help-sidebar.spec.ts index d61c290..264460a 100644 --- a/src/app/features/store/pages/help-page/components/help-sidebar/help-sidebar.spec.ts +++ b/src/app/features/store/pages/help-page/components/help-sidebar/help-sidebar.spec.ts @@ -22,6 +22,7 @@ describe('HelpSidebar', () => { footer_bg_color: '#ffffff', header_logo: '', footer_logo: '', + categories: [], menues: [ { id: 1, diff --git a/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts index 98b2d8d..0ac1315 100644 --- a/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts +++ b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts @@ -18,6 +18,7 @@ const tenant: Tenant = { footer_bg_color: '#202020', header_logo: '', footer_logo: '', + categories: [], menues: [ { id: 1, diff --git a/src/app/features/store/pages/help-page/pages/faq-page/faq-page.spec.ts b/src/app/features/store/pages/help-page/pages/faq-page/faq-page.spec.ts index f4a761d..54c9e1a 100644 --- a/src/app/features/store/pages/help-page/pages/faq-page/faq-page.spec.ts +++ b/src/app/features/store/pages/help-page/pages/faq-page/faq-page.spec.ts @@ -18,6 +18,7 @@ const tenant: Tenant = { footer_bg_color: '#202020', header_logo: '', footer_logo: '', + categories: [], menues: [ { id: 1, diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts index b5218ad..743f34a 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts @@ -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, }; } diff --git a/src/app/features/store/store.routes.ts b/src/app/features/store/store.routes.ts index 3195142..ae173cb 100644 --- a/src/app/features/store/store.routes.ts +++ b/src/app/features/store/store.routes.ts @@ -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')],