feat(catalog): sprint 10.2 ux responsive empty-states polish
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<section class="catalog-empty-state card" [attr.data-variant]="variant">
|
||||
<div class="catalog-empty-state__icon" aria-hidden="true">
|
||||
@if (variant === 'category') {
|
||||
<svg viewBox="0 0 64 64" focusable="false">
|
||||
<rect x="8" y="16" width="48" height="34" rx="6"></rect>
|
||||
<path d="M14 44l10-10 8 7 12-13 6 6"></path>
|
||||
<circle cx="24" cy="27" r="4"></circle>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg viewBox="0 0 64 64" focusable="false">
|
||||
<circle cx="28" cy="28" r="16"></circle>
|
||||
<path d="M18 28h20"></path>
|
||||
<path d="M52 52L39 39"></path>
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="catalog-empty-state__content">
|
||||
<h2>{{ title }}</h2>
|
||||
|
||||
@if (categoryTitle) {
|
||||
<p class="catalog-empty-state__category">{{ categoryTitle }}</p>
|
||||
}
|
||||
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
|
||||
@if (actionLabel) {
|
||||
<button type="button" class="catalog-empty-state__action" (click)="action.emit()">{{ actionLabel }}</button>
|
||||
}
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<void>();
|
||||
}
|
||||
@@ -94,20 +94,37 @@
|
||||
|
||||
@if (!loading() && !error() && viewMode() === 'products') {
|
||||
<section class="catalog-section catalog-products-section">
|
||||
<aside class="catalog-left-panel">
|
||||
@if (showDesktopFilters()) {
|
||||
<aside class="catalog-left-panel" [attr.aria-label]="'catalog.filtersPanelAria' | translate">
|
||||
<app-catalog-filters-panel
|
||||
[definitions]="filterDefinitions()"
|
||||
[state]="filterState()"
|
||||
(stateChange)="onFilterStateChange($event)" />
|
||||
</aside>
|
||||
}
|
||||
|
||||
<div class="catalog-results-panel">
|
||||
@if (showMobileToolbar()) {
|
||||
<div class="catalog-mobile-toolbar" [attr.aria-label]="'catalog.mobileToolbarAria' | translate">
|
||||
<button type="button" class="catalog-mobile-toolbar-btn" (click)="openFilterDrawer()">{{ 'catalog.filtersTitle' | translate }}</button>
|
||||
<button type="button" class="catalog-mobile-toolbar-btn" (click)="openSortSheet()">{{ 'catalog.sortBy' | translate }}</button>
|
||||
<button type="button" class="catalog-mobile-toolbar-btn" (click)="cycleLayout()">{{ 'catalog.gridCycle' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showCatalogTools()) {
|
||||
<div class="catalog-tools-row">
|
||||
<div class="catalog-sort-reset-group">
|
||||
@if (isDrawerLayout() && !isMobile()) {
|
||||
<button type="button" class="catalog-reset-btn" (click)="openFilterDrawer()">{{ 'catalog.filtersTitle' | translate }}</button>
|
||||
}
|
||||
|
||||
@if (!isMobile()) {
|
||||
<app-catalog-sorting-control
|
||||
[options]="sortDefinitions()"
|
||||
[selected]="state().sort"
|
||||
(selectedChange)="changeSort($event)" />
|
||||
}
|
||||
|
||||
<button type="button" class="catalog-reset-btn" (click)="resetFilters()">{{ 'catalog.resetFilters' | translate }}</button>
|
||||
</div>
|
||||
@@ -116,11 +133,14 @@
|
||||
<button type="button" class="catalog-save-search-btn" (click)="saveCurrentSearch()">{{ 'ux.saveSearch' | translate }}</button>
|
||||
}
|
||||
|
||||
@if (!isMobile()) {
|
||||
<app-catalog-layout-switcher
|
||||
[active]="state().layout"
|
||||
[available]="availableLayouts"
|
||||
(activeChange)="changeLayout($event)" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (catalogConfig().navigationMode !== 'default') {
|
||||
<div class="catalog-navigation-placeholder card">
|
||||
@@ -129,13 +149,48 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loadingProducts()) {
|
||||
<app-catalog-search-results
|
||||
[products]="[]"
|
||||
[total]="state().pagination.total"
|
||||
[page]="state().pagination.page"
|
||||
[pageSize]="state().pagination.count"
|
||||
[summary]="searchSummary()"
|
||||
[loading]="true"
|
||||
[layout]="state().layout"
|
||||
[showRatings]="catalogConfig().showRatings"
|
||||
[showDiscounts]="catalogConfig().showDiscounts"
|
||||
[showAvailability]="catalogConfig().showAvailability"
|
||||
[showShareAction]="userExperienceConfig().share.enabled"
|
||||
[favoriteIds]="favoriteIds()"
|
||||
[comparedIds]="comparedIds()" />
|
||||
} @else if (isEmptyCategoryState()) {
|
||||
<app-catalog-empty-state
|
||||
variant="category"
|
||||
[title]="'catalog.emptyCategoryTitle' | translate"
|
||||
[categoryTitle]="state().category?.title ?? null"
|
||||
[message]="'catalog.emptyCategoryMessage' | translate"
|
||||
[actionLabel]="'catalog.browseCategories' | translate"
|
||||
(action)="browseCategories()" />
|
||||
} @else if (isFilteredEmptyState()) {
|
||||
<app-catalog-empty-state
|
||||
variant="filtered"
|
||||
[title]="'catalog.noFilterMatchTitle' | translate"
|
||||
[message]="'catalog.noFilterMatchMessage' | translate"
|
||||
[actionLabel]="'catalog.clearFilters' | translate"
|
||||
(action)="resetFilters()" />
|
||||
|
||||
@if (isDrawerLayout()) {
|
||||
<button type="button" class="catalog-open-filters-btn" (click)="openFilterDrawer()">{{ 'catalog.openFilters' | translate }}</button>
|
||||
}
|
||||
} @else {
|
||||
<app-catalog-search-results
|
||||
[products]="products()"
|
||||
[total]="state().pagination.total"
|
||||
[page]="state().pagination.page"
|
||||
[pageSize]="state().pagination.count"
|
||||
[summary]="searchSummary()"
|
||||
[loading]="loadingProducts()"
|
||||
[loading]="false"
|
||||
[layout]="state().layout"
|
||||
[showRatings]="catalogConfig().showRatings"
|
||||
[showDiscounts]="catalogConfig().showDiscounts"
|
||||
@@ -150,7 +205,48 @@
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(compareToggled)="onCompareToggled($event)"
|
||||
(shareRequested)="onShareRequested($event)" />
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (isDrawerLayout() && filterDrawerOpen()) {
|
||||
<div class="catalog-overlay" (click)="closeFilterDrawer()" aria-hidden="true"></div>
|
||||
<aside class="catalog-filter-drawer" role="dialog" [attr.aria-label]="'catalog.filtersDrawerAria' | translate" aria-modal="true" cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
|
||||
<header class="catalog-filter-drawer-head">
|
||||
<h2>{{ 'catalog.filtersTitle' | translate }}</h2>
|
||||
<button type="button" class="catalog-icon-btn" [attr.aria-label]="'catalog.close' | translate" (click)="closeFilterDrawer()">×</button>
|
||||
</header>
|
||||
|
||||
<div class="catalog-filter-drawer-body">
|
||||
<app-catalog-filters-panel
|
||||
[definitions]="filterDefinitions()"
|
||||
[state]="filterState()"
|
||||
(stateChange)="onFilterStateChange($event)" />
|
||||
</div>
|
||||
|
||||
<footer class="catalog-filter-drawer-actions">
|
||||
<button type="button" class="catalog-reset-btn" (click)="resetFilters()">{{ 'catalog.resetFilters' | translate }}</button>
|
||||
<button type="button" class="catalog-apply-btn" (click)="applyFilterDrawer()">{{ 'catalog.applyFilters' | translate }}</button>
|
||||
</footer>
|
||||
</aside>
|
||||
}
|
||||
|
||||
@if (isMobile() && sortSheetOpen()) {
|
||||
<div class="catalog-overlay" (click)="closeSortSheet()" aria-hidden="true"></div>
|
||||
<section class="catalog-sort-sheet" role="dialog" [attr.aria-label]="'catalog.sortSheetAria' | translate" aria-modal="true" cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
|
||||
<header class="catalog-sort-sheet-head">
|
||||
<h2>{{ 'catalog.sortBy' | translate }}</h2>
|
||||
<button type="button" class="catalog-icon-btn" [attr.aria-label]="'catalog.close' | translate" (click)="closeSortSheet()">×</button>
|
||||
</header>
|
||||
|
||||
<div class="catalog-sort-sheet-options">
|
||||
@for (option of mobileSortOptions(); track option.id) {
|
||||
<button type="button" class="catalog-sort-option" [class.active]="state().sort === option.id" (click)="selectSortFromSheet(option.id)">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</main>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Առկա է',
|
||||
|
||||
@@ -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: 'В наличии',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user