diff --git a/src/app/core/search/models/search-state.model.ts b/src/app/core/search/models/search-state.model.ts index 6685aaf..003757b 100644 --- a/src/app/core/search/models/search-state.model.ts +++ b/src/app/core/search/models/search-state.model.ts @@ -1,27 +1 @@ -import { CatalogLayoutMode } from '../../products/models/catalog-experience.model'; -import { ProductSort } from '../../products/models/product-domain.model'; -import { SearchFilterState } from './search.model'; - -export interface SearchState { - text: string; - sort: ProductSort | 'discount'; - layout: CatalogLayoutMode; - page: number; - pageSize: number; - filters: SearchFilterState; -} - -export function createInitialSearchState(pageSize = 24): SearchState { - return { - text: '', - sort: 'relevance', - layout: 'grid', - page: 1, - pageSize, - filters: { - values: {}, - ranges: {}, - toggles: {}, - }, - }; -} +export * from '../../../features/search/models/search-state.model'; diff --git a/src/app/core/search/models/search.model.ts b/src/app/core/search/models/search.model.ts index 06e2a53..9a2d47c 100644 --- a/src/app/core/search/models/search.model.ts +++ b/src/app/core/search/models/search.model.ts @@ -1,76 +1 @@ -import { Product, ProductSort } from '../../products/models/product-domain.model'; - -export type SearchFilterType = - | 'checkbox' - | 'radio' - | 'toggle' - | 'range' - | 'slider' - | 'color' - | 'size' - | 'rating' - | 'availability'; - -export interface SearchQuery { - text: string; - categoryIds: number[]; - subcategoryIds: string[]; - filters: SearchFilterState; - sort: ProductSort | 'discount'; - page: number; - pageSize: number; -} - -export interface SearchResult { - query: SearchQuery; - items: TItem[]; - total: number; - page: number; - pageSize: number; - summary: string; -} - -export interface FilterOption { - id: string; - label: string; - value: string; - count?: number; - colorHex?: string; - availability?: 'in-stock' | 'low-stock' | 'out-of-stock'; - rating?: number; -} - -export interface FilterGroup { - id: string; - label: string; - type: SearchFilterType; - options: FilterOption[]; - min?: number; - max?: number; - step?: number; - enabled: boolean; -} - -export interface SortOption { - id: ProductSort | 'discount'; - label: string; - enabled: boolean; -} - -export interface SearchSuggestion { - id: string; - text: string; - kind: 'live' | 'recent' | 'popular'; -} - -export interface SearchHistory { - items: string[]; - recent: string[]; - popular: string[]; -} - -export interface SearchFilterState { - values: Record; - ranges: Record; - toggles: Record; -} +export * from '../../../features/search/models/search.model'; diff --git a/src/app/core/search/services/search-history.service.ts b/src/app/core/search/services/search-history.service.ts index 8cf93d9..1dc8cae 100644 --- a/src/app/core/search/services/search-history.service.ts +++ b/src/app/core/search/services/search-history.service.ts @@ -1,76 +1 @@ -import { Injectable } from '@angular/core'; -import { SearchHistory } from '../models/search.model'; - -const STORAGE_KEY = 'marketplace.catalog.search.history'; - -@Injectable({ providedIn: 'root' }) -export class SearchHistoryService { - private readonly maxSize = 10; - - getHistory(): string[] { - if (typeof window === 'undefined') { - return []; - } - - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) { - return []; - } - - const parsed = JSON.parse(raw); - return Array.isArray(parsed) - ? parsed.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) - : []; - } catch { - return []; - } - } - - getSnapshot(popular: string[]): SearchHistory { - const items = this.getHistory(); - return { - items, - recent: items.slice(0, 5), - popular, - }; - } - - push(term: string, popular: string[]): SearchHistory { - const normalized = term.trim(); - if (!normalized.length) { - return this.getSnapshot(popular); - } - - const history = [normalized, ...this.getHistory().filter(entry => entry.toLowerCase() !== normalized.toLowerCase())] - .slice(0, this.maxSize); - - this.save(history); - - return { - items: history, - recent: history.slice(0, 5), - popular, - }; - } - - clear(popular: string[]): SearchHistory { - if (typeof window !== 'undefined') { - localStorage.removeItem(STORAGE_KEY); - } - - return { - items: [], - recent: [], - popular, - }; - } - - private save(history: string[]): void { - if (typeof window === 'undefined') { - return; - } - - localStorage.setItem(STORAGE_KEY, JSON.stringify(history)); - } -} +export { SearchHistoryService } from '../../../features/search/services/search-history.service'; diff --git a/src/app/facades/platform/search.facade.ts b/src/app/facades/platform/search.facade.ts index e495aca..9ee298a 100644 --- a/src/app/facades/platform/search.facade.ts +++ b/src/app/facades/platform/search.facade.ts @@ -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(); - - 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 { - 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 = { - 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 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 { - 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 { - 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 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(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 = { - 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'; diff --git a/src/app/features/search/facade/search.facade.ts b/src/app/features/search/facade/search.facade.ts new file mode 100644 index 0000000..281224c --- /dev/null +++ b/src/app/features/search/facade/search.facade.ts @@ -0,0 +1,584 @@ +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 { 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' | 'list'; + page: number; + pageSize: number; + filters: SearchFilterState; +} + +@Injectable({ providedIn: 'root' }) +export class SearchFacade { + private readonly productFacade = inject(ProductFacade); + 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.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, + }; + } + + pushSearchHistory(term: string, maxHistory = 12): SearchHistory { + const snapshot = this.historyService.push(term, maxHistory); + this.store.setRecentSearches(snapshot.recent); + return snapshot; + } + + clearSearchHistory(): SearchHistory { + const snapshot = this.historyService.clear(); + this.store.setRecentSearches([]); + return snapshot; + } + + 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.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[] { + 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))]; + } + + 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'; + } +} diff --git a/src/app/features/search/models/search-state.model.ts b/src/app/features/search/models/search-state.model.ts new file mode 100644 index 0000000..e0e0964 --- /dev/null +++ b/src/app/features/search/models/search-state.model.ts @@ -0,0 +1,39 @@ +import { CatalogLayoutMode } from '../../../core/products/models/catalog-experience.model'; +import { ProductSort } from '../../../core/products/models/product-domain.model'; +import { SearchFilterState, SearchResult, SearchSuggestion } from './search.model'; + +export interface SearchState { + currentQuery: string; + loading: boolean; + results: SearchResult | null; + suggestions: SearchSuggestion[]; + recentSearches: string[]; + popularSearches: string[]; + selectedFilters: SearchFilterState; + currentSort: ProductSort | 'discount'; + currentPage: number; + pageSize: number; + totalResults: number; + layout: CatalogLayoutMode; +} + +export function createInitialSearchState(pageSize = 24): SearchState { + return { + currentQuery: '', + loading: false, + results: null, + suggestions: [], + recentSearches: [], + popularSearches: [], + selectedFilters: { + values: {}, + ranges: {}, + toggles: {}, + }, + currentSort: 'relevance', + currentPage: 1, + pageSize, + totalResults: 0, + layout: 'grid', + }; +} diff --git a/src/app/features/search/models/search.model.ts b/src/app/features/search/models/search.model.ts new file mode 100644 index 0000000..254f0e7 --- /dev/null +++ b/src/app/features/search/models/search.model.ts @@ -0,0 +1,103 @@ +import { Product, ProductSort } from '../../../core/products/models/product-domain.model'; + +export type SearchFilterType = + | 'checkbox' + | 'radio' + | 'toggle' + | 'range' + | 'slider' + | 'color' + | 'size' + | 'rating' + | 'availability'; + +export type SearchSuggestionType = + | 'product' + | 'category' + | 'brand' + | 'collection' + | 'seller' + | 'static-page' + | 'ai'; + +export interface SearchNavigationTarget { + route: string; + params?: Record; + query?: Record; +} + +export interface SearchSuggestion { + id: string; + type: SearchSuggestionType; + title: string; + text?: string; + subtitle?: string; + icon: string; + target: SearchNavigationTarget; + score?: number; +} + +export interface SearchQuery { + text: string; + categoryIds: number[]; + subcategoryIds: string[]; + filters: SearchFilterState; + sort: ProductSort | 'discount'; + page: number; + pageSize: number; +} + +export interface SearchResult { + query: SearchQuery; + items: TItem[]; + total: number; + page: number; + pageSize: number; + summary: string; +} + +export interface FilterOption { + id: string; + label: string; + value: string; + count?: number; + colorHex?: string; + availability?: 'in-stock' | 'low-stock' | 'out-of-stock'; + rating?: number; +} + +export interface FilterGroup { + id: string; + label: string; + type: SearchFilterType; + options: FilterOption[]; + min?: number; + max?: number; + step?: number; + enabled: boolean; +} + +export interface SortOption { + id: ProductSort | 'discount'; + label: string; + enabled: boolean; +} + +export interface SearchHistory { + items: string[]; + recent: string[]; +} + +export interface SearchFilterState { + values: Record; + ranges: Record; + toggles: Record; +} + +export interface SearchAnalyticsEvent { + query: string; + tenant: string; + language: string; + timestamp: string; + resultCount: number; +} diff --git a/src/app/features/search/services/search-analytics.service.ts b/src/app/features/search/services/search-analytics.service.ts new file mode 100644 index 0000000..1903ba1 --- /dev/null +++ b/src/app/features/search/services/search-analytics.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { SearchAnalyticsEvent } from '../models/search.model'; + +@Injectable({ providedIn: 'root' }) +export class SearchAnalyticsService { + buildEvent(input: { + query: string; + tenant: string; + language: string; + resultCount: number; + timestamp?: string; + }): SearchAnalyticsEvent { + return { + query: input.query, + tenant: input.tenant, + language: input.language, + timestamp: input.timestamp ?? new Date().toISOString(), + resultCount: input.resultCount, + }; + } +} diff --git a/src/app/features/search/services/search-autocomplete.service.ts b/src/app/features/search/services/search-autocomplete.service.ts new file mode 100644 index 0000000..52ebf97 --- /dev/null +++ b/src/app/features/search/services/search-autocomplete.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@angular/core'; +import { Category } from '../../../core/categories/models/category-domain.model'; +import { Product } from '../../../core/products/models/product-domain.model'; +import { SearchSuggestion } from '../models/search.model'; + +@Injectable({ providedIn: 'root' }) +export class SearchAutocompleteService { + createSuggestions(query: string, products: Product[], categories: Category[] = [], limit = 10): SearchSuggestion[] { + const normalized = query.trim().toLowerCase(); + if (normalized.length < 2) { + return []; + } + + const productSuggestions: SearchSuggestion[] = this.collectUniqueById( + products + .filter(item => item.name.toLowerCase().includes(normalized)) + .slice(0, limit) + .map(item => ({ + id: `product-${item.itemID}`, + type: 'product' as const, + title: item.name, + subtitle: item.simpleDescription ?? '', + icon: 'inventory_2', + target: { route: '/product', params: { id: item.itemID } }, + score: this.score(item.name, normalized), + })) + ); + + const categorySuggestions: SearchSuggestion[] = this.collectUniqueById( + categories + .filter(item => item.title.toLowerCase().includes(normalized)) + .slice(0, Math.ceil(limit / 2)) + .map(item => ({ + id: `category-${item.id}`, + type: 'category' as const, + title: item.title, + subtitle: '', + icon: 'category', + target: { route: '/catalog', params: { id: item.id } }, + score: this.score(item.title, normalized), + })) + ); + + const brandSuggestions: SearchSuggestion[] = this.collectUniqueById( + products + .flatMap(item => item.tags ?? []) + .filter(tag => tag.toLowerCase().includes(normalized)) + .slice(0, Math.ceil(limit / 2)) + .map(tag => ({ + id: `brand-${tag}`, + type: 'brand' as const, + title: tag, + subtitle: '', + icon: 'sell', + target: { route: '/search', query: { q: tag } }, + score: this.score(tag, normalized), + })) + ); + + const aiSuggestion: SearchSuggestion | null = normalized.length >= 4 + ? { + id: `ai-${normalized}`, + type: 'ai', + title: query, + subtitle: 'search.aiSuggestionHint', + icon: 'auto_awesome', + target: { route: '/search', query: { q: query } }, + score: 0, + } + : null; + + return [...productSuggestions, ...categorySuggestions, ...brandSuggestions, ...(aiSuggestion ? [aiSuggestion] : [])] + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) + .slice(0, limit); + } + + private score(value: string, query: string): number { + const normalized = value.toLowerCase(); + if (normalized.startsWith(query)) { + return 2; + } + + if (normalized.includes(query)) { + return 1; + } + + return 0; + } + + private collectUniqueById(items: T[]): T[] { + const map = new Map(); + for (const item of items) { + if (!map.has(item.id)) { + map.set(item.id, item); + } + } + + return [...map.values()]; + } +} diff --git a/src/app/features/search/services/search-cache.service.ts b/src/app/features/search/services/search-cache.service.ts new file mode 100644 index 0000000..eccfe37 --- /dev/null +++ b/src/app/features/search/services/search-cache.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { ProductListResult } from '../../../core/products/models/product-domain.model'; + +@Injectable({ providedIn: 'root' }) +export class SearchCacheService { + private readonly cache = new Map(); + + get(key: string): ProductListResult | null { + return this.cache.get(key) ?? null; + } + + set(key: string, value: ProductListResult): void { + this.cache.set(key, value); + if (this.cache.size > 30) { + const oldest = this.cache.keys().next().value; + if (oldest) { + this.cache.delete(oldest); + } + } + } + + clear(): void { + this.cache.clear(); + } +} diff --git a/src/app/features/search/services/search-history.repository.ts b/src/app/features/search/services/search-history.repository.ts new file mode 100644 index 0000000..a96e13e --- /dev/null +++ b/src/app/features/search/services/search-history.repository.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; + +export interface SearchHistoryRepository { + load(): string[]; + save(items: string[]): void; + clear(): void; +} + +const STORAGE_KEY = 'marketplace.search.feature.history'; + +@Injectable({ providedIn: 'root' }) +export class LocalSearchHistoryRepository implements SearchHistoryRepository { + load(): string[] { + if (typeof window === 'undefined') { + return []; + } + + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + : []; + } catch { + return []; + } + } + + save(items: string[]): void { + if (typeof window === 'undefined') { + return; + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } + + clear(): void { + if (typeof window === 'undefined') { + return; + } + + localStorage.removeItem(STORAGE_KEY); + } +} + +@Injectable({ providedIn: 'root' }) +export class BackendSearchHistoryRepository implements SearchHistoryRepository { + load(): string[] { + return []; + } + + save(_items: string[]): void { + // Placeholder for future backend endpoint. + } + + clear(): void { + // Placeholder for future backend endpoint. + } +} diff --git a/src/app/features/search/services/search-history.service.ts b/src/app/features/search/services/search-history.service.ts new file mode 100644 index 0000000..062b8b4 --- /dev/null +++ b/src/app/features/search/services/search-history.service.ts @@ -0,0 +1,47 @@ +import { Injectable, inject } from '@angular/core'; +import { AuthService } from '../../../services/auth.service'; +import { SearchHistory } from '../models/search.model'; +import { BackendSearchHistoryRepository, LocalSearchHistoryRepository, SearchHistoryRepository } from './search-history.repository'; + +@Injectable({ providedIn: 'root' }) +export class SearchHistoryService { + private readonly authService = inject(AuthService); + private readonly localRepository = inject(LocalSearchHistoryRepository); + private readonly backendRepository = inject(BackendSearchHistoryRepository); + + private readonly maxSize = 12; + + private get repository(): SearchHistoryRepository { + return this.authService.session() ? this.backendRepository : this.localRepository; + } + + getSnapshot(): SearchHistory { + const items = this.repository.load(); + return { + items, + recent: items.slice(0, 8), + }; + } + + push(query: string, maxHistory = this.maxSize): SearchHistory { + const normalized = query.trim(); + if (!normalized.length) { + return this.getSnapshot(); + } + + const existing = this.repository.load(); + const next = [normalized, ...existing.filter(item => item.toLowerCase() !== normalized.toLowerCase())] + .slice(0, Math.max(1, maxHistory)); + + this.repository.save(next); + return { + items: next, + recent: next.slice(0, 8), + }; + } + + clear(): SearchHistory { + this.repository.clear(); + return { items: [], recent: [] }; + } +} diff --git a/src/app/features/search/services/search-trending.service.ts b/src/app/features/search/services/search-trending.service.ts new file mode 100644 index 0000000..190f8dc --- /dev/null +++ b/src/app/features/search/services/search-trending.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { SearchSuggestion } from '../models/search.model'; + +@Injectable({ providedIn: 'root' }) +export class SearchTrendingService { + loadTrending(): Observable { + // Endpoint not available yet. Return null so UI can hide gracefully. + return of(null); + } +} diff --git a/src/app/features/search/store/search.store.ts b/src/app/features/search/store/search.store.ts new file mode 100644 index 0000000..26b9923 --- /dev/null +++ b/src/app/features/search/store/search.store.ts @@ -0,0 +1,50 @@ +import { Injectable, computed, signal } from '@angular/core'; +import { SearchFilterState, SearchResult, SearchSuggestion } from '../models/search.model'; +import { SearchState, createInitialSearchState } from '../models/search-state.model'; + +@Injectable({ providedIn: 'root' }) +export class SearchStore { + private readonly stateSignal = signal(createInitialSearchState()); + + readonly state = this.stateSignal.asReadonly(); + readonly suggestions = computed(() => this.stateSignal().suggestions); + readonly recentSearches = computed(() => this.stateSignal().recentSearches); + readonly popularSearches = computed(() => this.stateSignal().popularSearches); + + patch(patch: Partial): void { + this.stateSignal.update(current => ({ ...current, ...patch })); + } + + setLoading(loading: boolean): void { + this.patch({ loading }); + } + + setQuery(query: string): void { + this.patch({ currentQuery: query }); + } + + setSuggestions(suggestions: SearchSuggestion[]): void { + this.patch({ suggestions }); + } + + setResults(results: SearchResult | null): void { + this.patch({ + results, + totalResults: results?.total ?? 0, + currentPage: results?.page ?? 1, + pageSize: results?.pageSize ?? this.stateSignal().pageSize, + }); + } + + setRecentSearches(items: string[]): void { + this.patch({ recentSearches: items }); + } + + setPopularSearches(items: string[]): void { + this.patch({ popularSearches: items }); + } + + setFilters(selectedFilters: SearchFilterState): void { + this.patch({ selectedFilters }); + } +} diff --git a/src/app/features/search/utils/search-query-key.util.ts b/src/app/features/search/utils/search-query-key.util.ts new file mode 100644 index 0000000..8aab7ed --- /dev/null +++ b/src/app/features/search/utils/search-query-key.util.ts @@ -0,0 +1,13 @@ +import { SearchQuery } from '../models/search.model'; + +export function createSearchQueryKey(query: SearchQuery): string { + return JSON.stringify({ + text: query.text.trim().toLowerCase(), + categoryIds: query.categoryIds, + subcategoryIds: query.subcategoryIds, + filters: query.filters, + sort: query.sort, + page: query.page, + pageSize: query.pageSize, + }); +}