fixes
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

This commit is contained in:
sdarbinyan
2026-07-10 15:55:38 +04:00
parent 49d8226411
commit ee269a9e33
33 changed files with 559 additions and 52 deletions

25
.gitignore vendored
View File

@@ -45,3 +45,28 @@ testem.log
# System files
.DS_Store
Thumbs.db
<!-- barry-cache:start -->
.context-state/
.context-cache/
.barry-cache/
<!-- barry-cache:end -->
AGENTS.md
CLAUDE.md
GEMINI.md
llms.txt
.cursor/rules/barry-cache.mdc
.github/copilot-instructions.md
docs/context/INDEX.md
docs/context/LOG.md
docs/context/MAINTENANCE.md
docs/context/README.md
docs/context/adrs/README.md
docs/context/concepts/project-context-model.md
docs/context/schema/adr.schema.json
docs/context/schema/fact.schema.json
docs/context/schema/failure.schema.json
docs/context/schema/route.schema.json
docs/context/schema/strategy.schema.json
docs/context/schema/work-state.schema.json
docs/context/schema/workspace.schema.json

View File

@@ -0,0 +1,104 @@
# Catalog UX, Navigation and Loading Strategies - Sprint 16
## Scope
Sprint 16 improves catalog UX without introducing marketplace-specific logic.
Areas covered:
- empty category behavior
- root navigation consistency
- multiple loading strategies
- grid selector completion
- mobile catalog behavior
- future slug routing preparation
- reusable catalog states
- centralized feature flags
- project skills documentation
## Empty Category Behavior
Catalog now distinguishes three category outcomes:
- subcategories exist: show category browser
- products exist: show product list
- neither exist: show dedicated catalog empty state
Empty category state belongs to catalog surface, not product grid.
## Root Navigation
`All Categories` always routes to `/catalog` and shows category browser.
Continue-browsing restoration no longer hijacks this root navigation path.
## Loading Strategies
Configured via `catalog.loadingStrategy`:
- `pagination`
- `loadMore`
- `infiniteScroll`
Single product list component remains source of truth. Strategy changes only affect controls and page-windowing.
## Grid System
Supported layouts:
- `grid-2`
- `grid-3`
- `grid-4`
- `list`
- `compact`
User preference persists locally. Bootstrap default still seeds first render.
Legacy layout aliases normalize to new modes for backward compatibility.
## Mobile Behavior
Mobile catalog uses:
- filter drawer
- sort popup sheet
- grid popup sheet
Inline filter density is avoided.
## Breadcrumb and Slug Preparation
Current URLs remain ID-based.
Routing layer now tolerates future slug-like category tokens by resolving them to internal IDs without changing current public contract.
## Centralized Features
`bootstrap.features` is new central toggle surface for UI features such as:
- wishlist
- compare
- reviews
- comments
- questions
- recommendations
- recentlyViewed
- searchHistory
- recentlySearched
- ratings
- share
- brands
- manufacturers
- availability
- discounts
- badges
Feature resolver falls back to older config surfaces to preserve behavior.
## Project Skills
Added repo skills:
- `.agents/skills/marketplace-architecture/SKILL.md`
- `.agents/skills/ui-standards/SKILL.md`
- `.agents/skills/backend-contract/SKILL.md`
## Future Work
- true backend paging for load-more/infinite strategies
- offline-aware cached catalog data
- explicit slug field on categories
- admin editing surface for centralized feature toggles

View File

@@ -16,6 +16,7 @@
## Опциональные свойства
- catalog.navigationMode
- catalog.loadingStrategy
- catalog.showBreadcrumbs
- catalog.showCategoryBanner
- catalog.showSubcategoryChips
@@ -24,12 +25,16 @@
- catalog.showAvailability
- catalog.suggestionsEnabled
- catalog.searchHistoryEnabled
- catalog.features (via bootstrap.features)
- catalog.facets (future backend-driven)
## Строгие правила
- Каталог не содержит tenant-specific условий в frontend-коде.
- Сортировка и фильтры должны быть согласованы между frontend и backend.
- Видимость товаров контролируется данными backend, а не frontend-хардкодом.
- Пустая категория рендерит отдельный catalog empty-state, а не product grid empty-state.
- Корневой переход `All Categories` всегда возвращает category browser `/catalog`.
- Loading strategy выбирается конфигурацией без дублирования list logic.
## Reusable Domain Models
- SearchCriteria
@@ -43,7 +48,8 @@
```json
{
"catalog": {
"layout": "grid",
"layout": "grid-4",
"loadingStrategy": "pagination",
"defaultSort": "relevance",
"availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"],
"enabledFilters": ["price", "availability", "rating", "brand", "category", "subcategory", "discount", "new", "color", "size", "attributes"],
@@ -54,6 +60,23 @@
}
```
## Loading Strategies
- `pagination`
- `loadMore`
- `infiniteScroll`
## Grid Modes
- `grid-2`
- `grid-3`
- `grid-4`
- `list`
- `compact`
Legacy aliases still normalize safely:
- `grid`
- `large-grid`
- `compact-grid`
## Ответственность Frontend
- Отобразить листинг, фильтры, сортировки и пагинацию.
- Синхронизировать состояние каталога с URL.

View File

@@ -3,6 +3,11 @@
- Static pages with `showInFooter: true` may be grouped into footer sections using `footerGroup`.
- Social links remain footer-config driven and can coexist with CMS page groups.
- Navigation stays configuration-driven; no marketplace-specific page names are hardcoded in frontend.
## Catalog Routing Preparation
- Catalog root `/catalog` renders category browser.
- Category detail keeps current ID routes while resolving future slug tokens in routing layer.
- Breadcrumb root action must always navigate to `/catalog`, not all-products grid.
# 11. Система навигации
## Назначение

