feat(search): add Search Intelligence module
This commit is contained in:
@@ -28,4 +28,43 @@
|
||||
@if (actionLabel) {
|
||||
<button type="button" class="catalog-empty-state__action" (click)="action.emit()">{{ actionLabel }}</button>
|
||||
}
|
||||
|
||||
@if (variant === 'filtered' && popularCategories.length > 0) {
|
||||
<div class="catalog-empty-state__block">
|
||||
<strong>{{ 'search.popularCategories' | translate }}</strong>
|
||||
<div class="catalog-empty-state__chips">
|
||||
@for (category of popularCategories; track category.id) {
|
||||
<button type="button" class="catalog-empty-state__chip" (click)="popularCategorySelected.emit(category.id)">{{ category.label }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (variant === 'filtered' && popularSearches.length > 0) {
|
||||
<div class="catalog-empty-state__block">
|
||||
<strong>{{ 'catalog.popularSearchesTitle' | translate }}</strong>
|
||||
<div class="catalog-empty-state__chips">
|
||||
@for (entry of popularSearches; track $index) {
|
||||
<button type="button" class="catalog-empty-state__chip" (click)="popularSearchSelected.emit(entry)">{{ entry }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (variant === 'filtered' && recommendedProducts.length > 0) {
|
||||
<div class="catalog-empty-state__block catalog-empty-state__recommendations">
|
||||
<strong>{{ 'search.recommendedProducts' | translate }}</strong>
|
||||
<div class="catalog-empty-state__products">
|
||||
@for (product of recommendedProducts; track product.itemID) {
|
||||
<app-product-card
|
||||
[item]="product"
|
||||
[title]="product.name"
|
||||
appearance="compact"
|
||||
[addToCartLabel]="'catalog.addToCart' | translate"
|
||||
(selected)="recommendedProductSelected.emit(product)"
|
||||
(addToCart)="recommendedAddToCart.emit({ product, event: $event.event })" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>();
|
||||
@Output() popularCategorySelected = new EventEmitter<string | number>();
|
||||
@Output() popularSearchSelected = new EventEmitter<string>();
|
||||
@Output() recommendedProductSelected = new EventEmitter<Product>();
|
||||
@Output() recommendedAddToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||
}
|
||||
|
||||
@@ -1,65 +1,13 @@
|
||||
<section class="catalog-search-box card">
|
||||
<form class="search-form" (submit)="onSubmit($event)">
|
||||
<input
|
||||
type="search"
|
||||
[ngModel]="query"
|
||||
(ngModelChange)="onQueryInput($event)"
|
||||
(keydown)="onKeydown($event)"
|
||||
name="query"
|
||||
[placeholder]="'catalog.searchPlaceholder' | translate"
|
||||
autocomplete="off" />
|
||||
<button type="button" class="search-clear-btn" [disabled]="!query" (click)="clearQuery()" [attr.aria-label]="'catalog.clearSearch' | translate">×</button>
|
||||
<button type="submit" [disabled]="loading">{{ 'catalog.searchSubmit' | translate }}</button>
|
||||
</form>
|
||||
|
||||
@if (suggestions.length > 0) {
|
||||
<div class="suggestions">
|
||||
<strong>{{ 'catalog.suggestionsTitle' | translate }}</strong>
|
||||
<div class="chip-list">
|
||||
@for (item of suggestions; track item.id) {
|
||||
<button type="button" class="chip" [class.active]="$index === activeSuggestionIndex" (click)="selectSuggestion(item.text)">{{ item.text }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (popularSearches.length > 0) {
|
||||
<div class="popular">
|
||||
<strong>{{ 'catalog.popularSearchesTitle' | translate }}</strong>
|
||||
<div class="chip-list">
|
||||
@for (item of popularSearches; track $index) {
|
||||
<button type="button" class="chip" (click)="onRecentSelected(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recentSearches.length > 0) {
|
||||
<div class="recent">
|
||||
<strong>{{ 'catalog.recentSearchesTitle' | translate }}</strong>
|
||||
<div class="chip-list">
|
||||
@for (item of recentSearches; track $index) {
|
||||
<button type="button" class="chip" (click)="onRecentSelected(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (searchHistory.length > 0) {
|
||||
<div class="history">
|
||||
<div class="history-head">
|
||||
<strong>{{ 'catalog.searchHistoryTitle' | translate }}</strong>
|
||||
<button type="button" (click)="historyCleared.emit()">{{ 'catalog.clearHistory' | translate }}</button>
|
||||
</div>
|
||||
<div class="chip-list">
|
||||
@for (item of searchHistory; track $index) {
|
||||
<button type="button" class="chip" (click)="onRecentSelected(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (noResults && !loading) {
|
||||
<p class="no-results">{{ 'catalog.searchNoResultsHint' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
<app-search-bar
|
||||
[query]="query"
|
||||
[loading]="loading"
|
||||
[suggestions]="suggestions"
|
||||
[recentSearches]="recentSearches"
|
||||
[popularSearches]="popularSearches"
|
||||
[searchHistory]="searchHistory"
|
||||
[noResults]="noResults"
|
||||
(queryChange)="queryChange.emit($event)"
|
||||
(searchSubmit)="searchSubmit.emit($event)"
|
||||
(suggestionSelected)="suggestionSelected.emit($event)"
|
||||
(recentSelected)="recentSelected.emit($event)"
|
||||
(historyCleared)="historyCleared.emit()" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
@Output() searchSubmit = new EventEmitter<string>();
|
||||
@Output() suggestionSelected = new EventEmitter<string>();
|
||||
@Output() suggestionSelected = new EventEmitter<SearchSuggestion>();
|
||||
@Output() recentSelected = new EventEmitter<string>();
|
||||
@Output() historyCleared = new EventEmitter<void>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
<button type="button" class="catalog-open-filters-btn" (click)="openFilterDrawer()">{{ 'catalog.openFilters' | translate }}</button>
|
||||
|
||||
@@ -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<string | number, { id: string | number; label: string }>();
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user