feat(catalog): sprint 10.2 ux responsive empty-states polish
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

This commit is contained in:
sdarbinyan
2026-07-09 01:46:32 +04:00
parent 55216817b2
commit a16c856537
11 changed files with 619 additions and 46 deletions

View File

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

View File

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

View File

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

View File

@@ -94,33 +94,53 @@
@if (!loading() && !error() && viewMode() === 'products') {
<section class="catalog-section catalog-products-section">
<aside class="catalog-left-panel">
<app-catalog-filters-panel
[definitions]="filterDefinitions()"
[state]="filterState()"
(stateChange)="onFilterStateChange($event)" />
</aside>
@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">
<div class="catalog-tools-row">
<div class="catalog-sort-reset-group">
<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>
@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 (userExperienceConfig().savedSearches.enabled) {
<button type="button" class="catalog-save-search-btn" (click)="saveCurrentSearch()">{{ 'ux.saveSearch' | translate }}</button>
}
@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>
}
<app-catalog-layout-switcher
[active]="state().layout"
[available]="availableLayouts"
(activeChange)="changeLayout($event)" />
</div>
@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>
@if (userExperienceConfig().savedSearches.enabled) {
<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,28 +149,104 @@
</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"
[showShareAction]="userExperienceConfig().share.enabled"
[favoriteIds]="favoriteIds()"
[comparedIds]="comparedIds()"
(pageChange)="onResultsPageChange($event)"
(productSelected)="selectProduct($event)"
(addToCart)="addToCart($event)"
(productPreview)="previewProduct($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(compareToggled)="onCompareToggled($event)"
(shareRequested)="onShareRequested($event)" />
@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]="false"
[layout]="state().layout"
[showRatings]="catalogConfig().showRatings"
[showDiscounts]="catalogConfig().showDiscounts"
[showAvailability]="catalogConfig().showAvailability"
[showShareAction]="userExperienceConfig().share.enabled"
[favoriteIds]="favoriteIds()"
[comparedIds]="comparedIds()"
(pageChange)="onResultsPageChange($event)"
(productSelected)="selectProduct($event)"
(addToCart)="addToCart($event)"
(productPreview)="previewProduct($event)"
(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>

View File

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

View File

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