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 @@
+
+
+
+ @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')],