feat(platform): sprint 11.5 standardization
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
This commit is contained in:
@@ -4,18 +4,21 @@
|
||||
</div>
|
||||
|
||||
@for (filter of definitions; track filter.id) {
|
||||
@if (filter.enabled !== false) {
|
||||
@if (filter.enabled) {
|
||||
<section class="filter-group">
|
||||
<h4>{{ filter.label }}</h4>
|
||||
|
||||
@if (filter.type === 'multi-select') {
|
||||
@if (filter.type === 'checkbox' || filter.type === 'availability') {
|
||||
<div class="options">
|
||||
@for (option of filter.options ?? []; track option.id) {
|
||||
@for (option of filter.options; track option.id) {
|
||||
<label class="option-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(filter.id, option.value)"
|
||||
(change)="toggleOption(filter.id, option.value)" />
|
||||
@if (filter.type === 'availability') {
|
||||
<span class="availability-dot" [attr.data-availability]="option.availability ?? option.value" aria-hidden="true"></span>
|
||||
}
|
||||
<span>{{ option.label }}</span>
|
||||
@if (option.count != null) {
|
||||
<small>{{ option.count }}</small>
|
||||
@@ -25,6 +28,21 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filter.type === 'radio') {
|
||||
<div class="options">
|
||||
@for (option of filter.options; track option.id) {
|
||||
<label class="option-check">
|
||||
<input
|
||||
type="radio"
|
||||
[name]="'radio-' + filter.id"
|
||||
[checked]="isSelected(filter.id, option.value)"
|
||||
(change)="selectSingleOption(filter.id, option.value)" />
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filter.type === 'range') {
|
||||
<div class="range-inputs">
|
||||
<input
|
||||
@@ -40,6 +58,60 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filter.type === 'slider') {
|
||||
<div class="slider-inputs">
|
||||
<input
|
||||
type="range"
|
||||
[min]="filter.min ?? 0"
|
||||
[max]="filter.max ?? 1000"
|
||||
[step]="filter.step ?? 1"
|
||||
[value]="state.ranges[filter.id]?.max ?? filter.max ?? 1000"
|
||||
(input)="updateSlider(filter.id, $any($event.target).value)" />
|
||||
<span>{{ state.ranges[filter.id]?.max ?? filter.max ?? 0 }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filter.type === 'color') {
|
||||
<div class="color-options">
|
||||
@for (option of filter.options; track option.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-swatch"
|
||||
[class.active]="isSelected(filter.id, option.value)"
|
||||
[style.background]="option.colorHex ?? '#94a3b8'"
|
||||
[attr.aria-label]="option.label"
|
||||
(click)="toggleOption(filter.id, option.value)"></button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filter.type === 'size') {
|
||||
<div class="size-options">
|
||||
@for (option of filter.options; track option.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="size-chip"
|
||||
[class.active]="isSelected(filter.id, option.value)"
|
||||
(click)="toggleOption(filter.id, option.value)">{{ option.label }}</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filter.type === 'rating') {
|
||||
<div class="rating-options">
|
||||
@for (option of filter.options; track option.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="rating-chip"
|
||||
[class.active]="isSelected(filter.id, option.value)"
|
||||
(click)="toggleOption(filter.id, option.value)">
|
||||
<span aria-hidden="true">★</span>
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filter.type === 'toggle') {
|
||||
<label class="option-check">
|
||||
<input
|
||||
|
||||
@@ -37,13 +37,32 @@
|
||||
|
||||
.option-check {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.availability-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.availability-dot[data-availability='in-stock'] {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.availability-dot[data-availability='low-stock'] {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.availability-dot[data-availability='out-of-stock'] {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.range-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -57,6 +76,59 @@
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.slider-inputs {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slider-inputs input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-inputs span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.color-options,
|
||||
.size-options,
|
||||
.rating-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-swatch.active {
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.size-chip,
|
||||
.rating-chip {
|
||||
min-height: 30px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-primary);
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.size-chip.active,
|
||||
.rating-chip.active {
|
||||
border-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, white);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.catalog-filters {
|
||||
gap: 12px;
|
||||
@@ -65,4 +137,8 @@
|
||||
.range-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.option-check {
|
||||
grid-template-columns: auto auto 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FilterDefinition } from '../../../../../core/products/models/catalog-experience.model';
|
||||
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
export interface CatalogFilterStateValue {
|
||||
values: Record<string, string[]>;
|
||||
ranges: Record<string, { min?: number; max?: number }>;
|
||||
toggles: Record<string, boolean>;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalog-filters-panel',
|
||||
standalone: true,
|
||||
@@ -18,10 +12,10 @@ export interface CatalogFilterStateValue {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CatalogFiltersPanelComponent {
|
||||
@Input() definitions: FilterDefinition[] = [];
|
||||
@Input() state: CatalogFilterStateValue = { values: {}, ranges: {}, toggles: {} };
|
||||
@Input() definitions: FilterGroup[] = [];
|
||||
@Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
|
||||
|
||||
@Output() stateChange = new EventEmitter<CatalogFilterStateValue>();
|
||||
@Output() stateChange = new EventEmitter<SearchFilterState>();
|
||||
|
||||
isSelected(filterId: string, optionValue: string): boolean {
|
||||
return (this.state.values[filterId] ?? []).includes(optionValue);
|
||||
@@ -42,6 +36,16 @@ export class CatalogFiltersPanelComponent {
|
||||
});
|
||||
}
|
||||
|
||||
selectSingleOption(filterId: string, optionValue: string): void {
|
||||
this.stateChange.emit({
|
||||
...this.state,
|
||||
values: {
|
||||
...this.state.values,
|
||||
[filterId]: [optionValue],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
|
||||
const value = rawValue.trim().length ? Number(rawValue) : undefined;
|
||||
const current = this.state.ranges[filterId] ?? {};
|
||||
@@ -58,6 +62,20 @@ export class CatalogFiltersPanelComponent {
|
||||
});
|
||||
}
|
||||
|
||||
updateSlider(filterId: string, value: string): void {
|
||||
const numeric = Number(value);
|
||||
this.stateChange.emit({
|
||||
...this.state,
|
||||
ranges: {
|
||||
...this.state.ranges,
|
||||
[filterId]: {
|
||||
...this.state.ranges[filterId],
|
||||
max: Number.isFinite(numeric) ? numeric : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateToggle(filterId: string, checked: boolean): void {
|
||||
this.stateChange.emit({
|
||||
...this.state,
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
<input
|
||||
type="search"
|
||||
[ngModel]="query"
|
||||
(ngModelChange)="queryChange.emit($event)"
|
||||
(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>
|
||||
|
||||
@@ -14,8 +16,19 @@
|
||||
<div class="suggestions">
|
||||
<strong>{{ 'catalog.suggestionsTitle' | translate }}</strong>
|
||||
<div class="chip-list">
|
||||
@for (item of suggestions; track $index) {
|
||||
<button type="button" class="chip" (click)="selectSuggestion(item)">{{ item }}</button>
|
||||
@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>
|
||||
@@ -26,7 +39,7 @@
|
||||
<strong>{{ 'catalog.recentSearchesTitle' | translate }}</strong>
|
||||
<div class="chip-list">
|
||||
@for (item of recentSearches; track $index) {
|
||||
<button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button>
|
||||
<button type="button" class="chip" (click)="onRecentSelected(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,7 +53,7 @@
|
||||
</div>
|
||||
<div class="chip-list">
|
||||
@for (item of searchHistory; track $index) {
|
||||
<button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button>
|
||||
<button type="button" class="chip" (click)="onRecentSelected(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
.search-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,17 @@
|
||||
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);
|
||||
@@ -65,6 +76,12 @@
|
||||
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;
|
||||
@@ -87,10 +104,11 @@
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.search-form {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.search-form button {
|
||||
.search-form button[type='submit'] {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@Component({
|
||||
@@ -13,8 +14,9 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
export class CatalogSearchBoxComponent {
|
||||
@Input() query = '';
|
||||
@Input() loading = false;
|
||||
@Input() suggestions: string[] = [];
|
||||
@Input() suggestions: SearchSuggestion[] = [];
|
||||
@Input() recentSearches: string[] = [];
|
||||
@Input() popularSearches: string[] = [];
|
||||
@Input() searchHistory: string[] = [];
|
||||
@Input() noResults = false;
|
||||
|
||||
@@ -24,12 +26,60 @@ export class CatalogSearchBoxComponent {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SortDefinition } from '../../../../../core/products/models/catalog-experience.model';
|
||||
import { SortOption } from '../../../../../core/search/models/search.model';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
@@ -12,7 +12,7 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CatalogSortingControlComponent {
|
||||
@Input() options: SortDefinition[] = [];
|
||||
@Input() options: SortOption[] = [];
|
||||
@Input() selected = 'relevance';
|
||||
|
||||
@Output() selectedChange = new EventEmitter<string>();
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
[loading]="loadingProducts()"
|
||||
[suggestions]="searchSuggestions()"
|
||||
[recentSearches]="recentSearches()"
|
||||
[popularSearches]="popularSearches()"
|
||||
[searchHistory]="searchHistory()"
|
||||
[noResults]="noResults()"
|
||||
(queryChange)="onSearchQueryChange($event)"
|
||||
|
||||
@@ -5,11 +5,11 @@ import { A11yModule } from '@angular/cdk/a11y';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Category } from '../../../../core/categories/models/category-domain.model';
|
||||
import { CatalogLayoutMode, FilterDefinition, SearchCriteria, SearchResult, SortDefinition, toSearchResult } from '../../../../core/products/models/catalog-experience.model';
|
||||
import { CatalogLayoutMode } from '../../../../core/products/models/catalog-experience.model';
|
||||
import { Product } from '../../../../core/products/models/product-domain.model';
|
||||
import { ConfigService } from '../../../../core/config/config.service';
|
||||
import { CategoryFacade } from '../../../../facades/platform/category.facade';
|
||||
import { ProductFacade } from '../../../../facades/platform/product.facade';
|
||||
import { SearchFacade } from '../../../../facades/platform/search.facade';
|
||||
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
|
||||
import { CartService } from '../../../../services';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
@@ -18,15 +18,16 @@ import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { DEFAULT_CATALOG_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../shared/models/config';
|
||||
import { FilterGroup, SearchFilterState, SearchQuery, SearchResult, SearchSuggestion, SortOption } from '../../../../core/search/models/search.model';
|
||||
import { SearchState } from '../../../../core/search/models/search-state.model';
|
||||
import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component';
|
||||
import { CatalogEmptyStateComponent } from '../components/catalog-empty-state/catalog-empty-state.component';
|
||||
import { CatalogFiltersPanelComponent, CatalogFilterStateValue } from '../components/filters-panel/filters-panel.component';
|
||||
import { CatalogFiltersPanelComponent } from '../components/filters-panel/filters-panel.component';
|
||||
import { CatalogLayoutSwitcherComponent } from '../components/layout-switcher/layout-switcher.component';
|
||||
import { CatalogSearchBoxComponent } from '../components/search-box/search-box.component';
|
||||
import { CatalogSearchResultsComponent } from '../components/search-results/search-results.component';
|
||||
import { CatalogSortingControlComponent } from '../components/sorting-control/sorting-control.component';
|
||||
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
|
||||
import { CatalogSearchHistoryService } from '../services/catalog-search-history.service';
|
||||
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
||||
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
||||
|
||||
@@ -58,11 +59,10 @@ export class CatalogContainerComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly configService = inject(ConfigService);
|
||||
private readonly categoryFacade = inject(CategoryFacade);
|
||||
private readonly productFacade = inject(ProductFacade);
|
||||
private readonly searchFacade = inject(SearchFacade);
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly prefetchService = inject(PrefetchService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly searchHistoryService = inject(CatalogSearchHistoryService);
|
||||
private readonly uxFacade = inject(UserExperienceFacade);
|
||||
private readonly shareService = inject(ProductShareService);
|
||||
private readonly notifications = inject(UserNotificationService);
|
||||
@@ -78,12 +78,13 @@ export class CatalogContainerComponent {
|
||||
readonly subcategoryChips = signal<Category[]>([]);
|
||||
readonly breadcrumb = signal<Category[]>([]);
|
||||
readonly searchSummary = signal('');
|
||||
readonly searchResult = signal<SearchResult | null>(null);
|
||||
readonly filterDefinitions = signal<FilterDefinition[]>([]);
|
||||
readonly filterState = signal<CatalogFilterStateValue>({ values: {}, ranges: {}, toggles: {} });
|
||||
readonly searchSuggestions = signal<string[]>([]);
|
||||
readonly searchResult = signal<SearchResult<Product> | null>(null);
|
||||
readonly filterDefinitions = signal<FilterGroup[]>([]);
|
||||
readonly filterState = signal<SearchFilterState>({ values: {}, ranges: {}, toggles: {} });
|
||||
readonly searchSuggestions = signal<SearchSuggestion[]>([]);
|
||||
readonly recentSearches = signal<string[]>([]);
|
||||
readonly searchHistory = signal<string[]>(this.catalogConfig().searchHistoryEnabled ? this.searchHistoryService.getHistory() : []);
|
||||
readonly popularSearches = signal<string[]>([]);
|
||||
readonly searchHistory = signal<string[]>([]);
|
||||
readonly savedSearches = this.uxFacade.savedSearches;
|
||||
readonly viewMode = signal<CatalogViewMode>('categories');
|
||||
readonly loading = signal(true);
|
||||
@@ -109,19 +110,7 @@ export class CatalogContainerComponent {
|
||||
readonly favoriteIds = computed(() => this.uxFacade.wishlist().map(item => item.product.itemID));
|
||||
readonly comparedIds = computed(() => this.uxFacade.comparedProducts().map(item => item.product.itemID));
|
||||
|
||||
readonly sortDefinitions = computed<SortDefinition[]>(() => {
|
||||
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 this.catalogConfig().availableSorts.map((id: string) => ({ id, label: labels[id] ?? id, enabled: true }));
|
||||
});
|
||||
readonly sortDefinitions = computed<SortOption[]>(() => this.searchFacade.createSortOptions(this.catalogConfig().availableSorts));
|
||||
|
||||
readonly availableLayouts: CatalogLayoutMode[] = ['grid', 'large-grid', 'compact-grid', 'list'];
|
||||
|
||||
@@ -130,30 +119,39 @@ export class CatalogContainerComponent {
|
||||
private dataSubscription?: Subscription;
|
||||
private readonly backendFetchSize = 200;
|
||||
private pendingScrollY: number | null = null;
|
||||
private syncingUrl = false;
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe());
|
||||
this.recentSearches.set(this.searchHistory().slice(0, 5));
|
||||
if (this.catalogConfig().searchHistoryEnabled) {
|
||||
const snapshot = this.searchFacade.getSearchHistory();
|
||||
this.searchHistory.set(snapshot.items);
|
||||
this.recentSearches.set(snapshot.recent);
|
||||
this.popularSearches.set(snapshot.popular);
|
||||
} else {
|
||||
this.popularSearches.set(this.searchFacade.popularSearches.map(item => item.text));
|
||||
}
|
||||
|
||||
combineLatest([this.route.paramMap, this.route.queryParamMap])
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(([params, queryParams]) => {
|
||||
const categoryId = Number(params.get('id')) || null;
|
||||
const searchQuery = (queryParams.get('q') ?? '').trim();
|
||||
|
||||
if (categoryId == null && searchQuery.length === 0 && this.userExperienceConfig().continueBrowsing.enabled && this.restoreContinueBrowsing()) {
|
||||
if (this.syncingUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enterCategory(categoryId);
|
||||
const categoryId = Number(params.get('id')) || null;
|
||||
const queryPatch = this.searchFacade.fromQueryParams(queryParams);
|
||||
const hasQueryState = this.hasSearchState(queryPatch);
|
||||
|
||||
if (searchQuery.length > 0) {
|
||||
this.submitSearch(searchQuery);
|
||||
if (categoryId == null && !hasQueryState && this.userExperienceConfig().continueBrowsing.enabled && this.restoreContinueBrowsing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enterCategory(categoryId, queryPatch, hasQueryState);
|
||||
});
|
||||
}
|
||||
|
||||
enterCategory(categoryId: number | null): void {
|
||||
enterCategory(categoryId: number | null, queryPatch?: Partial<SearchState>, forceProductsMode = false): void {
|
||||
this.dataSubscription?.unsubscribe();
|
||||
this.loading.set(true);
|
||||
this.loadingProducts.set(false);
|
||||
@@ -179,7 +177,9 @@ export class CatalogContainerComponent {
|
||||
},
|
||||
});
|
||||
|
||||
if (categoryId == null) {
|
||||
this.applySearchStatePatch(queryPatch);
|
||||
|
||||
if (categoryId == null && !forceProductsMode) {
|
||||
this.dataSubscription = this.categoryFacade.getRootCategories()
|
||||
.subscribe({
|
||||
next: categories => this.renderCategories(null, categories, []),
|
||||
@@ -188,10 +188,18 @@ export class CatalogContainerComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (categoryId == null && forceProductsMode) {
|
||||
this.viewMode.set('products');
|
||||
this.loadCatalog();
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCategoryId = categoryId as number;
|
||||
|
||||
this.dataSubscription = combineLatest([
|
||||
this.categoryFacade.getCategoryById(categoryId),
|
||||
this.categoryFacade.getChildren(categoryId),
|
||||
this.categoryFacade.getBreadcrumb(categoryId),
|
||||
this.categoryFacade.getCategoryById(selectedCategoryId),
|
||||
this.categoryFacade.getChildren(selectedCategoryId),
|
||||
this.categoryFacade.getBreadcrumb(selectedCategoryId),
|
||||
]).subscribe({
|
||||
next: ([category, children, breadcrumb]) => {
|
||||
this.breadcrumb.set(breadcrumb);
|
||||
@@ -203,7 +211,7 @@ export class CatalogContainerComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadProducts(categoryId);
|
||||
this.loadProducts(selectedCategoryId);
|
||||
},
|
||||
error: () => this.setError('catalog.error'),
|
||||
});
|
||||
@@ -235,18 +243,12 @@ export class CatalogContainerComponent {
|
||||
this.state.update(current => ({ ...current, search: query }));
|
||||
this.persistContinueBrowsing();
|
||||
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) {
|
||||
if (!this.catalogConfig().suggestionsEnabled) {
|
||||
this.searchSuggestions.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = this.rawProducts()
|
||||
.map(product => product.name)
|
||||
.filter(name => name.toLowerCase().includes(normalized))
|
||||
.slice(0, 6);
|
||||
|
||||
this.searchSuggestions.set([...new Set(suggestions)]);
|
||||
this.searchSuggestions.set(this.searchFacade.buildLiveSuggestions(query, this.rawProducts()));
|
||||
}
|
||||
|
||||
submitSearch(query: string): void {
|
||||
@@ -263,10 +265,13 @@ export class CatalogContainerComponent {
|
||||
}));
|
||||
|
||||
if (this.catalogConfig().searchHistoryEnabled && normalized.length > 0) {
|
||||
this.searchHistory.set(this.searchHistoryService.push(normalized));
|
||||
this.recentSearches.set(this.searchHistory().slice(0, 5));
|
||||
const snapshot = this.searchFacade.pushSearchHistory(normalized);
|
||||
this.searchHistory.set(snapshot.items);
|
||||
this.recentSearches.set(snapshot.recent);
|
||||
this.popularSearches.set(snapshot.popular);
|
||||
}
|
||||
|
||||
this.syncUrlFromState();
|
||||
this.persistContinueBrowsing();
|
||||
this.loadCatalog();
|
||||
}
|
||||
@@ -282,9 +287,10 @@ export class CatalogContainerComponent {
|
||||
}
|
||||
|
||||
clearSearchHistory(): void {
|
||||
this.searchHistoryService.clear();
|
||||
this.searchHistory.set([]);
|
||||
this.recentSearches.set([]);
|
||||
const snapshot = this.searchFacade.clearSearchHistory();
|
||||
this.searchHistory.set(snapshot.items);
|
||||
this.recentSearches.set(snapshot.recent);
|
||||
this.popularSearches.set(snapshot.popular);
|
||||
}
|
||||
|
||||
onFavoriteToggled(product: Product): void {
|
||||
@@ -375,9 +381,14 @@ export class CatalogContainerComponent {
|
||||
...current.pagination,
|
||||
page: 1,
|
||||
skip: 0
|
||||
},
|
||||
filters: {
|
||||
...current.filters,
|
||||
categoryIds: target.categoryId == null ? [] : [target.categoryId],
|
||||
}
|
||||
}));
|
||||
this.filterState.set(target.filters);
|
||||
this.syncUrlFromState();
|
||||
this.loadCatalog(target.categoryId);
|
||||
}
|
||||
|
||||
@@ -406,7 +417,7 @@ export class CatalogContainerComponent {
|
||||
}
|
||||
}
|
||||
|
||||
onFilterStateChange(next: CatalogFilterStateValue): void {
|
||||
onFilterStateChange(next: SearchFilterState): void {
|
||||
this.filterState.set(next);
|
||||
this.state.update(current => ({
|
||||
...current,
|
||||
@@ -418,12 +429,14 @@ export class CatalogContainerComponent {
|
||||
}));
|
||||
|
||||
this.recomputeResults();
|
||||
this.syncUrlFromState();
|
||||
this.persistContinueBrowsing();
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.filterState.set({ values: {}, ranges: {}, toggles: {} });
|
||||
this.recomputeResults();
|
||||
this.syncUrlFromState();
|
||||
this.persistContinueBrowsing();
|
||||
}
|
||||
|
||||
@@ -439,11 +452,13 @@ export class CatalogContainerComponent {
|
||||
}));
|
||||
|
||||
this.recomputeResults();
|
||||
this.syncUrlFromState();
|
||||
this.persistContinueBrowsing();
|
||||
}
|
||||
|
||||
changeLayout(layout: CatalogLayoutMode): void {
|
||||
this.state.update(current => ({ ...current, layout }));
|
||||
this.syncUrlFromState();
|
||||
this.persistContinueBrowsing();
|
||||
}
|
||||
|
||||
@@ -495,6 +510,7 @@ export class CatalogContainerComponent {
|
||||
}));
|
||||
|
||||
this.recomputeResults();
|
||||
this.syncUrlFromState();
|
||||
this.persistContinueBrowsing();
|
||||
}
|
||||
|
||||
@@ -515,14 +531,22 @@ export class CatalogContainerComponent {
|
||||
|
||||
private loadCatalog(categoryId?: number): void {
|
||||
this.loadingProducts.set(true);
|
||||
const categoryID = categoryId ?? this.state().category?.id ?? this.state().filters.categoryIds[0] ?? null;
|
||||
const criteria = this.buildCriteria(categoryID);
|
||||
const categoryID = categoryId ?? this.state().category?.id ?? this.state().filters.categoryIds[0];
|
||||
const query: SearchQuery = {
|
||||
text: this.state().search,
|
||||
categoryIds: categoryID == null ? [] : [categoryID],
|
||||
subcategoryIds: [],
|
||||
filters: this.filterState(),
|
||||
sort: this.state().sort,
|
||||
page: 1,
|
||||
pageSize: this.backendFetchSize,
|
||||
};
|
||||
|
||||
this.dataSubscription = this.productFacade.loadCatalog(criteria)
|
||||
this.dataSubscription = this.searchFacade.loadCatalog(query)
|
||||
.subscribe({
|
||||
next: result => {
|
||||
this.rawProducts.set(result.items);
|
||||
this.buildFilterDefinitions(result.items);
|
||||
this.filterDefinitions.set(this.searchFacade.buildFilterGroups(result.items, this.catalogConfig().enabledFilters));
|
||||
this.recomputeResults();
|
||||
this.loading.set(false);
|
||||
this.loadingProducts.set(false);
|
||||
@@ -531,119 +555,6 @@ export class CatalogContainerComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private buildCriteria(categoryId: number | null): SearchCriteria {
|
||||
const state = this.state();
|
||||
|
||||
return {
|
||||
keyword: state.search,
|
||||
categoryIDs: categoryId == null ? [] : [categoryId],
|
||||
sort: state.sort,
|
||||
page: 1,
|
||||
pageSize: this.backendFetchSize,
|
||||
minPrice: this.filterState().ranges['price']?.min,
|
||||
maxPrice: this.filterState().ranges['price']?.max,
|
||||
discountOnly: this.filterState().toggles['discount'],
|
||||
newOnly: this.filterState().toggles['new']
|
||||
};
|
||||
}
|
||||
|
||||
private buildFilterDefinitions(products: Product[]): void {
|
||||
const enabled = new Set(this.catalogConfig().enabledFilters);
|
||||
const priceValues = products.map(product => product.price).filter(price => Number.isFinite(price));
|
||||
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 ratings = [5, 4, 3, 2, 1];
|
||||
|
||||
const definitions: FilterDefinition[] = [
|
||||
{
|
||||
id: 'price',
|
||||
label: this.translate.t('catalog.filterPrice'),
|
||||
type: 'range',
|
||||
min: priceValues.length ? Math.min(...priceValues) : 0,
|
||||
max: priceValues.length ? Math.max(...priceValues) : 0,
|
||||
enabled: enabled.has('price')
|
||||
},
|
||||
{
|
||||
id: 'availability',
|
||||
label: this.translate.t('catalog.filterAvailability'),
|
||||
type: 'multi-select',
|
||||
options: [
|
||||
{ id: 'in-stock', label: this.translate.t('catalog.filterInStock'), value: 'in-stock' },
|
||||
{ id: 'low-stock', label: this.translate.t('catalog.filterLowStock'), value: 'low-stock' },
|
||||
{ id: 'out-of-stock', label: this.translate.t('catalog.filterOutOfStock'), value: 'out-of-stock' }
|
||||
],
|
||||
enabled: enabled.has('availability')
|
||||
},
|
||||
{
|
||||
id: 'rating',
|
||||
label: this.translate.t('catalog.filterRating'),
|
||||
type: 'multi-select',
|
||||
options: ratings.map(value => ({ id: `rating-${value}`, label: this.translate.t('catalog.filterStars', { count: value }), value: String(value) })),
|
||||
enabled: enabled.has('rating')
|
||||
},
|
||||
{
|
||||
id: 'brand',
|
||||
label: this.translate.t('catalog.filterBrand'),
|
||||
type: 'multi-select',
|
||||
options: brands.map(value => ({ id: `brand-${value}`, label: value, value })),
|
||||
enabled: enabled.has('brand')
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
label: this.translate.t('catalog.filterCategory'),
|
||||
type: 'multi-select',
|
||||
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: 'multi-select',
|
||||
options: subcategories.map(value => ({ id: `sub-${value}`, label: value, value })),
|
||||
enabled: enabled.has('subcategory')
|
||||
},
|
||||
{
|
||||
id: 'discount',
|
||||
label: this.translate.t('catalog.filterDiscount'),
|
||||
type: 'toggle',
|
||||
enabled: enabled.has('discount')
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
label: this.translate.t('catalog.filterNew'),
|
||||
type: 'toggle',
|
||||
enabled: enabled.has('new')
|
||||
},
|
||||
{
|
||||
id: 'color',
|
||||
label: this.translate.t('catalog.filterColor'),
|
||||
type: 'multi-select',
|
||||
options: colors.map(value => ({ id: `color-${value}`, label: value, value })),
|
||||
enabled: enabled.has('color')
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
label: this.translate.t('catalog.filterSize'),
|
||||
type: 'multi-select',
|
||||
options: sizes.map(value => ({ id: `size-${value}`, label: value, value })),
|
||||
enabled: enabled.has('size')
|
||||
},
|
||||
{
|
||||
id: 'attributes',
|
||||
label: this.translate.t('catalog.filterAttributes'),
|
||||
type: 'multi-select',
|
||||
options: attributes.map(value => ({ id: `attr-${value}`, label: value, value })),
|
||||
enabled: enabled.has('attributes')
|
||||
}
|
||||
];
|
||||
|
||||
this.filterDefinitions.set(definitions);
|
||||
}
|
||||
|
||||
private recomputeResults(): void {
|
||||
if (this.rawProducts().length === 0) {
|
||||
this.products.set([]);
|
||||
@@ -663,40 +574,29 @@ export class CatalogContainerComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = this.applyFilters(this.rawProducts());
|
||||
const sorted = this.applySort(filtered);
|
||||
const filtered = this.searchFacade.applyFilters(this.rawProducts(), this.filterState(), this.state().search);
|
||||
const sorted = this.searchFacade.applySort(filtered, this.state().sort);
|
||||
const pagination = this.state().pagination;
|
||||
const page = Math.max(1, pagination.page);
|
||||
const pageSize = Math.max(1, pagination.count);
|
||||
const skip = (page - 1) * pageSize;
|
||||
const paged = sorted.slice(skip, skip + pageSize);
|
||||
const paged = this.searchFacade.paginateProducts(sorted, page, pageSize);
|
||||
|
||||
this.products.set(paged);
|
||||
this.products.set(paged.items);
|
||||
this.state.update(current => ({
|
||||
...current,
|
||||
pagination: {
|
||||
...current.pagination,
|
||||
total: sorted.length,
|
||||
skip,
|
||||
hasMore: skip + pageSize < sorted.length
|
||||
skip: paged.skip,
|
||||
hasMore: paged.skip + paged.pageSize < sorted.length
|
||||
}
|
||||
}));
|
||||
|
||||
const result = toSearchResult({
|
||||
items: paged,
|
||||
total: sorted.length,
|
||||
count: pageSize,
|
||||
skip
|
||||
}, {
|
||||
keyword: this.state().search,
|
||||
page,
|
||||
pageSize,
|
||||
sort: this.state().sort
|
||||
});
|
||||
const result = this.searchFacade.buildResult(this.toSearchState(), sorted.length, paged.items);
|
||||
|
||||
this.searchResult.set(result);
|
||||
this.searchSummary.set(result.summary);
|
||||
this.searchSuggestions.set(this.buildSuggestions(this.state().search, sorted));
|
||||
this.searchSuggestions.set(this.searchFacade.buildLiveSuggestions(this.state().search, sorted));
|
||||
|
||||
if (this.pendingScrollY != null && typeof window !== 'undefined') {
|
||||
const scrollY = this.pendingScrollY;
|
||||
@@ -705,138 +605,64 @@ export class CatalogContainerComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private buildSuggestions(query: string, products: Product[]): string[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) {
|
||||
return [];
|
||||
private toSearchState(): SearchState {
|
||||
const current = this.state();
|
||||
|
||||
return {
|
||||
text: current.search,
|
||||
sort: current.sort,
|
||||
layout: current.layout,
|
||||
page: current.pagination.page,
|
||||
pageSize: current.pagination.count,
|
||||
filters: this.filterState(),
|
||||
};
|
||||
}
|
||||
|
||||
private applySearchStatePatch(patch?: Partial<SearchState>): void {
|
||||
if (!patch) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.collectUnique(
|
||||
products
|
||||
.map(product => product.name)
|
||||
.filter(name => name.toLowerCase().includes(normalized))
|
||||
.slice(0, 6)
|
||||
if (patch.filters) {
|
||||
this.filterState.set(patch.filters);
|
||||
}
|
||||
|
||||
this.state.update(current => ({
|
||||
...current,
|
||||
search: patch.text ?? current.search,
|
||||
sort: patch.sort ?? current.sort,
|
||||
layout: patch.layout ?? current.layout,
|
||||
pagination: {
|
||||
...current.pagination,
|
||||
page: patch.page ?? current.pagination.page,
|
||||
count: patch.pageSize ?? current.pagination.count,
|
||||
skip: Math.max(0, ((patch.page ?? current.pagination.page) - 1) * (patch.pageSize ?? current.pagination.count)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private hasSearchState(patch: Partial<SearchState>): boolean {
|
||||
return Boolean(
|
||||
patch.text?.length ||
|
||||
patch.sort ||
|
||||
patch.layout ||
|
||||
(patch.page && patch.page > 1) ||
|
||||
patch.filters
|
||||
);
|
||||
}
|
||||
|
||||
private applyFilters(products: Product[]): Product[] {
|
||||
const filterState = this.filterState();
|
||||
const query = this.state().search.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;
|
||||
private syncUrlFromState(): void {
|
||||
const queryParams = this.searchFacade.toQueryParams(this.toSearchState());
|
||||
this.syncingUrl = true;
|
||||
void this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams,
|
||||
replaceUrl: true,
|
||||
}).finally(() => {
|
||||
this.syncingUrl = false;
|
||||
});
|
||||
}
|
||||
|
||||
private applySort(products: Product[]): Product[] {
|
||||
const sort = this.state().sort;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private collectUnique(values: string[]): string[] {
|
||||
return [...new Set(values.map(value => value.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
private resolveCatalogConfig() {
|
||||
const snapshot = this.configService.getBootstrapSnapshot() as any;
|
||||
const raw = snapshot?.catalog ?? {};
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const STORAGE_KEY = 'marketplace.catalog.search.history';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CatalogSearchHistoryService {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
push(term: string): string[] {
|
||||
const normalized = term.trim();
|
||||
if (!normalized.length) {
|
||||
return this.getHistory();
|
||||
}
|
||||
|
||||
const history = [normalized, ...this.getHistory().filter(entry => entry.toLowerCase() !== normalized.toLowerCase())]
|
||||
.slice(0, this.maxSize);
|
||||
|
||||
this.save(history);
|
||||
return history;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
private save(history: string[]): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Attribute</th>
|
||||
<th>{{ 'ux.compareAttribute' | translate }}</th>
|
||||
@for (product of products; track product.itemID) {
|
||||
<th>
|
||||
<div class="compare-product-title">{{ product.name }}</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, computed } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, Input, computed, inject } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { TranslateService } from '../../../../../i18n/translate.service';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
interface CompareRow {
|
||||
key: string;
|
||||
@@ -11,12 +13,14 @@ interface CompareRow {
|
||||
@Component({
|
||||
selector: 'app-compare-table',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './compare-table.component.html',
|
||||
styleUrls: ['./compare-table.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CompareTableComponent {
|
||||
private readonly i18n = inject(TranslateService);
|
||||
|
||||
@Input() products: Product[] = [];
|
||||
@Input() hideIdentical = false;
|
||||
@Input() highlightDifferences = true;
|
||||
@@ -28,12 +32,12 @@ export class CompareTableComponent {
|
||||
}
|
||||
|
||||
const baseRows: CompareRow[] = [
|
||||
this.toRow('price', 'Price', products.map(product => `${product.price.toFixed(2)} ${product.currency}`)),
|
||||
this.toRow('rating', 'Rating', products.map(product => `${(product.rating ?? 0).toFixed(1)}`)),
|
||||
this.toRow('stock', 'Stock', products.map(product => product.remainings ?? 'unknown')),
|
||||
this.toRow('discount', 'Discount', products.map(product => `${product.discount ?? 0}%`)),
|
||||
this.toRow('color', 'Color', products.map(product => product.colour ?? '—')),
|
||||
this.toRow('size', 'Size', products.map(product => product.size ?? '—'))
|
||||
this.toRow('price', this.i18n.t('ux.comparePrice'), products.map(product => `${product.price.toFixed(2)} ${product.currency}`)),
|
||||
this.toRow('rating', this.i18n.t('ux.compareRating'), products.map(product => `${(product.rating ?? 0).toFixed(1)}`)),
|
||||
this.toRow('stock', this.i18n.t('ux.compareStock'), products.map(product => product.remainings ?? this.i18n.t('ux.compareUnknown'))),
|
||||
this.toRow('discount', this.i18n.t('ux.compareDiscount'), products.map(product => `${product.discount ?? 0}%`)),
|
||||
this.toRow('color', this.i18n.t('ux.compareColor'), products.map(product => product.colour ?? '—')),
|
||||
this.toRow('size', this.i18n.t('ux.compareSize'), products.map(product => product.size ?? '—'))
|
||||
];
|
||||
|
||||
const dynamicKeys = [...new Set(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key)))];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ProductCardComponent } from '../../../../../components/product-card/pro
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../../i18n/translate.service';
|
||||
import { CartService } from '../../../../../services';
|
||||
import { UserNotificationService } from '../../services/user-notification.service';
|
||||
|
||||
@@ -15,13 +16,14 @@ import { UserNotificationService } from '../../services/user-notification.servic
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RecentlyViewedStripComponent {
|
||||
@Input() title = 'Recently Viewed';
|
||||
@Input() title = '';
|
||||
@Input() maxItems = 8;
|
||||
@Input() showClear = false;
|
||||
|
||||
private readonly uxFacade = inject(UserExperienceFacade);
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly notifications = inject(UserNotificationService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly items = computed(() => this.uxFacade.recentlyViewed().slice(0, Math.max(1, this.maxItems)));
|
||||
readonly hasItems = computed(() => this.items().length > 0);
|
||||
@@ -30,12 +32,12 @@ export class RecentlyViewedStripComponent {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cartService.addItem(itemID);
|
||||
this.notifications.show('Added to cart', 'success');
|
||||
this.notifications.show(this.translate.t('catalog.addToCart'), 'success');
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.uxFacade.clearRecentlyViewed();
|
||||
this.notifications.show('Recently viewed cleared', 'info');
|
||||
this.notifications.show(this.translate.t('ux.clearRecentlyViewed'), 'info');
|
||||
}
|
||||
|
||||
itemProduct(index: number): Product {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RouterLink } from '@angular/router';
|
||||
import { ProductCardComponent } from '../../../../../components/product-card/product-card.component';
|
||||
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../../i18n/translate.service';
|
||||
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
|
||||
import { CartService } from '../../../../../services';
|
||||
import { UserNotificationService } from '../../services/user-notification.service';
|
||||
@@ -18,6 +19,7 @@ import { UserNotificationService } from '../../services/user-notification.servic
|
||||
export class WishlistPageComponent {
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly uxFacade = inject(UserExperienceFacade);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly notifications = inject(UserNotificationService);
|
||||
|
||||
readonly favorites = this.uxFacade.wishlist;
|
||||
@@ -27,7 +29,7 @@ export class WishlistPageComponent {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cartService.addItem(itemId);
|
||||
this.notifications.show('Added to cart', 'success');
|
||||
this.notifications.show(this.translate.t('ux.addedToCart'), 'success');
|
||||
}
|
||||
|
||||
removeFromWishlist(itemId: number): void {
|
||||
@@ -37,11 +39,11 @@ export class WishlistPageComponent {
|
||||
}
|
||||
|
||||
this.uxFacade.toggleWishlist(target.product);
|
||||
this.notifications.show('Removed from wishlist', 'info');
|
||||
this.notifications.show(this.translate.t('ux.wishlistRemoved'), 'info');
|
||||
}
|
||||
|
||||
clearWishlist(): void {
|
||||
this.uxFacade.clearWishlist();
|
||||
this.notifications.show('Wishlist cleared', 'info');
|
||||
this.notifications.show(this.translate.t('ux.wishlistCleared'), 'info');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user