From 7ecb19cb1a99e6545013263306985109981ea531 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 10 Jul 2026 13:25:31 +0400 Subject: [PATCH] feat(search): add Search Intelligence module --- docs/Search.md | 188 +++++++++++++++++ .../core/search/models/search-state.model.ts | 28 ++- .../search-empty-results.component.html | 44 ++++ .../search-empty-results.component.scss | 48 +++++ .../search-empty-results.component.ts | 24 +++ .../search-bar/search-bar.component.html | 86 ++++++++ .../search-bar/search-bar.component.scss | 195 ++++++++++++++++++ .../search-bar/search-bar.component.ts | 130 ++++++++++++ .../trending-searches.component.html | 8 + .../trending-searches.component.scss | 20 ++ .../trending-searches.component.ts | 15 ++ .../features/search/facade/search.facade.ts | 12 +- .../search/models/search-state.model.ts | 15 ++ .../features/search/models/search.model.ts | 4 +- .../services/search-autocomplete.service.ts | 8 + .../search/services/search-history.service.ts | 4 +- src/app/features/search/store/search.store.ts | 5 +- .../catalog-empty-state.component.html | 39 ++++ .../catalog-empty-state.component.scss | 33 +++ .../catalog-empty-state.component.ts | 11 + .../search-box/search-box.component.html | 78 ++----- .../search-box/search-box.component.scss | 115 +---------- .../search-box/search-box.component.ts | 64 +----- .../catalog-container.component.html | 9 +- .../containers/catalog-container.component.ts | 42 +++- src/app/i18n/en.ts | 14 ++ src/app/i18n/hy.ts | 14 ++ src/app/i18n/ru.ts | 14 ++ src/app/i18n/translations.ts | 14 ++ 29 files changed, 1030 insertions(+), 251 deletions(-) create mode 100644 docs/Search.md create mode 100644 src/app/features/search/components/empty-results/search-empty-results.component.html create mode 100644 src/app/features/search/components/empty-results/search-empty-results.component.scss create mode 100644 src/app/features/search/components/empty-results/search-empty-results.component.ts create mode 100644 src/app/features/search/components/search-bar/search-bar.component.html create mode 100644 src/app/features/search/components/search-bar/search-bar.component.scss create mode 100644 src/app/features/search/components/search-bar/search-bar.component.ts create mode 100644 src/app/features/search/components/trending-searches/trending-searches.component.html create mode 100644 src/app/features/search/components/trending-searches/trending-searches.component.scss create mode 100644 src/app/features/search/components/trending-searches/trending-searches.component.ts diff --git a/docs/Search.md b/docs/Search.md new file mode 100644 index 0000000..ed365eb --- /dev/null +++ b/docs/Search.md @@ -0,0 +1,188 @@ +# Search Intelligence Engine - Sprint 12 + +## Scope + +Sprint 12 introduces a standalone Search Feature architecture reusable across marketplaces. + +Constraints respected: +- No authentication changes +- No payment changes +- No runtime bootstrap changes +- No Widget Manifest changes +- No Section Engine changes +- No Product/Catalog business rule changes + +## Architecture + +```text +src/app/features/search/ + components/ + search-bar/ + trending-searches/ + empty-results/ + services/ + search-autocomplete.service.ts + search-history.service.ts + search-history.repository.ts + search-trending.service.ts + search-cache.service.ts + search-analytics.service.ts + facade/ + search.facade.ts + models/ + search.model.ts + search-state.model.ts + store/ + search.store.ts + utils/ + search-query-key.util.ts +``` + +Legacy compatibility kept: +- `src/app/facades/platform/search.facade.ts` now re-exports feature facade +- `src/app/core/search/models/*` re-export feature models +- `src/app/core/search/services/search-history.service.ts` re-exports feature history service + +## Facade API + +Search UI communicates through `SearchFacade`: +- `search(query)` +- `loadCatalog(query)` +- `suggestions(query, products, categories, limit)` +- `autocomplete(query, products, categories, limit)` +- `getSearchHistory()` +- `pushSearchHistory(term, maxHistory)` +- `clearSearchHistory()` +- `trending()` +- existing helpers reused by catalog: filters, sorting, pagination, query params + +## State Model + +Managed in `SearchStore`: +- current query +- loading +- results +- suggestions +- recent searches +- popular searches +- selected filters +- current sort +- current page +- total results + +Compatibility aliases preserved for existing catalog integration. + +## Suggestion Model + +Each suggestion includes: +- `type` +- `title` +- `subtitle` +- `icon` +- `target` + +Supported suggestion types: +- product +- category +- brand +- collection +- seller +- static-page +- ai + +## Autocomplete + +Behavior: +- Debounced typing (`debounceTime`) +- Previous request cancellation (`switchMap`) +- Distinct query suppression (`distinctUntilChanged`) +- Suggestion state controlled by facade/store + +Current source: +- In-memory products/categories/tags + +Future-ready for backend endpoint: +- autocomplete service can switch to API provider without UI changes + +## Search History + +Abstraction: +- `SearchHistoryRepository` + - `LocalSearchHistoryRepository` for guest users + - `BackendSearchHistoryRepository` placeholder for logged users + +Behavior: +- Newest first +- Configurable max length +- Clear history support + +## Trending Searches + +`SearchTrendingService` contract introduced. + +Current behavior: +- Returns `null` when endpoint unavailable +- UI hides trending block gracefully + +## Empty Results UX + +Reusable empty state supports: +- no results messaging +- popular categories +- popular searches +- recommended products +- reset filters action + +## Filters and Sorting Reuse + +No duplicated filter/sort engines. + +Search facade reuses existing catalog filter metadata generation, +filter application, and sort application pathways. + +## Search Bar UX + +Reusable `SearchBarComponent` supports: +- ESC closes suggestions/overlay +- arrow navigation +- Enter opens highlighted suggestion +- mouse selection +- loading indicator +- clear button +- mobile fullscreen overlay with large touch targets + +## Performance + +Implemented: +- debounce +- switch-map cancellation +- duplicate suppression +- query-result cache for repeated searches + +## Analytics Architecture + +`SearchAnalyticsService` provides event factory only. + +Event shape: +- query +- tenant +- language +- timestamp +- result count + +No analytics transport implementation in Sprint 12. + +## Configuration and Extension Points + +Extension points: +- replace history repository with backend endpoint +- replace trending provider with backend endpoint +- replace autocomplete provider with API or AI provider +- enrich suggestion mapper with static pages/sellers/collections sources + +## Responsiveness and Accessibility + +- Desktop suggestion dropdown behavior +- Mobile fullscreen overlay behavior +- ARIA labels and keyboard navigation +- touch target sizing in mobile mode diff --git a/src/app/core/search/models/search-state.model.ts b/src/app/core/search/models/search-state.model.ts index 003757b..e903935 100644 --- a/src/app/core/search/models/search-state.model.ts +++ b/src/app/core/search/models/search-state.model.ts @@ -1 +1,27 @@ -export * from '../../../features/search/models/search-state.model'; +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: {}, + }, + }; +} diff --git a/src/app/features/search/components/empty-results/search-empty-results.component.html b/src/app/features/search/components/empty-results/search-empty-results.component.html new file mode 100644 index 0000000..ea90ce2 --- /dev/null +++ b/src/app/features/search/components/empty-results/search-empty-results.component.html @@ -0,0 +1,44 @@ +
+

