import { Injectable, inject } from '@angular/core'; import { ParamMap, Params } from '@angular/router'; import { Observable, Subject, of } from 'rxjs'; import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; import { Category } from '../../../core/categories/models/category-domain.model'; import { Product, ProductListResult } from '../../../core/products/models/product-domain.model'; import { CategoryFacade } from '../../../facades/platform/category.facade'; import { ProductFacade } from '../../../facades/platform/product.facade'; import { TranslateService } from '../../../i18n/translate.service'; import { SearchAutocompleteService } from '../services/search-autocomplete.service'; import { SearchCacheService } from '../services/search-cache.service'; import { SearchHistoryService } from '../services/search-history.service'; import { SearchStore } from '../store/search.store'; import { FilterGroup, SearchFilterState, SearchHistory, SearchQuery, SearchResult, SearchSuggestion, SortOption, } from '../models/search.model'; import { createSearchQueryKey } from '../utils/search-query-key.util'; import { SearchTrendingService } from '../services/search-trending.service'; interface LegacySearchState { text: string; sort: SearchQuery['sort']; layout: 'grid' | 'large-grid' | 'compact-grid' | 'grid-2' | 'grid-3' | 'grid-4' | 'compact' | 'list'; page: number; pageSize: number; filters: SearchFilterState; } @Injectable({ providedIn: 'root' }) export class SearchFacade { private readonly productFacade = inject(ProductFacade); private readonly categoryFacade = inject(CategoryFacade); private readonly translate = inject(TranslateService); private readonly autocompleteService = inject(SearchAutocompleteService); private readonly historyService = inject(SearchHistoryService); private readonly trendingService = inject(SearchTrendingService); private readonly cacheService = inject(SearchCacheService); private readonly store = inject(SearchStore); private readonly metadataMemo = new Map(); private readonly autocompleteInput$ = new Subject<{ query: string; products: Product[]; categories: Category[]; limit: number; }>(); readonly state = this.store.state; readonly popularSearches: SearchSuggestion[] = [ { id: 'popular-smartphones', type: 'collection', title: 'Smartphones', text: 'Smartphones', icon: 'trending_up', target: { route: '/search', query: { q: 'Smartphones' } } }, { id: 'popular-sneakers', type: 'collection', title: 'Sneakers', text: 'Sneakers', icon: 'trending_up', target: { route: '/search', query: { q: 'Sneakers' } } }, { id: 'popular-headphones', type: 'collection', title: 'Headphones', text: 'Headphones', icon: 'trending_up', target: { route: '/search', query: { q: 'Headphones' } } }, { id: 'popular-laptops', type: 'collection', title: 'Laptops', text: 'Laptops', icon: 'trending_up', target: { route: '/search', query: { q: 'Laptops' } } }, ]; constructor() { const history = this.historyService.getSnapshot(); this.store.setRecentSearches(history.recent); this.store.setPopularSearches(this.popularSearches.map(item => item.title)); this.autocompleteInput$ .pipe( debounceTime(220), distinctUntilChanged((a, b) => a.query.trim().toLowerCase() === b.query.trim().toLowerCase()), switchMap(({ query, products, categories, limit }) => of( this.autocompleteService.createSuggestions(query, products, categories, limit) )), ) .subscribe(suggestions => this.store.setSuggestions(suggestions)); this.trendingService.loadTrending().subscribe(trending => { if (!trending || trending.length === 0) { this.store.setPopularSearches([]); return; } this.store.setPopularSearches(trending.map(item => item.title)); }); } search(query: SearchQuery): Observable { return this.loadCatalog(query); } suggestions(query: string, products: Product[], categories: Category[] = [], limit = 10): Observable { return of(this.autocompleteService.createSuggestions(query, products, categories, limit)); } autocomplete(query: string, products: Product[], categories: Category[] = [], limit = 10): void { this.store.setQuery(query); this.autocompleteInput$.next({ query, products, categories, limit }); } getSearchHistory(): SearchHistory { const snapshot = this.historyService.getSnapshot(); return { items: snapshot.items, recent: snapshot.recent, popular: this.popularSearches.map(item => item.title), }; } pushSearchHistory(term: string, maxHistory = 12): SearchHistory { const snapshot = this.historyService.push(term, maxHistory); this.store.setRecentSearches(snapshot.recent); return { ...snapshot, popular: this.popularSearches.map(item => item.title), }; } clearSearchHistory(): SearchHistory { const snapshot = this.historyService.clear(); this.store.setRecentSearches([]); return { ...snapshot, popular: this.popularSearches.map(item => item.title), }; } trending(): Observable { return this.trendingService.loadTrending(); } loadCatalog(query: SearchQuery): Observable { const key = createSearchQueryKey(query); const cached = this.cacheService.get(key); if (cached) { return of(cached); } 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: query.page, pageSize: query.pageSize, }).pipe( tap(result => this.cacheService.set(key, result)) ); } createSortOptions(availableSorts: string[]): SortOption[] { const labels: Record = { 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>((acc, product) => { const state = product.remainings === 'out' ? 'out-of-stock' : product.remainings === 'low' ? 'low-stock' : 'in-stock'; acc[state] = (acc[state] ?? 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.resolveCategoryLabel(value), value })) .filter((option): option is { id: string; label: string; value: string } => option.label !== null), 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[] { return this.autocompleteService.createSuggestions(query, products, [], limit) .map(item => ({ ...item, text: item.title })); } 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: LegacySearchState['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: LegacySearchState, total: number, items: Product[]): SearchResult { const result: SearchResult = { 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 }), }; this.store.setResults(result); return result; } toQueryParams(state: LegacySearchState): 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 { const values = this.deserializeObject>(paramMap.get('fv')) ?? {}; const ranges = this.deserializeObject>(paramMap.get('fr')) ?? {}; const toggles = this.deserializeObject>(paramMap.get('ft')) ?? {}; const page = Number(paramMap.get('page')); return { text: (paramMap.get('q') ?? '').trim(), sort: (paramMap.get('sort') as LegacySearchState['sort']) ?? 'relevance', layout: (paramMap.get('layout') as LegacySearchState['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(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))]; } /** * Returns the real category title, or null when the id has no match in the loaded * category tree — callers drop unresolved options instead of showing a fabricated * "Category {id}" label (see RELEASE-CANDIDATE-AUDIT.md P0-4). */ private resolveCategoryLabel(categoryId: string): string | null { const id = Number(categoryId); if (!Number.isFinite(id)) { return null; } return this.categoryFacade.allCategories().find(category => category.id === id)?.title ?? null; } 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 = { 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'; } }