Compare commits

...

3 Commits

13 changed files with 481 additions and 21 deletions

View File

@@ -21,20 +21,37 @@
<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">
<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"
/>
<button
type="button"
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 class="store-layout__search-wrapper">
<div class="input-group store-layout__search" role="search">
<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"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
@if (ticketsMenu(); as menu) {

View File

@@ -41,11 +41,22 @@
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search {
.store-layout__search-wrapper {
width: min(100%, 260px);
min-width: 0;
}
.store-layout__search {
width: 100%;
}
.store-layout__search-error {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.2;
color: var(--tenant-danger, #dc3545);
}
.store-layout__search-input {
color: #666666;
border-color: #cccccc;

View File

@@ -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,12 @@ 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 minSearchLength = 3;
protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true });
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
@@ -70,4 +76,22 @@ export class StoreHeaderComponent {
this.isUserDropdownOpen.set(false);
this.logoutClick.emit();
}
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchControl.value.trim();
if (term.length < this.minSearchLength) {
this.showSearchError.set(true);
return;
}
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected clearSearchError(): void {
this.showSearchError.set(false);
}
}

View File

@@ -10,6 +10,7 @@
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()"
(logoutClick)="onLogoutClick()"
(searchSubmit)="onSearch($event)"
/>
@if (isCartOpen()) {

View File

@@ -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,68 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
});
it('navigates to search results when the search button is clicked', () => {
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 searchButton = header.query(By.css('.store-layout__search-button'));
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
queryParams: { q: 'zapatillas', page: 1 },
});
});
it('prevents the native submit and searches when Enter is pressed', () => {
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 input = header.query(By.css('#store-search-input'));
const keyboardEvent = {
preventDefault: vi.fn(),
};
(header.componentInstance as any).searchControl.setValue('remera');
input.triggerEventHandler('keydown.enter', keyboardEvent);
expect(keyboardEvent.preventDefault).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
queryParams: { q: 'remera', page: 1 },
});
});
it('requires at least three characters to search', () => {
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 input = header.query(By.css('#store-search-input'));
const searchButton = header.query(By.css('.store-layout__search-button'));
expect(input.nativeElement.getAttribute('minlength')).toBe('3');
(header.componentInstance as any).searchControl.setValue('ab');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Ingresá al menos 3 caracteres para buscar.',
);
expect(input.nativeElement.getAttribute('aria-invalid')).toBe('true');
});
it('renders the primary tickets action when the tenant has the tickets menu', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
@@ -261,9 +325,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 +335,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();
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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[];

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
.search-results__alert-error {
color: var(--tenant-danger-color, var(--bs-danger));
}

View File

@@ -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 { 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.',
);
});
it('does not search when the query has fewer than three characters', () => {
TestBed.overrideProvider(ActivatedRoute, {
useValue: {
queryParamMap: of(convertToParamMap({ q: 'ab', page: '1' })),
},
});
const fixture = TestBed.createComponent(SearchPageComponent);
fixture.detectChanges();
expect(searchCatalog).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Ingresá al menos 3 caracteres para buscar.',
);
});
});

View File

@@ -0,0 +1,155 @@
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 minSearchLength = 3;
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 >= this.minSearchLength
? null
: `Ingresá al menos ${this.minSearchLength} caracteres para buscar.`,
);
this.loading.set(query.length >= this.minSearchLength);
}),
switchMap(({ query, page }) => {
if (query.length < this.minSearchLength) {
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;
}
}

View File

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