feat(platform): sprint 11.5 standardization
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

This commit is contained in:
sdarbinyan
2026-07-09 02:29:12 +04:00
parent a16c856537
commit 8d652c8259
57 changed files with 2162 additions and 848 deletions

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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%;
}
}

View File

@@ -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;
}
}

View File

@@ -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>();

View File

@@ -17,6 +17,7 @@
[loading]="loadingProducts()"
[suggestions]="searchSuggestions()"
[recentSearches]="recentSearches()"
[popularSearches]="popularSearches()"
[searchHistory]="searchHistory()"
[noResults]="noResults()"
(queryChange)="onSearchQueryChange($event)"

View File

@@ -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 ?? {};

View File

@@ -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));
}
}