feat(search): implement search functionality with results page and routing
This commit is contained in:
@@ -21,21 +21,24 @@
|
|||||||
<div
|
<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"
|
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>
|
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
||||||
<input
|
<input
|
||||||
id="store-search-input"
|
id="store-search-input"
|
||||||
type="search"
|
type="search"
|
||||||
class="form-control store-layout__search-input border-end-0 rounded-start"
|
class="form-control store-layout__search-input border-end-0 rounded-start"
|
||||||
|
placeholder="Buscar productos"
|
||||||
|
autocomplete="off"
|
||||||
|
[formControl]="searchControl"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
||||||
aria-label="Buscar"
|
aria-label="Buscar"
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
@if (ticketsMenu(); as menu) {
|
@if (ticketsMenu(); as menu) {
|
||||||
<app-button
|
<app-button
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IconButtonComponent } from '../../../../shared/components/icon-button/i
|
|||||||
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
|
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
|
||||||
import { Menu } from '../../../services/tenant.interface';
|
import { Menu } from '../../../services/tenant.interface';
|
||||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
|
import { ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-store-header',
|
selector: 'app-store-header',
|
||||||
@@ -14,6 +15,7 @@ import { ButtonComponent } from '../../../../shared/components/button/button.com
|
|||||||
CartIconComponent,
|
CartIconComponent,
|
||||||
IconButtonComponent,
|
IconButtonComponent,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
|
ReactiveFormsModule,
|
||||||
UserDropdownComponent,
|
UserDropdownComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './store-header.component.html',
|
templateUrl: './store-header.component.html',
|
||||||
@@ -32,8 +34,10 @@ export class StoreHeaderComponent {
|
|||||||
readonly ticketsClick = output<void>();
|
readonly ticketsClick = output<void>();
|
||||||
readonly loginClick = output<void>();
|
readonly loginClick = output<void>();
|
||||||
readonly logoutClick = output<void>();
|
readonly logoutClick = output<void>();
|
||||||
|
readonly searchSubmit = output<string>();
|
||||||
|
|
||||||
protected readonly isUserDropdownOpen = signal(false);
|
protected readonly isUserDropdownOpen = signal(false);
|
||||||
|
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
protected onDocumentClick(event: MouseEvent): void {
|
protected onDocumentClick(event: MouseEvent): void {
|
||||||
@@ -70,4 +74,12 @@ export class StoreHeaderComponent {
|
|||||||
this.isUserDropdownOpen.set(false);
|
this.isUserDropdownOpen.set(false);
|
||||||
this.logoutClick.emit();
|
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()"
|
(ticketsClick)="onTicketsClick()"
|
||||||
(loginClick)="onLoginClick()"
|
(loginClick)="onLoginClick()"
|
||||||
(logoutClick)="onLogoutClick()"
|
(logoutClick)="onLogoutClick()"
|
||||||
|
(searchSubmit)="onSearch($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@if (isCartOpen()) {
|
@if (isCartOpen()) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
import { provideRouter, Router } from '@angular/router';
|
import { provideRouter, Router } from '@angular/router';
|
||||||
|
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
@@ -13,6 +14,7 @@ import { ToastService } from '../../services/toast.service';
|
|||||||
import { AuthService } from '../../services/auth/auth.service';
|
import { AuthService } from '../../services/auth/auth.service';
|
||||||
import { AuthUser } from '../../services/auth/auth.interfaces';
|
import { AuthUser } from '../../services/auth/auth.interfaces';
|
||||||
import { StoreLayoutComponent } from './store-layout.component';
|
import { StoreLayoutComponent } from './store-layout.component';
|
||||||
|
import { StoreHeaderComponent } from './store-header/store-header.component';
|
||||||
|
|
||||||
const tenant: Tenant = {
|
const tenant: Tenant = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -216,6 +218,24 @@ describe('StoreLayoutComponent', () => {
|
|||||||
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
|
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', () => {
|
it('renders the primary tickets action when the tenant has the tickets menu', () => {
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
vi.spyOn(router, 'navigate');
|
vi.spyOn(router, 'navigate');
|
||||||
@@ -261,9 +281,7 @@ describe('StoreLayoutComponent', () => {
|
|||||||
menu.code === 'account'
|
menu.code === 'account'
|
||||||
? {
|
? {
|
||||||
...menu,
|
...menu,
|
||||||
submenues: menu.submenues.filter(
|
submenues: menu.submenues.filter((submenu) => submenu.code !== 'account.tickets'),
|
||||||
(submenu) => submenu.code !== 'account.tickets',
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
: menu,
|
: menu,
|
||||||
),
|
),
|
||||||
@@ -273,9 +291,7 @@ describe('StoreLayoutComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(fixture.nativeElement as HTMLElement).querySelector(
|
(fixture.nativeElement as HTMLElement).querySelector('[data-testid="store-header-tickets"]'),
|
||||||
'[data-testid="store-header-tickets"]',
|
|
||||||
),
|
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
void this.router.navigate(['/login']);
|
void this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onSearch(term: string): void {
|
||||||
|
void this.router.navigate(['/buscar'], {
|
||||||
|
queryParams: { q: term, page: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected onTicketsClick(): void {
|
protected onTicketsClick(): void {
|
||||||
const route = this.ticketsMenu()?.route;
|
const route = this.ticketsMenu()?.route;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ApiResponse } from '../api-response.interface';
|
|||||||
import { TenantService } from '../tenant.service';
|
import { TenantService } from '../tenant.service';
|
||||||
import {
|
import {
|
||||||
CatalogFeaturedGroup,
|
CatalogFeaturedGroup,
|
||||||
|
CatalogFeaturedItem,
|
||||||
CatalogFeaturedItems,
|
CatalogFeaturedItems,
|
||||||
CatalogItemDetail,
|
CatalogItemDetail,
|
||||||
Product,
|
Product,
|
||||||
@@ -15,6 +16,10 @@ import {
|
|||||||
|
|
||||||
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
|
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
|
||||||
|
|
||||||
|
export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
|
||||||
|
q: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
@@ -36,6 +41,15 @@ export class CatalogService {
|
|||||||
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
|
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(
|
getFeaturedGroupItems(
|
||||||
featuredGroupId: number,
|
featuredGroupId: number,
|
||||||
params?: ApiPaginationQueryParams,
|
params?: ApiPaginationQueryParams,
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export interface Tenant {
|
|||||||
selected_bank_account?: BankAccount | null;
|
selected_bank_account?: BankAccount | null;
|
||||||
hero_config?: HeroConfig | null;
|
hero_config?: HeroConfig | null;
|
||||||
event_config?: EventConfig | 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[];
|
main_carousel_images?: string[];
|
||||||
social_media?: SocialMedia[];
|
social_media?: SocialMedia[];
|
||||||
menues?: Menu[];
|
menues?: Menu[];
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<section class="search-results" aria-labelledby="search-results-title">
|
||||||
|
<header class="search-results__header mb-4">
|
||||||
|
<h1 id="search-results-title" class="h3 mb-2">Resultados de búsqueda</h1>
|
||||||
|
@if (query()) {
|
||||||
|
<p class="text-body-secondary mb-0">
|
||||||
|
Resultados para <strong>“{{ query() }}”</strong>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<p class="alert text-center mb-0 search-results__alert-error">{{ error() }}</p>
|
||||||
|
} @else if (loading()) {
|
||||||
|
<p class="alert alert-light border text-center mb-0">Buscando productos...</p>
|
||||||
|
} @else if (results(); as searchResults) {
|
||||||
|
@if (!searchResults.data.length) {
|
||||||
|
<p class="alert alert-light border text-center mb-0">
|
||||||
|
No encontramos productos para “{{ query() }}”.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<app-product-list
|
||||||
|
[layout]="productLayout()"
|
||||||
|
[groupLayout]="groupLayout()"
|
||||||
|
[items]="displayItems()"
|
||||||
|
(buy)="onBuyProduct($event)"
|
||||||
|
(addToCart)="onAddToCart($event)"
|
||||||
|
(pageChange)="onPageChange($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (hasHiddenResults()) {
|
||||||
|
<p class="text-body-secondary text-center mt-3 mb-0">
|
||||||
|
Se muestran {{ searchResults.data.length }} de {{ searchResults.meta.total }} resultados.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.search-results__alert-error {
|
||||||
|
color: var(--tenant-danger-color, var(--bs-danger));
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ApiPaginatedResponse<CatalogFeaturedItem[]> | null>(null);
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
|
protected readonly productLayout = computed<CatalogProductLayout>(
|
||||||
|
() => this.tenantService.tenant()?.search_product_layout ?? 'column_with_image',
|
||||||
|
);
|
||||||
|
protected readonly groupLayout = computed<CatalogGroupLayout>(
|
||||||
|
() => this.tenantService.tenant()?.search_group_layout ?? 'paginated',
|
||||||
|
);
|
||||||
|
protected readonly displayItems = computed<CatalogFeaturedItems>(() => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
path: 'producto/:id',
|
||||||
canActivate: [hasMenuGuard('product.detail')],
|
canActivate: [hasMenuGuard('product.detail')],
|
||||||
|
|||||||
Reference in New Issue
Block a user