-
+
+ @if (isDrawerLayout() && filterDrawerOpen()) {
+
+
+ }
+
+ @if (isMobile() && sortSheetOpen()) {
+
+
+
+ {{ 'catalog.sortBy' | translate }}
+
+
+
+
+ @for (option of mobileSortOptions(); track option.id) {
+
+ }
+
+
+ }
}
diff --git a/src/app/features/website/catalog/containers/catalog-container.component.scss b/src/app/features/website/catalog/containers/catalog-container.component.scss
index 919e6ce..d6e0992 100644
--- a/src/app/features/website/catalog/containers/catalog-container.component.scss
+++ b/src/app/features/website/catalog/containers/catalog-container.component.scss
@@ -3,6 +3,7 @@
margin: 0 auto;
padding: 24px;
color: #1e3c38;
+ overflow-x: clip;
}
.catalog-header {
@@ -101,6 +102,36 @@
justify-content: space-between;
}
+.catalog-mobile-toolbar {
+ position: sticky;
+ bottom: 10px;
+ z-index: 5;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ padding: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--bg-primary) 92%, white);
+ backdrop-filter: blur(8px);
+}
+
+.catalog-mobile-toolbar-btn,
+.catalog-open-filters-btn {
+ min-height: 40px;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ padding: 0 10px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.catalog-open-filters-btn {
+ width: fit-content;
+}
+
.catalog-sort-reset-group {
display: inline-flex;
align-items: center;
@@ -138,6 +169,118 @@
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}
+.catalog-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.45);
+ z-index: 25;
+}
+
+.catalog-filter-drawer {
+ position: fixed;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: min(420px, 100vw);
+ background: var(--bg-primary);
+ z-index: 26;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ border-left: 1px solid var(--border-color);
+ box-shadow: -12px 0 28px rgba(15, 23, 42, 0.14);
+}
+
+.catalog-filter-drawer-head,
+.catalog-sort-sheet-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.catalog-filter-drawer-head h2,
+.catalog-sort-sheet-head h2 {
+ margin: 0;
+ color: var(--text-primary);
+ font-size: 1.05rem;
+}
+
+.catalog-icon-btn {
+ width: 36px;
+ height: 36px;
+ border: 1px solid var(--border-color);
+ border-radius: 50%;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 1.15rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.catalog-filter-drawer-body {
+ overflow: auto;
+ padding: 12px;
+}
+
+.catalog-filter-drawer-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ padding: 12px;
+ border-top: 1px solid var(--border-color);
+}
+
+.catalog-apply-btn {
+ min-height: 40px;
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: var(--primary-color);
+ color: #fff;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.catalog-sort-sheet {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--bg-primary);
+ border-top-left-radius: 16px;
+ border-top-right-radius: 16px;
+ border: 1px solid var(--border-color);
+ border-bottom: 0;
+ z-index: 26;
+ box-shadow: 0 -10px 24px rgba(15, 23, 42, 0.16);
+ max-height: 80vh;
+ overflow: auto;
+}
+
+.catalog-sort-sheet-options {
+ display: grid;
+ gap: 6px;
+ padding: 12px;
+}
+
+.catalog-sort-option {
+ min-height: 42px;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ text-align: left;
+ padding: 0 12px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.catalog-sort-option.active {
+ border-color: var(--primary-color);
+ background: color-mix(in srgb, var(--primary-color) 10%, white);
+}
+
.catalog-save-search-btn:hover {
transform: translateY(-1px);
border-color: var(--primary-color);
@@ -338,6 +481,10 @@
min-height: 40px;
}
+ .catalog-open-filters-btn {
+ width: 100%;
+ }
+
.catalog-root-link {
font-size: 1.5rem;
}
diff --git a/src/app/features/website/catalog/containers/catalog-container.component.ts b/src/app/features/website/catalog/containers/catalog-container.component.ts
index 757f19f..ab85eac 100644
--- a/src/app/features/website/catalog/containers/catalog-container.component.ts
+++ b/src/app/features/website/catalog/containers/catalog-container.component.ts
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, DestroyRef, HostListener, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { A11yModule } from '@angular/cdk/a11y';
import { combineLatest } from 'rxjs';
import { Subscription } from 'rxjs';
import { Category } from '../../../../core/categories/models/category-domain.model';
@@ -18,6 +19,7 @@ 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 { 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 { CatalogLayoutSwitcherComponent } from '../components/layout-switcher/layout-switcher.component';
import { CatalogSearchBoxComponent } from '../components/search-box/search-box.component';
@@ -34,9 +36,11 @@ type CatalogViewMode = 'categories' | 'products';
selector: 'app-catalog-container',
standalone: true,
imports: [
+ A11yModule,
RouterLink,
LangRoutePipe,
TranslatePipe,
+ CatalogEmptyStateComponent,
CatalogSearchBoxComponent,
CatalogFiltersPanelComponent,
CatalogSortingControlComponent,
@@ -85,7 +89,23 @@ export class CatalogContainerComponent {
readonly loading = signal(true);
readonly loadingProducts = signal(false);
readonly error = signal
(null);
+ readonly viewportWidth = signal(typeof window !== 'undefined' ? window.innerWidth : 1280);
+ readonly filterDrawerOpen = signal(false);
+ readonly sortSheetOpen = signal(false);
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
+ readonly isDrawerLayout = computed(() => this.viewportWidth() <= 1024);
+ readonly isMobile = computed(() => this.viewportWidth() <= 767);
+ readonly hasRawProducts = computed(() => this.rawProducts().length > 0);
+ readonly isEmptyCategoryState = computed(() => this.viewMode() === 'products' && !this.loadingProducts() && this.rawProducts().length === 0);
+ readonly isFilteredEmptyState = computed(() => this.viewMode() === 'products' && !this.loadingProducts() && this.rawProducts().length > 0 && this.products().length === 0);
+ readonly showDesktopFilters = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && !this.isDrawerLayout());
+ readonly showMobileToolbar = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && this.isMobile());
+ readonly showCatalogTools = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && !this.loadingProducts());
+ readonly mobileSortOptions = computed(() => {
+ const allowed = new Set(['relevance', 'latest', 'price_asc', 'price_desc', 'rating', 'popular']);
+ const options = this.sortDefinitions().filter(option => allowed.has(option.id));
+ return options.length > 0 ? options : this.sortDefinitions();
+ });
readonly favoriteIds = computed(() => this.uxFacade.wishlist().map(item => item.product.itemID));
readonly comparedIds = computed(() => this.uxFacade.comparedProducts().map(item => item.product.itemID));
@@ -370,6 +390,22 @@ export class CatalogContainerComponent {
this.persistContinueBrowsing();
}
+ @HostListener('window:resize')
+ onWindowResize(): void {
+ this.viewportWidth.set(typeof window !== 'undefined' ? window.innerWidth : 1280);
+ }
+
+ @HostListener('document:keydown.escape')
+ onEscapeKey(): void {
+ if (this.sortSheetOpen()) {
+ this.closeSortSheet();
+ }
+
+ if (this.filterDrawerOpen()) {
+ this.closeFilterDrawer();
+ }
+ }
+
onFilterStateChange(next: CatalogFilterStateValue): void {
this.filterState.set(next);
this.state.update(current => ({
@@ -411,6 +447,43 @@ export class CatalogContainerComponent {
this.persistContinueBrowsing();
}
+ cycleLayout(): void {
+ const layouts = this.availableLayouts;
+ const current = this.state().layout;
+ const currentIndex = Math.max(0, layouts.indexOf(current));
+ const nextLayout = layouts[(currentIndex + 1) % layouts.length];
+ this.changeLayout(nextLayout);
+ }
+
+ openFilterDrawer(): void {
+ this.filterDrawerOpen.set(true);
+ }
+
+ closeFilterDrawer(): void {
+ this.filterDrawerOpen.set(false);
+ }
+
+ applyFilterDrawer(): void {
+ this.closeFilterDrawer();
+ }
+
+ openSortSheet(): void {
+ this.sortSheetOpen.set(true);
+ }
+
+ closeSortSheet(): void {
+ this.sortSheetOpen.set(false);
+ }
+
+ selectSortFromSheet(sortId: string): void {
+ this.changeSort(sortId);
+ this.closeSortSheet();
+ }
+
+ browseCategories(): void {
+ this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`]);
+ }
+
onResultsPageChange(page: number): void {
this.state.update(current => ({
...current,
diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts
index d40254d..64c0ae5 100644
--- a/src/app/i18n/en.ts
+++ b/src/app/i18n/en.ts
@@ -181,7 +181,7 @@ export const en: Translations = {
clearHistory: 'Clear',
searchNoResultsHint: 'No results found for this query. Try broader keywords.',
sortBy: 'Sort by',
- sortRelevance: 'Relevance',
+ sortRelevance: 'Recommended',
sortLatest: 'Newest',
sortPriceAsc: 'Price Low -> High',
sortPriceDesc: 'Price High -> Low',
@@ -199,6 +199,20 @@ export const en: Translations = {
previousPage: 'Previous',
nextPage: 'Next',
pageOf: 'Page {{page}} / {{total}}',
+ emptyCategoryTitle: 'This category has no products yet',
+ emptyCategoryMessage: 'Try browsing parent categories or return to the full catalog.',
+ browseCategories: 'Browse Categories',
+ noFilterMatchTitle: 'No products match your selected filters.',
+ noFilterMatchMessage: 'Try broadening price, attributes, or brand filters.',
+ clearFilters: 'Clear Filters',
+ openFilters: 'Open Filters',
+ applyFilters: 'Apply',
+ close: 'Close',
+ filtersDrawerAria: 'Filters drawer',
+ sortSheetAria: 'Sort options',
+ mobileToolbarAria: 'Catalog mobile toolbar',
+ gridCycle: 'Grid',
+ filtersPanelAria: 'Catalog filters',
filterPrice: 'Price',
filterAvailability: 'Availability',
filterInStock: 'In stock',
diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts
index 25fc1a7..6ae99b6 100644
--- a/src/app/i18n/hy.ts
+++ b/src/app/i18n/hy.ts
@@ -181,7 +181,7 @@ export const hy: Translations = {
clearHistory: 'Մաքրել',
searchNoResultsHint: 'Արդյունքներ չկան։ Փորձեք ավելի լայն բանալի բառեր։',
sortBy: 'Տեսակավորել ըստ',
- sortRelevance: 'Համապատասխանության',
+ sortRelevance: 'Առաջարկվող',
sortLatest: 'Նորության',
sortPriceAsc: 'Գին․ աճման',
sortPriceDesc: 'Գին․ նվազման',
@@ -199,6 +199,20 @@ export const hy: Translations = {
previousPage: 'Նախորդ',
nextPage: 'Հաջորդ',
pageOf: 'Էջ {{page}} / {{total}}',
+ emptyCategoryTitle: 'Այս կատեգորիայում դեռ ապրանքներ չկան',
+ emptyCategoryMessage: 'Փորձեք անցնել ծնող կատեգորիաներ կամ վերադառնալ ամբողջ կատալոգ։',
+ browseCategories: 'Դիտել կատեգորիաները',
+ noFilterMatchTitle: 'Ձեր ընտրած ֆիլտրերին համապատասխան ապրանքներ չկան։',
+ noFilterMatchMessage: 'Փորձեք ընդլայնել գնի միջակայքը, հատկանիշները կամ բրենդները։',
+ clearFilters: 'Մաքրել ֆիլտրերը',
+ openFilters: 'Բացել ֆիլտրերը',
+ applyFilters: 'Կիրառել',
+ close: 'Փակել',
+ filtersDrawerAria: 'Ֆիլտրերի վահանակ',
+ sortSheetAria: 'Տեսակավորման տարբերակներ',
+ mobileToolbarAria: 'Կատալոգի բջջային գործիքաշար',
+ gridCycle: 'Ցանց',
+ filtersPanelAria: 'Կատալոգի ֆիլտրեր',
filterPrice: 'Գին',
filterAvailability: 'Առկայություն',
filterInStock: 'Առկա է',
diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts
index a8e7be1..7ada0fd 100644
--- a/src/app/i18n/ru.ts
+++ b/src/app/i18n/ru.ts
@@ -181,7 +181,7 @@ export const ru: Translations = {
clearHistory: 'Очистить',
searchNoResultsHint: 'Ничего не найдено. Попробуйте более общий запрос.',
sortBy: 'Сортировать по',
- sortRelevance: 'Релевантности',
+ sortRelevance: 'Рекомендуемые',
sortLatest: 'Новизне',
sortPriceAsc: 'Цене: по возрастанию',
sortPriceDesc: 'Цене: по убыванию',
@@ -199,6 +199,20 @@ export const ru: Translations = {
previousPage: 'Назад',
nextPage: 'Вперед',
pageOf: 'Страница {{page}} / {{total}}',
+ emptyCategoryTitle: 'В этой категории пока нет товаров',
+ emptyCategoryMessage: 'Попробуйте перейти в родительские категории или вернуться в общий каталог.',
+ browseCategories: 'Смотреть категории',
+ noFilterMatchTitle: 'Нет товаров, соответствующих выбранным фильтрам.',
+ noFilterMatchMessage: 'Попробуйте расширить диапазон цены, атрибуты или бренды.',
+ clearFilters: 'Очистить фильтры',
+ openFilters: 'Открыть фильтры',
+ applyFilters: 'Применить',
+ close: 'Закрыть',
+ filtersDrawerAria: 'Панель фильтров',
+ sortSheetAria: 'Параметры сортировки',
+ mobileToolbarAria: 'Мобильная панель каталога',
+ gridCycle: 'Сетка',
+ filtersPanelAria: 'Фильтры каталога',
filterPrice: 'Цена',
filterAvailability: 'Наличие',
filterInStock: 'В наличии',
diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts
index 68fd403..c47e36d 100644
--- a/src/app/i18n/translations.ts
+++ b/src/app/i18n/translations.ts
@@ -197,6 +197,20 @@ export interface Translations {
previousPage: string;
nextPage: string;
pageOf: string;
+ emptyCategoryTitle: string;
+ emptyCategoryMessage: string;
+ browseCategories: string;
+ noFilterMatchTitle: string;
+ noFilterMatchMessage: string;
+ clearFilters: string;
+ openFilters: string;
+ applyFilters: string;
+ close: string;
+ filtersDrawerAria: string;
+ sortSheetAria: string;
+ mobileToolbarAria: string;
+ gridCycle: string;
+ filtersPanelAria: string;
filterPrice: string;
filterAvailability: string;
filterInStock: string;