{{ 'search.noResults' | translate }}

+ + + + @if (popularCategories.length > 0) { +
+ {{ 'search.popularCategories' | translate }} +
+ @for (category of popularCategories; track category.id) { + + } +
+
+ } + + @if (popularSearches.length > 0) { +
+ {{ 'catalog.popularSearchesTitle' | translate }} +
+ @for (item of popularSearches; track $index) { + + } +
+
+ } + + @if (recommendedProducts.length > 0) { +
+ {{ 'search.recommendedProducts' | translate }} +
+ @for (product of recommendedProducts; track product.itemID) { + + } +
+
+ } +
diff --git a/src/app/features/search/components/empty-results/search-empty-results.component.scss b/src/app/features/search/components/empty-results/search-empty-results.component.scss new file mode 100644 index 0000000..53fc855 --- /dev/null +++ b/src/app/features/search/components/empty-results/search-empty-results.component.scss @@ -0,0 +1,48 @@ +.search-empty-results { + display: grid; + gap: 14px; + padding: 16px; +} + +h3 { + margin: 0; +} + +.reset-btn { + justify-self: start; + min-height: 40px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--text-primary); + padding: 0 14px; + cursor: pointer; + font-weight: 700; +} + +.block { + display: grid; + gap: 8px; +} + +.chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + min-height: 36px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-primary); + color: var(--text-secondary); + padding: 0 12px; + cursor: pointer; +} + +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 10px; +} diff --git a/src/app/features/search/components/empty-results/search-empty-results.component.ts b/src/app/features/search/components/empty-results/search-empty-results.component.ts new file mode 100644 index 0000000..60f7921 --- /dev/null +++ b/src/app/features/search/components/empty-results/search-empty-results.component.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { Product } from '../../../../core/products/models/product-domain.model'; +import { ProductCardComponent } from '../../../../components/product-card/product-card.component'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; + +@Component({ + selector: 'app-search-empty-results', + standalone: true, + imports: [ProductCardComponent, TranslatePipe], + templateUrl: './search-empty-results.component.html', + styleUrls: ['./search-empty-results.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SearchEmptyResultsComponent { + @Input() popularCategories: Array<{ id: string | number; label: string }> = []; + @Input() popularSearches: string[] = []; + @Input() recommendedProducts: Product[] = []; + + @Output() resetFilters = new EventEmitter(); + @Output() popularCategorySelected = new EventEmitter(); + @Output() popularSearchSelected = new EventEmitter(); + @Output() productSelected = new EventEmitter(); + @Output() productAddToCart = new EventEmitter<{ product: Product; event: Event }>(); +} diff --git a/src/app/features/search/components/search-bar/search-bar.component.html b/src/app/features/search/components/search-bar/search-bar.component.html new file mode 100644 index 0000000..c74f766 --- /dev/null +++ b/src/app/features/search/components/search-bar/search-bar.component.html @@ -0,0 +1,86 @@ + diff --git a/src/app/features/search/components/search-bar/search-bar.component.scss b/src/app/features/search/components/search-bar/search-bar.component.scss new file mode 100644 index 0000000..a01f5aa --- /dev/null +++ b/src/app/features/search/components/search-bar/search-bar.component.scss @@ -0,0 +1,195 @@ +.search-bar { + position: relative; + padding: 14px; + display: grid; + gap: 12px; +} + +.search-shell { + display: grid; + gap: 12px; +} + +.search-form { + display: grid; + grid-template-columns: 1fr auto auto auto; + gap: 8px; +} + +.search-form input { + min-height: 44px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0 12px; + font: inherit; +} + +.search-form input:focus-visible { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent); + outline: 0; +} + +.loading-dot { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--border-color); + border-top-color: var(--primary-color); + animation: search-spin 0.8s linear infinite; + align-self: center; + justify-self: center; +} + +@keyframes search-spin { + to { transform: rotate(360deg); } +} + +.search-form button { + min-height: 44px; + border: 0; + border-radius: var(--radius-sm); + background: var(--primary-color); + color: #fff; + font-weight: 700; + padding: 0 14px; + cursor: pointer; +} + +.search-clear-btn { + min-width: 44px; + min-height: 44px; + border: 1px solid var(--border-color) !important; + border-radius: var(--radius-sm); + background: var(--bg-primary) !important; + color: var(--text-secondary) !important; + font-size: 1.2rem; + line-height: 1; +} + +.suggestions { + display: grid; + gap: 8px; +} + +.suggestion-list { + display: grid; + gap: 6px; +} + +.suggestion-item { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 8px; + min-height: 44px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--text-primary); + padding: 8px 10px; + text-align: left; + cursor: pointer; +} + +.suggestion-item.active { + border-color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 7%, white); +} + +.suggestion-icon { + font-size: 0.75rem; + text-transform: uppercase; + color: var(--text-secondary); +} + +.suggestion-copy { + display: grid; +} + +.suggestion-title { + font-weight: 700; +} + +.suggestion-copy small, +.suggestion-type { + color: var(--text-secondary); + font-size: 0.78rem; +} + +.chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + min-height: 32px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-primary); + color: var(--text-secondary); + padding: 0 10px; + cursor: pointer; +} + +.history-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.history-head button { + border: 0; + background: transparent; + color: var(--primary-color); + cursor: pointer; + font-weight: 700; +} + +.no-results { + margin: 0; + color: var(--text-secondary); +} + +.mobile-overlay-backdrop { + display: none; +} + +@media (max-width: 767px) { + .search-form { + grid-template-columns: 1fr auto auto; + } + + .search-form button[type='submit'] { + grid-column: 1 / -1; + width: 100%; + } + + .mobile-overlay-open .mobile-overlay-backdrop { + display: block; + position: fixed; + inset: 0; + background: rgba(16, 24, 24, 0.5); + z-index: 30; + } + + .search-shell.mobile-overlay { + position: fixed; + inset: auto 0 0 0; + max-height: 82vh; + overflow: auto; + border-radius: 14px 14px 0 0; + background: #fff; + padding: 12px; + z-index: 31; + box-shadow: 0 -8px 28px rgba(0, 0, 0, 0.18); + } + + .chip, + .suggestion-item, + .history-head button { + min-height: 44px; + } +} diff --git a/src/app/features/search/components/search-bar/search-bar.component.ts b/src/app/features/search/components/search-bar/search-bar.component.ts new file mode 100644 index 0000000..1ba4d12 --- /dev/null +++ b/src/app/features/search/components/search-bar/search-bar.component.ts @@ -0,0 +1,130 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, HostListener, Input, Output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SearchSuggestion } from '../../models/search.model'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { TrendingSearchesComponent } from '../trending-searches/trending-searches.component'; + +@Component({ + selector: 'app-search-bar', + standalone: true, + imports: [FormsModule, TranslatePipe, TrendingSearchesComponent], + templateUrl: './search-bar.component.html', + styleUrls: ['./search-bar.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SearchBarComponent { + @Input() query = ''; + @Input() loading = false; + @Input() suggestions: SearchSuggestion[] = []; + @Input() recentSearches: string[] = []; + @Input() popularSearches: string[] = []; + @Input() searchHistory: string[] = []; + @Input() noResults = false; + + @Output() queryChange = new EventEmitter(); + @Output() searchSubmit = new EventEmitter(); + @Output() suggestionSelected = new EventEmitter(); + @Output() recentSelected = new EventEmitter(); + @Output() historyCleared = new EventEmitter(); + + readonly activeSuggestionIndex = signal(-1); + readonly focused = signal(false); + readonly mobileOverlayOpen = signal(false); + + @HostListener('window:resize') + onResize(): void { + if (!this.isMobile()) { + this.mobileOverlayOpen.set(false); + } + } + + onSubmit(event: Event): void { + event.preventDefault(); + + const active = this.suggestions[this.activeSuggestionIndex()]; + if (active) { + this.selectSuggestion(active); + return; + } + + this.searchSubmit.emit(this.query.trim()); + this.mobileOverlayOpen.set(false); + } + + onQueryInput(value: string): void { + this.queryChange.emit(value); + this.activeSuggestionIndex.set(-1); + } + + onFocus(): void { + this.focused.set(true); + if (this.isMobile()) { + this.mobileOverlayOpen.set(true); + } + } + + onBlur(): void { + this.focused.set(false); + } + + selectSuggestion(suggestion: SearchSuggestion): void { + this.suggestionSelected.emit(suggestion); + this.activeSuggestionIndex.set(-1); + this.mobileOverlayOpen.set(false); + } + + clearQuery(): void { + this.queryChange.emit(''); + this.searchSubmit.emit(''); + this.activeSuggestionIndex.set(-1); + } + + closeOverlay(): void { + this.mobileOverlayOpen.set(false); + } + + onKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + this.activeSuggestionIndex.set(-1); + this.mobileOverlayOpen.set(false); + return; + } + + if (this.suggestions.length === 0) { + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.activeSuggestionIndex.set(Math.min(this.suggestions.length - 1, this.activeSuggestionIndex() + 1)); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.activeSuggestionIndex.set(Math.max(-1, this.activeSuggestionIndex() - 1)); + return; + } + + if (event.key === 'Enter' && this.activeSuggestionIndex() >= 0) { + event.preventDefault(); + const active = this.suggestions[this.activeSuggestionIndex()]; + this.selectSuggestion(active); + } + } + + onRecentSelected(value: string): void { + this.recentSelected.emit(value); + this.activeSuggestionIndex.set(-1); + this.mobileOverlayOpen.set(false); + } + + suggestionLabel(item: SearchSuggestion): string { + return item.subtitle ? `${item.title} · ${item.subtitle}` : item.title; + } + + private isMobile(): boolean { + return typeof window !== 'undefined' && window.innerWidth <= 767; + } +} diff --git a/src/app/features/search/components/trending-searches/trending-searches.component.html b/src/app/features/search/components/trending-searches/trending-searches.component.html new file mode 100644 index 0000000..590b2a7 --- /dev/null +++ b/src/app/features/search/components/trending-searches/trending-searches.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/features/search/components/trending-searches/trending-searches.component.scss b/src/app/features/search/components/trending-searches/trending-searches.component.scss new file mode 100644 index 0000000..b3d91b8 --- /dev/null +++ b/src/app/features/search/components/trending-searches/trending-searches.component.scss @@ -0,0 +1,20 @@ +.trending-searches { + display: grid; + gap: 8px; +} + +.chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + min-height: 32px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-primary); + color: var(--text-secondary); + padding: 0 10px; + cursor: pointer; +} diff --git a/src/app/features/search/components/trending-searches/trending-searches.component.ts b/src/app/features/search/components/trending-searches/trending-searches.component.ts new file mode 100644 index 0000000..edafeab --- /dev/null +++ b/src/app/features/search/components/trending-searches/trending-searches.component.ts @@ -0,0 +1,15 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-trending-searches', + standalone: true, + templateUrl: './trending-searches.component.html', + styleUrls: ['./trending-searches.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TrendingSearchesComponent { + @Input() title = ''; + @Input() items: string[] = []; + + @Output() selected = new EventEmitter(); +} diff --git a/src/app/features/search/facade/search.facade.ts b/src/app/features/search/facade/search.facade.ts index 281224c..32436dd 100644 --- a/src/app/features/search/facade/search.facade.ts +++ b/src/app/features/search/facade/search.facade.ts @@ -89,6 +89,7 @@ export class SearchFacade { constructor() { const history = this.historyService.getSnapshot(); this.store.setRecentSearches(history.recent); + this.store.setPopularSearches(this.popularSearches.map(item => item.title)); this.autocompleteInput$ .pipe( @@ -128,19 +129,26 @@ export class SearchFacade { return { items: snapshot.items, recent: snapshot.recent, + popular: this.popularSearches.map(item => item.title), }; } pushSearchHistory(term: string, maxHistory = 12): SearchHistory { const snapshot = this.historyService.push(term, maxHistory); this.store.setRecentSearches(snapshot.recent); - return snapshot; + return { + ...snapshot, + popular: this.popularSearches.map(item => item.title), + }; } clearSearchHistory(): SearchHistory { const snapshot = this.historyService.clear(); this.store.setRecentSearches([]); - return snapshot; + return { + ...snapshot, + popular: this.popularSearches.map(item => item.title), + }; } trending(): Observable { diff --git a/src/app/features/search/models/search-state.model.ts b/src/app/features/search/models/search-state.model.ts index e0e0964..d38660d 100644 --- a/src/app/features/search/models/search-state.model.ts +++ b/src/app/features/search/models/search-state.model.ts @@ -3,6 +3,7 @@ import { ProductSort } from '../../../core/products/models/product-domain.model' import { SearchFilterState, SearchResult, SearchSuggestion } from './search.model'; export interface SearchState { + // Canonical feature state currentQuery: string; loading: boolean; results: SearchResult | null; @@ -15,6 +16,12 @@ export interface SearchState { pageSize: number; totalResults: number; layout: CatalogLayoutMode; + + // Legacy compatibility fields + text: string; + sort: ProductSort | 'discount'; + page: number; + filters: SearchFilterState; } export function createInitialSearchState(pageSize = 24): SearchState { @@ -35,5 +42,13 @@ export function createInitialSearchState(pageSize = 24): SearchState { pageSize, totalResults: 0, layout: 'grid', + text: '', + sort: 'relevance', + page: 1, + filters: { + values: {}, + ranges: {}, + toggles: {}, + }, }; } diff --git a/src/app/features/search/models/search.model.ts b/src/app/features/search/models/search.model.ts index 254f0e7..b3e433a 100644 --- a/src/app/features/search/models/search.model.ts +++ b/src/app/features/search/models/search.model.ts @@ -30,7 +30,8 @@ export interface SearchSuggestion { id: string; type: SearchSuggestionType; title: string; - text?: string; + text: string; + kind?: 'live' | 'recent' | 'popular'; subtitle?: string; icon: string; target: SearchNavigationTarget; @@ -86,6 +87,7 @@ export interface SortOption { export interface SearchHistory { items: string[]; recent: string[]; + popular: string[]; } export interface SearchFilterState { diff --git a/src/app/features/search/services/search-autocomplete.service.ts b/src/app/features/search/services/search-autocomplete.service.ts index 52ebf97..215ff56 100644 --- a/src/app/features/search/services/search-autocomplete.service.ts +++ b/src/app/features/search/services/search-autocomplete.service.ts @@ -19,6 +19,8 @@ export class SearchAutocompleteService { id: `product-${item.itemID}`, type: 'product' as const, title: item.name, + text: item.name, + kind: 'live' as const, subtitle: item.simpleDescription ?? '', icon: 'inventory_2', target: { route: '/product', params: { id: item.itemID } }, @@ -34,6 +36,8 @@ export class SearchAutocompleteService { id: `category-${item.id}`, type: 'category' as const, title: item.title, + text: item.title, + kind: 'live' as const, subtitle: '', icon: 'category', target: { route: '/catalog', params: { id: item.id } }, @@ -50,6 +54,8 @@ export class SearchAutocompleteService { id: `brand-${tag}`, type: 'brand' as const, title: tag, + text: tag, + kind: 'live' as const, subtitle: '', icon: 'sell', target: { route: '/search', query: { q: tag } }, @@ -62,6 +68,8 @@ export class SearchAutocompleteService { id: `ai-${normalized}`, type: 'ai', title: query, + text: query, + kind: 'live', subtitle: 'search.aiSuggestionHint', icon: 'auto_awesome', target: { route: '/search', query: { q: query } }, diff --git a/src/app/features/search/services/search-history.service.ts b/src/app/features/search/services/search-history.service.ts index 062b8b4..7fb1ec6 100644 --- a/src/app/features/search/services/search-history.service.ts +++ b/src/app/features/search/services/search-history.service.ts @@ -20,6 +20,7 @@ export class SearchHistoryService { return { items, recent: items.slice(0, 8), + popular: [], }; } @@ -37,11 +38,12 @@ export class SearchHistoryService { return { items: next, recent: next.slice(0, 8), + popular: [], }; } clear(): SearchHistory { this.repository.clear(); - return { items: [], recent: [] }; + return { items: [], recent: [], popular: [] }; } } diff --git a/src/app/features/search/store/search.store.ts b/src/app/features/search/store/search.store.ts index 26b9923..a489279 100644 --- a/src/app/features/search/store/search.store.ts +++ b/src/app/features/search/store/search.store.ts @@ -20,7 +20,7 @@ export class SearchStore { } setQuery(query: string): void { - this.patch({ currentQuery: query }); + this.patch({ currentQuery: query, text: query }); } setSuggestions(suggestions: SearchSuggestion[]): void { @@ -32,6 +32,7 @@ export class SearchStore { results, totalResults: results?.total ?? 0, currentPage: results?.page ?? 1, + page: results?.page ?? 1, pageSize: results?.pageSize ?? this.stateSignal().pageSize, }); } @@ -45,6 +46,6 @@ export class SearchStore { } setFilters(selectedFilters: SearchFilterState): void { - this.patch({ selectedFilters }); + this.patch({ selectedFilters, filters: selectedFilters }); } } diff --git a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.html b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.html index 11ef193..cc7952a 100644 --- a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.html +++ b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.html @@ -28,4 +28,43 @@ @if (actionLabel) { } + + @if (variant === 'filtered' && popularCategories.length > 0) { +
+ {{ 'search.popularCategories' | translate }} +
+ @for (category of popularCategories; track category.id) { + + } +
+
+ } + + @if (variant === 'filtered' && popularSearches.length > 0) { +
+ {{ 'catalog.popularSearchesTitle' | translate }} +
+ @for (entry of popularSearches; track $index) { + + } +
+
+ } + + @if (variant === 'filtered' && recommendedProducts.length > 0) { +
+ {{ 'search.recommendedProducts' | translate }} +
+ @for (product of recommendedProducts; track product.itemID) { + + } +
+
+ } diff --git a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss index 8f36c7f..10ba457 100644 --- a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss +++ b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss @@ -70,6 +70,39 @@ transform: translateY(-1px); } +.catalog-empty-state__block { + width: 100%; + display: grid; + gap: 10px; +} + +.catalog-empty-state__chips { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +} + +.catalog-empty-state__chip { + min-height: 34px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-primary); + color: var(--text-secondary); + padding: 0 12px; + cursor: pointer; +} + +.catalog-empty-state__recommendations { + text-align: left; +} + +.catalog-empty-state__products { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 10px; +} + @media (max-width: 640px) { .catalog-empty-state { min-height: 260px; diff --git a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts index 282117e..33d46a9 100644 --- a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts +++ b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts @@ -1,8 +1,12 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { Product } from '../../../../../core/products/models/product-domain.model'; +import { ProductCardComponent } from '../../../../../components/product-card/product-card.component'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; @Component({ selector: 'app-catalog-empty-state', standalone: true, + imports: [ProductCardComponent, TranslatePipe], templateUrl: './catalog-empty-state.component.html', styleUrls: ['./catalog-empty-state.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -13,6 +17,13 @@ export class CatalogEmptyStateComponent { @Input() message = ''; @Input() categoryTitle: string | null = null; @Input() actionLabel = ''; + @Input() popularCategories: Array<{ id: string | number; label: string }> = []; + @Input() popularSearches: string[] = []; + @Input() recommendedProducts: Product[] = []; @Output() action = new EventEmitter(); + @Output() popularCategorySelected = new EventEmitter(); + @Output() popularSearchSelected = new EventEmitter(); + @Output() recommendedProductSelected = new EventEmitter(); + @Output() recommendedAddToCart = new EventEmitter<{ product: Product; event: Event }>(); } diff --git a/src/app/features/website/catalog/components/search-box/search-box.component.html b/src/app/features/website/catalog/components/search-box/search-box.component.html index 96cc875..81c43d2 100644 --- a/src/app/features/website/catalog/components/search-box/search-box.component.html +++ b/src/app/features/website/catalog/components/search-box/search-box.component.html @@ -1,65 +1,13 @@ - + diff --git a/src/app/features/website/catalog/components/search-box/search-box.component.scss b/src/app/features/website/catalog/components/search-box/search-box.component.scss index 2a4961f..5d4e87f 100644 --- a/src/app/features/website/catalog/components/search-box/search-box.component.scss +++ b/src/app/features/website/catalog/components/search-box/search-box.component.scss @@ -1,114 +1,3 @@ -.catalog-search-box { - padding: 14px; - display: grid; - gap: 12px; -} - -.search-form { - display: grid; - grid-template-columns: 1fr auto auto; - gap: 8px; -} - -.search-form input { - min-height: 44px; - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - padding: 0 12px; - font: inherit; - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.search-form input:focus-visible { - border-color: var(--primary-color); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent); - outline: 0; -} - -.search-form button { - min-height: 44px; - border: 0; - border-radius: var(--radius-sm); - background: var(--primary-color); - color: #fff; - font-weight: 700; - padding: 0 14px; - cursor: pointer; - transition: transform 0.2s ease, filter 0.2s ease; -} - -.search-clear-btn { - min-width: 44px; - min-height: 44px; - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - background: var(--bg-primary); - color: var(--text-secondary); - font-size: 1.2rem; - line-height: 1; -} - -.search-form button:hover:not(:disabled) { - transform: translateY(-1px); - filter: brightness(0.96); -} - -.chip-list { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.chip { - min-height: 30px; - border: 1px solid var(--border-color); - border-radius: 999px; - background: var(--bg-primary); - color: var(--text-secondary); - padding: 0 10px; - cursor: pointer; - transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease; -} - -.chip:hover { - border-color: var(--primary-color); - color: var(--text-primary); - background: color-mix(in srgb, var(--primary-color) 8%, white); -} - -.chip.active { - border-color: var(--primary-color); - color: var(--text-primary); - background: color-mix(in srgb, var(--primary-color) 10%, white); -} - -.history-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.history-head button { - border: 0; - background: transparent; - color: var(--primary-color); - cursor: pointer; - font-weight: 700; -} - -.no-results { - margin: 0; - color: var(--text-secondary); -} - -@media (max-width: 680px) { - .search-form { - grid-template-columns: 1fr auto; - } - - .search-form button[type='submit'] { - grid-column: 1 / -1; - width: 100%; - } +:host { + display: block; } diff --git a/src/app/features/website/catalog/components/search-box/search-box.component.ts b/src/app/features/website/catalog/components/search-box/search-box.component.ts index 52d8322..590ef75 100644 --- a/src/app/features/website/catalog/components/search-box/search-box.component.ts +++ b/src/app/features/website/catalog/components/search-box/search-box.component.ts @@ -1,12 +1,11 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { SearchSuggestion } from '../../../../../core/search/models/search.model'; -import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { SearchBarComponent } from '../../../../search/components/search-bar/search-bar.component'; @Component({ selector: 'app-catalog-search-box', standalone: true, - imports: [FormsModule, TranslatePipe], + imports: [SearchBarComponent], templateUrl: './search-box.component.html', styleUrls: ['./search-box.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -22,64 +21,7 @@ export class CatalogSearchBoxComponent { @Output() queryChange = new EventEmitter(); @Output() searchSubmit = new EventEmitter(); - @Output() suggestionSelected = new EventEmitter(); + @Output() suggestionSelected = new EventEmitter(); @Output() recentSelected = new EventEmitter(); @Output() historyCleared = new EventEmitter(); - - activeSuggestionIndex = -1; - - onSubmit(event: Event): void { - event.preventDefault(); - this.searchSubmit.emit(this.query.trim()); - } - - onQueryInput(value: string): void { - this.queryChange.emit(value); - this.activeSuggestionIndex = -1; - } - - selectSuggestion(value: string): void { - this.suggestionSelected.emit(value); - this.activeSuggestionIndex = -1; - } - - clearQuery(): void { - this.queryChange.emit(''); - this.searchSubmit.emit(''); - this.activeSuggestionIndex = -1; - } - - onKeydown(event: KeyboardEvent): void { - if (this.suggestions.length === 0) { - return; - } - - if (event.key === 'ArrowDown') { - event.preventDefault(); - this.activeSuggestionIndex = Math.min(this.suggestions.length - 1, this.activeSuggestionIndex + 1); - return; - } - - if (event.key === 'ArrowUp') { - event.preventDefault(); - this.activeSuggestionIndex = Math.max(-1, this.activeSuggestionIndex - 1); - return; - } - - if (event.key === 'Enter' && this.activeSuggestionIndex >= 0) { - event.preventDefault(); - const active = this.suggestions[this.activeSuggestionIndex]; - this.selectSuggestion(active.text); - return; - } - - if (event.key === 'Escape') { - this.activeSuggestionIndex = -1; - } - } - - onRecentSelected(value: string): void { - this.recentSelected.emit(value); - this.activeSuggestionIndex = -1; - } } diff --git a/src/app/features/website/catalog/containers/catalog-container.component.html b/src/app/features/website/catalog/containers/catalog-container.component.html index 9506696..542f655 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.html +++ b/src/app/features/website/catalog/containers/catalog-container.component.html @@ -179,7 +179,14 @@ [title]="'catalog.noFilterMatchTitle' | translate" [message]="'catalog.noFilterMatchMessage' | translate" [actionLabel]="'catalog.clearFilters' | translate" - (action)="resetFilters()" /> + [popularCategories]="popularCategorySuggestions()" + [popularSearches]="popularSearches()" + [recommendedProducts]="recommendedProductsForEmpty()" + (action)="resetFilters()" + (popularCategorySelected)="usePopularCategory($event)" + (popularSearchSelected)="useRecentSearch($event)" + (recommendedProductSelected)="selectProduct($event)" + (recommendedAddToCart)="addRecommendedToCart($event)" /> @if (isDrawerLayout()) { diff --git a/src/app/features/website/catalog/containers/catalog-container.component.ts b/src/app/features/website/catalog/containers/catalog-container.component.ts index 0de18ae..25990f8 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.ts +++ b/src/app/features/website/catalog/containers/catalog-container.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, DestroyRef, HostListener, computed, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, HostListener, computed, effect, inject, signal } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { A11yModule } from '@angular/cdk/a11y'; @@ -30,6 +30,7 @@ import { CatalogSortingControlComponent } from '../components/sorting-control/so import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model'; import { ProductShareService } from '../../user-experience/services/product-share.service'; import { UserNotificationService } from '../../user-experience/services/user-notification.service'; +import { SearchSuggestion as SearchSuggestionItem } from '../../../../features/search/models/search.model'; type CatalogViewMode = 'categories' | 'products'; @@ -103,6 +104,21 @@ export class CatalogContainerComponent { readonly showDesktopFilters = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && !this.isDrawerLayout()); readonly showMobileToolbar = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && this.isMobile()); readonly showCatalogTools = computed(() => this.viewMode() === 'products' && this.products().length > 0 && !this.loadingProducts()); + readonly popularCategorySuggestions = computed(() => { + const sources = [...this.subcategoryChips(), ...this.categories()] + .slice(0, 8) + .map(category => ({ id: category.id, label: category.title })); + + const byId = new Map(); + for (const entry of sources) { + if (!byId.has(entry.id)) { + byId.set(entry.id, entry); + } + } + + return [...byId.values()]; + }); + readonly recommendedProductsForEmpty = computed(() => this.rawProducts().slice(0, 4)); readonly mobileSortOptions = computed(() => { const allowed = new Set(['relevance', 'latest', 'price_asc', 'price_desc', 'rating', 'popular']); const options = this.sortDefinitions().filter(option => allowed.has(option.id)); @@ -124,6 +140,10 @@ export class CatalogContainerComponent { private syncingUrl = false; constructor() { + effect(() => { + this.searchSuggestions.set(this.searchFacade.state().suggestions); + }); + this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe()); if (this.catalogConfig().searchHistoryEnabled) { const snapshot = this.searchFacade.getSearchHistory(); @@ -131,7 +151,7 @@ export class CatalogContainerComponent { this.recentSearches.set(snapshot.recent); this.popularSearches.set(snapshot.popular); } else { - this.popularSearches.set(this.searchFacade.popularSearches.map(item => item.text)); + this.popularSearches.set(this.searchFacade.popularSearches.map(item => item.title)); } combineLatest([this.route.paramMap, this.route.queryParamMap]) @@ -250,7 +270,7 @@ export class CatalogContainerComponent { return; } - this.searchSuggestions.set(this.searchFacade.buildLiveSuggestions(query, this.rawProducts())); + this.searchFacade.autocomplete(query, this.rawProducts()); } submitSearch(query: string): void { @@ -278,7 +298,8 @@ export class CatalogContainerComponent { this.loadCatalog(); } - useSuggestion(value: string): void { + useSuggestion(suggestion: SearchSuggestionItem): void { + const value = suggestion.title; this.onSearchQueryChange(value); this.submitSearch(value); } @@ -288,6 +309,15 @@ export class CatalogContainerComponent { this.submitSearch(value); } + usePopularCategory(categoryId: string | number): void { + const numeric = Number(categoryId); + if (!Number.isFinite(numeric)) { + return; + } + + this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`, numeric]); + } + clearSearchHistory(): void { const snapshot = this.searchFacade.clearSearchHistory(); this.searchHistory.set(snapshot.items); @@ -295,6 +325,10 @@ export class CatalogContainerComponent { this.popularSearches.set(snapshot.popular); } + addRecommendedToCart(payload: { product: Product; event: Event }): void { + this.addToCart(payload); + } + onFavoriteToggled(product: Product): void { const result = this.uxFacade.toggleWishlist(product); this.notifications.show( diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 3453134..e481f1c 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -127,12 +127,26 @@ export const en: Translations = { search: { title: 'Product search', placeholder: 'Enter product name...', + searchBarAria: 'Search bar', resultsCount: 'Products found:', searching: 'Searching...', retry: 'Try again', noResults: 'Nothing found', noResultsFor: 'No products found for "{{query}}"', noResultsHint: 'Try changing your query or using different keywords', + emptyResultsAria: 'Empty search results', + popularCategories: 'Popular categories', + recommendedProducts: 'Recommended products', + aiSuggestionHint: 'AI suggestion (future)', + suggestionType: { + product: 'Product', + category: 'Category', + brand: 'Brand', + collection: 'Collection', + seller: 'Seller', + 'static-page': 'Page', + ai: 'AI', + }, addToCart: 'Add to cart', loadingMore: 'Loading...', allLoaded: 'All results loaded', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 7c8a8a9..554353a 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -127,12 +127,26 @@ export const hy: Translations = { search: { title: 'Ապրանքների որոնում', placeholder: 'Մուտքագրեք ապրանքի անվանումը...', + searchBarAria: 'Որոնման դաշտ', resultsCount: 'Գտնված ապրանքներ՝', searching: 'Որոնում...', retry: 'Փորձել կրկին', noResults: 'Ոչինչ չի գտնվել', noResultsFor: '"{{query}}" հարցմամբ ապրանքներ չեն գտնվել', noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր', + emptyResultsAria: 'Դատարկ որոնման արդյունքներ', + popularCategories: 'Հանրաճանաչ կատեգորիաներ', + recommendedProducts: 'Առաջարկվող ապրանքներ', + aiSuggestionHint: 'AI առաջարկ (ապագայում)', + suggestionType: { + product: 'Ապրանք', + category: 'Կատեգորիա', + brand: 'Բրենդ', + collection: 'Հավաքածու', + seller: 'Վաճառող', + 'static-page': 'Էջ', + ai: 'AI', + }, addToCart: 'Ավելացնել զամբյուղ', loadingMore: 'Բեռնում...', allLoaded: 'Բոլոր արդյունքները բեռնված են', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 16b8f7c..5e10485 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -127,12 +127,26 @@ export const ru: Translations = { search: { title: 'Поиск товаров', placeholder: 'Введите название товара...', + searchBarAria: 'Поисковая строка', resultsCount: 'Найдено товаров:', searching: 'Поиск...', retry: 'Попробовать снова', noResults: 'Ничего не найдено', noResultsFor: 'По запросу "{{query}}" товары не найдены', noResultsHint: 'Попробуйте изменить запрос или используйте другие ключевые слова', + emptyResultsAria: 'Пустые результаты поиска', + popularCategories: 'Популярные категории', + recommendedProducts: 'Рекомендуемые товары', + aiSuggestionHint: 'AI-подсказка (в будущем)', + suggestionType: { + product: 'Товар', + category: 'Категория', + brand: 'Бренд', + collection: 'Подборка', + seller: 'Продавец', + 'static-page': 'Страница', + ai: 'AI', + }, addToCart: 'В корзину', loadingMore: 'Загрузка...', allLoaded: 'Все результаты загружены', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index dff27bf..1fe51df 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -125,12 +125,26 @@ export interface Translations { search: { title: string; placeholder: string; + searchBarAria: string; resultsCount: string; searching: string; retry: string; noResults: string; noResultsFor: string; noResultsHint: string; + emptyResultsAria: string; + popularCategories: string; + recommendedProducts: string; + aiSuggestionHint: string; + suggestionType: { + product: string; + category: string; + brand: string; + collection: string; + seller: string; + 'static-page': string; + ai: string; + }; addToCart: string; loadingMore: string; allLoaded: string;