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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,22 @@
gap: 12px;
}
.results-load-more {
display: flex;
justify-content: center;
}
.results-load-more button {
min-height: 42px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
padding: 0 16px;
cursor: pointer;
font-weight: 700;
}
.results-pager button {
min-height: 40px;
border: 1px solid var(--border-color);

View File

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

View File

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

View File

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

View File

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