Files
marketplaces/src/app/pages/search/search.component.ts
2026-07-05 01:10:56 +04:00

174 lines
5.6 KiB
TypeScript

import { Component, signal, HostListener, OnDestroy, ChangeDetectionStrategy, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CartService } from '../../services';
import { PrefetchService } from '../../services/prefetch.service';
import { Item } from '../../models';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service';
import { SEARCH_DEBOUNCE_MS, ITEMS_PER_PAGE, SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS } from '../../config/constants';
import { ProductFacade } from '../../facades/platform/product.facade';
import { ProductCardComponent } from '../../components/product-card/product-card.component';
@Component({
selector: 'app-search',
imports: [FormsModule, TranslatePipe, ProductCardComponent],
templateUrl: './search.component.html',
styleUrls: ['./search.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SearchComponent implements OnDestroy {
readonly minSearchLength = 3;
searchQuery = '';
items = signal<Item[]>([]);
loading = signal(false);
error = signal<string | null>(null);
hasMore = signal(true);
totalResults = signal<number>(0);
private skip = 0;
private readonly count = ITEMS_PER_PAGE;
private isLoadingMore = false;
private searchSubject = new Subject<string>();
private searchSubscription: Subscription;
private i18n = inject(TranslateService);
constructor(
private productFacade: ProductFacade,
private cartService: CartService,
private prefetchService: PrefetchService
) {
this.searchSubscription = this.searchSubject
.pipe(
debounceTime(SEARCH_DEBOUNCE_MS),
distinctUntilChanged()
)
.subscribe(query => {
if (query.trim().length >= 3 || query.trim().length === 0) {
this.performSearch(query);
}
});
}
ngOnDestroy(): void {
this.searchSubscription.unsubscribe();
this.searchSubject.complete();
if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
}
onSearchInput(query: string): void {
this.searchQuery = query;
this.error.set(null);
if (this.isQueryTooShort()) {
this.resetSearchState();
return;
}
this.searchSubject.next(query);
}
performSearch(query: string): void {
if (!query.trim()) {
this.resetSearchState();
return;
}
this.error.set(null);
this.items.set([]);
this.skip = 0;
this.hasMore.set(true);
this.totalResults.set(0);
this.loadResults();
}
loadResults(): void {
if (this.isLoadingMore || !this.hasMore() || !this.searchQuery.trim()) return;
this.loading.set(true);
this.isLoadingMore = true;
this.productFacade.searchProducts({ search: this.searchQuery.trim(), count: this.count, skip: this.skip }).subscribe({
next: (response) => {
// Update total results (only on first load)
if (this.skip === 0) {
this.totalResults.set(response.total);
}
// Handle empty results
if (!response.items || response.items.length === 0) {
this.hasMore.set(false);
} else {
// Check if there are more items to load
if (response.items.length < this.count || this.skip + response.items.length >= response.total) {
this.hasMore.set(false);
}
this.items.update(current => [...current, ...response.items]);
this.skip += response.items.length;
}
this.loading.set(false);
this.isLoadingMore = false;
},
error: (err) => {
this.error.set(this.i18n.t('home.errorTitle'));
this.loading.set(false);
this.isLoadingMore = false;
console.error('Error searching items:', err);
}
});
}
private resetSearchState(): void {
this.items.set([]);
this.loading.set(false);
this.hasMore.set(false);
this.totalResults.set(0);
this.skip = 0;
this.isLoadingMore = false;
}
isQueryTooShort(): boolean {
const length = this.searchQuery.trim().length;
return length > 0 && length < this.minSearchLength;
}
private scrollTimeout?: ReturnType<typeof setTimeout>;
@HostListener('window:scroll')
onScroll(): void {
if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
this.scrollTimeout = setTimeout(() => {
const scrollPosition = window.innerHeight + window.scrollY;
const bottomPosition = document.documentElement.scrollHeight - SCROLL_THRESHOLD_PX;
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore()) {
this.loadResults();
}
}, SCROLL_DEBOUNCE_MS);
}
addToCart(itemID: number, event: Event): void {
event.preventDefault();
event.stopPropagation();
this.cartService.addItem(itemID);
}
onItemHover(itemID: number): void {
this.prefetchService.prefetchItem(itemID);
}
readonly skeletonSlots = Array.from({ length: 8 });
readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId;
readonly getBadgeClass = getBadgeClass;
private langService = inject(LanguageService);
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); }
}