search engien

This commit is contained in:
sdarbinyan
2026-07-10 13:15:46 +04:00
parent aed0a47388
commit 494451bb96
15 changed files with 1060 additions and 649 deletions

View File

@@ -1,470 +1 @@
import { Injectable, inject } from '@angular/core';
import { ParamMap, Params } from '@angular/router';
import { Observable } from 'rxjs';
import { Product, ProductListResult } from '../../core/products/models/product-domain.model';
import { ProductFacade } from './product.facade';
import { TranslateService } from '../../i18n/translate.service';
import { SearchHistoryService } from '../../core/search/services/search-history.service';
import { SearchFilterState, SearchHistory, SearchQuery, SearchResult, SearchSuggestion, SortOption, FilterGroup } from '../../core/search/models/search.model';
import { SearchState } from '../../core/search/models/search-state.model';
@Injectable({ providedIn: 'root' })
export class SearchFacade {
private readonly productFacade = inject(ProductFacade);
private readonly translate = inject(TranslateService);
private readonly historyService = inject(SearchHistoryService);
private readonly metadataMemo = new Map<string, FilterGroup[]>();
readonly popularSearches: SearchSuggestion[] = [
{ id: 'popular-smartphones', text: 'Smartphones', kind: 'popular' },
{ id: 'popular-sneakers', text: 'Sneakers', kind: 'popular' },
{ id: 'popular-headphones', text: 'Headphones', kind: 'popular' },
{ id: 'popular-laptops', text: 'Laptops', kind: 'popular' },
];
getSearchHistory(): SearchHistory {
return this.historyService.getSnapshot(this.popularSearches.map(entry => entry.text));
}
pushSearchHistory(term: string): SearchHistory {
return this.historyService.push(term, this.popularSearches.map(entry => entry.text));
}
clearSearchHistory(): SearchHistory {
return this.historyService.clear(this.popularSearches.map(entry => entry.text));
}
loadCatalog(query: SearchQuery): Observable<ProductListResult> {
return this.productFacade.loadCatalog({
keyword: query.text,
categoryIDs: query.categoryIds,
subcategoryIDs: query.subcategoryIds,
minPrice: query.filters.ranges['price']?.min,
maxPrice: query.filters.ranges['price']?.max,
discountOnly: query.filters.toggles['discount'],
newOnly: query.filters.toggles['new'],
sort: query.sort,
page: 1,
pageSize: 200,
});
}
createSortOptions(availableSorts: string[]): SortOption[] {
const labels: Record<string, string> = {
relevance: this.translate.t('catalog.sortRelevance'),
latest: this.translate.t('catalog.sortLatest'),
price_asc: this.translate.t('catalog.sortPriceAsc'),
price_desc: this.translate.t('catalog.sortPriceDesc'),
rating: this.translate.t('catalog.sortRating'),
popular: this.translate.t('catalog.sortPopular'),
discount: this.translate.t('catalog.sortDiscount'),
};
return availableSorts.map(id => ({
id: id as SortOption['id'],
label: labels[id] ?? id,
enabled: true,
}));
}
buildFilterGroups(products: Product[], enabledFilters: string[]): FilterGroup[] {
const enabled = new Set(enabledFilters);
const key = `${enabledFilters.join('|')}::${products.map(item => item.itemID).join(',')}`;
const cached = this.metadataMemo.get(key);
if (cached) {
return cached;
}
const priceValues = products.map(product => product.price).filter(value => Number.isFinite(value));
const brands = this.collectUnique(products.flatMap(product => product.tags ?? []).filter(tag => !tag.startsWith('new') && !tag.startsWith('sale')));
const colors = this.collectUnique(products.flatMap(product => [product.colour, ...(product.itemDetails?.map(detail => detail.colour || detail.color) ?? [])]).filter(Boolean) as string[]);
const sizes = this.collectUnique(products.flatMap(product => [product.size, ...(product.itemDetails?.map(detail => detail.size) ?? [])]).filter(Boolean) as string[]);
const categories = this.collectUnique(products.map(product => String(product.categoryID)).filter(Boolean));
const subcategories = this.collectUnique(products.map(product => product.subcategoryId ?? '').filter(Boolean));
const attributes = this.collectUnique(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key)));
const availabilityCounts = products.reduce<Record<string, number>>((acc, product) => {
const key = product.remainings === 'out'
? 'out-of-stock'
: product.remainings === 'low'
? 'low-stock'
: 'in-stock';
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {});
const availabilityBase = [
{ id: 'in-stock', label: this.translate.t('catalog.filterInStock'), value: 'in-stock', availability: 'in-stock' as const },
{ id: 'low-stock', label: this.translate.t('catalog.filterLowStock'), value: 'low-stock', availability: 'low-stock' as const },
{ id: 'out-of-stock', label: this.translate.t('catalog.filterOutOfStock'), value: 'out-of-stock', availability: 'out-of-stock' as const },
];
const availabilityOptions = availabilityBase
.filter(option => (availabilityCounts[option.value] ?? 0) > 0)
.map(option => ({ ...option, count: availabilityCounts[option.value] }));
const ratingOptions = this.collectUnique(
products
.map(product => String(Math.max(1, Math.min(5, Math.floor(product.rating || 0)))))
.filter(value => Number(value) > 0)
)
.map(value => Number(value))
.sort((a, b) => b - a)
.map(value => ({
id: `rating-${value}`,
label: this.translate.t('catalog.filterStars', { count: value }),
value: String(value),
rating: value,
}));
const groups: FilterGroup[] = [
{
id: 'price',
label: this.translate.t('catalog.filterPrice'),
type: 'range',
min: priceValues.length ? Math.min(...priceValues) : 0,
max: priceValues.length ? Math.max(...priceValues) : 0,
step: 1,
options: [],
enabled: enabled.has('price'),
},
{
id: 'availability',
label: this.translate.t('catalog.filterAvailability'),
type: 'availability',
options: availabilityOptions,
enabled: enabled.has('availability'),
},
{
id: 'rating',
label: this.translate.t('catalog.filterRating'),
type: 'rating',
options: ratingOptions,
enabled: enabled.has('rating'),
},
{
id: 'brand',
label: this.translate.t('catalog.filterBrand'),
type: 'checkbox',
options: brands.map(value => ({ id: `brand-${value}`, label: value, value })),
enabled: enabled.has('brand'),
},
{
id: 'category',
label: this.translate.t('catalog.filterCategory'),
type: 'radio',
options: categories.map(value => ({ id: `cat-${value}`, label: this.translate.t('catalog.filterCategoryValue', { value }), value })),
enabled: enabled.has('category'),
},
{
id: 'subcategory',
label: this.translate.t('catalog.filterSubcategory'),
type: 'checkbox',
options: subcategories.map(value => ({ id: `sub-${value}`, label: value, value })),
enabled: enabled.has('subcategory'),
},
{
id: 'discount',
label: this.translate.t('catalog.filterDiscount'),
type: 'toggle',
options: [],
enabled: enabled.has('discount'),
},
{
id: 'new',
label: this.translate.t('catalog.filterNew'),
type: 'toggle',
options: [],
enabled: enabled.has('new'),
},
{
id: 'color',
label: this.translate.t('catalog.filterColor'),
type: 'color',
options: colors.map(value => ({ id: `color-${value}`, label: value, value, colorHex: this.toColorHex(value) })),
enabled: enabled.has('color'),
},
{
id: 'size',
label: this.translate.t('catalog.filterSize'),
type: 'size',
options: sizes.map(value => ({ id: `size-${value}`, label: value, value })),
enabled: enabled.has('size'),
},
{
id: 'attributes',
label: this.translate.t('catalog.filterAttributes'),
type: 'checkbox',
options: attributes.map(value => ({ id: `attr-${value}`, label: value, value })),
enabled: enabled.has('attributes'),
},
];
this.metadataMemo.set(key, groups);
if (this.metadataMemo.size > 15) {
const firstKey = this.metadataMemo.keys().next().value;
if (firstKey) {
this.metadataMemo.delete(firstKey);
}
}
return groups;
}
buildLiveSuggestions(query: string, products: Product[], limit = 6): SearchSuggestion[] {
const normalized = query.trim().toLowerCase();
if (normalized.length < 2) {
return [];
}
return this.collectUnique(
products
.map(product => product.name)
.filter(name => name.toLowerCase().includes(normalized))
.slice(0, limit)
).map((text, index) => ({ id: `live-${index}`, text, kind: 'live' }));
}
applyFilters(products: Product[], filterState: SearchFilterState, searchText: string): Product[] {
const query = searchText.trim().toLowerCase();
return products.filter(product => {
if (query.length > 0) {
const haystack = `${product.name} ${product.simpleDescription ?? ''}`.toLowerCase();
if (!haystack.includes(query)) {
return false;
}
}
const priceRange = filterState.ranges['price'];
if (priceRange?.min != null && product.price < priceRange.min) {
return false;
}
if (priceRange?.max != null && product.price > priceRange.max) {
return false;
}
if (filterState.toggles['discount'] && !(product.discount > 0)) {
return false;
}
if (filterState.toggles['new'] && !(product.badges ?? []).some(badge => badge.toLowerCase().includes('new'))) {
return false;
}
const selectedRatings = filterState.values['rating'] ?? [];
if (selectedRatings.length > 0 && !selectedRatings.includes(String(Math.floor(product.rating || 0)))) {
return false;
}
const selectedAvailability = filterState.values['availability'] ?? [];
if (selectedAvailability.length > 0) {
const availability = product.remainings === 'out'
? 'out-of-stock'
: product.remainings === 'low'
? 'low-stock'
: 'in-stock';
if (!selectedAvailability.includes(availability)) {
return false;
}
}
const selectedBrands = filterState.values['brand'] ?? [];
if (selectedBrands.length > 0) {
const tags = product.tags ?? [];
if (!selectedBrands.some(brand => tags.includes(brand))) {
return false;
}
}
const selectedCategories = filterState.values['category'] ?? [];
if (selectedCategories.length > 0 && !selectedCategories.includes(String(product.categoryID))) {
return false;
}
const selectedSubcategories = filterState.values['subcategory'] ?? [];
if (selectedSubcategories.length > 0 && !selectedSubcategories.includes(product.subcategoryId ?? '')) {
return false;
}
const selectedColors = filterState.values['color'] ?? [];
if (selectedColors.length > 0) {
const colors = this.collectUnique([product.colour ?? '', ...(product.itemDetails?.map(detail => detail.colour || detail.color || '') ?? [])]);
if (!selectedColors.some(color => colors.includes(color))) {
return false;
}
}
const selectedSizes = filterState.values['size'] ?? [];
if (selectedSizes.length > 0) {
const sizes = this.collectUnique([product.size ?? '', ...(product.itemDetails?.map(detail => detail.size || '') ?? [])]);
if (!selectedSizes.some(size => sizes.includes(size))) {
return false;
}
}
const selectedAttributes = filterState.values['attributes'] ?? [];
if (selectedAttributes.length > 0) {
const keys = (product.descriptionFields ?? []).map(field => field.key);
if (!selectedAttributes.some(attribute => keys.includes(attribute))) {
return false;
}
}
return true;
});
}
applySort(products: Product[], sort: SearchState['sort']): Product[] {
const sorted = [...products];
switch (sort) {
case 'price_asc':
return sorted.sort((a, b) => a.price - b.price);
case 'price_desc':
return sorted.sort((a, b) => b.price - a.price);
case 'rating':
return sorted.sort((a, b) => (b.rating || 0) - (a.rating || 0));
case 'popular':
return sorted.sort((a, b) => (b.callbacks?.length ?? 0) - (a.callbacks?.length ?? 0));
case 'discount':
return sorted.sort((a, b) => (b.discount || 0) - (a.discount || 0));
case 'latest':
case 'relevance':
default:
return sorted;
}
}
paginateProducts(products: Product[], page: number, pageSize: number): { items: Product[]; skip: number; page: number; pageSize: number } {
const safePage = Math.max(1, page);
const safePageSize = Math.max(1, pageSize);
const skip = (safePage - 1) * safePageSize;
return {
items: products.slice(skip, skip + safePageSize),
skip,
page: safePage,
pageSize: safePageSize,
};
}
buildResult(state: SearchState, total: number, items: Product[]): SearchResult<Product> {
return {
query: {
text: state.text,
categoryIds: [],
subcategoryIds: [],
filters: state.filters,
sort: state.sort,
page: state.page,
pageSize: state.pageSize,
},
items,
total,
page: state.page,
pageSize: state.pageSize,
summary: state.text.trim().length > 0
? this.translate.t('catalog.searchResultsSummary', { query: state.text.trim(), total })
: this.translate.t('catalog.searchProductsFound', { total }),
};
}
toQueryParams(state: SearchState): Params {
return {
q: state.text || null,
sort: state.sort || null,
page: state.page > 1 ? state.page : null,
layout: state.layout !== 'grid' ? state.layout : null,
fv: this.serializeObject(state.filters.values),
fr: this.serializeObject(state.filters.ranges),
ft: this.serializeObject(state.filters.toggles),
};
}
fromQueryParams(paramMap: ParamMap): Partial<SearchState> {
const values = this.deserializeObject<Record<string, string[]>>(paramMap.get('fv')) ?? {};
const ranges = this.deserializeObject<Record<string, { min?: number; max?: number }>>(paramMap.get('fr')) ?? {};
const toggles = this.deserializeObject<Record<string, boolean>>(paramMap.get('ft')) ?? {};
const page = Number(paramMap.get('page'));
return {
text: (paramMap.get('q') ?? '').trim(),
sort: (paramMap.get('sort') as SearchState['sort']) ?? 'relevance',
layout: (paramMap.get('layout') as SearchState['layout']) ?? 'grid',
page: Number.isFinite(page) && page > 0 ? page : 1,
filters: {
values,
ranges,
toggles,
},
};
}
private serializeObject(value: unknown): string | null {
if (!value || typeof value !== 'object' || Object.keys(value as object).length === 0) {
return null;
}
return encodeURIComponent(JSON.stringify(value));
}
private deserializeObject<T>(value: string | null): T | null {
if (!value) {
return null;
}
try {
return JSON.parse(decodeURIComponent(value)) as T;
} catch {
return null;
}
}
private collectUnique(values: string[]): string[] {
return [...new Set(values.map(value => value.trim()).filter(Boolean))];
}
private toColorHex(value: string): string {
const normalized = value.trim().toLowerCase();
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(normalized)) {
return normalized;
}
const palette: Record<string, string> = {
black: '#111111',
white: '#ffffff',
red: '#ef4444',
blue: '#3b82f6',
green: '#22c55e',
yellow: '#eab308',
orange: '#f97316',
purple: '#8b5cf6',
pink: '#ec4899',
gray: '#6b7280',
grey: '#6b7280',
brown: '#92400e',
beige: '#d6c6a8',
navy: '#1e3a8a',
cyan: '#06b6d4',
teal: '#0d9488',
lime: '#65a30d',
olive: '#4d7c0f',
maroon: '#7f1d1d',
silver: '#94a3b8',
gold: '#eab308',
};
if (palette[normalized]) {
return palette[normalized];
}
const tokens = normalized.split(/[\s,\-/]+/).filter(Boolean);
for (const token of tokens) {
if (palette[token]) {
return palette[token];
}
}
return '#94a3b8';
}
}
export { SearchFacade } from '../../features/search/facade/search.facade';