feat(search): implement search functionality with results page and routing
This commit is contained in:
@@ -21,21 +21,24 @@
|
||||
<div
|
||||
class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
|
||||
>
|
||||
<div class="input-group store-layout__search" role="search">
|
||||
<form class="input-group store-layout__search" role="search" (ngSubmit)="submitSearch()">
|
||||
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
||||
<input
|
||||
id="store-search-input"
|
||||
type="search"
|
||||
class="form-control store-layout__search-input border-end-0 rounded-start"
|
||||
placeholder="Buscar productos"
|
||||
autocomplete="off"
|
||||
[formControl]="searchControl"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
type="submit"
|
||||
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
||||
aria-label="Buscar"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (ticketsMenu(); as menu) {
|
||||
<app-button
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IconButtonComponent } from '../../../../shared/components/icon-button/i
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-store-header',
|
||||
@@ -14,6 +15,7 @@ import { ButtonComponent } from '../../../../shared/components/button/button.com
|
||||
CartIconComponent,
|
||||
IconButtonComponent,
|
||||
RouterLink,
|
||||
ReactiveFormsModule,
|
||||
UserDropdownComponent,
|
||||
],
|
||||
templateUrl: './store-header.component.html',
|
||||
@@ -32,8 +34,10 @@ export class StoreHeaderComponent {
|
||||
readonly ticketsClick = output<void>();
|
||||
readonly loginClick = output<void>();
|
||||
readonly logoutClick = output<void>();
|
||||
readonly searchSubmit = output<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
(ticketsClick)="onTicketsClick()"
|
||||
(loginClick)="onLoginClick()"
|
||||
(logoutClick)="onLogoutClick()"
|
||||
(searchSubmit)="onSearch($event)"
|
||||
/>
|
||||
|
||||
@if (isCartOpen()) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
|
||||
}
|
||||
|
||||
searchCatalog(
|
||||
params: CatalogSearchQueryParams,
|
||||
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
|
||||
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
|
||||
`${this.tenantApiUrl}/catalog-items`,
|
||||
{ params: this.buildHttpParams(params) },
|
||||
);
|
||||
}
|
||||
|
||||
getFeaturedGroupItems(
|
||||
featuredGroupId: number,
|
||||
params?: ApiPaginationQueryParams,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user