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 b2f2ac5..cbb9f3b 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 @@ -21,21 +21,24 @@
- + @if (ticketsMenu(); as menu) { (); readonly loginClick = output(); readonly logoutClick = output(); + readonly searchSubmit = output(); protected readonly isUserDropdownOpen = signal(false); + protected readonly searchControl = new FormControl('', { nonNullable: true }); @HostListener('document:click', ['$event']) protected onDocumentClick(event: MouseEvent): void { @@ -70,4 +74,12 @@ export class StoreHeaderComponent { this.isUserDropdownOpen.set(false); this.logoutClick.emit(); } + + protected submitSearch(): void { + const term = this.searchControl.value.trim(); + + if (term.length >= 2) { + this.searchSubmit.emit(term); + } + } } 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 ab98f43..2533ecc 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -10,6 +10,7 @@ (ticketsClick)="onTicketsClick()" (loginClick)="onLoginClick()" (logoutClick)="onLogoutClick()" + (searchSubmit)="onSearch($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 dbf1ed0..3a498c9 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 @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { provideRouter, Router } from '@angular/router'; import { of } from 'rxjs'; @@ -13,6 +14,7 @@ import { ToastService } from '../../services/toast.service'; import { AuthService } from '../../services/auth/auth.service'; import { AuthUser } from '../../services/auth/auth.interfaces'; import { StoreLayoutComponent } from './store-layout.component'; +import { StoreHeaderComponent } from './store-header/store-header.component'; const tenant: Tenant = { id: 1, @@ -216,6 +218,24 @@ describe('StoreLayoutComponent', () => { expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull(); }); + it('navigates to search results when the search form is submitted', () => { + const router = TestBed.inject(Router); + vi.spyOn(router, 'navigate'); + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + + const header = fixture.debugElement.query(By.directive(StoreHeaderComponent)); + const form = header.query(By.css('form[role="search"]')); + + (header.componentInstance as any).searchControl.setValue(' zapatillas '); + form.triggerEventHandler('ngSubmit'); + fixture.detectChanges(); + + expect(router.navigate).toHaveBeenCalledWith(['/buscar'], { + queryParams: { q: 'zapatillas', page: 1 }, + }); + }); + it('renders the primary tickets action when the tenant has the tickets menu', () => { const router = TestBed.inject(Router); vi.spyOn(router, 'navigate'); @@ -261,9 +281,7 @@ describe('StoreLayoutComponent', () => { menu.code === 'account' ? { ...menu, - submenues: menu.submenues.filter( - (submenu) => submenu.code !== 'account.tickets', - ), + submenues: menu.submenues.filter((submenu) => submenu.code !== 'account.tickets'), } : menu, ), @@ -273,9 +291,7 @@ describe('StoreLayoutComponent', () => { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '[data-testid="store-header-tickets"]', - ), + (fixture.nativeElement as HTMLElement).querySelector('[data-testid="store-header-tickets"]'), ).toBeNull(); }); 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 027aaf6..5ff75f4 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -114,6 +114,12 @@ export class StoreLayoutComponent implements OnInit { void this.router.navigate(['/login']); } + protected onSearch(term: string): void { + void this.router.navigate(['/buscar'], { + queryParams: { q: term, page: 1 }, + }); + } + protected onTicketsClick(): void { const route = this.ticketsMenu()?.route; diff --git a/src/app/core/services/catalog/catalog.service.ts b/src/app/core/services/catalog/catalog.service.ts index 627f22b..329dbed 100644 --- a/src/app/core/services/catalog/catalog.service.ts +++ b/src/app/core/services/catalog/catalog.service.ts @@ -8,6 +8,7 @@ import { ApiResponse } from '../api-response.interface'; import { TenantService } from '../tenant.service'; import { CatalogFeaturedGroup, + CatalogFeaturedItem, CatalogFeaturedItems, CatalogItemDetail, Product, @@ -15,6 +16,10 @@ import { type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[]; +export interface CatalogSearchQueryParams extends ApiPaginationQueryParams { + q: string; +} + @Injectable({ providedIn: 'root', }) @@ -36,6 +41,15 @@ export class CatalogService { return this.http.get(`${this.tenantApiUrl}/catalog`); } + searchCatalog( + params: CatalogSearchQueryParams, + ): Observable> { + return this.http.get>( + `${this.tenantApiUrl}/catalog-items`, + { 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 4c1c98b..a64e9b7 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -58,6 +58,9 @@ export interface Tenant { selected_bank_account?: BankAccount | null; hero_config?: HeroConfig | null; event_config?: EventConfig | null; + search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart'; + search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel'; + search_items_per_page?: number; main_carousel_images?: string[]; social_media?: SocialMedia[]; menues?: Menu[]; diff --git a/src/app/features/store/pages/search-page/search-page.component.html b/src/app/features/store/pages/search-page/search-page.component.html new file mode 100644 index 0000000..888283d --- /dev/null +++ b/src/app/features/store/pages/search-page/search-page.component.html @@ -0,0 +1,37 @@ +
+
+

Resultados de búsqueda

+ @if (query()) { +

+ Resultados para “{{ query() }}” +

+ } +
+ + @if (error()) { +

{{ error() }}

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

Buscando productos...

+ } @else if (results(); as searchResults) { + @if (!searchResults.data.length) { +

+ No encontramos productos para “{{ query() }}”. +

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

+ Se muestran {{ searchResults.data.length }} de {{ searchResults.meta.total }} resultados. +

+ } + } + } +
diff --git a/src/app/features/store/pages/search-page/search-page.component.scss b/src/app/features/store/pages/search-page/search-page.component.scss new file mode 100644 index 0000000..dad323d --- /dev/null +++ b/src/app/features/store/pages/search-page/search-page.component.scss @@ -0,0 +1,3 @@ +.search-results__alert-error { + color: var(--tenant-danger-color, var(--bs-danger)); +} diff --git a/src/app/features/store/pages/search-page/search-page.component.spec.ts b/src/app/features/store/pages/search-page/search-page.component.spec.ts new file mode 100644 index 0000000..ae26075 --- /dev/null +++ b/src/app/features/store/pages/search-page/search-page.component.spec.ts @@ -0,0 +1,108 @@ +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 { SearchPageComponent } from './search-page.component'; + +describe('SearchPageComponent', () => { + const searchCatalog = vi.fn(); + const tenant = signal({ + search_product_layout: 'row', + search_group_layout: 'simple_vertical', + search_items_per_page: 8, + } as Tenant); + + beforeEach(async () => { + searchCatalog.mockReset(); + searchCatalog.mockReturnValue( + of({ + data: [ + { + id: 1, + nombre: 'Running', + descripcion: 'Zapatillas', + precio: '100.00', + variants: [], + }, + ], + links: { + first: '/catalog-items?page=1', + last: '/catalog-items?page=2', + prev: null, + next: '/catalog-items?page=2', + }, + meta: { + current_page: 1, + from: 1, + last_page: 2, + links: [], + path: '/catalog-items', + per_page: 8, + to: 1, + total: 10, + }, + }), + ); + + await TestBed.configureTestingModule({ + imports: [SearchPageComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + queryParamMap: of(convertToParamMap({ q: 'running', page: '1' })), + }, + }, + { + provide: CatalogService, + useValue: { searchCatalog }, + }, + { + provide: TenantService, + useValue: { tenant: tenant.asReadonly() }, + }, + { + provide: CartService, + useValue: { addItem: vi.fn() }, + }, + { + provide: ToastService, + useValue: { + success: vi.fn(), + danger: vi.fn(), + }, + }, + ], + }).compileComponents(); + }); + + it('searches from the URL and applies the tenant presentation configuration', () => { + const fixture = TestBed.createComponent(SearchPageComponent); + fixture.detectChanges(); + + expect(searchCatalog).toHaveBeenCalledWith({ + q: 'running', + page: 1, + }); + + const productList = fixture.debugElement.query(By.directive(ProductListComponent)) + .componentInstance as ProductListComponent; + + expect(productList.layout()).toBe('row'); + expect(productList.groupLayout()).toBe('simple_vertical'); + expect(Array.isArray(productList.items())).toBe(true); + expect((fixture.nativeElement as HTMLElement).textContent).toContain( + 'Se muestran 1 de 10 resultados.', + ); + }); +}); diff --git a/src/app/features/store/pages/search-page/search-page.component.ts b/src/app/features/store/pages/search-page/search-page.component.ts new file mode 100644 index 0000000..570066f --- /dev/null +++ b/src/app/features/store/pages/search-page/search-page.component.ts @@ -0,0 +1,150 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + signal, +} from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { catchError, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { CartService } from '../../../../core/services/cart/cart.service'; +import { + CatalogFeaturedItem, + CatalogFeaturedItems, + CatalogGroupLayout, + CatalogProductLayout, +} from '../../../../core/services/catalog/catalog.interface'; +import { CatalogService } from '../../../../core/services/catalog/catalog.service'; +import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface'; +import { TenantService } from '../../../../core/services/tenant.service'; +import { ToastService } from '../../../../core/services/toast.service'; +import { + ProductListCartEvent, + ProductListComponent, + ProductListItem, +} from '../../../../shared/components/product-list/product-list.component'; + +interface SearchRouteState { + query: string; + page: number; +} + +@Component({ + selector: 'app-search-page', + imports: [ProductListComponent], + templateUrl: './search-page.component.html', + styleUrl: './search-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SearchPageComponent { + private readonly cartService = inject(CartService); + private readonly catalogService = inject(CatalogService); + 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 query = signal(''); + protected readonly results = signal | null>(null); + protected readonly loading = signal(false); + protected readonly error = signal(null); + + protected readonly productLayout = computed( + () => this.tenantService.tenant()?.search_product_layout ?? 'column_with_image', + ); + protected readonly groupLayout = computed( + () => this.tenantService.tenant()?.search_group_layout ?? 'paginated', + ); + protected readonly displayItems = computed(() => { + const results = this.results(); + + if (!results) { + return []; + } + + return this.groupLayout() === 'paginated' ? results : results.data; + }); + protected readonly hasHiddenResults = computed(() => { + const results = this.results(); + + return ( + results !== null && + this.groupLayout() !== 'paginated' && + results.meta.total > results.data.length + ); + }); + + constructor() { + this.route.queryParamMap + .pipe( + map( + (params): SearchRouteState => ({ + query: params.get('q')?.trim() ?? '', + page: this.parsePage(params.get('page')), + }), + ), + distinctUntilChanged( + (previous, current) => previous.query === current.query && previous.page === current.page, + ), + tap(({ query }) => { + this.query.set(query); + this.results.set(null); + this.error.set(query.length >= 2 ? null : 'Ingresá al menos 2 caracteres para buscar.'); + this.loading.set(query.length >= 2); + }), + switchMap(({ query, page }) => { + if (query.length < 2) { + return of(null); + } + + return this.catalogService.searchCatalog({ q: query, page }).pipe( + catchError(() => { + this.error.set('No pudimos realizar la búsqueda en este momento.'); + + 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 onBuyProduct(product: ProductListItem): void { + void this.router.navigate(['/producto', product.id]); + } + + 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 parsePage(value: string | null): number { + const page = Number(value ?? 1); + + return Number.isInteger(page) && page > 0 ? page : 1; + } +} diff --git a/src/app/features/store/store.routes.ts b/src/app/features/store/store.routes.ts index c5a41e9..eb9a2be 100644 --- a/src/app/features/store/store.routes.ts +++ b/src/app/features/store/store.routes.ts @@ -45,6 +45,11 @@ export const routes: Routes = [ }, ], }, + { + path: 'buscar', + loadComponent: () => + import('./pages/search-page/search-page.component').then((m) => m.SearchPageComponent), + }, { path: 'producto/:id', canActivate: [hasMenuGuard('product.detail')],