search engien
This commit is contained in:
@@ -1,27 +1 @@
|
|||||||
import { CatalogLayoutMode } from '../../products/models/catalog-experience.model';
|
export * from '../../../features/search/models/search-state.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: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,76 +1 @@
|
|||||||
import { Product, ProductSort } from '../../products/models/product-domain.model';
|
export * from '../../../features/search/models/search.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<TItem = Product> {
|
|
||||||
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<string, string[]>;
|
|
||||||
ranges: Record<string, { min?: number; max?: number }>;
|
|
||||||
toggles: Record<string, boolean>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,76 +1 @@
|
|||||||
import { Injectable } from '@angular/core';
|
export { SearchHistoryService } from '../../../features/search/services/search-history.service';
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,470 +1 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
export { SearchFacade } from '../../features/search/facade/search.facade';
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
584
src/app/features/search/facade/search.facade.ts
Normal file
584
src/app/features/search/facade/search.facade.ts
Normal file
@@ -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<string, FilterGroup[]>();
|
||||||
|
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<ProductListResult> {
|
||||||
|
return this.loadCatalog(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions(query: string, products: Product[], categories: Category[] = [], limit = 10): Observable<SearchSuggestion[]> {
|
||||||
|
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<SearchSuggestion[] | null> {
|
||||||
|
return this.trendingService.loadTrending();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCatalog(query: SearchQuery): Observable<ProductListResult> {
|
||||||
|
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<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 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<Product> {
|
||||||
|
const result: SearchResult<Product> = {
|
||||||
|
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<LegacySearchState> {
|
||||||
|
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 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<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';
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/features/search/models/search-state.model.ts
Normal file
39
src/app/features/search/models/search-state.model.ts
Normal file
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
103
src/app/features/search/models/search.model.ts
Normal file
103
src/app/features/search/models/search.model.ts
Normal file
@@ -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<string, string | number>;
|
||||||
|
query?: Record<string, string | number | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TItem = Product> {
|
||||||
|
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<string, string[]>;
|
||||||
|
ranges: Record<string, { min?: number; max?: number }>;
|
||||||
|
toggles: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchAnalyticsEvent {
|
||||||
|
query: string;
|
||||||
|
tenant: string;
|
||||||
|
language: string;
|
||||||
|
timestamp: string;
|
||||||
|
resultCount: number;
|
||||||
|
}
|
||||||
21
src/app/features/search/services/search-analytics.service.ts
Normal file
21
src/app/features/search/services/search-analytics.service.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/app/features/search/services/search-autocomplete.service.ts
Normal file
100
src/app/features/search/services/search-autocomplete.service.ts
Normal file
@@ -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<T extends { id: string }>(items: T[]): T[] {
|
||||||
|
const map = new Map<string, T>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (!map.has(item.id)) {
|
||||||
|
map.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/features/search/services/search-cache.service.ts
Normal file
25
src/app/features/search/services/search-cache.service.ts
Normal file
@@ -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<string, ProductListResult>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/features/search/services/search-history.service.ts
Normal file
47
src/app/features/search/services/search-history.service.ts
Normal file
@@ -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: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/app/features/search/services/search-trending.service.ts
Normal file
11
src/app/features/search/services/search-trending.service.ts
Normal file
@@ -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<SearchSuggestion[] | null> {
|
||||||
|
// Endpoint not available yet. Return null so UI can hide gracefully.
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/app/features/search/store/search.store.ts
Normal file
50
src/app/features/search/store/search.store.ts
Normal file
@@ -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<SearchState>(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<SearchState>): 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/app/features/search/utils/search-query-key.util.ts
Normal file
13
src/app/features/search/utils/search-query-key.util.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user