diff --git a/.gitignore b/.gitignore index 7cf105f..e62efc9 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,28 @@ testem.log # System files .DS_Store Thumbs.db + + +.context-state/ +.context-cache/ +.barry-cache/ + +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 diff --git a/docs/Catalog-UX-Architecture.md b/docs/Catalog-UX-Architecture.md new file mode 100644 index 0000000..3f0a17b --- /dev/null +++ b/docs/Catalog-UX-Architecture.md @@ -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 diff --git a/docs/platform/08-catalog-domain.md b/docs/platform/08-catalog-domain.md index 1b8c025..eb3b0d3 100644 --- a/docs/platform/08-catalog-domain.md +++ b/docs/platform/08-catalog-domain.md @@ -16,6 +16,7 @@ ## Опциональные свойства - catalog.navigationMode +- catalog.loadingStrategy - catalog.showBreadcrumbs - catalog.showCategoryBanner - catalog.showSubcategoryChips @@ -24,12 +25,16 @@ - catalog.showAvailability - catalog.suggestionsEnabled - catalog.searchHistoryEnabled +- catalog.features (via bootstrap.features) - catalog.facets (future backend-driven) ## Строгие правила - Каталог не содержит tenant-specific условий в frontend-коде. - Сортировка и фильтры должны быть согласованы между frontend и backend. - Видимость товаров контролируется данными backend, а не frontend-хардкодом. +- Пустая категория рендерит отдельный catalog empty-state, а не product grid empty-state. +- Корневой переход `All Categories` всегда возвращает category browser `/catalog`. +- Loading strategy выбирается конфигурацией без дублирования list logic. ## Reusable Domain Models - SearchCriteria @@ -43,7 +48,8 @@ ```json { "catalog": { - "layout": "grid", + "layout": "grid-4", + "loadingStrategy": "pagination", "defaultSort": "relevance", "availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"], "enabledFilters": ["price", "availability", "rating", "brand", "category", "subcategory", "discount", "new", "color", "size", "attributes"], @@ -54,6 +60,23 @@ } ``` +## Loading Strategies +- `pagination` +- `loadMore` +- `infiniteScroll` + +## Grid Modes +- `grid-2` +- `grid-3` +- `grid-4` +- `list` +- `compact` + +Legacy aliases still normalize safely: +- `grid` +- `large-grid` +- `compact-grid` + ## Ответственность Frontend - Отобразить листинг, фильтры, сортировки и пагинацию. - Синхронизировать состояние каталога с URL. diff --git a/docs/platform/11-navigation-system.md b/docs/platform/11-navigation-system.md index 652bafd..e882c77 100644 --- a/docs/platform/11-navigation-system.md +++ b/docs/platform/11-navigation-system.md @@ -3,6 +3,11 @@ - Static pages with `showInFooter: true` may be grouped into footer sections using `footerGroup`. - Social links remain footer-config driven and can coexist with CMS page groups. - Navigation stays configuration-driven; no marketplace-specific page names are hardcoded in frontend. + +## Catalog Routing Preparation +- Catalog root `/catalog` renders category browser. +- Category detail keeps current ID routes while resolving future slug tokens in routing layer. +- Breadcrumb root action must always navigate to `/catalog`, not all-products grid. # 11. Система навигации ## Назначение diff --git a/package.json b/package.json index f9ff004..7c1416c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,12 @@ "watch": "ng build --watch --configuration development", "arch:check:boundaries": "node tools/architecture/check-boundaries.mjs", "arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json", - "arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles" + "arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles", + "barry": "barry-cache", + "barry:validate": "barry-cache validate", + "barry:resume": "barry-cache resume", + "barry:finalize": "barry-cache finalize", + "barry:failure": "barry-cache failure" }, "private": true, "dependencies": { @@ -34,6 +39,7 @@ "@angular/build": "21.1.5", "@angular/cli": "21.1.5", "@angular/compiler-cli": "21.1.5", - "typescript": "~5.9.3" + "typescript": "~5.9.3", + "barry-cache": "^0.1.0" } } diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html index a81ad1c..6a9491c 100644 --- a/src/app/components/header/header.component.html +++ b/src/app/components/header/header.component.html @@ -53,7 +53,7 @@
- @if (headerConfig().showWishlist && userExperienceConfig().wishlist.enabled) { + @if (headerConfig().showWishlist && features().wishlist && userExperienceConfig().wishlist.enabled) { } - @if (headerConfig().showCompare && userExperienceConfig().compare.enabled) { + @if (headerConfig().showCompare && features().compare && userExperienceConfig().compare.enabled) { } + @if (secondaryActionLabel) { + + } + @if (variant === 'filtered' && popularCategories.length > 0) {
{{ 'search.popularCategories' | translate }} diff --git a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss index 10ba457..414783d 100644 --- a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss +++ b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.scss @@ -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; diff --git a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts index 33d46a9..9216a6b 100644 --- a/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts +++ b/src/app/features/website/catalog/components/catalog-empty-state/catalog-empty-state.component.ts @@ -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(); + @Output() secondaryAction = new EventEmitter(); @Output() popularCategorySelected = new EventEmitter(); @Output() popularSearchSelected = new EventEmitter(); @Output() recommendedProductSelected = new EventEmitter(); diff --git a/src/app/features/website/catalog/components/filters-panel/filters-panel.component.scss b/src/app/features/website/catalog/components/filters-panel/filters-panel.component.scss index f09304f..2ecb764 100644 --- a/src/app/features/website/catalog/components/filters-panel/filters-panel.component.scss +++ b/src/app/features/website/catalog/components/filters-panel/filters-panel.component.scss @@ -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; + } +} diff --git a/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.html b/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.html index 8b60e8e..f47e19a 100644 --- a/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.html +++ b/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.html @@ -15,6 +15,12 @@ } + @case ('grid-2') { + + } @case ('large-grid') { } + @case ('grid-3') { + + } + @case ('grid-4') { + + } @case ('compact-grid') { } + @case ('compact') { + + } @default { {{ labels[layout] | translate }} + {{ labels[layout] | translate }} }
diff --git a/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.scss b/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.scss index 60ce0f3..aefbb9e 100644 --- a/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.scss +++ b/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.scss @@ -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); diff --git a/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.ts b/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.ts index 41430bd..814abbd 100644 --- a/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.ts +++ b/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.ts @@ -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(); readonly labels: Record = { - 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' }; } diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.scss b/src/app/features/website/catalog/components/product-grid/product-grid.component.scss index c0da40c..a65b940 100644 --- a/src/app/features/website/catalog/components/product-grid/product-grid.component.scss +++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.scss @@ -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; diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.ts b/src/app/features/website/catalog/components/product-grid/product-grid.component.ts index a1b34a7..79a8ed2 100644 --- a/src/app/features/website/catalog/components/product-grid/product-grid.component.ts +++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.ts @@ -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': diff --git a/src/app/features/website/catalog/components/search-results/search-results.component.html b/src/app/features/website/catalog/components/search-results/search-results.component.html index 10b6730..a3da5f0 100644 --- a/src/app/features/website/catalog/components/search-results/search-results.component.html +++ b/src/app/features/website/catalog/components/search-results/search-results.component.html @@ -34,11 +34,17 @@
} - @if (!loading && totalPages > 1) { + @if (!loading && loadingStrategy === 'pagination' && totalPages > 1) {
{{ 'catalog.pageOf' | translate:{ page: page, total: totalPages } }}
} + + @if (!loading && loadingStrategy === 'loadMore' && hasMore) { +
+ +
+ } diff --git a/src/app/features/website/catalog/components/search-results/search-results.component.scss b/src/app/features/website/catalog/components/search-results/search-results.component.scss index 98de9dd..3ccb6d4 100644 --- a/src/app/features/website/catalog/components/search-results/search-results.component.scss +++ b/src/app/features/website/catalog/components/search-results/search-results.component.scss @@ -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); diff --git a/src/app/features/website/catalog/components/search-results/search-results.component.ts b/src/app/features/website/catalog/components/search-results/search-results.component.ts index 2399ead..bc3cd1e 100644 --- a/src/app/features/website/catalog/components/search-results/search-results.component.ts +++ b/src/app/features/website/catalog/components/search-results/search-results.component.ts @@ -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(); + @Output() loadMore = new EventEmitter(); @Output() productSelected = new EventEmitter(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @Output() productPreview = new EventEmitter(); @@ -50,4 +53,10 @@ export class CatalogSearchResultsComponent { this.pageChange.emit(this.page + 1); } } + + onLoadMore(): void { + if (this.hasMore) { + this.loadMore.emit(); + } + } } diff --git a/src/app/features/website/catalog/containers/catalog-container.component.html b/src/app/features/website/catalog/containers/catalog-container.component.html index 542f655..96e885b 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.html +++ b/src/app/features/website/catalog/containers/catalog-container.component.html @@ -1,10 +1,10 @@
- {{ 'catalog.title' | translate }} + @if (catalogConfig().showBreadcrumbs && breadcrumb().length > 0) {