This commit is contained in:
25
.gitignore
vendored
25
.gitignore
vendored
@@ -45,3 +45,28 @@ testem.log
|
|||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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.navigationMode
|
||||||
|
- catalog.loadingStrategy
|
||||||
- catalog.showBreadcrumbs
|
- catalog.showBreadcrumbs
|
||||||
- catalog.showCategoryBanner
|
- catalog.showCategoryBanner
|
||||||
- catalog.showSubcategoryChips
|
- catalog.showSubcategoryChips
|
||||||
@@ -24,12 +25,16 @@
|
|||||||
- catalog.showAvailability
|
- catalog.showAvailability
|
||||||
- catalog.suggestionsEnabled
|
- catalog.suggestionsEnabled
|
||||||
- catalog.searchHistoryEnabled
|
- catalog.searchHistoryEnabled
|
||||||
|
- catalog.features (via bootstrap.features)
|
||||||
- catalog.facets (future backend-driven)
|
- catalog.facets (future backend-driven)
|
||||||
|
|
||||||
## Строгие правила
|
## Строгие правила
|
||||||
- Каталог не содержит tenant-specific условий в frontend-коде.
|
- Каталог не содержит tenant-specific условий в frontend-коде.
|
||||||
- Сортировка и фильтры должны быть согласованы между frontend и backend.
|
- Сортировка и фильтры должны быть согласованы между frontend и backend.
|
||||||
- Видимость товаров контролируется данными backend, а не frontend-хардкодом.
|
- Видимость товаров контролируется данными backend, а не frontend-хардкодом.
|
||||||
|
- Пустая категория рендерит отдельный catalog empty-state, а не product grid empty-state.
|
||||||
|
- Корневой переход `All Categories` всегда возвращает category browser `/catalog`.
|
||||||
|
- Loading strategy выбирается конфигурацией без дублирования list logic.
|
||||||
|
|
||||||
## Reusable Domain Models
|
## Reusable Domain Models
|
||||||
- SearchCriteria
|
- SearchCriteria
|
||||||
@@ -43,7 +48,8 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"layout": "grid",
|
"layout": "grid-4",
|
||||||
|
"loadingStrategy": "pagination",
|
||||||
"defaultSort": "relevance",
|
"defaultSort": "relevance",
|
||||||
"availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"],
|
"availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"],
|
||||||
"enabledFilters": ["price", "availability", "rating", "brand", "category", "subcategory", "discount", "new", "color", "size", "attributes"],
|
"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
|
## Ответственность Frontend
|
||||||
- Отобразить листинг, фильтры, сортировки и пагинацию.
|
- Отобразить листинг, фильтры, сортировки и пагинацию.
|
||||||
- Синхронизировать состояние каталога с URL.
|
- Синхронизировать состояние каталога с URL.
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
- Static pages with `showInFooter: true` may be grouped into footer sections using `footerGroup`.
|
- 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.
|
- 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.
|
- 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. Система навигации
|
# 11. Система навигации
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -11,7 +11,12 @@
|
|||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"arch:check:boundaries": "node tools/architecture/check-boundaries.mjs",
|
"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: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,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -34,6 +39,7 @@
|
|||||||
"@angular/build": "21.1.5",
|
"@angular/build": "21.1.5",
|
||||||
"@angular/cli": "21.1.5",
|
"@angular/cli": "21.1.5",
|
||||||
"@angular/compiler-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 -->
|
<!-- Right Actions -->
|
||||||
<div class="platform-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">
|
<button type="button" class="platform-ux-btn" (click)="navigateToWishlist()" [attr.aria-label]="'header.wishlist' | translate">
|
||||||
<span class="platform-ux-icon">♥</span>
|
<span class="platform-ux-icon">♥</span>
|
||||||
@if (userExperienceConfig().wishlist.headerBadgeEnabled && wishlistCount() > 0) {
|
@if (userExperienceConfig().wishlist.headerBadgeEnabled && wishlistCount() > 0) {
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</button>
|
</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">
|
<button type="button" class="platform-ux-btn" (click)="navigateToCompare()" [attr.aria-label]="'header.compare' | translate">
|
||||||
<span class="platform-ux-icon">⇄</span>
|
<span class="platform-ux-icon">⇄</span>
|
||||||
@if (compareCount() > 0) {
|
@if (compareCount() > 0) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
|||||||
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
||||||
import { UserExperienceFacade } from '../../facades/platform/user-experience.facade';
|
import { UserExperienceFacade } from '../../facades/platform/user-experience.facade';
|
||||||
import { ConfigService } from '../../core/config/config.service';
|
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 { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
|
||||||
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
|
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
|
||||||
|
|
||||||
@@ -31,12 +32,14 @@ export class HeaderComponent {
|
|||||||
private uiRuntime = inject(UiRuntimeFacade);
|
private uiRuntime = inject(UiRuntimeFacade);
|
||||||
private uxFacade = inject(UserExperienceFacade);
|
private uxFacade = inject(UserExperienceFacade);
|
||||||
private configService = inject(ConfigService);
|
private configService = inject(ConfigService);
|
||||||
|
private featureConfig = inject(FeatureConfigService);
|
||||||
private staticPageResolver = inject(StaticPageResolverService);
|
private staticPageResolver = inject(StaticPageResolverService);
|
||||||
|
|
||||||
readonly wishlistCount = this.uxFacade.wishlistCount;
|
readonly wishlistCount = this.uxFacade.wishlistCount;
|
||||||
readonly compareCount = this.uxFacade.compareCount;
|
readonly compareCount = this.uxFacade.compareCount;
|
||||||
readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig());
|
readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig());
|
||||||
readonly headerConfig = computed(() => this.resolveHeaderConfig());
|
readonly headerConfig = computed(() => this.resolveHeaderConfig());
|
||||||
|
readonly features = this.featureConfig.features;
|
||||||
readonly headerPages = computed(() => this.resolveHeaderPages());
|
readonly headerPages = computed(() => this.resolveHeaderPages());
|
||||||
|
|
||||||
constructor(private cartService: CartService, private router: Router) {
|
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';
|
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 type CatalogNavigationMode = 'default' | 'left-category-navigation' | 'mega-category-layout' | 'top-category-carousel';
|
||||||
|
|
||||||
export interface SearchCriteria {
|
export interface SearchCriteria {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { SearchTrendingService } from '../services/search-trending.service';
|
|||||||
interface LegacySearchState {
|
interface LegacySearchState {
|
||||||
text: string;
|
text: string;
|
||||||
sort: SearchQuery['sort'];
|
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;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
filters: SearchFilterState;
|
filters: SearchFilterState;
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
<button type="button" class="catalog-empty-state__action" (click)="action.emit()">{{ actionLabel }}</button>
|
<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) {
|
@if (variant === 'filtered' && popularCategories.length > 0) {
|
||||||
<div class="catalog-empty-state__block">
|
<div class="catalog-empty-state__block">
|
||||||
<strong>{{ 'search.popularCategories' | translate }}</strong>
|
<strong>{{ 'search.popularCategories' | translate }}</strong>
|
||||||
|
|||||||
@@ -70,6 +70,11 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-empty-state__action.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-empty-state__block {
|
.catalog-empty-state__block {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ export class CatalogEmptyStateComponent {
|
|||||||
@Input() message = '';
|
@Input() message = '';
|
||||||
@Input() categoryTitle: string | null = null;
|
@Input() categoryTitle: string | null = null;
|
||||||
@Input() actionLabel = '';
|
@Input() actionLabel = '';
|
||||||
|
@Input() secondaryActionLabel = '';
|
||||||
@Input() popularCategories: Array<{ id: string | number; label: string }> = [];
|
@Input() popularCategories: Array<{ id: string | number; label: string }> = [];
|
||||||
@Input() popularSearches: string[] = [];
|
@Input() popularSearches: string[] = [];
|
||||||
@Input() recommendedProducts: Product[] = [];
|
@Input() recommendedProducts: Product[] = [];
|
||||||
|
|
||||||
@Output() action = new EventEmitter<void>();
|
@Output() action = new EventEmitter<void>();
|
||||||
|
@Output() secondaryAction = new EventEmitter<void>();
|
||||||
@Output() popularCategorySelected = new EventEmitter<string | number>();
|
@Output() popularCategorySelected = new EventEmitter<string | number>();
|
||||||
@Output() popularSearchSelected = new EventEmitter<string>();
|
@Output() popularSearchSelected = new EventEmitter<string>();
|
||||||
@Output() recommendedProductSelected = new EventEmitter<Product>();
|
@Output() recommendedProductSelected = new EventEmitter<Product>();
|
||||||
|
|||||||
@@ -152,6 +152,8 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
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;
|
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>
|
<rect x="13" y="13" width="8" height="8" rx="1"></rect>
|
||||||
</svg>
|
</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') {
|
@case ('large-grid') {
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<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="3" width="18" height="8" rx="1"></rect>
|
||||||
@@ -22,6 +28,29 @@
|
|||||||
<rect x="13" y="13" width="8" height="8" rx="1"></rect>
|
<rect x="13" y="13" width="8" height="8" rx="1"></rect>
|
||||||
</svg>
|
</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') {
|
@case ('compact-grid') {
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
<rect x="3" y="3" width="5" height="5" rx="1"></rect>
|
<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>
|
<rect x="17" y="17" width="4" height="4" rx="1"></rect>
|
||||||
</svg>
|
</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 {
|
@default {
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
<rect x="3" y="4" width="18" height="4" rx="1"></rect>
|
<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>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
|
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,10 @@
|
|||||||
stroke-width: 1.7;
|
stroke-width: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-btn span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.layout-btn.active {
|
.layout-btn.active {
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
background: color-mix(in srgb, var(--primary-color) 12%, white);
|
background: color-mix(in srgb, var(--primary-color) 12%, white);
|
||||||
|
|||||||
@@ -11,15 +11,19 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class CatalogLayoutSwitcherComponent {
|
export class CatalogLayoutSwitcherComponent {
|
||||||
@Input() active: CatalogLayoutMode = 'grid';
|
@Input() active: CatalogLayoutMode = 'grid-4';
|
||||||
@Input() available: CatalogLayoutMode[] = ['grid', 'large-grid', 'compact-grid', 'list'];
|
@Input() available: CatalogLayoutMode[] = ['grid-2', 'grid-3', 'grid-4', 'list', 'compact'];
|
||||||
|
|
||||||
@Output() activeChange = new EventEmitter<CatalogLayoutMode>();
|
@Output() activeChange = new EventEmitter<CatalogLayoutMode>();
|
||||||
|
|
||||||
readonly labels: Record<CatalogLayoutMode, string> = {
|
readonly labels: Record<CatalogLayoutMode, string> = {
|
||||||
grid: 'catalog.layoutGrid',
|
grid: 'catalog.layoutGrid4',
|
||||||
'large-grid': 'catalog.layoutLargeGrid',
|
'large-grid': 'catalog.layoutGrid2',
|
||||||
'compact-grid': 'catalog.layoutCompactGrid',
|
'compact-grid': 'catalog.layoutCompact',
|
||||||
|
'grid-2': 'catalog.layoutGrid2',
|
||||||
|
'grid-3': 'catalog.layoutGrid3',
|
||||||
|
'grid-4': 'catalog.layoutGrid4',
|
||||||
|
compact: 'catalog.layoutCompact',
|
||||||
list: 'catalog.layoutList'
|
list: 'catalog.layoutList'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,26 @@
|
|||||||
align-items: stretch;
|
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 {
|
.catalog-layout-large-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-layout-compact {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-layout-list {
|
.catalog-layout-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -58,8 +58,16 @@ export class CatalogProductGridComponent {
|
|||||||
|
|
||||||
layoutClass(): string {
|
layoutClass(): string {
|
||||||
switch (this.layout) {
|
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':
|
case 'large-grid':
|
||||||
return 'grid grid-3 catalog-layout-large-grid';
|
return 'grid grid-3 catalog-layout-large-grid';
|
||||||
|
case 'compact':
|
||||||
|
return 'grid grid-4 catalog-layout-compact';
|
||||||
case 'compact-grid':
|
case 'compact-grid':
|
||||||
return 'grid grid-4 catalog-layout-compact-grid';
|
return 'grid grid-4 catalog-layout-compact-grid';
|
||||||
case 'list':
|
case 'list':
|
||||||
|
|||||||
@@ -34,11 +34,17 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!loading && totalPages > 1) {
|
@if (!loading && loadingStrategy === 'pagination' && totalPages > 1) {
|
||||||
<div class="results-pager">
|
<div class="results-pager">
|
||||||
<button type="button" (click)="previous()" [disabled]="page <= 1">{{ 'catalog.previousPage' | translate }}</button>
|
<button type="button" (click)="previous()" [disabled]="page <= 1">{{ 'catalog.previousPage' | translate }}</button>
|
||||||
<span>{{ 'catalog.pageOf' | translate:{ page: page, total: totalPages } }}</span>
|
<span>{{ 'catalog.pageOf' | translate:{ page: page, total: totalPages } }}</span>
|
||||||
<button type="button" (click)="next()" [disabled]="page >= totalPages">{{ 'catalog.nextPage' | translate }}</button>
|
<button type="button" (click)="next()" [disabled]="page >= totalPages">{{ 'catalog.nextPage' | translate }}</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (!loading && loadingStrategy === 'loadMore' && hasMore) {
|
||||||
|
<div class="results-load-more">
|
||||||
|
<button type="button" (click)="onLoadMore()">{{ 'catalog.loadMore' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -62,6 +62,22 @@
|
|||||||
gap: 12px;
|
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 {
|
.results-pager button {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ export class CatalogSearchResultsComponent {
|
|||||||
@Input() showShareAction = true;
|
@Input() showShareAction = true;
|
||||||
@Input() favoriteIds: number[] = [];
|
@Input() favoriteIds: number[] = [];
|
||||||
@Input() comparedIds: number[] = [];
|
@Input() comparedIds: number[] = [];
|
||||||
|
@Input() loadingStrategy: 'pagination' | 'loadMore' | 'infiniteScroll' = 'pagination';
|
||||||
|
@Input() hasMore = false;
|
||||||
|
|
||||||
@Output() pageChange = new EventEmitter<number>();
|
@Output() pageChange = new EventEmitter<number>();
|
||||||
|
@Output() loadMore = new EventEmitter<void>();
|
||||||
@Output() productSelected = new EventEmitter<Product>();
|
@Output() productSelected = new EventEmitter<Product>();
|
||||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||||
@Output() productPreview = new EventEmitter<number>();
|
@Output() productPreview = new EventEmitter<number>();
|
||||||
@@ -50,4 +53,10 @@ export class CatalogSearchResultsComponent {
|
|||||||
this.pageChange.emit(this.page + 1);
|
this.pageChange.emit(this.page + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onLoadMore(): void {
|
||||||
|
if (this.hasMore) {
|
||||||
|
this.loadMore.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<main class="catalog-page page-container">
|
<main class="catalog-page page-container">
|
||||||
<header class="catalog-header">
|
<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) {
|
@if (catalogConfig().showBreadcrumbs && breadcrumb().length > 0) {
|
||||||
<nav class="catalog-breadcrumb" [attr.aria-label]="'catalog.breadcrumb' | translate">
|
<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) {
|
@for (category of breadcrumb(); track category.id) {
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
<button type="button" (click)="selectCategory(category)">{{ category.title }}</button>
|
<button type="button" (click)="selectCategory(category)">{{ category.title }}</button>
|
||||||
@@ -150,7 +150,12 @@
|
|||||||
</div>
|
</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
|
<app-catalog-search-results
|
||||||
[products]="[]"
|
[products]="[]"
|
||||||
[total]="state().pagination.total"
|
[total]="state().pagination.total"
|
||||||
@@ -158,11 +163,13 @@
|
|||||||
[pageSize]="state().pagination.count"
|
[pageSize]="state().pagination.count"
|
||||||
[summary]="searchSummary()"
|
[summary]="searchSummary()"
|
||||||
[loading]="true"
|
[loading]="true"
|
||||||
|
[loadingStrategy]="loadingStrategy()"
|
||||||
|
[hasMore]="state().pagination.hasMore"
|
||||||
[layout]="state().layout"
|
[layout]="state().layout"
|
||||||
[showRatings]="catalogConfig().showRatings"
|
[showRatings]="features().ratings"
|
||||||
[showDiscounts]="catalogConfig().showDiscounts"
|
[showDiscounts]="features().discounts"
|
||||||
[showAvailability]="catalogConfig().showAvailability"
|
[showAvailability]="features().availability"
|
||||||
[showShareAction]="userExperienceConfig().share.enabled"
|
[showShareAction]="features().share"
|
||||||
[favoriteIds]="favoriteIds()"
|
[favoriteIds]="favoriteIds()"
|
||||||
[comparedIds]="comparedIds()" />
|
[comparedIds]="comparedIds()" />
|
||||||
} @else if (isEmptyCategoryState()) {
|
} @else if (isEmptyCategoryState()) {
|
||||||
@@ -172,7 +179,9 @@
|
|||||||
[categoryTitle]="state().category?.title ?? null"
|
[categoryTitle]="state().category?.title ?? null"
|
||||||
[message]="'catalog.emptyCategoryMessage' | translate"
|
[message]="'catalog.emptyCategoryMessage' | translate"
|
||||||
[actionLabel]="'catalog.browseCategories' | translate"
|
[actionLabel]="'catalog.browseCategories' | translate"
|
||||||
|
[secondaryActionLabel]="'catalog.goToParentCategory' | translate"
|
||||||
(action)="browseCategories()" />
|
(action)="browseCategories()" />
|
||||||
|
(secondaryAction)="goToParentCategory()" />
|
||||||
} @else if (isFilteredEmptyState()) {
|
} @else if (isFilteredEmptyState()) {
|
||||||
<app-catalog-empty-state
|
<app-catalog-empty-state
|
||||||
variant="filtered"
|
variant="filtered"
|
||||||
@@ -199,14 +208,17 @@
|
|||||||
[pageSize]="state().pagination.count"
|
[pageSize]="state().pagination.count"
|
||||||
[summary]="searchSummary()"
|
[summary]="searchSummary()"
|
||||||
[loading]="false"
|
[loading]="false"
|
||||||
|
[loadingStrategy]="loadingStrategy()"
|
||||||
|
[hasMore]="state().pagination.hasMore"
|
||||||
[layout]="state().layout"
|
[layout]="state().layout"
|
||||||
[showRatings]="catalogConfig().showRatings"
|
[showRatings]="features().ratings"
|
||||||
[showDiscounts]="catalogConfig().showDiscounts"
|
[showDiscounts]="features().discounts"
|
||||||
[showAvailability]="catalogConfig().showAvailability"
|
[showAvailability]="features().availability"
|
||||||
[showShareAction]="userExperienceConfig().share.enabled"
|
[showShareAction]="features().share"
|
||||||
[favoriteIds]="favoriteIds()"
|
[favoriteIds]="favoriteIds()"
|
||||||
[comparedIds]="comparedIds()"
|
[comparedIds]="comparedIds()"
|
||||||
(pageChange)="onResultsPageChange($event)"
|
(pageChange)="onResultsPageChange($event)"
|
||||||
|
(loadMore)="loadNextPage()"
|
||||||
(productSelected)="selectProduct($event)"
|
(productSelected)="selectProduct($event)"
|
||||||
(addToCart)="addToCart($event)"
|
(addToCart)="addToCart($event)"
|
||||||
(productPreview)="previewProduct($event)"
|
(productPreview)="previewProduct($event)"
|
||||||
|
|||||||
@@ -50,6 +50,13 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-root-link-btn {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-breadcrumb {
|
.catalog-breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ChangeDetectionStrategy, Component, DestroyRef, HostListener, computed, effect, inject, signal } from '@angular/core';
|
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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { A11yModule } from '@angular/cdk/a11y';
|
import { A11yModule } from '@angular/cdk/a11y';
|
||||||
import { combineLatest } from 'rxjs';
|
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 { CatalogLayoutMode } from '../../../../core/products/models/catalog-experience.model';
|
||||||
import { Product } from '../../../../core/products/models/product-domain.model';
|
import { Product } from '../../../../core/products/models/product-domain.model';
|
||||||
import { ConfigService } from '../../../../core/config/config.service';
|
import { ConfigService } from '../../../../core/config/config.service';
|
||||||
|
import { FeatureConfigService } from '../../../../core/config/feature-config.service';
|
||||||
import { CategoryFacade } from '../../../../facades/platform/category.facade';
|
import { CategoryFacade } from '../../../../facades/platform/category.facade';
|
||||||
import { SearchFacade } from '../../../../facades/platform/search.facade';
|
import { SearchFacade } from '../../../../facades/platform/search.facade';
|
||||||
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
|
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
|
||||||
import { CartService } from '../../../../services';
|
import { CartService } from '../../../../services';
|
||||||
import { LanguageService } from '../../../../services/language.service';
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
import { PrefetchService } from '../../../../services/prefetch.service';
|
import { PrefetchService } from '../../../../services/prefetch.service';
|
||||||
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
|
|
||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../../../i18n/translate.service';
|
import { TranslateService } from '../../../../i18n/translate.service';
|
||||||
import { DEFAULT_CATALOG_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../shared/models/config';
|
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';
|
import { SearchSuggestion as SearchSuggestionItem } from '../../../../features/search/models/search.model';
|
||||||
|
|
||||||
type CatalogViewMode = 'categories' | 'products';
|
type CatalogViewMode = 'categories' | 'products';
|
||||||
|
type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-catalog-container',
|
selector: 'app-catalog-container',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
A11yModule,
|
A11yModule,
|
||||||
RouterLink,
|
|
||||||
LangRoutePipe,
|
|
||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
CatalogEmptyStateComponent,
|
CatalogEmptyStateComponent,
|
||||||
CatalogSearchBoxComponent,
|
CatalogSearchBoxComponent,
|
||||||
@@ -61,6 +60,7 @@ export class CatalogContainerComponent {
|
|||||||
private readonly configService = inject(ConfigService);
|
private readonly configService = inject(ConfigService);
|
||||||
private readonly categoryFacade = inject(CategoryFacade);
|
private readonly categoryFacade = inject(CategoryFacade);
|
||||||
private readonly searchFacade = inject(SearchFacade);
|
private readonly searchFacade = inject(SearchFacade);
|
||||||
|
private readonly featureConfig = inject(FeatureConfigService);
|
||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly prefetchService = inject(PrefetchService);
|
private readonly prefetchService = inject(PrefetchService);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
@@ -71,6 +71,7 @@ export class CatalogContainerComponent {
|
|||||||
|
|
||||||
readonly catalogConfig = signal(this.resolveCatalogConfig());
|
readonly catalogConfig = signal(this.resolveCatalogConfig());
|
||||||
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
||||||
|
readonly features = this.featureConfig.features;
|
||||||
|
|
||||||
readonly state = signal<CatalogState>(createInitialCatalogState());
|
readonly state = signal<CatalogState>(createInitialCatalogState());
|
||||||
readonly categories = signal<Category[]>([]);
|
readonly categories = signal<Category[]>([]);
|
||||||
@@ -91,19 +92,23 @@ export class CatalogContainerComponent {
|
|||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly loadingProducts = signal(false);
|
readonly loadingProducts = signal(false);
|
||||||
readonly error = signal<string | null>(null);
|
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 viewportWidth = signal(typeof window !== 'undefined' ? window.innerWidth : 1280);
|
||||||
readonly filterDrawerOpen = signal(false);
|
readonly filterDrawerOpen = signal(false);
|
||||||
readonly sortSheetOpen = signal(false);
|
readonly sortSheetOpen = signal(false);
|
||||||
readonly gridSheetOpen = signal(false);
|
readonly gridSheetOpen = signal(false);
|
||||||
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
|
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
|
||||||
|
readonly offlineState = computed(() => !this.online());
|
||||||
readonly isDrawerLayout = computed(() => this.viewportWidth() <= 1024);
|
readonly isDrawerLayout = computed(() => this.viewportWidth() <= 1024);
|
||||||
readonly isMobile = computed(() => this.viewportWidth() <= 767);
|
readonly isMobile = computed(() => this.viewportWidth() <= 767);
|
||||||
readonly hasRawProducts = computed(() => this.rawProducts().length > 0);
|
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 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 showDesktopFilters = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && !this.isDrawerLayout() && !this.offlineState());
|
||||||
readonly showMobileToolbar = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && this.isMobile());
|
readonly showMobileToolbar = computed(() => this.viewMode() === 'products' && !this.isEmptyCategoryState() && this.isMobile() && !this.offlineState());
|
||||||
readonly showCatalogTools = computed(() => this.viewMode() === 'products' && this.products().length > 0 && !this.loadingProducts());
|
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(() => {
|
readonly popularCategorySuggestions = computed(() => {
|
||||||
const sources = [...this.subcategoryChips(), ...this.categories()]
|
const sources = [...this.subcategoryChips(), ...this.categories()]
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
@@ -129,7 +134,7 @@ export class CatalogContainerComponent {
|
|||||||
|
|
||||||
readonly sortDefinitions = computed<SortOption[]>(() => this.searchFacade.createSortOptions(this.catalogConfig().availableSorts));
|
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 });
|
readonly skeletonSlots = Array.from({ length: 8 });
|
||||||
|
|
||||||
@@ -144,8 +149,17 @@ export class CatalogContainerComponent {
|
|||||||
this.searchSuggestions.set(this.searchFacade.state().suggestions);
|
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());
|
this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe());
|
||||||
if (this.catalogConfig().searchHistoryEnabled) {
|
if (this.features().searchHistory) {
|
||||||
const snapshot = this.searchFacade.getSearchHistory();
|
const snapshot = this.searchFacade.getSearchHistory();
|
||||||
this.searchHistory.set(snapshot.items);
|
this.searchHistory.set(snapshot.items);
|
||||||
this.recentSearches.set(snapshot.recent);
|
this.recentSearches.set(snapshot.recent);
|
||||||
@@ -161,15 +175,13 @@ export class CatalogContainerComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryId = Number(params.get('id')) || null;
|
const categoryToken = params.get('id');
|
||||||
const queryPatch = this.searchFacade.fromQueryParams(queryParams);
|
const queryPatch = this.searchFacade.fromQueryParams(queryParams);
|
||||||
const hasQueryState = this.hasSearchState(queryPatch);
|
const hasQueryState = this.hasSearchState(queryPatch);
|
||||||
|
|
||||||
if (categoryId == null && !hasQueryState && this.userExperienceConfig().continueBrowsing.enabled && this.restoreContinueBrowsing()) {
|
this.resolveCategoryId(categoryToken).then(categoryId => {
|
||||||
return;
|
this.enterCategory(categoryId, queryPatch, hasQueryState);
|
||||||
}
|
});
|
||||||
|
|
||||||
this.enterCategory(categoryId, queryPatch, hasQueryState);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +204,7 @@ export class CatalogContainerComponent {
|
|||||||
this.state.set({
|
this.state.set({
|
||||||
...createInitialCatalogState(),
|
...createInitialCatalogState(),
|
||||||
sort: this.catalogConfig().defaultSort,
|
sort: this.catalogConfig().defaultSort,
|
||||||
layout: this.catalogConfig().layout,
|
layout: this.resolveInitialLayout(),
|
||||||
filters: {
|
filters: {
|
||||||
...createInitialCatalogState().filters,
|
...createInitialCatalogState().filters,
|
||||||
categoryIds: categoryId == null ? [] : [categoryId],
|
categoryIds: categoryId == null ? [] : [categoryId],
|
||||||
@@ -265,7 +277,7 @@ export class CatalogContainerComponent {
|
|||||||
this.state.update(current => ({ ...current, search: query }));
|
this.state.update(current => ({ ...current, search: query }));
|
||||||
this.persistContinueBrowsing();
|
this.persistContinueBrowsing();
|
||||||
|
|
||||||
if (!this.catalogConfig().suggestionsEnabled) {
|
if (!this.features().searchHistory) {
|
||||||
this.searchSuggestions.set([]);
|
this.searchSuggestions.set([]);
|
||||||
return;
|
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);
|
const snapshot = this.searchFacade.pushSearchHistory(normalized);
|
||||||
this.searchHistory.set(snapshot.items);
|
this.searchHistory.set(snapshot.items);
|
||||||
this.recentSearches.set(snapshot.recent);
|
this.recentSearches.set(snapshot.recent);
|
||||||
@@ -435,6 +447,16 @@ export class CatalogContainerComponent {
|
|||||||
@HostListener('window:scroll')
|
@HostListener('window:scroll')
|
||||||
onWindowScroll(): void {
|
onWindowScroll(): void {
|
||||||
this.persistContinueBrowsing();
|
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')
|
@HostListener('window:resize')
|
||||||
@@ -497,7 +519,11 @@ export class CatalogContainerComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
changeLayout(layout: CatalogLayoutMode): void {
|
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.syncUrlFromState();
|
||||||
this.persistContinueBrowsing();
|
this.persistContinueBrowsing();
|
||||||
}
|
}
|
||||||
@@ -557,11 +583,18 @@ export class CatalogContainerComponent {
|
|||||||
layoutLabelKey(layout: CatalogLayoutMode): string {
|
layoutLabelKey(layout: CatalogLayoutMode): string {
|
||||||
switch (layout) {
|
switch (layout) {
|
||||||
case 'grid':
|
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':
|
case 'large-grid':
|
||||||
return 'catalog.layoutLargeGrid';
|
return 'catalog.layoutGrid2';
|
||||||
|
case 'compact':
|
||||||
|
return 'catalog.layoutCompact';
|
||||||
case 'compact-grid':
|
case 'compact-grid':
|
||||||
return 'catalog.layoutCompactGrid';
|
return 'catalog.layoutCompact';
|
||||||
case 'list':
|
case 'list':
|
||||||
default:
|
default:
|
||||||
return 'catalog.layoutList';
|
return 'catalog.layoutList';
|
||||||
@@ -569,9 +602,21 @@ export class CatalogContainerComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
browseCategories(): void {
|
browseCategories(): void {
|
||||||
|
this.uxFacade.clearContinueBrowsing('catalog');
|
||||||
this.router.navigate([`/${this.languageService.currentLanguage()}/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 {
|
onResultsPageChange(page: number): void {
|
||||||
this.state.update(current => ({
|
this.state.update(current => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -587,6 +632,15 @@ export class CatalogContainerComponent {
|
|||||||
this.persistContinueBrowsing();
|
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 {
|
private renderCategories(category: Category | null, categories: Category[], breadcrumb: Category[]): void {
|
||||||
const ordered = [...categories].sort((a, b) => a.priority - b.priority || a.id - b.id);
|
const ordered = [...categories].sort((a, b) => a.priority - b.priority || a.id - b.id);
|
||||||
this.viewMode.set('categories');
|
this.viewMode.set('categories');
|
||||||
@@ -651,7 +705,9 @@ export class CatalogContainerComponent {
|
|||||||
const sorted = this.searchFacade.applySort(filtered, this.state().sort);
|
const sorted = this.searchFacade.applySort(filtered, this.state().sort);
|
||||||
const pagination = this.state().pagination;
|
const pagination = this.state().pagination;
|
||||||
const page = Math.max(1, pagination.page);
|
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);
|
const paged = this.searchFacade.paginateProducts(sorted, page, pageSize);
|
||||||
|
|
||||||
this.products.set(paged.items);
|
this.products.set(paged.items);
|
||||||
@@ -669,7 +725,9 @@ export class CatalogContainerComponent {
|
|||||||
|
|
||||||
this.searchResult.set(result);
|
this.searchResult.set(result);
|
||||||
this.searchSummary.set(result.summary);
|
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') {
|
if (this.pendingScrollY != null && typeof window !== 'undefined') {
|
||||||
const scrollY = this.pendingScrollY;
|
const scrollY = this.pendingScrollY;
|
||||||
@@ -754,6 +812,7 @@ export class CatalogContainerComponent {
|
|||||||
return {
|
return {
|
||||||
...DEFAULT_CATALOG_CONFIG,
|
...DEFAULT_CATALOG_CONFIG,
|
||||||
...raw,
|
...raw,
|
||||||
|
layout: this.normalizeLayout(raw.layout ?? DEFAULT_CATALOG_CONFIG.layout),
|
||||||
availableSorts: Array.isArray(raw.availableSorts) && raw.availableSorts.length > 0
|
availableSorts: Array.isArray(raw.availableSorts) && raw.availableSorts.length > 0
|
||||||
? raw.availableSorts
|
? raw.availableSorts
|
||||||
: DEFAULT_CATALOG_CONFIG.availableSorts,
|
: DEFAULT_CATALOG_CONFIG.availableSorts,
|
||||||
@@ -853,6 +912,34 @@ export class CatalogContainerComponent {
|
|||||||
return true;
|
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 {
|
private setError(message: string): void {
|
||||||
this.error.set(message);
|
this.error.set(message);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export const en: Translations = {
|
|||||||
searchNoResultsHint: 'No results found for this query. Try broader keywords.',
|
searchNoResultsHint: 'No results found for this query. Try broader keywords.',
|
||||||
searchProductsFound: '{{total}} products found',
|
searchProductsFound: '{{total}} products found',
|
||||||
searchResultsSummary: '{{total}} results for "{{query}}"',
|
searchResultsSummary: '{{total}} results for "{{query}}"',
|
||||||
|
loadMore: 'Load more',
|
||||||
sortBy: 'Sort by',
|
sortBy: 'Sort by',
|
||||||
sortRelevance: 'Recommended',
|
sortRelevance: 'Recommended',
|
||||||
sortLatest: 'Newest',
|
sortLatest: 'Newest',
|
||||||
@@ -215,6 +216,10 @@ export const en: Translations = {
|
|||||||
layoutGrid: 'Grid',
|
layoutGrid: 'Grid',
|
||||||
layoutLargeGrid: 'Large grid',
|
layoutLargeGrid: 'Large grid',
|
||||||
layoutCompactGrid: 'Compact grid',
|
layoutCompactGrid: 'Compact grid',
|
||||||
|
layoutGrid2: '2 Columns',
|
||||||
|
layoutGrid3: '3 Columns',
|
||||||
|
layoutGrid4: '4 Columns',
|
||||||
|
layoutCompact: 'Compact',
|
||||||
layoutList: 'List',
|
layoutList: 'List',
|
||||||
noResults: 'No results',
|
noResults: 'No results',
|
||||||
itemsCount: '{{count}} items',
|
itemsCount: '{{count}} items',
|
||||||
@@ -225,7 +230,10 @@ export const en: Translations = {
|
|||||||
pageOf: 'Page {{page}} / {{total}}',
|
pageOf: 'Page {{page}} / {{total}}',
|
||||||
emptyCategoryTitle: 'This category has no products yet',
|
emptyCategoryTitle: 'This category has no products yet',
|
||||||
emptyCategoryMessage: 'Try browsing parent categories or return to the full catalog.',
|
emptyCategoryMessage: 'Try browsing parent categories or return to the full catalog.',
|
||||||
|
goToParentCategory: 'Go to parent category',
|
||||||
browseCategories: 'Browse Categories',
|
browseCategories: 'Browse Categories',
|
||||||
|
offlineTitle: 'You are offline',
|
||||||
|
offlineMessage: 'Reconnect to continue browsing this catalog.',
|
||||||
noFilterMatchTitle: 'No products match your selected filters.',
|
noFilterMatchTitle: 'No products match your selected filters.',
|
||||||
noFilterMatchMessage: 'Try broadening price, attributes, or brand filters.',
|
noFilterMatchMessage: 'Try broadening price, attributes, or brand filters.',
|
||||||
clearFilters: 'Clear Filters',
|
clearFilters: 'Clear Filters',
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export const hy: Translations = {
|
|||||||
searchNoResultsHint: 'Արդյունքներ չկան։ Փորձեք ավելի լայն բանալի բառեր։',
|
searchNoResultsHint: 'Արդյունքներ չկան։ Փորձեք ավելի լայն բանալի բառեր։',
|
||||||
searchProductsFound: 'Գտնված ապրանքներ՝ {{total}}',
|
searchProductsFound: 'Գտնված ապրանքներ՝ {{total}}',
|
||||||
searchResultsSummary: '{{total}} արդյունք "{{query}}" հարցմամբ',
|
searchResultsSummary: '{{total}} արդյունք "{{query}}" հարցմամբ',
|
||||||
|
loadMore: 'Բեռնել ավելին',
|
||||||
sortBy: 'Տեսակավորել ըստ',
|
sortBy: 'Տեսակավորել ըստ',
|
||||||
sortRelevance: 'Առաջարկվող',
|
sortRelevance: 'Առաջարկվող',
|
||||||
sortLatest: 'Նորության',
|
sortLatest: 'Նորության',
|
||||||
@@ -215,6 +216,10 @@ export const hy: Translations = {
|
|||||||
layoutGrid: 'Ցանց',
|
layoutGrid: 'Ցանց',
|
||||||
layoutLargeGrid: 'Մեծ ցանց',
|
layoutLargeGrid: 'Մեծ ցանց',
|
||||||
layoutCompactGrid: 'Կոմպակտ ցանց',
|
layoutCompactGrid: 'Կոմպակտ ցանց',
|
||||||
|
layoutGrid2: '2 սյուն',
|
||||||
|
layoutGrid3: '3 սյուն',
|
||||||
|
layoutGrid4: '4 սյուն',
|
||||||
|
layoutCompact: 'Կոմպակտ',
|
||||||
layoutList: 'Ցանկ',
|
layoutList: 'Ցանկ',
|
||||||
noResults: 'Արդյունք չկա',
|
noResults: 'Արդյունք չկա',
|
||||||
itemsCount: '{{count}} ապրանք',
|
itemsCount: '{{count}} ապրանք',
|
||||||
@@ -225,7 +230,10 @@ export const hy: Translations = {
|
|||||||
pageOf: 'Էջ {{page}} / {{total}}',
|
pageOf: 'Էջ {{page}} / {{total}}',
|
||||||
emptyCategoryTitle: 'Այս կատեգորիայում դեռ ապրանքներ չկան',
|
emptyCategoryTitle: 'Այս կատեգորիայում դեռ ապրանքներ չկան',
|
||||||
emptyCategoryMessage: 'Փորձեք անցնել ծնող կատեգորիաներ կամ վերադառնալ ամբողջ կատալոգ։',
|
emptyCategoryMessage: 'Փորձեք անցնել ծնող կատեգորիաներ կամ վերադառնալ ամբողջ կատալոգ։',
|
||||||
|
goToParentCategory: 'Գնալ ծնող կատեգորիա',
|
||||||
browseCategories: 'Դիտել կատեգորիաները',
|
browseCategories: 'Դիտել կատեգորիաները',
|
||||||
|
offlineTitle: 'Դուք offline եք',
|
||||||
|
offlineMessage: 'Միացեք ցանցին՝ կատալոգը շարունակելու համար։',
|
||||||
noFilterMatchTitle: 'Ձեր ընտրած ֆիլտրերին համապատասխան ապրանքներ չկան։',
|
noFilterMatchTitle: 'Ձեր ընտրած ֆիլտրերին համապատասխան ապրանքներ չկան։',
|
||||||
noFilterMatchMessage: 'Փորձեք ընդլայնել գնի միջակայքը, հատկանիշները կամ բրենդները։',
|
noFilterMatchMessage: 'Փորձեք ընդլայնել գնի միջակայքը, հատկանիշները կամ բրենդները։',
|
||||||
clearFilters: 'Մաքրել ֆիլտրերը',
|
clearFilters: 'Մաքրել ֆիլտրերը',
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export const ru: Translations = {
|
|||||||
searchNoResultsHint: 'Ничего не найдено. Попробуйте более общий запрос.',
|
searchNoResultsHint: 'Ничего не найдено. Попробуйте более общий запрос.',
|
||||||
searchProductsFound: 'Найдено товаров: {{total}}',
|
searchProductsFound: 'Найдено товаров: {{total}}',
|
||||||
searchResultsSummary: '{{total}} результатов по запросу "{{query}}"',
|
searchResultsSummary: '{{total}} результатов по запросу "{{query}}"',
|
||||||
|
loadMore: 'Показать еще',
|
||||||
sortBy: 'Сортировать по',
|
sortBy: 'Сортировать по',
|
||||||
sortRelevance: 'Рекомендуемые',
|
sortRelevance: 'Рекомендуемые',
|
||||||
sortLatest: 'Новизне',
|
sortLatest: 'Новизне',
|
||||||
@@ -215,6 +216,10 @@ export const ru: Translations = {
|
|||||||
layoutGrid: 'Сетка',
|
layoutGrid: 'Сетка',
|
||||||
layoutLargeGrid: 'Крупная сетка',
|
layoutLargeGrid: 'Крупная сетка',
|
||||||
layoutCompactGrid: 'Компактная сетка',
|
layoutCompactGrid: 'Компактная сетка',
|
||||||
|
layoutGrid2: '2 колонки',
|
||||||
|
layoutGrid3: '3 колонки',
|
||||||
|
layoutGrid4: '4 колонки',
|
||||||
|
layoutCompact: 'Компактно',
|
||||||
layoutList: 'Список',
|
layoutList: 'Список',
|
||||||
noResults: 'Нет результатов',
|
noResults: 'Нет результатов',
|
||||||
itemsCount: '{{count}} товаров',
|
itemsCount: '{{count}} товаров',
|
||||||
@@ -225,7 +230,10 @@ export const ru: Translations = {
|
|||||||
pageOf: 'Страница {{page}} / {{total}}',
|
pageOf: 'Страница {{page}} / {{total}}',
|
||||||
emptyCategoryTitle: 'В этой категории пока нет товаров',
|
emptyCategoryTitle: 'В этой категории пока нет товаров',
|
||||||
emptyCategoryMessage: 'Попробуйте перейти в родительские категории или вернуться в общий каталог.',
|
emptyCategoryMessage: 'Попробуйте перейти в родительские категории или вернуться в общий каталог.',
|
||||||
|
goToParentCategory: 'Перейти в родительскую категорию',
|
||||||
browseCategories: 'Смотреть категории',
|
browseCategories: 'Смотреть категории',
|
||||||
|
offlineTitle: 'Нет подключения',
|
||||||
|
offlineMessage: 'Подключитесь к сети, чтобы продолжить просмотр каталога.',
|
||||||
noFilterMatchTitle: 'Нет товаров, соответствующих выбранным фильтрам.',
|
noFilterMatchTitle: 'Нет товаров, соответствующих выбранным фильтрам.',
|
||||||
noFilterMatchMessage: 'Попробуйте расширить диапазон цены, атрибуты или бренды.',
|
noFilterMatchMessage: 'Попробуйте расширить диапазон цены, атрибуты или бренды.',
|
||||||
clearFilters: 'Очистить фильтры',
|
clearFilters: 'Очистить фильтры',
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export interface Translations {
|
|||||||
searchNoResultsHint: string;
|
searchNoResultsHint: string;
|
||||||
searchProductsFound: string;
|
searchProductsFound: string;
|
||||||
searchResultsSummary: string;
|
searchResultsSummary: string;
|
||||||
|
loadMore: string;
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
sortRelevance: string;
|
sortRelevance: string;
|
||||||
sortLatest: string;
|
sortLatest: string;
|
||||||
@@ -213,6 +214,10 @@ export interface Translations {
|
|||||||
layoutGrid: string;
|
layoutGrid: string;
|
||||||
layoutLargeGrid: string;
|
layoutLargeGrid: string;
|
||||||
layoutCompactGrid: string;
|
layoutCompactGrid: string;
|
||||||
|
layoutGrid2: string;
|
||||||
|
layoutGrid3: string;
|
||||||
|
layoutGrid4: string;
|
||||||
|
layoutCompact: string;
|
||||||
layoutList: string;
|
layoutList: string;
|
||||||
noResults: string;
|
noResults: string;
|
||||||
itemsCount: string;
|
itemsCount: string;
|
||||||
@@ -223,7 +228,10 @@ export interface Translations {
|
|||||||
pageOf: string;
|
pageOf: string;
|
||||||
emptyCategoryTitle: string;
|
emptyCategoryTitle: string;
|
||||||
emptyCategoryMessage: string;
|
emptyCategoryMessage: string;
|
||||||
|
goToParentCategory: string;
|
||||||
browseCategories: string;
|
browseCategories: string;
|
||||||
|
offlineTitle: string;
|
||||||
|
offlineMessage: string;
|
||||||
noFilterMatchTitle: string;
|
noFilterMatchTitle: string;
|
||||||
noFilterMatchMessage: string;
|
noFilterMatchMessage: string;
|
||||||
clearFilters: string;
|
clearFilters: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BrandingConfig } from './branding.model';
|
|||||||
import { CatalogConfig } from './catalog-config.model';
|
import { CatalogConfig } from './catalog-config.model';
|
||||||
import { CompanyConfig } from './company.model';
|
import { CompanyConfig } from './company.model';
|
||||||
import { FeatureFlagsConfig } from './feature-flags.model';
|
import { FeatureFlagsConfig } from './feature-flags.model';
|
||||||
|
import { MarketplaceFeaturesConfig } from './features-config.model';
|
||||||
import { FooterConfig } from './footer-config.model';
|
import { FooterConfig } from './footer-config.model';
|
||||||
import { HeaderConfig } from './header-config.model';
|
import { HeaderConfig } from './header-config.model';
|
||||||
import { PlatformLayoutConfig } from './layout.model';
|
import { PlatformLayoutConfig } from './layout.model';
|
||||||
@@ -26,6 +27,7 @@ export interface BootstrapConfig {
|
|||||||
theme: ThemeConfig;
|
theme: ThemeConfig;
|
||||||
company: CompanyConfig;
|
company: CompanyConfig;
|
||||||
featureFlags: FeatureFlagsConfig;
|
featureFlags: FeatureFlagsConfig;
|
||||||
|
features?: MarketplaceFeaturesConfig;
|
||||||
apiEndpoints: ApiEndpointsConfig;
|
apiEndpoints: ApiEndpointsConfig;
|
||||||
localization: LocalizationConfig;
|
localization: LocalizationConfig;
|
||||||
seo: SeoConfig;
|
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 CatalogNavigationModeConfig = 'default' | 'left-category-navigation' | 'mega-category-layout' | 'top-category-carousel';
|
||||||
|
export type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
|
||||||
|
|
||||||
export interface CatalogConfig {
|
export interface CatalogConfig {
|
||||||
layout?: CatalogLayoutModeConfig;
|
layout?: CatalogLayoutModeConfig;
|
||||||
|
loadingStrategy?: CatalogLoadingStrategy;
|
||||||
navigationMode?: CatalogNavigationModeConfig;
|
navigationMode?: CatalogNavigationModeConfig;
|
||||||
defaultSort?: 'relevance' | 'latest' | 'price_asc' | 'price_desc' | 'rating' | 'popular' | 'discount';
|
defaultSort?: 'relevance' | 'latest' | 'price_asc' | 'price_desc' | 'rating' | 'popular' | 'discount';
|
||||||
availableSorts?: Array<'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> = {
|
export const DEFAULT_CATALOG_CONFIG: Required<CatalogConfig> = {
|
||||||
layout: 'grid',
|
layout: 'grid-4',
|
||||||
|
loadingStrategy: 'pagination',
|
||||||
navigationMode: 'default',
|
navigationMode: 'default',
|
||||||
defaultSort: 'relevance',
|
defaultSort: 'relevance',
|
||||||
availableSorts: ['relevance', 'latest', 'price_asc', 'price_desc', 'rating', 'popular', 'discount'],
|
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 './catalog-config.model';
|
||||||
export * from './company.model';
|
export * from './company.model';
|
||||||
export * from './feature-flags.model';
|
export * from './feature-flags.model';
|
||||||
|
export * from './features-config.model';
|
||||||
export * from './footer-config.model';
|
export * from './footer-config.model';
|
||||||
export * from './header-config.model';
|
export * from './header-config.model';
|
||||||
export * from './layout.model';
|
export * from './layout.model';
|
||||||
|
|||||||
Reference in New Issue
Block a user