feat(catalog): implement sprint 10 advanced search experience
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

This commit is contained in:
sdarbinyan
2026-07-09 00:55:50 +04:00
parent 3ef0bd711d
commit 1a8f916942
40 changed files with 1917 additions and 70 deletions

View File

@@ -0,0 +1,56 @@
<aside class="catalog-filters card">
<div class="filters-head">
<h3>Filters</h3>
<button type="button" (click)="resetFilters.emit()">Reset</button>
</div>
@for (filter of definitions; track filter.id) {
@if (filter.enabled !== false) {
<section class="filter-group">
<h4>{{ filter.label }}</h4>
@if (filter.type === 'multi-select') {
<div class="options">
@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)" />
<span>{{ option.label }}</span>
@if (option.count != null) {
<small>{{ option.count }}</small>
}
</label>
}
</div>
}
@if (filter.type === 'range') {
<div class="range-inputs">
<input
type="number"
[value]="state.ranges[filter.id]?.min ?? ''"
(input)="updateRange(filter.id, 'min', $any($event.target).value)"
[placeholder]="'Min ' + (filter.min ?? '')" />
<input
type="number"
[value]="state.ranges[filter.id]?.max ?? ''"
(input)="updateRange(filter.id, 'max', $any($event.target).value)"
[placeholder]="'Max ' + (filter.max ?? '')" />
</div>
}
@if (filter.type === 'toggle') {
<label class="option-check">
<input
type="checkbox"
[checked]="state.toggles[filter.id]"
(change)="updateToggle(filter.id, $any($event.target).checked)" />
<span>Enabled</span>
</label>
}
</section>
}
}
</aside>

View File

@@ -0,0 +1,66 @@
.catalog-filters {
padding: 14px;
display: grid;
gap: 14px;
align-content: start;
}
.filters-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.filters-head h3 {
margin: 0;
color: var(--text-primary);
}
.filters-head button {
border: 0;
background: transparent;
color: var(--primary-color);
font-weight: 700;
cursor: pointer;
}
.filter-group {
display: grid;
gap: 8px;
border-top: 1px solid var(--border-color);
padding-top: 10px;
}
.filter-group h4 {
margin: 0;
font-size: 0.95rem;
color: var(--text-primary);
}
.options {
display: grid;
gap: 6px;
}
.option-check {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.range-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.range-inputs input {
min-height: 34px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 0 8px;
}

View File

@@ -0,0 +1,70 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FilterDefinition } from '../../../../../core/products/models/catalog-experience.model';
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,
imports: [FormsModule],
templateUrl: './filters-panel.component.html',
styleUrls: ['./filters-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CatalogFiltersPanelComponent {
@Input() definitions: FilterDefinition[] = [];
@Input() state: CatalogFilterStateValue = { values: {}, ranges: {}, toggles: {} };
@Output() stateChange = new EventEmitter<CatalogFilterStateValue>();
@Output() resetFilters = new EventEmitter<void>();
isSelected(filterId: string, optionValue: string): boolean {
return (this.state.values[filterId] ?? []).includes(optionValue);
}
toggleOption(filterId: string, optionValue: string): void {
const current = this.state.values[filterId] ?? [];
const next = current.includes(optionValue)
? current.filter(value => value !== optionValue)
: [...current, optionValue];
this.stateChange.emit({
...this.state,
values: {
...this.state.values,
[filterId]: next
}
});
}
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
const value = rawValue.trim().length ? Number(rawValue) : undefined;
const current = this.state.ranges[filterId] ?? {};
this.stateChange.emit({
...this.state,
ranges: {
...this.state.ranges,
[filterId]: {
...current,
[key]: Number.isFinite(value as number) ? value : undefined
}
}
});
}
updateToggle(filterId: string, checked: boolean): void {
this.stateChange.emit({
...this.state,
toggles: {
...this.state.toggles,
[filterId]: checked
}
});
}
}

View File

@@ -0,0 +1,11 @@
<div class="catalog-layout-switcher card">
@for (layout of available; track layout) {
<button
type="button"
class="layout-btn"
[class.active]="layout === active"
(click)="activeChange.emit(layout)">
{{ labels[layout] }}
</button>
}
</div>

View File

