2026-02-26 23:09:20 +04:00
|
|
|
import { Component, signal, HostListener, OnDestroy, ChangeDetectionStrategy, inject } from '@angular/core';
|
2026-01-18 18:57:06 +04:00
|
|
|
import { FormsModule } from '@angular/forms';
|
2026-07-05 01:03:37 +04:00
|
|
|
import { CartService } from '../../services';
|
2026-03-06 18:40:58 +04:00
|
|
|
import { PrefetchService } from '../../services/prefetch.service';
|
2026-01-18 18:57:06 +04:00
|
|
|
import { Item } from '../../models';
|
|
|
|
|
import { Subject, Subscription } from 'rxjs';
|
|
|
|
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
2026-03-24 00:09:11 +04:00
|
|
|
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
|
|
|
|
import { LanguageService } from '../../services/language.service';
|
2026-02-26 23:09:20 +04:00
|
|
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
|
|
|
|
import { TranslateService } from '../../i18n/translate.service';
|
2026-03-06 18:40:58 +04:00
|
|
|
import { SEARCH_DEBOUNCE_MS, ITEMS_PER_PAGE, SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS } from '../../config/constants';
|
2026-07-05 01:03:37 +04:00
|
|
|
import { ProductFacade } from '../../facades/platform/product.facade';
|
2026-07-05 01:05:48 +04:00
|
|
|
import { ProductCardComponent } from '../../components/product-card/product-card.component';
|
2026-01-18 18:57:06 +04:00
|
|
|
|
|
|
|
|
@Component({
|
|
|
|
|
selector: 'app-search',
|
2026-07-05 01:05:48 +04:00
|
|
|
imports: [FormsModule, TranslatePipe, ProductCardComponent],
|
2026-01-18 18:57:06 +04:00
|
|
|
templateUrl: './search.component.html',
|
|
|
|
|
styleUrls: ['./search.component.scss'],
|
|
|
|
|
changeDetection: ChangeDetectionStrategy.OnPush
|
|
|
|
|
})
|
|
|
|
|
export class SearchComponent implements OnDestroy {
|
2026-07-05 01:10:56 +04:00
|
|
|
readonly minSearchLength = 3;
|
2026-01-18 18:57:06 +04:00
|
|
|
searchQuery = '';
|
|
|
|
|
items = signal<Item[]>([]);
|
|
|
|
|
loading = signal(false);
|
|
|
|
|
error = signal<string | null>(null);
|
|
|
|
|
hasMore = signal(true);
|
|
|
|
|
totalResults = signal<number>(0);
|
|
|
|
|
|
|
|
|
|
private skip = 0;
|
2026-03-06 18:40:58 +04:00
|
|
|
private readonly count = ITEMS_PER_PAGE;
|
2026-01-18 18:57:06 +04:00
|
|
|
private isLoadingMore = false;
|
|
|
|
|
private searchSubject = new Subject<string>();
|
|
|
|
|
private searchSubscription: Subscription;
|
2026-02-26 23:09:20 +04:00
|
|
|
private i18n = inject(TranslateService);
|
2026-01-18 18:57:06 +04:00
|
|
|
|
|
|
|
|
constructor(
|
2026-07-05 01:03:37 +04:00
|
|
|
private productFacade: ProductFacade,
|
2026-03-06 18:40:58 +04:00
|
|
|
private cartService: CartService,
|
|
|
|
|
private prefetchService: PrefetchService
|
2026-01-18 18:57:06 +04:00
|
|
|
) {
|
|
|
|
|
this.searchSubscription = this.searchSubject
|
|
|
|
|
.pipe(
|
2026-03-06 18:40:58 +04:00
|
|
|
debounceTime(SEARCH_DEBOUNCE_MS),
|
2026-01-18 18:57:06 +04:00
|
|
|
distinctUntilChanged()
|
|
|
|
|
)
|
|
|
|
|
.subscribe(query => {
|
2026-02-19 01:23:25 +04:00
|
|
|
if (query.trim().length >= 3 || query.trim().length === 0) {
|
|
|
|
|
this.performSearch(query);
|
|
|
|
|
}
|
2026-01-18 18:57:06 +04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ngOnDestroy(): void {
|
|
|
|
|
this.searchSubscription.unsubscribe();
|
|
|
|
|
this.searchSubject.complete();
|
2026-02-26 21:54:21 +04:00
|
|
|
if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onSearchInput(query: string): void {
|
|
|
|
|
this.searchQuery = query;
|
2026-07-05 01:10:56 +04:00
|
|
|
this.error.set(null);
|
|
|
|
|
|
|
|
|
|
if (this.isQueryTooShort()) {
|
|
|
|
|
this.resetSearchState();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
this.searchSubject.next(query);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
performSearch(query: string): void {
|
|
|
|
|
if (!query.trim()) {
|
2026-07-05 01:10:56 +04:00
|
|
|
this.resetSearchState();
|
2026-01-18 18:57:06 +04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 01:10:56 +04:00
|
|
|
this.error.set(null);
|
2026-01-18 18:57:06 +04:00
|
|
|
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;
|
|
|
|
|
|
2026-07-05 01:10:56 +04:00
|
|
|
this.productFacade.searchProducts({ search: this.searchQuery.trim(), count: this.count, skip: this.skip }).subscribe({
|
2026-01-18 18:57:06 +04:00
|
|
|
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) => {
|
2026-02-26 23:09:20 +04:00
|
|
|
this.error.set(this.i18n.t('home.errorTitle'));
|
2026-01-18 18:57:06 +04:00
|
|
|
this.loading.set(false);
|
|
|
|
|
this.isLoadingMore = false;
|
|
|
|
|
console.error('Error searching items:', err);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 01:10:56 +04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 21:54:21 +04:00
|
|
|
private scrollTimeout?: ReturnType<typeof setTimeout>;
|
2026-01-18 18:57:06 +04:00
|
|
|
|
|
|
|
|
@HostListener('window:scroll')
|
|
|
|
|
onScroll(): void {
|
|
|
|
|
if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
|
|
|
|
|
|
|
|
|
|
this.scrollTimeout = setTimeout(() => {
|
|
|
|
|
const scrollPosition = window.innerHeight + window.scrollY;
|
2026-03-06 18:40:58 +04:00
|
|
|
const bottomPosition = document.documentElement.scrollHeight - SCROLL_THRESHOLD_PX;
|
2026-01-18 18:57:06 +04:00
|
|
|
|
|
|
|
|
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore()) {
|
|
|
|
|
this.loadResults();
|
|
|
|
|
}
|
2026-03-06 18:40:58 +04:00
|
|
|
}, SCROLL_DEBOUNCE_MS);
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addToCart(itemID: number, event: Event): void {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
this.cartService.addItem(itemID);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 18:40:58 +04:00
|
|
|
onItemHover(itemID: number): void {
|
|
|
|
|
this.prefetchService.prefetchItem(itemID);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
readonly skeletonSlots = Array.from({ length: 8 });
|
2026-02-19 01:23:25 +04:00
|
|
|
readonly getDiscountedPrice = getDiscountedPrice;
|
|
|
|
|
readonly getMainImage = getMainImage;
|
|
|
|
|
readonly trackByItemId = trackByItemId;
|
2026-02-20 10:44:03 +04:00
|
|
|
readonly getBadgeClass = getBadgeClass;
|
2026-03-24 00:09:11 +04:00
|
|
|
|
|
|
|
|
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()); }
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|