diff --git a/docs/Catalog-Module-Report.md b/docs/Catalog-Module-Report.md index c08bd83..a9b2188 100644 --- a/docs/Catalog-Module-Report.md +++ b/docs/Catalog-Module-Report.md @@ -146,3 +146,72 @@ npm run build ## Stop Point Catalog Module implementation is complete for Sprint 5. Stop here for approval before starting the next module or any Builder/Backoffice work. + +## Sprint 10.2 Catalog UX Polish + +Sprint 10.2 improves catalog UX and responsiveness without changing facades, business logic, bootstrap flow, runtime architecture, routing, authentication, or payment. + +### Empty State Behavior + +Two separate states are now rendered in the catalog container: + +- Empty category state (`rawProducts.length === 0`): + - hides filter/sort/layout/result controls + - shows dedicated empty category component with icon, category context, friendly message, and "Browse Categories" action +- Filtered empty state (`rawProducts.length > 0 && products.length === 0`): + - shows "no filter match" message + - provides "Clear Filters" action + - keeps filter access available (sidebar on desktop, drawer trigger on tablet/mobile) + +### Mobile Filter Drawer + +- Desktop keeps visible sticky sidebar filters. +- Tablet and mobile switch to a drawer-based filter UI. +- Drawer includes filter groups, Reset, and Apply actions. +- Apply closes the drawer. +- Accessibility: + - drawer uses dialog semantics (`role="dialog"`, `aria-modal="true"`) + - focus trap is enabled while drawer is open + - `Esc` closes the drawer + +### Mobile Sort + +- Desktop keeps dropdown sort control. +- Tablet keeps compact dropdown with drawer-based filters. +- Mobile opens a bottom-sheet sort modal. +- Supported mobile sort options: + - Recommended + - Newest + - Price Low -> High + - Price High -> Low + - Rating + - Popularity + +### Responsive Grid Modes and Toolbar + +- Grid selector uses icon buttons and keeps active-state highlighting. +- Mobile sticky toolbar added with quick actions: + - Filters + - Sort + - Grid cycle +- Grid cycle rotates through supported layouts while preserving existing layout architecture. + +### Responsive Spacing and Overflow + +Catalog spacing and controls were polished for desktop/tablet/mobile: + +- filter/input/button spacing +- sort/reset row behavior (single row on desktop, stacked naturally on mobile) +- card and grid spacing +- search block spacing +- drawer/sheet interaction surfaces +- horizontal overflow prevention + +### Localization and Accessibility + +- New strings for empty states, drawer/sheet UI, and toolbar were added to all languages: + - `src/app/i18n/en.ts` + - `src/app/i18n/ru.ts` + - `src/app/i18n/hy.ts` + - `src/app/i18n/translations.ts` +- No hardcoded catalog UX strings were introduced for Sprint 10.2 additions. diff --git a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.html b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.html new file mode 100644 index 0000000..11ef193 --- /dev/null +++ b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.html @@ -0,0 +1,31 @@ +
+ + +
+

{{ title }}

+ + @if (categoryTitle) { +

{{ categoryTitle }}

+ } + +

{{ message }}

+
+ + @if (actionLabel) { + + } +
diff --git a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss new file mode 100644 index 0000000..8f36c7f --- /dev/null +++ b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss @@ -0,0 +1,83 @@ +.catalog-empty-state { + min-height: 320px; + display: grid; + gap: 16px; + align-content: center; + justify-items: center; + text-align: center; + padding: 24px; +} + +.catalog-empty-state__icon { + width: 72px; + height: 72px; + border-radius: 20px; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--primary-color) 10%, white); +} + +.catalog-empty-state__icon svg { + width: 42px; + height: 42px; + fill: none; + stroke: var(--primary-color); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.catalog-empty-state__content { + display: grid; + gap: 8px; +} + +.catalog-empty-state__content h2, +.catalog-empty-state__content p { + margin: 0; +} + +.catalog-empty-state__content h2 { + color: var(--text-primary); + font-size: 1.3rem; +} + +.catalog-empty-state__category { + color: var(--primary-color); + font-weight: 800; +} + +.catalog-empty-state__content p { + color: var(--text-secondary); + max-width: 460px; + line-height: 1.45; +} + +.catalog-empty-state__action { + min-height: 42px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--text-primary); + padding: 0 16px; + font-weight: 700; + cursor: pointer; + transition: border-color 0.2s ease, transform 0.2s ease; +} + +.catalog-empty-state__action:hover { + border-color: var(--primary-color); + transform: translateY(-1px); +} + +@media (max-width: 640px) { + .catalog-empty-state { + min-height: 260px; + padding: 16px; + } + + .catalog-empty-state__action { + width: 100%; + max-width: 260px; + } +} diff --git a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts new file mode 100644 index 0000000..282117e --- /dev/null +++ b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-catalog-empty-state', + standalone: true, + templateUrl: './catalog-empty-state.component.html', + styleUrls: ['./catalog-empty-state.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CatalogEmptyStateComponent { + @Input() variant: 'category' | 'filtered' = 'category'; + @Input() title = ''; + @Input() message = ''; + @Input() categoryTitle: string | null = null; + @Input() actionLabel = ''; + + @Output() action = new EventEmitter(); +} diff --git a/src/app/features/website/catalog/containers/catalog-container.component.html b/src/app/features/website/catalog/containers/catalog-container.component.html index 69eda3c..3e422cd 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.html +++ b/src/app/features/website/catalog/containers/catalog-container.component.html @@ -94,33 +94,53 @@ @if (!loading() && !error() && viewMode() === 'products') {
- + @if (showDesktopFilters()) { + + }
-
-
- - - + @if (showMobileToolbar()) { +
+ + +
+ } - @if (userExperienceConfig().savedSearches.enabled) { - - } + @if (showCatalogTools()) { +
+
+ @if (isDrawerLayout() && !isMobile()) { + + } - -
+ @if (!isMobile()) { + + } + + +
+ + @if (userExperienceConfig().savedSearches.enabled) { + + } + + @if (!isMobile()) { + + } +
+ } @if (catalogConfig().navigationMode !== 'default') {
@@ -129,28 +149,104 @@
} - + @if (loadingProducts()) { + + } @else if (isEmptyCategoryState()) { + + } @else if (isFilteredEmptyState()) { + + + @if (isDrawerLayout()) { + + } + } @else { + + }
+ + @if (isDrawerLayout() && filterDrawerOpen()) { + + + } + + @if (isMobile() && sortSheetOpen()) { + + + } } 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;