View File

@@ -11,7 +11,12 @@
"watch": "ng build --watch --configuration development",
"arch:check:boundaries": "node tools/architecture/check-boundaries.mjs",
"arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json",
"arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles"
"arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles",
"barry": "barry-cache",
"barry:validate": "barry-cache validate",
"barry:resume": "barry-cache resume",
"barry:finalize": "barry-cache finalize",
"barry:failure": "barry-cache failure"
},
"private": true,
"dependencies": {
@@ -34,6 +39,7 @@
"@angular/build": "21.1.5",
"@angular/cli": "21.1.5",
"@angular/compiler-cli": "21.1.5",
"typescript": "~5.9.3"
"typescript": "~5.9.3",
"barry-cache": "^0.1.0"
}
}

View File

@@ -53,7 +53,7 @@
<!-- Right Actions -->
<div class="platform-actions">
@if (headerConfig().showWishlist && userExperienceConfig().wishlist.enabled) {
@if (headerConfig().showWishlist && features().wishlist && userExperienceConfig().wishlist.enabled) {
<button type="button" class="platform-ux-btn" (click)="navigateToWishlist()" [attr.aria-label]="'header.wishlist' | translate">
<span class="platform-ux-icon"></span>
@if (userExperienceConfig().wishlist.headerBadgeEnabled && wishlistCount() > 0) {
@@ -62,7 +62,7 @@
</button>
}
@if (headerConfig().showCompare && userExperienceConfig().compare.enabled) {
@if (headerConfig().showCompare && features().compare && userExperienceConfig().compare.enabled) {
<button type="button" class="platform-ux-btn" (click)="navigateToCompare()" [attr.aria-label]="'header.compare' | translate">
<span class="platform-ux-icon"></span>
@if (compareCount() > 0) {

View File

@@ -10,6 +10,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
import { UserExperienceFacade } from '../../facades/platform/user-experience.facade';
import { ConfigService } from '../../core/config/config.service';
import { FeatureConfigService } from '../../core/config/feature-config.service';
import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
@@ -31,12 +32,14 @@ export class HeaderComponent {
private uiRuntime = inject(UiRuntimeFacade);
private uxFacade = inject(UserExperienceFacade);
private configService = inject(ConfigService);
private featureConfig = inject(FeatureConfigService);
private staticPageResolver = inject(StaticPageResolverService);
readonly wishlistCount = this.uxFacade.wishlistCount;
readonly compareCount = this.uxFacade.compareCount;
readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig());
readonly headerConfig = computed(() => this.resolveHeaderConfig());
readonly features = this.featureConfig.features;
readonly headerPages = computed(() => this.resolveHeaderPages());
constructor(private cartService: CartService, private router: Router) {

View File

@@ -0,0 +1,34 @@
import { Injectable, computed, inject } from '@angular/core';
import { ConfigService } from './config.service';
import { DEFAULT_MARKETPLACE_FEATURES_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, MarketplaceFeaturesConfig } from '../../shared/models/config';
@Injectable({ providedIn: 'root' })
export class FeatureConfigService {
private readonly configService = inject(ConfigService);
readonly features = computed<Required<MarketplaceFeaturesConfig>>(() => {
this.configService.bootstrapRevision();
const snapshot = this.configService.getBootstrapSnapshot() as any;
const features = snapshot?.features ?? {};
const userExperience = snapshot?.userExperience ?? {};
const productPage = snapshot?.productPage ?? {};
return {
...DEFAULT_MARKETPLACE_FEATURES_CONFIG,
...features,
wishlist: features.wishlist ?? userExperience.wishlist?.enabled ?? DEFAULT_USER_EXPERIENCE_CONFIG.wishlist.enabled,
compare: features.compare ?? userExperience.compare?.enabled ?? DEFAULT_USER_EXPERIENCE_CONFIG.compare.enabled,
recentlyViewed: features.recentlyViewed ?? userExperience.recentlyViewed?.enabled ?? DEFAULT_USER_EXPERIENCE_CONFIG.recentlyViewed.enabled,
searchHistory: features.searchHistory ?? snapshot?.catalog?.searchHistoryEnabled ?? true,
recentlySearched: features.recentlySearched ?? snapshot?.catalog?.searchHistoryEnabled ?? true,
share: features.share ?? userExperience.share?.enabled ?? DEFAULT_USER_EXPERIENCE_CONFIG.share.enabled,
reviews: features.reviews ?? productPage.reviews?.enabled ?? true,
questions: features.questions ?? productPage.questions?.enabled ?? true,
ratings: features.ratings ?? snapshot?.catalog?.showRatings ?? true,
availability: features.availability ?? snapshot?.catalog?.showAvailability ?? true,
discounts: features.discounts ?? snapshot?.catalog?.showDiscounts ?? true,
recommendations: features.recommendations ?? true,
badges: features.badges ?? true,
};
});
}

View File

@@ -1,6 +1,6 @@
import { Product, ProductListResult, ProductSort } from './product-domain.model';
export type CatalogLayoutMode = 'grid' | 'large-grid' | 'compact-grid' | 'list';
export type CatalogLayoutMode = 'grid' | 'large-grid' | 'compact-grid' | 'grid-2' | 'grid-3' | 'grid-4' | 'compact' | 'list';
export type CatalogNavigationMode = 'default' | 'left-category-navigation' | 'mega-category-layout' | 'top-category-carousel';
export interface SearchCriteria {

View File

@@ -25,7 +25,7 @@ import { SearchTrendingService } from '../services/search-trending.service';
interface LegacySearchState {
text: string;
sort: SearchQuery['sort'];
layout: 'grid' | 'large-grid' | 'compact-grid' | 'list';
layout: 'grid' | 'large-grid' | 'compact-grid' | 'grid-2' | 'grid-3' | 'grid-4' | 'compact' | 'list';
page: number;
pageSize: number;
filters: SearchFilterState;

View File

@@ -29,6 +29,10 @@
<button type="button" class="catalog-empty-state__action" (click)="action.emit()">{{ actionLabel }}</button>
}
@if (secondaryActionLabel) {
<button type="button" class="catalog-empty-state__action secondary" (click)="secondaryAction.emit()">{{ secondaryActionLabel }}</button>
}
@if (variant === 'filtered' && popularCategories.length > 0) {
<div class="catalog-empty-state__block">
<strong>{{ 'search.popularCategories' | translate }}</strong>

View File

@@ -70,6 +70,11 @@
transform: translateY(-1px);
}
.catalog-empty-state__action.secondary {
background: transparent;
color: var(--text-secondary);
}
.catalog-empty-state__block {
width: 100%;
display: grid;

View File

@@ -17,11 +17,13 @@ export class CatalogEmptyStateComponent {
@Input() message = '';
@Input() categoryTitle: string | null = null;
@Input() actionLabel = '';
@Input() secondaryActionLabel = '';
@Input() popularCategories: Array<{ id: string | number; label: string }> = [];
@Input() popularSearches: string[] = [];
@Input() recommendedProducts: Product[] = [];
@Output() action = new EventEmitter<void>();
@Output() secondaryAction = new EventEmitter<void>();
@Output() popularCategorySelected = new EventEmitter<string | number>();
@Output() popularSearchSelected = new EventEmitter<string>();
@Output() recommendedProductSelected = new EventEmitter<Product>();

View File

@@ -152,6 +152,8 @@
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 0 12px;
min-width: 0;
max-width: 100%;
font: inherit;
color: var(--text-primary);
transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
@@ -242,3 +244,13 @@
grid-template-columns: auto auto 1fr;
}
}
@media (max-width: 640px) {
.range-inputs {
grid-template-columns: 1fr;
}
.catalog-filters {
padding-inline: 0;
}
}

View File

@@ -15,6 +15,12 @@
<rect x="13" y="13" width="8" height="8" rx="1"></rect>
</svg>
}
@case ('grid-2') {
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="18" height="8" rx="1"></rect>
<rect x="3" y="13" width="18" height="8" rx="1"></rect>
</svg>
}
@case ('large-grid') {
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="18" height="8" rx="1"></rect>
@@ -22,6 +28,29 @@
<rect x="13" y="13" width="8" height="8" rx="1"></rect>
</svg>
}
@case ('grid-3') {
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="5" height="8" rx="1"></rect>
<rect x="10" y="3" width="4" height="8" rx="1"></rect>
<rect x="16" y="3" width="5" height="8" rx="1"></rect>
<rect x="3" y="13" width="5" height="8" rx="1"></rect>
<rect x="10" y="13" width="4" height="8" rx="1"></rect>
<rect x="16" y="13" width="5" height="8" rx="1"></rect>
</svg>
}
@case ('grid-4') {
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="4" height="4" rx="1"></rect>
<rect x="9" y="3" width="4" height="4" rx="1"></rect>
<rect x="15" y="3" width="4" height="4" rx="1"></rect>
<rect x="3" y="9" width="4" height="4" rx="1"></rect>
<rect x="9" y="9" width="4" height="4" rx="1"></rect>
<rect x="15" y="9" width="4" height="4" rx="1"></rect>
<rect x="3" y="15" width="4" height="4" rx="1"></rect>
<rect x="9" y="15" width="4" height="4" rx="1"></rect>
<rect x="15" y="15" width="4" height="4" rx="1"></rect>
</svg>
}
@case ('compact-grid') {
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="5" height="5" rx="1"></rect>
@@ -35,6 +64,16 @@
<rect x="17" y="17" width="4" height="4" rx="1"></rect>
</svg>
}
@case ('compact') {
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="4" width="6" height="5" rx="1"></rect>
<rect x="11" y="4" width="10" height="2" rx="1"></rect>
<rect x="11" y="7" width="10" height="2" rx="1"></rect>
<rect x="3" y="13" width="6" height="5" rx="1"></rect>
<rect x="11" y="13" width="10" height="2" rx="1"></rect>
<rect x="11" y="16" width="10" height="2" rx="1"></rect>
</svg>
}
@default {
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="4" width="18" height="4" rx="1"></rect>
@@ -44,7 +83,7 @@
}
}
<span>{{ labels[layout] | translate }}</span>
<span [title]="labels[layout] | translate">{{ labels[layout] | translate }}</span>
</button>
}
</div>

View File

@@ -16,6 +16,7 @@
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: center;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
}
@@ -29,6 +30,10 @@
stroke-width: 1.7;
}
.layout-btn span {
white-space: nowrap;
}
.layout-btn.active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 12%, white);

View File

@@ -11,15 +11,19 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CatalogLayoutSwitcherComponent {
@Input() active: CatalogLayoutMode = 'grid';
@Input() available: CatalogLayoutMode[] = ['grid', 'large-grid', 'compact-grid', 'list'];
@Input() active: CatalogLayoutMode = 'grid-4';
@Input() available: CatalogLayoutMode[] = ['grid-2', 'grid-3', 'grid-4', 'list', 'compact'];
@Output() activeChange = new EventEmitter<CatalogLayoutMode>();
readonly labels: Record<CatalogLayoutMode, string> = {
grid: 'catalog.layoutGrid',
'large-grid': 'catalog.layoutLargeGrid',
'compact-grid': 'catalog.layoutCompactGrid',
grid: 'catalog.layoutGrid4',
'large-grid': 'catalog.layoutGrid2',
'compact-grid': 'catalog.layoutCompact',
'grid-2': 'catalog.layoutGrid2',
'grid-3': 'catalog.layoutGrid3',
'grid-4': 'catalog.layoutGrid4',
compact: 'catalog.layoutCompact',
list: 'catalog.layoutList'
};
}

View File

@@ -2,10 +2,26 @@
align-items: stretch;
}
.catalog-layout-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.catalog-layout-grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.catalog-layout-grid-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.catalog-layout-large-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.catalog-layout-compact {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.catalog-layout-list {
display: grid;
grid-template-columns: 1fr;

View File

@@ -58,8 +58,16 @@ export class CatalogProductGridComponent {
layoutClass(): string {
switch (this.layout) {
case 'grid-2':
return 'grid grid-2 catalog-layout-grid-2';
case 'grid-3':
return 'grid grid-3 catalog-layout-grid-3';
case 'grid-4':
return 'grid grid-4 catalog-layout-grid-4';
case 'large-grid':
return 'grid grid-3 catalog-layout-large-grid';
case 'compact':
return 'grid grid-4 catalog-layout-compact';
case 'compact-grid':
return 'grid grid-4 catalog-layout-compact-grid';
case 'list':

View File

@@ -34,11 +34,17 @@
</div>
}
@if (!loading && totalPages > 1) {
@if (!loading && loadingStrategy === 'pagination' && totalPages > 1) {
<div class="results-pager">
<button type="button" (click)="previous()" [disabled]="page <= 1">{{ 'catalog.previousPage' | translate }}</button>
<span>{{ 'catalog.pageOf' | translate:{ page: page, total: totalPages } }}</span>
<button type="button" (click)="next()" [disabled]="page >= totalPages">{{ 'catalog.nextPage' | translate }}</button>
</div>
}
@if (!loading && loadingStrategy === 'loadMore' && hasMore) {
<div class="results-load-more">
<button type="button" (click)="onLoadMore()">{{ 'catalog.loadMore' | translate }}</button>
</div>
}
</section>

View File

@@ -62,6 +62,22 @@
gap: 12px;
}
.results-load-more {
display: flex;
justify-content: center;
}
.results-load-more button {
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;
cursor: pointer;
font-weight: 700;
}
.results-pager button {
min-height: 40px;
border: 1px solid var(--border-color);

View File

@@ -26,8 +26,11 @@ export class CatalogSearchResultsComponent {
@Input() showShareAction = true;
@Input() favoriteIds: number[] = [];
@Input() comparedIds: number[] = [];
@Input() loadingStrategy: 'pagination' | 'loadMore' | 'infiniteScroll' = 'pagination';
@Input() hasMore = false;
@Output() pageChange = new EventEmitter<number>();
@Output() loadMore = new EventEmitter<void>();
@Output() productSelected = new EventEmitter<Product>();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@Output() productPreview = new EventEmitter<number>();
@@ -50,4 +53,10 @@ export class CatalogSearchResultsComponent {
this.pageChange.emit(this.page + 1);
}
}
onLoadMore(): void {
if (this.hasMore) {
this.loadMore.emit();
}
}
}

View File

@@ -1,10 +1,10 @@
<main class="catalog-page page-container">
<header class="catalog-header">
<a [routerLink]="'/catalog' | langRoute" class="catalog-root-link">{{ 'catalog.title' | translate }}</a>
<button type="button" class="catalog-root-link catalog-root-link-btn" (click)="browseCategories()">{{ 'catalog.title' | translate }}</button>
@if (catalogConfig().showBreadcrumbs && breadcrumb().length > 0) {
<nav class="catalog-breadcrumb" [attr.aria-label]="'catalog.breadcrumb' | translate">
<a [routerLink]="'/catalog' | langRoute">{{ 'catalog.allCategories' | translate }}</a>
<button type="button" (click)="browseCategories()">{{ 'catalog.allCategories' | translate }}</button>
@for (category of breadcrumb(); track category.id) {
<span aria-hidden="true">/</span>
<button type="button" (click)="selectCategory(category)">{{ category.title }}</button>
@@ -150,7 +150,12 @@
</div>
}
@if (loadingProducts()) {
@if (offlineState()) {
<div class="catalog-message">
<h2>{{ 'catalog.offlineTitle' | translate }}</h2>
<p>{{ 'catalog.offlineMessage' | translate }}</p>
</div>
} @else if (loadingProducts()) {
<app-catalog-search-results
[products]="[]"
[total]="state().pagination.total"
@@ -158,11 +163,13 @@
[pageSize]="state().pagination.count"
[summary]="searchSummary()"
[loading]="true"
[loadingStrategy]="loadingStrategy()"
[hasMore]="state().pagination.hasMore"
[layout]="state().layout"
[showRatings]="catalogConfig().showRatings"
[showDiscounts]="catalogConfig().showDiscounts"
[showAvailability]="catalogConfig().showAvailability"
[showShareAction]="userExperienceConfig().share.enabled"
[showRatings]="features().ratings"
[showDiscounts]="features().discounts"
[showAvailability]="features().availability"
[showShareAction]="features().share"
[favoriteIds]="favoriteIds()"
[comparedIds]="comparedIds()" />
} @else if (isEmptyCategoryState()) {
@@ -172,7 +179,9 @@
[categoryTitle]="state().category?.title ?? null"
[message]="'catalog.emptyCategoryMessage' | translate"
[actionLabel]="'catalog.browseCategories' | translate"
[secondaryActionLabel]="'catalog.goToParentCategory' | translate"
(action)="browseCategories()" />
(secondaryAction)="goToParentCategory()" />
} @else if (isFilteredEmptyState()) {
<app-catalog-empty-state
variant="filtered"
@@ -199,14 +208,17 @@
[pageSize]="state().pagination.count"
[summary]="searchSummary()"
[loading]="false"
[loadingStrategy]="loadingStrategy()"
[hasMore]="state().pagination.hasMore"
[layout]="state().layout"
[showRatings]="catalogConfig().showRatings"
[showDiscounts]="catalogConfig().showDiscounts"
[showAvailability]="catalogConfig().showAvailability"
[showShareAction]="userExperienceConfig().share.enabled"
[showRatings]="features().ratings"
[showDiscounts]="features().discounts"
[showAvailability]="features().availability"
[showShareAction]="features().share"
[favoriteIds]="favoriteIds()"
[comparedIds]="comparedIds()"
(pageChange)="onResultsPageChange($event)"
(loadMore)="loadNextPage()"
(productSelected)="selectProduct($event)"
(addToCart)="addToCart($event)"
(productPreview)="previewProduct($event)"

View File

@@ -50,6 +50,13 @@
text-decoration: none;
}
.catalog-root-link-btn {
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
}
.catalog-breadcrumb {
display: flex;
align-items: center;

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, DestroyRef, HostListener, computed, effect, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { A11yModule } from '@angular/cdk/a11y';
import { combineLatest } from 'rxjs';
@@ -8,13 +8,13 @@ import { Category } from '../../../../core/categories/models/category-domain.mod
import { CatalogLayoutMode } from '../../../../core/products/models/catalog-experience.model';
import { Product } from '../../../../core/products/models/product-domain.model';
import { ConfigService } from '../../../../core/config/config.service';
import { FeatureConfigService } from '../../../../core/config/feature-config.service';
import { CategoryFacade } from '../../../../facades/platform/category.facade';
import { SearchFacade } from '../../../../facades/platform/search.facade';
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { CartService } from '../../../../services';
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 { TranslateService } from '../../../../i18n/translate.service';
import { DEFAULT_CATALOG_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../shared/models/config';
@@ -33,14 +33,13 @@ import { UserNotificationService } from '../../user-experience/services/user-not
import { SearchSuggestion as SearchSuggestionItem } from '../../../../features/search/models/search.model';
type CatalogViewMode = 'categories' | 'products';
type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
@Component({
selector: 'app-catalog-container',
standalone: true,
imports: [
A11yModule,
RouterLink,
LangRoutePipe,
TranslatePipe,
CatalogEmptyStateComponent,
CatalogSearchBoxComponent,
@@ -61,6 +60,7 @@ export class CatalogContainerComponent {
private readonly configService = inject(ConfigService);
private readonly categoryFacade = inject(CategoryFacade);
private readonly searchFacade = inject(SearchFacade);
private readonly featureConfig = inject(FeatureConfigService);
private readonly cartService = inject(CartService);
private readonly prefetchService = inject(PrefetchService);
private readonly languageService = inject(LanguageService);
@@ -71,6 +71,7 @@ export class CatalogContainerComponent {
readonly catalogConfig = signal(this.resolveCatalogConfig());
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
readonly features = this.featureConfig.features;
readonly state = signal<CatalogState>(createInitialCatalogState());
readonly categories = signal<Category[]>([]);
@@ -91,19 +92,23 @@ export class CatalogContainerComponent {
readonly loading = signal(true);
readonly loadingProducts = signal(false);
readonly error = signal<string | null>(null);
readonly online = signal(typeof navigator === 'undefined' ? true : navigator.onLine);
readonly viewportWidth = signal(typeof window !== 'undefined' ? window.innerWidth : 1280);
readonly filterDrawerOpen = signal(false);
readonly sortSheetOpen = signal(false);
readonly gridSheetOpen = signal(false);
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
readonly offlineState = computed(() => !this.online());
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 isEmptyCategoryState = computed(() => this.viewMode() === 'products' && !this.loadingProducts() && this.rawProducts().length === 0 && this.subcategoryChips().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.products().length > 0 && !this.loadingProducts());
readonly showDesktopFilters = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && !this.isDrawerLayout() && !this.offlineState());
readonly showMobileToolbar = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && this.isMobile() && !this.offlineState());
readonly showCatalogTools = computed(() => this.viewMode() === 'products' && this.products().length > 0 && !this.loadingProducts() && !this.offlineState());
readonly loadingStrategy = computed<CatalogLoadingStrategy>(() => this.catalogConfig().loadingStrategy ?? 'pagination');
readonly currentColumnsLabel = computed(() => this.layoutLabelKey(this.state().layout));
readonly popularCategorySuggestions = computed(() => {
const sources = [...this.subcategoryChips(), ...this.categories()]
.slice(0, 8)
@@ -129,7 +134,7 @@ export class CatalogContainerComponent {
readonly sortDefinitions = computed<SortOption[]>(() => this.searchFacade.createSortOptions(this.catalogConfig().availableSorts));
readonly availableLayouts: CatalogLayoutMode[] = ['grid', 'large-grid', 'compact-grid', 'list'];
readonly availableLayouts: CatalogLayoutMode[] = ['grid-2', 'grid-3', 'grid-4', 'list', 'compact'];
readonly skeletonSlots = Array.from({ length: 8 });
@@ -144,8 +149,17 @@ export class CatalogContainerComponent {
this.searchSuggestions.set(this.searchFacade.state().suggestions);
});
effect(() => {
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('catalog.layout.preference') : null;
if (!saved) {
return;
}
this.state.update(current => ({ ...current, layout: this.normalizeLayout(saved as CatalogLayoutMode) }));
});
this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe());
if (this.catalogConfig().searchHistoryEnabled) {
if (this.features().searchHistory) {
const snapshot = this.searchFacade.getSearchHistory();
this.searchHistory.set(snapshot.items);
this.recentSearches.set(snapshot.recent);
@@ -161,15 +175,13 @@ export class CatalogContainerComponent {
return;
}
const categoryId = Number(params.get('id')) || null;
const categoryToken = params.get('id');
const queryPatch = this.searchFacade.fromQueryParams(queryParams);
const hasQueryState = this.hasSearchState(queryPatch);
if (categoryId == null && !hasQueryState && this.userExperienceConfig().continueBrowsing.enabled && this.restoreContinueBrowsing()) {
return;
}
this.enterCategory(categoryId, queryPatch, hasQueryState);
this.resolveCategoryId(categoryToken).then(categoryId => {
this.enterCategory(categoryId, queryPatch, hasQueryState);
});
});
}
@@ -192,7 +204,7 @@ export class CatalogContainerComponent {
this.state.set({
...createInitialCatalogState(),
sort: this.catalogConfig().defaultSort,
layout: this.catalogConfig().layout,
layout: this.resolveInitialLayout(),
filters: {
...createInitialCatalogState().filters,
categoryIds: categoryId == null ? [] : [categoryId],
@@ -265,7 +277,7 @@ export class CatalogContainerComponent {
this.state.update(current => ({ ...current, search: query }));
this.persistContinueBrowsing();
if (!this.catalogConfig().suggestionsEnabled) {
if (!this.features().searchHistory) {
this.searchSuggestions.set([]);
return;
}
@@ -286,7 +298,7 @@ export class CatalogContainerComponent {
}
}));
if (this.catalogConfig().searchHistoryEnabled && normalized.length > 0) {
if (this.features().searchHistory && normalized.length > 0) {
const snapshot = this.searchFacade.pushSearchHistory(normalized);
this.searchHistory.set(snapshot.items);
this.recentSearches.set(snapshot.recent);
@@ -435,6 +447,16 @@ export class CatalogContainerComponent {
@HostListener('window:scroll')
onWindowScroll(): void {
this.persistContinueBrowsing();
if (this.loadingStrategy() !== 'infiniteScroll' || this.loadingProducts() || !this.state().pagination.hasMore || this.viewMode() !== 'products') {
return;
}
const scrollPosition = window.innerHeight + window.scrollY;
const bottomPosition = document.documentElement.scrollHeight - 180;
if (scrollPosition >= bottomPosition) {
this.loadNextPage();
}
}
@HostListener('window:resize')
@@ -497,7 +519,11 @@ export class CatalogContainerComponent {
}
changeLayout(layout: CatalogLayoutMode): void {
this.state.update(current => ({ ...current, layout }));
const normalized = this.normalizeLayout(layout);
this.state.update(current => ({ ...current, layout: normalized }));
if (typeof localStorage !== 'undefined') {
localStorage.setItem('catalog.layout.preference', normalized);
}
this.syncUrlFromState();
this.persistContinueBrowsing();
}
@@ -557,11 +583,18 @@ export class CatalogContainerComponent {
layoutLabelKey(layout: CatalogLayoutMode): string {
switch (layout) {
case 'grid':
return 'catalog.layoutGrid';
case 'grid-4':
return 'catalog.layoutGrid4';
case 'grid-2':
return 'catalog.layoutGrid2';
case 'grid-3':
return 'catalog.layoutGrid3';
case 'large-grid':
return 'catalog.layoutLargeGrid';
return 'catalog.layoutGrid2';
case 'compact':
return 'catalog.layoutCompact';
case 'compact-grid':
return 'catalog.layoutCompactGrid';
return 'catalog.layoutCompact';
case 'list':
default:
return 'catalog.layoutList';
@@ -569,9 +602,21 @@ export class CatalogContainerComponent {
}
browseCategories(): void {
this.uxFacade.clearContinueBrowsing('catalog');
this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`]);
}
goToParentCategory(): void {
const breadcrumb = this.breadcrumb();
const parent = breadcrumb.length > 1 ? breadcrumb[breadcrumb.length - 2] : null;
if (parent) {
this.selectCategory(parent);
return;
}
this.browseCategories();
}
onResultsPageChange(page: number): void {
this.state.update(current => ({
...current,
@@ -587,6 +632,15 @@ export class CatalogContainerComponent {
this.persistContinueBrowsing();
}
loadNextPage(): void {
const nextPage = this.state().pagination.page + 1;
if (!this.state().pagination.hasMore) {
return;
}
this.onResultsPageChange(nextPage);
}
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');
@@ -651,7 +705,9 @@ export class CatalogContainerComponent {
const sorted = this.searchFacade.applySort(filtered, this.state().sort);
const pagination = this.state().pagination;
const page = Math.max(1, pagination.page);
const pageSize = Math.max(1, pagination.count);
const pageSize = this.loadingStrategy() === 'loadMore' || this.loadingStrategy() === 'infiniteScroll'
? Math.max(1, pagination.page * pagination.count)
: Math.max(1, pagination.count);
const paged = this.searchFacade.paginateProducts(sorted, page, pageSize);
this.products.set(paged.items);
@@ -669,7 +725,9 @@ export class CatalogContainerComponent {
this.searchResult.set(result);
this.searchSummary.set(result.summary);
this.searchSuggestions.set(this.searchFacade.buildLiveSuggestions(this.state().search, sorted));
if (this.features().searchHistory) {
this.searchSuggestions.set(this.searchFacade.buildLiveSuggestions(this.state().search, sorted));
}
if (this.pendingScrollY != null && typeof window !== 'undefined') {
const scrollY = this.pendingScrollY;
@@ -754,6 +812,7 @@ export class CatalogContainerComponent {
return {
...DEFAULT_CATALOG_CONFIG,
...raw,
layout: this.normalizeLayout(raw.layout ?? DEFAULT_CATALOG_CONFIG.layout),
availableSorts: Array.isArray(raw.availableSorts) && raw.availableSorts.length > 0
? raw.availableSorts
: DEFAULT_CATALOG_CONFIG.availableSorts,
@@ -853,6 +912,34 @@ export class CatalogContainerComponent {
return true;
}
private normalizeLayout(layout: CatalogLayoutMode): CatalogLayoutMode {
if (layout === 'grid') return 'grid-4';
if (layout === 'large-grid') return 'grid-2';
if (layout === 'compact-grid') return 'compact';
return layout;
}
private resolveInitialLayout(): CatalogLayoutMode {
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('catalog.layout.preference') : null;
return this.normalizeLayout((stored as CatalogLayoutMode) || this.catalogConfig().layout);
}
private async resolveCategoryId(categoryToken: string | null): Promise<number | null> {
if (!categoryToken) {
return null;
}
const numeric = Number(categoryToken);
if (Number.isFinite(numeric) && numeric > 0) {
return numeric;
}
const categories = await new Promise<Category[]>(resolve => this.categoryFacade.getAllCategories().subscribe(value => resolve(value)));
const normalized = categoryToken.trim().toLowerCase();
const matched = categories.find(category => category.title.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-') === normalized);
return matched?.id ?? null;
}
private setError(message: string): void {
this.error.set(message);
this.loading.set(false);

View File

@@ -204,6 +204,7 @@ export const en: Translations = {
searchNoResultsHint: 'No results found for this query. Try broader keywords.',
searchProductsFound: '{{total}} products found',
searchResultsSummary: '{{total}} results for "{{query}}"',
loadMore: 'Load more',
sortBy: 'Sort by',
sortRelevance: 'Recommended',
sortLatest: 'Newest',
@@ -215,6 +216,10 @@ export const en: Translations = {
layoutGrid: 'Grid',
layoutLargeGrid: 'Large grid',
layoutCompactGrid: 'Compact grid',
layoutGrid2: '2 Columns',
layoutGrid3: '3 Columns',
layoutGrid4: '4 Columns',
layoutCompact: 'Compact',
layoutList: 'List',
noResults: 'No results',
itemsCount: '{{count}} items',
@@ -225,7 +230,10 @@ export const en: Translations = {
pageOf: 'Page {{page}} / {{total}}',
emptyCategoryTitle: 'This category has no products yet',
emptyCategoryMessage: 'Try browsing parent categories or return to the full catalog.',
goToParentCategory: 'Go to parent category',
browseCategories: 'Browse Categories',
offlineTitle: 'You are offline',
offlineMessage: 'Reconnect to continue browsing this catalog.',
noFilterMatchTitle: 'No products match your selected filters.',
noFilterMatchMessage: 'Try broadening price, attributes, or brand filters.',
clearFilters: 'Clear Filters',

View File

@@ -204,6 +204,7 @@ export const hy: Translations = {
searchNoResultsHint: 'Արդյունքներ չկան։ Փորձեք ավելի լայն բանալի բառեր։',
searchProductsFound: 'Գտնված ապրանքներ՝ {{total}}',
searchResultsSummary: '{{total}} արդյունք "{{query}}" հարցմամբ',
loadMore: 'Բեռնել ավելին',
sortBy: 'Տեսակավորել ըստ',
sortRelevance: 'Առաջարկվող',
sortLatest: 'Նորության',
@@ -215,6 +216,10 @@ export const hy: Translations = {
layoutGrid: 'Ցանց',
layoutLargeGrid: 'Մեծ ցանց',
layoutCompactGrid: 'Կոմպակտ ցանց',
layoutGrid2: '2 սյուն',
layoutGrid3: '3 սյուն',
layoutGrid4: '4 սյուն',
layoutCompact: 'Կոմպակտ',
layoutList: 'Ցանկ',
noResults: 'Արդյունք չկա',
itemsCount: '{{count}} ապրանք',
@@ -225,7 +230,10 @@ export const hy: Translations = {
pageOf: 'Էջ {{page}} / {{total}}',
emptyCategoryTitle: 'Այս կատեգորիայում դեռ ապրանքներ չկան',
emptyCategoryMessage: 'Փորձեք անցնել ծնող կատեգորիաներ կամ վերադառնալ ամբողջ կատալոգ։',
goToParentCategory: 'Գնալ ծնող կատեգորիա',
browseCategories: 'Դիտել կատեգորիաները',
offlineTitle: 'Դուք offline եք',
offlineMessage: 'Միացեք ցանցին՝ կատալոգը շարունակելու համար։',
noFilterMatchTitle: 'Ձեր ընտրած ֆիլտրերին համապատասխան ապրանքներ չկան։',
noFilterMatchMessage: 'Փորձեք ընդլայնել գնի միջակայքը, հատկանիշները կամ բրենդները։',
clearFilters: 'Մաքրել ֆիլտրերը',

View File

@@ -204,6 +204,7 @@ export const ru: Translations = {
searchNoResultsHint: 'Ничего не найдено. Попробуйте более общий запрос.',
searchProductsFound: 'Найдено товаров: {{total}}',
searchResultsSummary: '{{total}} результатов по запросу "{{query}}"',
loadMore: 'Показать еще',
sortBy: 'Сортировать по',
sortRelevance: 'Рекомендуемые',
sortLatest: 'Новизне',
@@ -215,6 +216,10 @@ export const ru: Translations = {
layoutGrid: 'Сетка',
layoutLargeGrid: 'Крупная сетка',
layoutCompactGrid: 'Компактная сетка',
layoutGrid2: '2 колонки',
layoutGrid3: '3 колонки',
layoutGrid4: '4 колонки',
layoutCompact: 'Компактно',
layoutList: 'Список',
noResults: 'Нет результатов',
itemsCount: '{{count}} товаров',
@@ -225,7 +230,10 @@ export const ru: Translations = {
pageOf: 'Страница {{page}} / {{total}}',
emptyCategoryTitle: 'В этой категории пока нет товаров',
emptyCategoryMessage: 'Попробуйте перейти в родительские категории или вернуться в общий каталог.',
goToParentCategory: 'Перейти в родительскую категорию',
browseCategories: 'Смотреть категории',
offlineTitle: 'Нет подключения',
offlineMessage: 'Подключитесь к сети, чтобы продолжить просмотр каталога.',
noFilterMatchTitle: 'Нет товаров, соответствующих выбранным фильтрам.',
noFilterMatchMessage: 'Попробуйте расширить диапазон цены, атрибуты или бренды.',
clearFilters: 'Очистить фильтры',

View File

@@ -202,6 +202,7 @@ export interface Translations {
searchNoResultsHint: string;
searchProductsFound: string;
searchResultsSummary: string;
loadMore: string;
sortBy: string;
sortRelevance: string;
sortLatest: string;
@@ -213,6 +214,10 @@ export interface Translations {
layoutGrid: string;
layoutLargeGrid: string;
layoutCompactGrid: string;
layoutGrid2: string;
layoutGrid3: string;
layoutGrid4: string;
layoutCompact: string;
layoutList: string;
noResults: string;
itemsCount: string;
@@ -223,7 +228,10 @@ export interface Translations {
pageOf: string;
emptyCategoryTitle: string;
emptyCategoryMessage: string;
goToParentCategory: string;
browseCategories: string;
offlineTitle: string;
offlineMessage: string;
noFilterMatchTitle: string;
noFilterMatchMessage: string;
clearFilters: string;

View File

@@ -3,6 +3,7 @@ import { BrandingConfig } from './branding.model';
import { CatalogConfig } from './catalog-config.model';
import { CompanyConfig } from './company.model';
import { FeatureFlagsConfig } from './feature-flags.model';
import { MarketplaceFeaturesConfig } from './features-config.model';
import { FooterConfig } from './footer-config.model';
import { HeaderConfig } from './header-config.model';
import { PlatformLayoutConfig } from './layout.model';
@@ -26,6 +27,7 @@ export interface BootstrapConfig {
theme: ThemeConfig;
company: CompanyConfig;
featureFlags: FeatureFlagsConfig;
features?: MarketplaceFeaturesConfig;
apiEndpoints: ApiEndpointsConfig;
localization: LocalizationConfig;
seo: SeoConfig;

View File

@@ -1,8 +1,10 @@
export type CatalogLayoutModeConfig = 'grid' | 'large-grid' | 'compact-grid' | 'list';
export type CatalogLayoutModeConfig = 'grid' | 'large-grid' | 'compact-grid' | 'grid-2' | 'grid-3' | 'grid-4' | 'compact' | 'list';
export type CatalogNavigationModeConfig = 'default' | 'left-category-navigation' | 'mega-category-layout' | 'top-category-carousel';
export type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
export interface CatalogConfig {
layout?: CatalogLayoutModeConfig;
loadingStrategy?: CatalogLoadingStrategy;
navigationMode?: CatalogNavigationModeConfig;
defaultSort?: 'relevance' | 'latest' | 'price_asc' | 'price_desc' | 'rating' | 'popular' | 'discount';
availableSorts?: Array<'relevance' | 'latest' | 'price_asc' | 'price_desc' | 'rating' | 'popular' | 'discount'>;
@@ -18,7 +20,8 @@ export interface CatalogConfig {
}
export const DEFAULT_CATALOG_CONFIG: Required<CatalogConfig> = {
layout: 'grid',
layout: 'grid-4',
loadingStrategy: 'pagination',
navigationMode: 'default',
defaultSort: 'relevance',
availableSorts: ['relevance', 'latest', 'price_asc', 'price_desc', 'rating', 'popular', 'discount'],

View File

@@ -0,0 +1,37 @@
export interface MarketplaceFeaturesConfig {
wishlist?: boolean;
compare?: boolean;
reviews?: boolean;
comments?: boolean;
questions?: boolean;
recommendations?: boolean;
recentlyViewed?: boolean;
searchHistory?: boolean;
recentlySearched?: boolean;
ratings?: boolean;
share?: boolean;
brands?: boolean;
manufacturers?: boolean;
availability?: boolean;
discounts?: boolean;
badges?: boolean;
}
export const DEFAULT_MARKETPLACE_FEATURES_CONFIG: Required<MarketplaceFeaturesConfig> = {
wishlist: true,
compare: true,
reviews: true,
comments: true,
questions: true,
recommendations: true,
recentlyViewed: true,
searchHistory: true,
recentlySearched: true,
ratings: true,
share: true,
brands: true,
manufacturers: true,
availability: true,
discounts: true,
badges: true,
};

View File

@@ -4,6 +4,7 @@ export * from './branding.model';
export * from './catalog-config.model';
export * from './company.model';
export * from './feature-flags.model';
export * from './features-config.model';
export * from './footer-config.model';
export * from './header-config.model';
export * from './layout.model';