@@ -0,0 +1,22 @@
.catalog-layout-switcher {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
}
.layout-btn {
min-height: 34px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
font-weight: 700;
padding: 0 10px;
cursor: pointer;
}
.layout-btn.active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 10%, white);
}

View File

@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model';
@Component({
selector: 'app-catalog-layout-switcher',
standalone: true,
templateUrl: './layout-switcher.component.html',
styleUrls: ['./layout-switcher.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CatalogLayoutSwitcherComponent {
@Input() active: CatalogLayoutMode = 'grid';
@Input() available: CatalogLayoutMode[] = ['grid', 'large-grid', 'compact-grid', 'list'];
@Output() activeChange = new EventEmitter<CatalogLayoutMode>();
readonly labels: Record<CatalogLayoutMode, string> = {
grid: 'Grid',
'large-grid': 'Large Grid',
'compact-grid': 'Compact Grid',
list: 'List'
};
}

View File

@@ -1,13 +1,18 @@
<div class="catalog-product-grid grid grid-4">
<div [class]="'catalog-product-grid ' + layoutClass()">
@for (product of products; track trackByItemId($index, product)) {
<div class="catalog-product-shell">
<app-product-card
[item]="product"
[title]="productTitle(product)"
[description]="productDescription(product)"
[showDescription]="true"
[showStock]="true"
[showRating]="true"
[appearance]="layout === 'compact-grid' ? 'compact' : 'standard'"
[showDescription]="layout !== 'compact-grid'"
[showStock]="showAvailability"
[showRating]="showRatings"
[showDiscountBadge]="showDiscounts"
[showFavoritePlaceholder]="showActionsPlaceholder"
[showComparePlaceholder]="showActionsPlaceholder"
[showQuickViewPlaceholder]="showActionsPlaceholder"
[addToCartLabel]="'catalog.addToCart' | translate"
(selected)="productSelected.emit(product)"
(addToCart)="onAddToCart(product, $event.event)"

View File

@@ -2,6 +2,20 @@
align-items: stretch;
}
.catalog-layout-large-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.catalog-layout-list {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.catalog-layout-list .catalog-product-shell {
min-height: 240px;
}
.catalog-product-shell {
min-width: 0;
display: flex;

View File

@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output } from '@angular/core';
import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model';
import { Product } from '../../../../../core/products/models/product-domain.model';
import { ProductCardComponent } from '../../../../../components/product-card/product-card.component';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@@ -15,6 +16,11 @@ import { getTranslatedField, trackByItemId } from '../../../../../utils/item.uti
})
export class CatalogProductGridComponent {
@Input({ required: true }) products: Product[] = [];
@Input() layout: CatalogLayoutMode = 'grid';
@Input() showRatings = true;
@Input() showDiscounts = true;
@Input() showAvailability = true;
@Input() showActionsPlaceholder = false;
@Output() productSelected = new EventEmitter<Product>();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@@ -35,4 +41,18 @@ export class CatalogProductGridComponent {
onAddToCart(product: Product, event: Event): void {
this.addToCart.emit({ product, event });
}
layoutClass(): string {
switch (this.layout) {
case 'large-grid':
return 'grid grid-3 catalog-layout-large-grid';
case 'compact-grid':
return 'grid grid-4 catalog-layout-compact-grid';
case 'list':
return 'catalog-layout-list';
case 'grid':
default:
return 'grid grid-4 catalog-layout-grid';
}
}
}

View File

@@ -0,0 +1,52 @@
<section class="catalog-search-box card">
<form class="search-form" (submit)="onSubmit($event)">
<input
type="search"
[ngModel]="query"
(ngModelChange)="queryChange.emit($event)"
name="query"
placeholder="Search products, brands, categories"
autocomplete="off" />
<button type="submit" [disabled]="loading">Search</button>
</form>
@if (suggestions.length > 0) {
<div class="suggestions">
<strong>Suggestions</strong>
<div class="chip-list">
@for (item of suggestions; track item) {
<button type="button" class="chip" (click)="selectSuggestion(item)">{{ item }}</button>
}
</div>
</div>
}
@if (recentSearches.length > 0) {
<div class="recent">
<strong>Recent searches</strong>
<div class="chip-list">
@for (item of recentSearches; track item) {
<button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button>
}
</div>
</div>
}
@if (searchHistory.length > 0) {
<div class="history">
<div class="history-head">
<strong>Search history</strong>
<button type="button" (click)="historyCleared.emit()">Clear</button>
</div>
<div class="chip-list">
@for (item of searchHistory; track item) {
<button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button>
}
</div>
</div>
}
@if (noResults && !loading) {
<p class="no-results">No results found for this query. Try broader keywords.</p>
}
</section>

View File

@@ -0,0 +1,70 @@
.catalog-search-box {
padding: 14px;
display: grid;
gap: 12px;
}
.search-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.search-form input {
min-height: 44px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 0 12px;
font: inherit;
}
.search-form button {
min-height: 44px;
border: 0;
border-radius: var(--radius-sm);
background: var(--primary-color);
color: #fff;
font-weight: 700;
padding: 0 14px;
}
.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;
}
.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;
}
.no-results {
margin: 0;
color: var(--text-secondary);
}
@media (max-width: 680px) {
.search-form {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,34 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-catalog-search-box',
standalone: true,
imports: [FormsModule],
templateUrl: './search-box.component.html',
styleUrls: ['./search-box.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CatalogSearchBoxComponent {
@Input() query = '';
@Input() loading = false;
@Input() suggestions: string[] = [];
@Input() recentSearches: string[] = [];
@Input() searchHistory: string[] = [];
@Input() noResults = false;
@Output() queryChange = new EventEmitter<string>();
@Output() searchSubmit = new EventEmitter<string>();
@Output() suggestionSelected = new EventEmitter<string>();
@Output() recentSelected = new EventEmitter<string>();
@Output() historyCleared = new EventEmitter<void>();
onSubmit(event: Event): void {
event.preventDefault();
this.searchSubmit.emit(this.query.trim());
}
selectSuggestion(value: string): void {
this.suggestionSelected.emit(value);
}
}

View File

@@ -0,0 +1,38 @@
<section class="catalog-search-results section">
<div class="results-head">
<strong>{{ summary }}</strong>
<span>{{ total }} items</span>
</div>
@if (loading) {
<div class="results-skeletons">
@for (_ of [1,2,3,4,5,6,7,8]; track $index) {
<div class="skeleton-card"></div>
}
</div>
} @else if (products.length > 0) {
<app-catalog-product-grid
[products]="products"
[layout]="layout"
[showRatings]="showRatings"
[showDiscounts]="showDiscounts"
[showAvailability]="showAvailability"
[showActionsPlaceholder]="true"
(productSelected)="productSelected.emit($event)"
(addToCart)="addToCart.emit($event)"
(productPreview)="productPreview.emit($event)" />
} @else {
<div class="empty-state card">
<h3>No results found</h3>
<p>Try changing filters, sorting, or search keywords.</p>
</div>
}
@if (!loading && totalPages > 1) {
<div class="results-pager">
<button type="button" (click)="previous()" [disabled]="page <= 1">Previous</button>
<span>Page {{ page }} / {{ totalPages }}</span>
<button type="button" (click)="next()" [disabled]="page >= totalPages">Next</button>
</div>
}
</section>

View File

@@ -0,0 +1,71 @@
.catalog-search-results {
display: grid;
gap: 14px;
}
.results-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.results-head strong {
color: var(--text-primary);
}
.results-head span {
color: var(--text-secondary);
}
.results-skeletons {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.skeleton-card {
min-height: 220px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%);
background-size: 200% 100%;
animation: shimmer 1.2s linear infinite;
}
.empty-state {
min-height: 180px;
display: grid;
place-content: center;
gap: 6px;
text-align: center;
padding: 18px;
}
.empty-state h3,
.empty-state p {
margin: 0;
}
.empty-state p {
color: var(--text-secondary);
}
.results-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.results-pager button {
min-height: 36px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
padding: 0 12px;
}
@keyframes shimmer {
to { background-position: -200% 0; }
}

View File

@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { Product } from '../../../../../core/products/models/product-domain.model';
import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model';
import { CatalogProductGridComponent } from '../product-grid/product-grid.component';
@Component({
selector: 'app-catalog-search-results',
standalone: true,
imports: [CatalogProductGridComponent],
templateUrl: './search-results.component.html',
styleUrls: ['./search-results.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CatalogSearchResultsComponent {
@Input() products: Product[] = [];
@Input() total = 0;
@Input() page = 1;
@Input() pageSize = 24;
@Input() summary = '';
@Input() loading = false;
@Input() layout: CatalogLayoutMode = 'grid';
@Input() showRatings = true;
@Input() showDiscounts = true;
@Input() showAvailability = true;
@Output() pageChange = new EventEmitter<number>();
@Output() productSelected = new EventEmitter<Product>();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@Output() productPreview = new EventEmitter<number>();
get totalPages(): number {
return Math.max(1, Math.ceil(this.total / Math.max(1, this.pageSize)));
}
previous(): void {
if (this.page > 1) {
this.pageChange.emit(this.page - 1);
}
}
next(): void {
if (this.page < this.totalPages) {
this.pageChange.emit(this.page + 1);
}
}
}

View File

@@ -0,0 +1,10 @@
<label class="catalog-sorting card">
<span>Sort by</span>
<select [ngModel]="selected" (ngModelChange)="selectedChange.emit($event)">
@for (option of options; track option.id) {
@if (option.enabled !== false) {
<option [value]="option.id">{{ option.label }}</option>
}
}
</select>
</label>

View File

@@ -0,0 +1,22 @@
.catalog-sorting {
padding: 10px 12px;
min-height: 52px;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
}
.catalog-sorting span {
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 700;
}
.catalog-sorting select {
min-height: 34px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 0 8px;
font: inherit;
}

View File

@@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SortDefinition } from '../../../../../core/products/models/catalog-experience.model';
@Component({
selector: 'app-catalog-sorting-control',
standalone: true,
imports: [FormsModule],
templateUrl: './sorting-control.component.html',
styleUrls: ['./sorting-control.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CatalogSortingControlComponent {
@Input() options: SortDefinition[] = [];
@Input() selected = 'relevance';
@Output() selectedChange = new EventEmitter<string>();
}

View File

@@ -2,7 +2,7 @@
<header class="catalog-header">
<a [routerLink]="'/catalog' | langRoute" class="catalog-root-link">{{ 'catalog.title' | translate }}</a>
@if (breadcrumb().length > 0) {
@if (catalogConfig().showBreadcrumbs && breadcrumb().length > 0) {
<nav class="catalog-breadcrumb" [attr.aria-label]="'catalog.breadcrumb' | translate">
<a [routerLink]="'/catalog' | langRoute">{{ 'catalog.allCategories' | translate }}</a>
@for (category of breadcrumb(); track category.id) {
@@ -11,6 +11,19 @@
}
</nav>
}
<app-catalog-search-box
[query]="state().search"
[loading]="loadingProducts()"
[suggestions]="searchSuggestions()"
[recentSearches]="recentSearches()"
[searchHistory]="searchHistory()"
[noResults]="noResults()"
(queryChange)="onSearchQueryChange($event)"
(searchSubmit)="submitSearch($event)"
(suggestionSelected)="useSuggestion($event)"
(recentSelected)="useRecentSearch($event)"
(historyCleared)="clearSearchHistory()" />
</header>
@if (loading()) {
@@ -37,9 +50,26 @@
<section class="catalog-section">
<div class="catalog-section-heading">
<h1>{{ state().category?.title || ('catalog.allCategories' | translate) }}</h1>
<p>{{ 'catalog.categoryHint' | translate }}</p>
<p>{{ 'catalog.categoryHint' | translate }} • {{ categories().length }} entries</p>
</div>
@if (catalogConfig().showCategoryBanner) {
<div class="catalog-category-banner card">
<h3>{{ state().category?.title || ('catalog.allCategories' | translate) }}</h3>
<p>Category banner placeholder for backend-driven media and description.</p>
</div>
}
@if (catalogConfig().showSubcategoryChips && subcategoryChips().length > 0) {
<div class="subcategory-chips">
@for (subcategory of subcategoryChips(); track subcategory.id) {
<button type="button" class="subcategory-chip" (click)="selectCategory(subcategory)">
{{ subcategory.title }}
</button>
}
</div>
}
@if (categories().length > 0) {
<app-catalog-category-grid [categories]="categories()" (categorySelected)="selectCategory($event)" />
} @else {
@@ -52,29 +82,51 @@
}
@if (!loading() && !error() && viewMode() === 'products') {
<section class="catalog-section">
<div class="catalog-section-heading">
<h1>{{ state().category?.title || ('catalog.products' | translate) }}</h1>
<p>{{ 'catalog.productHint' | translate }}</p>
</div>
<section class="catalog-section catalog-products-section">
<aside class="catalog-left-panel">
<app-catalog-filters-panel
[definitions]="filterDefinitions()"
[state]="filterState()"
(stateChange)="onFilterStateChange($event)"
(resetFilters)="resetFilters()" />
</aside>
@if (loadingProducts()) {
<div class="catalog-inline-loading">{{ 'catalog.loadingProducts' | translate }}</div>
}
<div class="catalog-results-panel">
<div class="catalog-tools-row">
<app-catalog-sorting-control
[options]="sortDefinitions()"
[selected]="state().sort"
(selectedChange)="changeSort($event)" />
@if (!loadingProducts() && products().length > 0) {
<app-catalog-product-grid
<app-catalog-layout-switcher
[active]="state().layout"
[available]="availableLayouts"
(activeChange)="changeLayout($event)" />
</div>
@if (catalogConfig().navigationMode !== 'default') {
<div class="catalog-navigation-placeholder card">
<strong>{{ catalogConfig().navigationMode }}</strong>
<p>Navigation layout placeholder prepared for backend/bootstrap-driven rendering.</p>
</div>
}
<app-catalog-search-results
[products]="products()"
[total]="state().pagination.total"
[page]="state().pagination.page"
[pageSize]="state().pagination.count"
[summary]="searchSummary()"
[loading]="loadingProducts()"
[layout]="state().layout"
[showRatings]="catalogConfig().showRatings"
[showDiscounts]="catalogConfig().showDiscounts"
[showAvailability]="catalogConfig().showAvailability"
(pageChange)="onResultsPageChange($event)"
(productSelected)="selectProduct($event)"
(addToCart)="addToCart($event)"
(productPreview)="previewProduct($event)"
/>
} @else if (!loadingProducts()) {
<div class="catalog-message">
<h2>{{ 'catalog.emptyTitle' | translate }}</h2>
<p>{{ 'catalog.emptyProducts' | translate }}</p>
</div>
}
(productPreview)="previewProduct($event)" />
</div>
</section>
}
</main>

View File

@@ -8,8 +8,8 @@
.catalog-header {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 32px;
gap: 14px;
margin-bottom: 24px;
}
.catalog-root-link {
@@ -47,6 +47,83 @@
gap: 24px;
}
.catalog-products-section {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
align-items: start;
gap: 16px;
}
.catalog-left-panel {
position: sticky;
top: 16px;
}
.catalog-results-panel {
display: grid;
gap: 14px;
}
.catalog-tools-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.catalog-category-banner {
padding: 16px;
background: linear-gradient(120deg, color-mix(in srgb, var(--primary-color) 14%, white), #f8fbfb);
}
.catalog-category-banner h3,
.catalog-category-banner p {
margin: 0;
}
.catalog-category-banner p {
margin-top: 6px;
color: var(--text-secondary);
}
.subcategory-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.subcategory-chip {
min-height: 32px;
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--bg-primary);
color: var(--text-primary);
padding: 0 10px;
cursor: pointer;
transition: border-color 0.2s ease, transform 0.2s ease;
}
.subcategory-chip:hover {
border-color: var(--primary-color);
transform: translateY(-1px);
}
.catalog-navigation-placeholder {
padding: 14px;
display: grid;
gap: 6px;
}
.catalog-navigation-placeholder strong,
.catalog-navigation-placeholder p {
margin: 0;
}
.catalog-navigation-placeholder p {
color: var(--text-secondary);
}
.catalog-section-heading {
display: flex;
flex-direction: column;
@@ -155,6 +232,14 @@
padding: 16px;
}
.catalog-products-section {
grid-template-columns: 1fr;
}
.catalog-left-panel {
position: static;
}
.catalog-root-link {
font-size: 1.5rem;
}

View File

@@ -1,10 +1,12 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 { 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 { CartService } from '../../../../services';
@@ -12,16 +14,32 @@ import { LanguageService } from '../../../../services/language.service';
import { PrefetchService } from '../../../../services/prefetch.service';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { DEFAULT_CATALOG_CONFIG } from '../../../../shared/models/config';
import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component';
import { CatalogProductGridComponent } from '../components/product-grid/product-grid.component';
import { CatalogFiltersPanelComponent, CatalogFilterStateValue } 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';
type CatalogViewMode = 'categories' | 'products';
@Component({
selector: 'app-catalog-container',
standalone: true,
imports: [RouterLink, LangRoutePipe, TranslatePipe, CatalogCategoryGridComponent, CatalogProductGridComponent],
imports: [
RouterLink,
LangRoutePipe,
TranslatePipe,
CatalogSearchBoxComponent,
CatalogFiltersPanelComponent,
CatalogSortingControlComponent,
CatalogLayoutSwitcherComponent,
CatalogSearchResultsComponent,
CatalogCategoryGridComponent
],
templateUrl: './catalog-container.component.html',
styleUrls: ['./catalog-container.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -30,33 +48,70 @@ export class CatalogContainerComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly configService = inject(ConfigService);
private readonly categoryFacade = inject(CategoryFacade);
private readonly productFacade = inject(ProductFacade);
private readonly cartService = inject(CartService);
private readonly prefetchService = inject(PrefetchService);
private readonly languageService = inject(LanguageService);
private readonly searchHistoryService = inject(CatalogSearchHistoryService);
readonly catalogConfig = signal(this.resolveCatalogConfig());
readonly state = signal<CatalogState>(createInitialCatalogState());
readonly categories = signal<Category[]>([]);
readonly rawProducts = signal<Product[]>([]);
readonly products = signal<Product[]>([]);
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 recentSearches = signal<string[]>([]);
readonly searchHistory = signal<string[]>(this.catalogConfig().searchHistoryEnabled ? this.searchHistoryService.getHistory() : []);
readonly viewMode = signal<CatalogViewMode>('categories');
readonly loading = signal(true);
readonly loadingProducts = signal(false);
readonly error = signal<string | null>(null);
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
readonly sortDefinitions = computed<SortDefinition[]>(() => {
const labels: Record<string, string> = {
relevance: 'Relevance',
latest: 'Newest',
price_asc: 'Price Low -> High',
price_desc: 'Price High -> Low',
rating: 'Highest Rated',
popular: 'Most Popular',
discount: 'Discount'
};
return this.catalogConfig().availableSorts.map((id: string) => ({ id, label: labels[id] ?? id, enabled: true }));
});
readonly availableLayouts: CatalogLayoutMode[] = ['grid', 'large-grid', 'compact-grid', 'list'];
readonly skeletonSlots = Array.from({ length: 8 });
private dataSubscription?: Subscription;
private readonly backendFetchSize = 200;
constructor() {
this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe());
this.recentSearches.set(this.searchHistory().slice(0, 5));
this.route.paramMap
combineLatest([this.route.paramMap, this.route.queryParamMap])
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(params => {
.subscribe(([params, queryParams]) => {
const categoryId = Number(params.get('id')) || null;
const searchQuery = (queryParams.get('q') ?? '').trim();
this.enterCategory(categoryId);
if (searchQuery.length > 0) {
this.submitSearch(searchQuery);
}
});
}
@@ -66,11 +121,20 @@ export class CatalogContainerComponent {
this.loadingProducts.set(false);
this.error.set(null);
this.categories.set([]);
this.rawProducts.set([]);
this.products.set([]);
this.breadcrumb.set([]);
this.subcategoryChips.set([]);
this.searchSummary.set('');
this.searchResult.set(null);
this.filterDefinitions.set([]);
this.filterState.set({ values: {}, ranges: {}, toggles: {} });
this.searchSuggestions.set([]);
this.categoryFacade.selectCategory(categoryId);
this.state.set({
...createInitialCatalogState(),
sort: this.catalogConfig().defaultSort,
layout: this.catalogConfig().layout,
filters: {
...createInitialCatalogState().filters,
categoryIds: categoryId == null ? [] : [categoryId],
@@ -93,6 +157,7 @@ export class CatalogContainerComponent {
]).subscribe({
next: ([category, children, breadcrumb]) => {
this.breadcrumb.set(breadcrumb);
this.subcategoryChips.set(children);
this.state.update(current => ({ ...current, category: category ?? null }));
if (children.length > 0) {
@@ -128,6 +193,110 @@ export class CatalogContainerComponent {
this.enterCategory(this.state().category?.id ?? null);
}
onSearchQueryChange(query: string): void {
this.state.update(current => ({ ...current, search: query }));
const normalized = query.trim().toLowerCase();
if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) {
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)]);
}
submitSearch(query: string): void {
const normalized = query.trim();
this.viewMode.set('products');
this.state.update(current => ({
...current,
search: normalized,
pagination: {
...current.pagination,
page: 1,
skip: 0
}
}));
if (this.catalogConfig().searchHistoryEnabled && normalized.length > 0) {
this.searchHistory.set(this.searchHistoryService.push(normalized));
this.recentSearches.set(this.searchHistory().slice(0, 5));
}
this.loadCatalog();
}
useSuggestion(value: string): void {
this.onSearchQueryChange(value);
this.submitSearch(value);
}
useRecentSearch(value: string): void {
this.onSearchQueryChange(value);
this.submitSearch(value);
}
clearSearchHistory(): void {
this.searchHistoryService.clear();
this.searchHistory.set([]);
this.recentSearches.set([]);
}
onFilterStateChange(next: CatalogFilterStateValue): void {
this.filterState.set(next);
this.state.update(current => ({
...current,
pagination: {
...current.pagination,
page: 1,
skip: 0
}
}));
this.recomputeResults();
}
resetFilters(): void {
this.filterState.set({ values: {}, ranges: {}, toggles: {} });
this.recomputeResults();
}
changeSort(sortId: string): void {
this.state.update(current => ({
...current,
sort: sortId as CatalogState['sort'],
pagination: {
...current.pagination,
page: 1,
skip: 0
}
}));
this.recomputeResults();
}
changeLayout(layout: CatalogLayoutMode): void {
this.state.update(current => ({ ...current, layout }));
}
onResultsPageChange(page: number): void {
this.state.update(current => ({
...current,
pagination: {
...current.pagination,
page,
skip: (page - 1) * current.pagination.count
}
}));
this.recomputeResults();
}
private renderCategories(category: Category | null, categories: Category[], breadcrumb: Category[]): void {
const ordered = [...categories].sort((a, b) => a.priority - b.priority || a.id - b.id);
this.viewMode.set('categories');
@@ -139,29 +308,344 @@ export class CatalogContainerComponent {
}
private loadProducts(categoryId: number): void {
const pagination = this.state().pagination;
this.viewMode.set('products');
this.loadingProducts.set(true);
this.loadCatalog(categoryId);
}
this.dataSubscription = this.productFacade.getProductsByCategory(categoryId, { count: pagination.count, skip: pagination.skip })
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);
this.dataSubscription = this.productFacade.loadCatalog(criteria)
.subscribe({
next: result => {
this.products.set(result.items);
this.state.update(current => ({
...current,
pagination: {
...current.pagination,
total: result.total,
hasMore: result.items.length >= current.pagination.count,
},
}));
this.loading.set(false);
this.loadingProducts.set(false);
},
error: () => this.setError('catalog.error'),
next: result => {
this.rawProducts.set(result.items);
this.buildFilterDefinitions(result.items);
this.recomputeResults();
this.loading.set(false);
this.loadingProducts.set(false);
},
error: () => this.setError('catalog.error')
});
}
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: 'Price',
type: 'range',
min: priceValues.length ? Math.min(...priceValues) : 0,
max: priceValues.length ? Math.max(...priceValues) : 0,
enabled: enabled.has('price')
},
{
id: 'availability',
label: 'Availability',
type: 'multi-select',
options: [
{ id: 'in-stock', label: 'In stock', value: 'in-stock' },
{ id: 'low-stock', label: 'Low stock', value: 'low-stock' },
{ id: 'out-of-stock', label: 'Out of stock', value: 'out-of-stock' }
],
enabled: enabled.has('availability')
},
{
id: 'rating',
label: 'Rating',
type: 'multi-select',
options: ratings.map(value => ({ id: `rating-${value}`, label: `${value} stars`, value: String(value) })),
enabled: enabled.has('rating')
},
{
id: 'brand',
label: 'Brand',
type: 'multi-select',
options: brands.map(value => ({ id: `brand-${value}`, label: value, value })),
enabled: enabled.has('brand')
},
{
id: 'category',
label: 'Category',
type: 'multi-select',
options: categories.map(value => ({ id: `cat-${value}`, label: `Category ${value}`, value })),
enabled: enabled.has('category')
},
{
id: 'subcategory',
label: 'Subcategory',
type: 'multi-select',
options: subcategories.map(value => ({ id: `sub-${value}`, label: value, value })),
enabled: enabled.has('subcategory')
},
{
id: 'discount',
label: 'Discount',
type: 'toggle',
enabled: enabled.has('discount')
},
{
id: 'new',
label: 'New',
type: 'toggle',
enabled: enabled.has('new')
},
{
id: 'color',
label: 'Color',
type: 'multi-select',
options: colors.map(value => ({ id: `color-${value}`, label: value, value })),
enabled: enabled.has('color')
},
{
id: 'size',
label: 'Size',
type: 'multi-select',
options: sizes.map(value => ({ id: `size-${value}`, label: value, value })),
enabled: enabled.has('size')
},
{
id: 'attributes',
label: 'Attributes',
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([]);
this.state.update(current => ({
...current,
pagination: {
...current.pagination,
total: 0,
hasMore: false,
skip: 0,
page: 1
}
}));
this.searchSummary.set('No results');
this.searchResult.set(null);
this.searchSuggestions.set([]);
return;
}
const filtered = this.applyFilters(this.rawProducts());
const sorted = this.applySort(filtered);
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);
this.products.set(paged);
this.state.update(current => ({
...current,
pagination: {
...current.pagination,
total: sorted.length,
skip,
hasMore: skip + pageSize < sorted.length
}
}));
const result = toSearchResult({
items: paged,
total: sorted.length,
count: pageSize,
skip
}, {
keyword: this.state().search,
page,
pageSize,
sort: this.state().sort
});
this.searchResult.set(result);
this.searchSummary.set(result.summary);
this.searchSuggestions.set(this.buildSuggestions(this.state().search, sorted));
}
private buildSuggestions(query: string, products: Product[]): string[] {
const normalized = query.trim().toLowerCase();
if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) {
return [];
}
return this.collectUnique(
products
.map(product => product.name)
.filter(name => name.toLowerCase().includes(normalized))
.slice(0, 6)
);
}
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 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 ?? {};
return {
...DEFAULT_CATALOG_CONFIG,
...raw,
availableSorts: Array.isArray(raw.availableSorts) && raw.availableSorts.length > 0
? raw.availableSorts
: DEFAULT_CATALOG_CONFIG.availableSorts,
enabledFilters: Array.isArray(raw.enabledFilters) && raw.enabledFilters.length > 0
? raw.enabledFilters
: DEFAULT_CATALOG_CONFIG.enabledFilters
};
}
private setError(message: string): void {
this.error.set(message);
this.loading.set(false);

View File

@@ -1,6 +1,7 @@
import { Category } from '../../../../core/categories/models/category-domain.model';
import { CatalogLayoutMode } from '../../../../core/products/models/catalog-experience.model';
export type CatalogSort = 'relevance' | 'price_asc' | 'price_desc' | 'popular' | 'rating' | 'latest';
export type CatalogSort = 'relevance' | 'price_asc' | 'price_desc' | 'popular' | 'rating' | 'latest' | 'discount';
export interface CatalogPriceRange {
min?: number;
@@ -8,6 +9,7 @@ export interface CatalogPriceRange {
}
export interface CatalogPaginationState {
page: number;
count: number;
skip: number;
total: number;
@@ -18,12 +20,16 @@ export interface CatalogFilterState {
categoryIds: number[];
priceRange: CatalogPriceRange;
attributes: Record<string, string[]>;
values: Record<string, string[]>;
ranges: Record<string, CatalogPriceRange>;
toggles: Record<string, boolean>;
}
export interface CatalogState {
category: Category | null;
search: string;
sort: CatalogSort;
layout: CatalogLayoutMode;
priceRange: CatalogPriceRange;
attributes: Record<string, string[]>;
pagination: CatalogPaginationState;
@@ -39,7 +45,9 @@ export function createInitialCatalogState(): CatalogState {
sort: 'relevance',
priceRange: {},
attributes: {},
layout: 'grid',
pagination: {
page: 1,
count: DEFAULT_CATALOG_PAGE_SIZE,
skip: 0,
total: 0,
@@ -49,6 +57,9 @@ export function createInitialCatalogState(): CatalogState {
categoryIds: [],
priceRange: {},
attributes: {},
values: {},
ranges: {},
toggles: {},
},
};
}

View File

@@ -0,0 +1,57 @@
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));
}
}