This commit is contained in:
25
.gitignore
vendored
25
.gitignore
vendored
@@ -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
|
||||
|
||||
104
docs/Catalog-UX-Architecture.md
Normal file
104
docs/Catalog-UX-Architecture.md
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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. Система навигации
|
||||
|
||||
## Назначение
|
||||
|
||||
10
package.json
10
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
34
src/app/core/config/feature-config.service.ts
Normal file
34
src/app/core/config/feature-config.service.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Մաքրել ֆիլտրերը',
|
||||
|
||||
@@ -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: 'Очистить фильтры',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
37
src/app/shared/models/config/features-config.model.ts
Normal file
37
src/app/shared/models/config/features-config.model.ts
Normal 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,
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user