From 1a8f916942792f34dbabbcbfefb4e4a52fa5452b Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Thu, 9 Jul 2026 00:55:50 +0400 Subject: [PATCH] feat(catalog): implement sprint 10 advanced search experience --- Sprint-10-Advanced-Search-Report.md | 133 +++++ docs/backend-platform/business-apis.md | 33 ++ docs/platform/00-bootstrap-example.md | 15 + docs/platform/02-bootstrap-json-spec.md | 17 + docs/platform/06-api-contracts.md | 11 + docs/platform/08-catalog-domain.md | 48 +- docs/platform/13-backend-requirements.md | 13 + src/app/app.routes.ts | 2 +- .../product-card/product-card.component.html | 20 +- .../product-card/product-card.component.scss | 42 ++ .../product-card/product-card.component.ts | 25 + .../models/catalog-experience.model.ts | 86 +++ src/app/facades/platform/product.facade.ts | 55 ++ .../filters-panel.component.html | 56 ++ .../filters-panel.component.scss | 66 +++ .../filters-panel/filters-panel.component.ts | 70 +++ .../layout-switcher.component.html | 11 + .../layout-switcher.component.scss | 22 + .../layout-switcher.component.ts | 23 + .../product-grid/product-grid.component.html | 13 +- .../product-grid/product-grid.component.scss | 14 + .../product-grid/product-grid.component.ts | 20 + .../search-box/search-box.component.html | 52 ++ .../search-box/search-box.component.scss | 70 +++ .../search-box/search-box.component.ts | 34 ++ .../search-results.component.html | 38 ++ .../search-results.component.scss | 71 +++ .../search-results.component.ts | 46 ++ .../sorting-control.component.html | 10 + .../sorting-control.component.scss | 22 + .../sorting-control.component.ts | 18 + .../catalog-container.component.html | 92 ++- .../catalog-container.component.scss | 89 ++- .../containers/catalog-container.component.ts | 528 +++++++++++++++++- .../catalog/models/catalog-state.model.ts | 13 +- .../catalog-search-history.service.ts | 57 ++ .../models/config/bootstrap-config.model.ts | 2 + .../models/config/catalog-config.model.ts | 34 ++ src/app/shared/models/config/index.ts | 1 + src/assets/mock/bootstrap/bootstrap.json | 15 + 40 files changed, 1917 insertions(+), 70 deletions(-) create mode 100644 Sprint-10-Advanced-Search-Report.md create mode 100644 src/app/core/products/models/catalog-experience.model.ts create mode 100644 src/app/features/website/catalog/components/filters-panel/filters-panel.component.html create mode 100644 src/app/features/website/catalog/components/filters-panel/filters-panel.component.scss create mode 100644 src/app/features/website/catalog/components/filters-panel/filters-panel.component.ts create mode 100644 src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.html create mode 100644 src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.scss create mode 100644 src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.ts create mode 100644 src/app/features/website/catalog/components/search-box/search-box.component.html create mode 100644 src/app/features/website/catalog/components/search-box/search-box.component.scss create mode 100644 src/app/features/website/catalog/components/search-box/search-box.component.ts create mode 100644 src/app/features/website/catalog/components/search-results/search-results.component.html create mode 100644 src/app/features/website/catalog/components/search-results/search-results.component.scss create mode 100644 src/app/features/website/catalog/components/search-results/search-results.component.ts create mode 100644 src/app/features/website/catalog/components/sorting-control/sorting-control.component.html create mode 100644 src/app/features/website/catalog/components/sorting-control/sorting-control.component.scss create mode 100644 src/app/features/website/catalog/components/sorting-control/sorting-control.component.ts create mode 100644 src/app/features/website/catalog/services/catalog-search-history.service.ts create mode 100644 src/app/shared/models/config/catalog-config.model.ts diff --git a/Sprint-10-Advanced-Search-Report.md b/Sprint-10-Advanced-Search-Report.md new file mode 100644 index 0000000..abde761 --- /dev/null +++ b/Sprint-10-Advanced-Search-Report.md @@ -0,0 +1,133 @@ +# Sprint 10 - Advanced Search & Catalog Report + +## 1. Architecture +- Existing platform architecture was preserved. +- No authentication flow changes. +- No payment flow changes. +- No bootstrap loading flow changes. +- Widget engine and section engine were not modified. +- ProductFacade was only extended (no contract removals or breaking changes). + +### Data Flow +- Catalog UI calls ProductFacade only. +- ProductFacade extensions for sprint 10: + - search(criteria) + - filter(criteria) + - sort(criteria) + - loadCatalog(criteria) +- No HttpClient usage in feature layer. + +## 2. New Reusable Components +### Search Module +- CatalogSearchBoxComponent + - keyword search + - instant suggestions (frontend-prepared, backend-ready) + - recent searches + - search history + - loading and no-results hint + +### Filters Module +- CatalogFiltersPanelComponent + - dynamic filter rendering from filter definitions + - supports multi-select, range, toggle filter types + +### Sorting Module +- CatalogSortingControlComponent + - configurable sort options + +### Layout Module +- CatalogLayoutSwitcherComponent + - grid + - large grid + - compact grid + - list + +### Search Results Module +- CatalogSearchResultsComponent + - loading skeletons + - summary + - pagination + - empty state + +### Product Card Enhancements +- Added placeholders (no business logic): + - favorite + - compare + - quick view +- Added stock badge and configurable discount/rating/availability visibility. + +## 3. Domain Models +Added reusable catalog/search models: +- SearchCriteria +- FilterDefinition +- FilterOption +- SortDefinition +- CatalogView +- SearchResult + +File: +- src/app/core/products/models/catalog-experience.model.ts + +## 4. Bootstrap Additions +Added catalog feature configuration (config only, no catalog data): +- layout +- navigationMode +- defaultSort +- availableSorts +- enabledFilters +- showBreadcrumbs +- showCategoryBanner +- showSubcategoryChips +- showRatings +- showDiscounts +- showAvailability +- suggestionsEnabled +- searchHistoryEnabled + +Files: +- src/app/shared/models/config/catalog-config.model.ts +- src/app/shared/models/config/bootstrap-config.model.ts +- src/assets/mock/bootstrap/bootstrap.json + +## 5. Backend Expectations +Documented current and future-ready expectations: +- Current: + - GET /searchitems + - GET /category/{id} +- Future-ready: + - GET /search/suggestions?q={term} + - GET /catalog/filters?category={id}&q={term} + +## 6. Validation Results +### Build +- npm run build: PASS + +### Architecture +- npm run arch:check:boundaries: PASS +- npm run arch:check:cycles: PASS + +### Layering checks +- HttpClient in feature layer: none found +- DTO leaks in feature layer: none found (search matched addToCart text only, not DTO usage) + +### Compatibility +- Widget compatibility preserved +- Section engine compatibility preserved + +## 7. Documentation Updated +- docs/platform/00-bootstrap-example.md +- docs/platform/02-bootstrap-json-spec.md +- docs/platform/06-api-contracts.md +- docs/platform/08-catalog-domain.md +- docs/platform/13-backend-requirements.md +- docs/backend-platform/business-apis.md + +## 8. Future Extension Points +- Backend-driven suggestions endpoint integration. +- Backend-driven dynamic filter metadata (option counts/ranges/facets). +- Navigation mode implementations: + - left category navigation + - mega category layout + - top category carousel +- URL-state sync for all search/filter/sort/layout inputs. +- Dedicated saved searches / personalized search suggestions. diff --git a/docs/backend-platform/business-apis.md b/docs/backend-platform/business-apis.md index f242d9c..de851bc 100644 --- a/docs/backend-platform/business-apis.md +++ b/docs/backend-platform/business-apis.md @@ -49,6 +49,39 @@ Tenant rule: Must not change: - item identifiers/price semantics relied on frontend checkout/cart logic. +## /searchitems +Purpose: +- keyword-based product search for catalog/search pages. + +High-level response shape: +- items array +- total count + +Tenant rule: +- results must respect tenant catalog visibility and pricing policies. + +## /search/suggestions (future-ready) +Purpose: +- return instant search suggestions for typed keywords. + +High-level response shape: +- suggestion strings or objects with label/value and optional popularity/count. + +Tenant rule: +- suggestions generated only from tenant-visible catalog corpus. + +## /catalog/filters (future-ready) +Purpose: +- provide dynamic filter definitions and options for current search/category context. + +High-level response shape: +- filter definitions +- option counts +- optional min/max ranges + +Tenant rule: +- filter options/counts must be tenant-scoped and inventory-aware. + ## /products/{id}/rating Purpose: - return product rating aggregate for engagement UI. diff --git a/docs/platform/00-bootstrap-example.md b/docs/platform/00-bootstrap-example.md index 068303f..c649aa7 100644 --- a/docs/platform/00-bootstrap-example.md +++ b/docs/platform/00-bootstrap-example.md @@ -296,6 +296,21 @@ "enabled": true } ], + "catalog": { + "layout": "grid", + "navigationMode": "default", + "defaultSort": "relevance", + "availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"], + "enabledFilters": ["price", "availability", "rating", "brand", "category", "subcategory", "discount", "new", "color", "size", "attributes"], + "showBreadcrumbs": true, + "showCategoryBanner": true, + "showSubcategoryChips": true, + "showRatings": true, + "showDiscounts": true, + "showAvailability": true, + "suggestionsEnabled": true, + "searchHistoryEnabled": true + }, "productPage": { "rating": { "enabled": true }, "reviews": { diff --git a/docs/platform/02-bootstrap-json-spec.md b/docs/platform/02-bootstrap-json-spec.md index 8504559..6f8bb57 100644 --- a/docs/platform/02-bootstrap-json-spec.md +++ b/docs/platform/02-bootstrap-json-spec.md @@ -54,6 +54,7 @@ Bootstrap JSON является главным конфигурационным - branding - navigation - footer +- catalog - productPage - staticPages - widgetRegistry @@ -71,6 +72,22 @@ Bootstrap JSON является главным конфигурационным - Для footer links/legal/payout icons источник истины — bootstrap JSON. - Для staticPages контент поддерживается в формате multilingual HTML и рендерится только через safe sanitizer. - Для productPage допускаются только feature-конфиги (enabled/pageSize/tabs/showSummary), без доменных данных отзывов и вопросов. +- Для catalog допускаются только UI/feature-конфиги (layout/sorts/filters/visibility toggles), без товарных данных. + +### Catalog Config (опционально) +- catalog.layout: grid | large-grid | compact-grid | list +- catalog.navigationMode: default | left-category-navigation | mega-category-layout | top-category-carousel +- catalog.defaultSort: relevance | latest | price_asc | price_desc | rating | popular | discount +- catalog.availableSorts: string[] +- catalog.enabledFilters: string[] +- catalog.showBreadcrumbs: boolean +- catalog.showCategoryBanner: boolean +- catalog.showSubcategoryChips: boolean +- catalog.showRatings: boolean +- catalog.showDiscounts: boolean +- catalog.showAvailability: boolean +- catalog.suggestionsEnabled: boolean +- catalog.searchHistoryEnabled: boolean ### Product Engagement Config (опционально) - productPage.rating.enabled: boolean diff --git a/docs/platform/06-api-contracts.md b/docs/platform/06-api-contracts.md index da3182c..5749126 100644 --- a/docs/platform/06-api-contracts.md +++ b/docs/platform/06-api-contracts.md @@ -39,6 +39,17 @@ Правило: - Feature UI не вызывает API напрямую; запросы идут через ProductFacade -> domain service -> provider/repository. +## Advanced Catalog/Search Expectations +- Suggestions endpoint (future-ready): + - GET /search/suggestions?q={term} + - response: suggestion strings with optional popularity/count metadata. +- Dynamic filter metadata endpoint (future-ready): + - GET /catalog/filters?category={id}&q={term} + - response: filter definitions/options that frontend can render without hardcoded filter schema. +- Sort extension contract: + - Backend may introduce new sort IDs via bootstrap `catalog.availableSorts`. + - Frontend must render unknown sort keys safely if label mapping is provided. + ## Пример API ответа: категории ```json { diff --git a/docs/platform/08-catalog-domain.md b/docs/platform/08-catalog-domain.md index 10345ec..1b8c025 100644 --- a/docs/platform/08-catalog-domain.md +++ b/docs/platform/08-catalog-domain.md @@ -9,37 +9,47 @@ - API возвращает содержимое: товары, категории, метаданные фильтров. ## Обязательные JSON-свойства каталога -- catalog.settings.defaultSort -- catalog.settings.pageSize -- catalog.routes.list -- catalog.routes.details +- catalog.defaultSort +- catalog.availableSorts +- catalog.enabledFilters +- catalog.layout ## Опциональные свойства -- catalog.filters.available -- catalog.facets -- catalog.badges -- catalog.promotions +- catalog.navigationMode +- catalog.showBreadcrumbs +- catalog.showCategoryBanner +- catalog.showSubcategoryChips +- catalog.showRatings +- catalog.showDiscounts +- catalog.showAvailability +- catalog.suggestionsEnabled +- catalog.searchHistoryEnabled +- catalog.facets (future backend-driven) ## Строгие правила - Каталог не содержит tenant-specific условий в frontend-коде. - Сортировка и фильтры должны быть согласованы между frontend и backend. - Видимость товаров контролируется данными backend, а не frontend-хардкодом. +## Reusable Domain Models +- SearchCriteria +- FilterDefinition +- FilterOption +- SortDefinition +- CatalogView +- SearchResult + ## Пример catalog JSON (bootstrap fragment) ```json { "catalog": { - "settings": { - "defaultSort": "priority_desc", - "pageSize": 20 - }, - "routes": { - "list": "/catalog", - "details": "/product/:id" - }, - "filters": { - "available": ["price", "brand", "availability"] - } + "layout": "grid", + "defaultSort": "relevance", + "availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"], + "enabledFilters": ["price", "availability", "rating", "brand", "category", "subcategory", "discount", "new", "color", "size", "attributes"], + "showBreadcrumbs": true, + "showCategoryBanner": true, + "showSubcategoryChips": true } } ``` diff --git a/docs/platform/13-backend-requirements.md b/docs/platform/13-backend-requirements.md index 7c6ad2b..c079c4e 100644 --- a/docs/platform/13-backend-requirements.md +++ b/docs/platform/13-backend-requirements.md @@ -9,6 +9,7 @@ - API категорий, товаров, карточек, поисковых выборок. - Выдача навигации, статических страниц и feature flags. - Product Engagement API для рейтинга, отзывов и вопросов. +- Advanced Search API для keyword/suggestions/filter metadata/sorting. ### Контракт статических страниц Backend должен поддерживать формат: @@ -40,6 +41,18 @@ Backend должен поддерживать формат: - POST /products/{id}/reviews - POST /products/{id}/questions +## Обязательные Catalog/Search endpoints (current + future-ready) +- GET /searchitems +- GET /category/{id} +- GET /items/randomitems +- GET /search/suggestions?q={term} (future-ready) +- GET /catalog/filters?category={id}&q={term} (future-ready) + +## Catalog bootstrap contract expectations +- Backend should populate `catalog.availableSorts` and `catalog.enabledFilters`. +- Backend should not include product list or filter results inside bootstrap. +- Bootstrap remains feature-configuration only. + ## Опциональные JSON-контракты - Персонализированные рекомендации. - Расширенные facets/filters. diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 347e88f..1a850e3 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -36,7 +36,7 @@ const coreRoutes: Routes = [ }, { path: 'search', - loadComponent: () => import('./pages/search/search.component').then(m => m.SearchComponent) + loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent) }, { path: 'cart', diff --git a/src/app/components/product-card/product-card.component.html b/src/app/components/product-card/product-card.component.html index 831dc2c..8ae4e03 100644 --- a/src/app/components/product-card/product-card.component.html +++ b/src/app/components/product-card/product-card.component.html @@ -2,9 +2,27 @@
- @if (item.discount > 0) { + @if (showDiscountBadge && item.discount > 0) {
-{{ item.discount }}%
} + @if (showStock && item.remainings) { + {{ item.remainings }} + } + + @if (showFavoritePlaceholder || showComparePlaceholder || showQuickViewPlaceholder) { +
+ @if (showFavoritePlaceholder) { + + } + @if (showComparePlaceholder) { + + } + @if (showQuickViewPlaceholder) { + + } +
+ } + @if (item.badges && item.badges.length > 0) {
@for (badge of item.badges; track badge) { diff --git a/src/app/components/product-card/product-card.component.scss b/src/app/components/product-card/product-card.component.scss index 7b95542..78e0cad 100644 --- a/src/app/components/product-card/product-card.component.scss +++ b/src/app/components/product-card/product-card.component.scss @@ -60,6 +60,48 @@ z-index: 1; } +.stock-badge { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 1; + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + background: rgba(22, 163, 74, 0.9); + color: #fff; + font-size: 0.72rem; + font-weight: 700; + text-transform: capitalize; +} + +.stock-badge.out { + background: rgba(239, 68, 68, 0.9); +} + +.product-actions-overlay { + position: absolute; + right: 12px; + top: 44px; + z-index: 2; + display: grid; + gap: 6px; +} + +.product-actions-overlay button { + min-height: 24px; + border: 1px solid rgba(30, 60, 56, 0.15); + border-radius: 999px; + background: rgba(255, 255, 255, 0.9); + color: #1e3c38; + font-size: 0.72rem; + font-weight: 700; + padding: 0 8px; + cursor: pointer; +} + .product-badges-overlay { position: absolute; left: 10px; diff --git a/src/app/components/product-card/product-card.component.ts b/src/app/components/product-card/product-card.component.ts index 67f4f13..283a316 100644 --- a/src/app/components/product-card/product-card.component.ts +++ b/src/app/components/product-card/product-card.component.ts @@ -24,10 +24,17 @@ export class ProductCardComponent { @Input() showDescription = false; @Input() showStock = true; @Input() showRating = true; + @Input() showDiscountBadge = true; + @Input() showFavoritePlaceholder = false; + @Input() showComparePlaceholder = false; + @Input() showQuickViewPlaceholder = false; @Output() addToCart = new EventEmitter<{ itemID: number; event: Event }>(); @Output() preview = new EventEmitter(); @Output() selected = new EventEmitter<{ itemID: number; event: Event }>(); + @Output() favoritePlaceholder = new EventEmitter(); + @Output() comparePlaceholder = new EventEmitter(); + @Output() quickViewPlaceholder = new EventEmitter(); readonly getMainImage = getMainImage; readonly getDiscountedPrice = getDiscountedPrice; @@ -40,4 +47,22 @@ export class ProductCardComponent { onSelected(event: Event): void { this.selected.emit({ itemID: this.item.itemID, event }); } + + onFavoritePlaceholder(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.favoritePlaceholder.emit(this.item.itemID); + } + + onComparePlaceholder(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.comparePlaceholder.emit(this.item.itemID); + } + + onQuickViewPlaceholder(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.quickViewPlaceholder.emit(this.item.itemID); + } } diff --git a/src/app/core/products/models/catalog-experience.model.ts b/src/app/core/products/models/catalog-experience.model.ts new file mode 100644 index 0000000..3ff68a9 --- /dev/null +++ b/src/app/core/products/models/catalog-experience.model.ts @@ -0,0 +1,86 @@ +import { Product, ProductListResult, ProductSort } from './product-domain.model'; + +export type CatalogLayoutMode = 'grid' | 'large-grid' | 'compact-grid' | 'list'; +export type CatalogNavigationMode = 'default' | 'left-category-navigation' | 'mega-category-layout' | 'top-category-carousel'; + +export interface SearchCriteria { + keyword?: string; + categoryIDs?: number[]; + subcategoryIDs?: string[]; + minPrice?: number; + maxPrice?: number; + availability?: Array<'in-stock' | 'low-stock' | 'out-of-stock'>; + rating?: number[]; + brand?: string[]; + discountOnly?: boolean; + newOnly?: boolean; + color?: string[]; + size?: string[]; + attributes?: Record; + sort?: ProductSort | 'discount'; + page?: number; + pageSize?: number; +} + +export type FilterValueType = 'multi-select' | 'range' | 'toggle'; + +export interface FilterOption { + id: string; + label: string; + value: string; + count?: number; +} + +export interface FilterDefinition { + id: string; + label: string; + type: FilterValueType; + options?: FilterOption[]; + min?: number; + max?: number; + step?: number; + enabled?: boolean; +} + +export interface SortDefinition { + id: ProductSort | 'discount'; + label: string; + enabled?: boolean; +} + +export interface CatalogView { + layout: CatalogLayoutMode; + navigationMode: CatalogNavigationMode; + showBreadcrumbs: boolean; + showCategoryBanner: boolean; + showSubcategoryChips: boolean; + showRatings: boolean; + showDiscounts: boolean; + showAvailability: boolean; +} + +export interface SearchResult { + criteria: SearchCriteria; + items: Product[]; + total: number; + page: number; + pageSize: number; + summary: string; +} + +export function toSearchResult(result: ProductListResult, criteria: SearchCriteria): SearchResult { + const pageSize = Math.max(1, criteria.pageSize ?? result.count ?? 24); + const page = Math.max(1, criteria.page ?? Math.floor((result.skip ?? 0) / pageSize) + 1); + const keyword = (criteria.keyword ?? '').trim(); + + return { + criteria, + items: result.items, + total: result.total, + page, + pageSize, + summary: keyword.length > 0 + ? `Results for "${keyword}" (${result.total})` + : `Products found: ${result.total}` + }; +} diff --git a/src/app/facades/platform/product.facade.ts b/src/app/facades/platform/product.facade.ts index 3833a74..043e225 100644 --- a/src/app/facades/platform/product.facade.ts +++ b/src/app/facades/platform/product.facade.ts @@ -9,6 +9,7 @@ import { ProductSearchQuery, RelatedProductsQuery, } from '../../core/products/models/product-domain.model'; +import { SearchCriteria } from '../../core/products/models/catalog-experience.model'; import { EngagementListQuery, EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../core/products/models/product-engagement.model'; @Injectable({ providedIn: 'root' }) @@ -66,4 +67,58 @@ export class ProductFacade { submitQuestion(productID: number, input: SubmitQuestionInput): Observable { return this.productData.submitQuestion(productID, input); } + + search(criteria: SearchCriteria): Observable { + const pageSize = Math.max(1, criteria.pageSize ?? 24); + const page = Math.max(1, criteria.page ?? 1); + const skip = (page - 1) * pageSize; + + return this.searchProducts({ + search: criteria.keyword ?? '', + count: pageSize, + skip, + categoryIDs: criteria.categoryIDs, + minPrice: criteria.minPrice, + maxPrice: criteria.maxPrice, + sort: criteria.sort === 'discount' ? 'price_desc' : criteria.sort + }); + } + + filter(criteria: SearchCriteria): Observable { + return this.search(criteria); + } + + sort(criteria: SearchCriteria): Observable { + return this.search({ ...criteria, sort: criteria.sort ?? 'relevance' }); + } + + loadCatalog(criteria: SearchCriteria): Observable { + const hasKeyword = (criteria.keyword ?? '').trim().length > 0; + if (hasKeyword) { + return this.search(criteria); + } + + const categoryID = criteria.categoryIDs?.[0]; + if (categoryID != null) { + const pageSize = Math.max(1, criteria.pageSize ?? 24); + const page = Math.max(1, criteria.page ?? 1); + const skip = (page - 1) * pageSize; + + return this.getProductsByCategory(categoryID, { + count: pageSize, + skip, + sort: criteria.sort === 'discount' ? 'price_desc' : criteria.sort + }); + } + + const pageSize = Math.max(1, criteria.pageSize ?? 24); + const page = Math.max(1, criteria.page ?? 1); + const skip = (page - 1) * pageSize; + + return this.getProducts({ + count: pageSize, + skip, + sort: criteria.sort === 'discount' ? 'price_desc' : criteria.sort + }); + } } diff --git a/src/app/features/website/catalog/components/filters-panel/filters-panel.component.html b/src/app/features/website/catalog/components/filters-panel/filters-panel.component.html new file mode 100644 index 0000000..74ae2aa --- /dev/null +++ b/src/app/features/website/catalog/components/filters-panel/filters-panel.component.html @@ -0,0 +1,56 @@ + 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 new file mode 100644 index 0000000..3abc0e7 --- /dev/null +++ b/src/app/features/website/catalog/components/filters-panel/filters-panel.component.scss @@ -0,0 +1,66 @@ +.catalog-filters { + padding: 14px; + display: grid; + gap: 14px; + align-content: start; +} + +.filters-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.filters-head h3 { + margin: 0; + color: var(--text-primary); +} + +.filters-head button { + border: 0; + background: transparent; + color: var(--primary-color); + font-weight: 700; + cursor: pointer; +} + +.filter-group { + display: grid; + gap: 8px; + border-top: 1px solid var(--border-color); + padding-top: 10px; +} + +.filter-group h4 { + margin: 0; + font-size: 0.95rem; + color: var(--text-primary); +} + +.options { + display: grid; + gap: 6px; +} + +.option-check { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 8px; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.range-inputs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.range-inputs input { + min-height: 34px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0 8px; +} diff --git a/src/app/features/website/catalog/components/filters-panel/filters-panel.component.ts b/src/app/features/website/catalog/components/filters-panel/filters-panel.component.ts new file mode 100644 index 0000000..cac5543 --- /dev/null +++ b/src/app/features/website/catalog/components/filters-panel/filters-panel.component.ts @@ -0,0 +1,70 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FilterDefinition } from '../../../../../core/products/models/catalog-experience.model'; + +export interface CatalogFilterStateValue { + values: Record; + ranges: Record; + toggles: Record; +} + +@Component({ + selector: 'app-catalog-filters-panel', + standalone: true, + imports: [FormsModule], + templateUrl: './filters-panel.component.html', + styleUrls: ['./filters-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CatalogFiltersPanelComponent { + @Input() definitions: FilterDefinition[] = []; + @Input() state: CatalogFilterStateValue = { values: {}, ranges: {}, toggles: {} }; + + @Output() stateChange = new EventEmitter(); + @Output() resetFilters = new EventEmitter(); + + isSelected(filterId: string, optionValue: string): boolean { + return (this.state.values[filterId] ?? []).includes(optionValue); + } + + toggleOption(filterId: string, optionValue: string): void { + const current = this.state.values[filterId] ?? []; + const next = current.includes(optionValue) + ? current.filter(value => value !== optionValue) + : [...current, optionValue]; + + this.stateChange.emit({ + ...this.state, + values: { + ...this.state.values, + [filterId]: next + } + }); + } + + updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void { + const value = rawValue.trim().length ? Number(rawValue) : undefined; + const current = this.state.ranges[filterId] ?? {}; + + this.stateChange.emit({ + ...this.state, + ranges: { + ...this.state.ranges, + [filterId]: { + ...current, + [key]: Number.isFinite(value as number) ? value : undefined + } + } + }); + } + + updateToggle(filterId: string, checked: boolean): void { + this.stateChange.emit({ + ...this.state, + toggles: { + ...this.state.toggles, + [filterId]: checked + } + }); + } +} 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 new file mode 100644 index 0000000..2524000 --- /dev/null +++ b/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.html @@ -0,0 +1,11 @@ +
+ @for (layout of available; track layout) { + + } +
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 new file mode 100644 index 0000000..76bea7f --- /dev/null +++ b/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.scss @@ -0,0 +1,22 @@ +.catalog-layout-switcher { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px; +} + +.layout-btn { + min-height: 34px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--text-primary); + font-weight: 700; + padding: 0 10px; + cursor: pointer; +} + +.layout-btn.active { + border-color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 10%, 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 new file mode 100644 index 0000000..40f172d --- /dev/null +++ b/src/app/features/website/catalog/components/layout-switcher/layout-switcher.component.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model'; + +@Component({ + selector: 'app-catalog-layout-switcher', + standalone: true, + templateUrl: './layout-switcher.component.html', + styleUrls: ['./layout-switcher.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CatalogLayoutSwitcherComponent { + @Input() active: CatalogLayoutMode = 'grid'; + @Input() available: CatalogLayoutMode[] = ['grid', 'large-grid', 'compact-grid', 'list']; + + @Output() activeChange = new EventEmitter(); + + readonly labels: Record = { + grid: 'Grid', + 'large-grid': 'Large Grid', + 'compact-grid': 'Compact Grid', + list: 'List' + }; +} diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.html b/src/app/features/website/catalog/components/product-grid/product-grid.component.html index 29e210a..a6c56d0 100644 --- a/src/app/features/website/catalog/components/product-grid/product-grid.component.html +++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.html @@ -1,13 +1,18 @@ -
+
@for (product of products; track trackByItemId($index, product)) {
(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @@ -35,4 +41,18 @@ export class CatalogProductGridComponent { onAddToCart(product: Product, event: Event): void { this.addToCart.emit({ product, event }); } + + layoutClass(): string { + switch (this.layout) { + case 'large-grid': + return 'grid grid-3 catalog-layout-large-grid'; + case 'compact-grid': + return 'grid grid-4 catalog-layout-compact-grid'; + case 'list': + return 'catalog-layout-list'; + case 'grid': + default: + return 'grid grid-4 catalog-layout-grid'; + } + } } diff --git a/src/app/features/website/catalog/components/search-box/search-box.component.html b/src/app/features/website/catalog/components/search-box/search-box.component.html new file mode 100644 index 0000000..c6de24b --- /dev/null +++ b/src/app/features/website/catalog/components/search-box/search-box.component.html @@ -0,0 +1,52 @@ + diff --git a/src/app/features/website/catalog/components/search-box/search-box.component.scss b/src/app/features/website/catalog/components/search-box/search-box.component.scss new file mode 100644 index 0000000..bafa27b --- /dev/null +++ b/src/app/features/website/catalog/components/search-box/search-box.component.scss @@ -0,0 +1,70 @@ +.catalog-search-box { + padding: 14px; + display: grid; + gap: 12px; +} + +.search-form { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.search-form input { + min-height: 44px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0 12px; + font: inherit; +} + +.search-form button { + min-height: 44px; + border: 0; + border-radius: var(--radius-sm); + background: var(--primary-color); + color: #fff; + font-weight: 700; + padding: 0 14px; +} + +.chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + min-height: 30px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-primary); + color: var(--text-secondary); + padding: 0 10px; + cursor: pointer; +} + +.history-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.history-head button { + border: 0; + background: transparent; + color: var(--primary-color); + cursor: pointer; +} + +.no-results { + margin: 0; + color: var(--text-secondary); +} + +@media (max-width: 680px) { + .search-form { + grid-template-columns: 1fr; + } +} diff --git a/src/app/features/website/catalog/components/search-box/search-box.component.ts b/src/app/features/website/catalog/components/search-box/search-box.component.ts new file mode 100644 index 0000000..6c99319 --- /dev/null +++ b/src/app/features/website/catalog/components/search-box/search-box.component.ts @@ -0,0 +1,34 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-catalog-search-box', + standalone: true, + imports: [FormsModule], + templateUrl: './search-box.component.html', + styleUrls: ['./search-box.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CatalogSearchBoxComponent { + @Input() query = ''; + @Input() loading = false; + @Input() suggestions: string[] = []; + @Input() recentSearches: string[] = []; + @Input() searchHistory: string[] = []; + @Input() noResults = false; + + @Output() queryChange = new EventEmitter(); + @Output() searchSubmit = new EventEmitter(); + @Output() suggestionSelected = new EventEmitter(); + @Output() recentSelected = new EventEmitter(); + @Output() historyCleared = new EventEmitter(); + + onSubmit(event: Event): void { + event.preventDefault(); + this.searchSubmit.emit(this.query.trim()); + } + + selectSuggestion(value: string): void { + this.suggestionSelected.emit(value); + } +} 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 new file mode 100644 index 0000000..a699d78 --- /dev/null +++ b/src/app/features/website/catalog/components/search-results/search-results.component.html @@ -0,0 +1,38 @@ +
+
+ {{ summary }} + {{ total }} items +
+ + @if (loading) { +
+ @for (_ of [1,2,3,4,5,6,7,8]; track $index) { +
+ } +
+ } @else if (products.length > 0) { + + } @else { +
+

No results found

+

Try changing filters, sorting, or search keywords.

+
+ } + + @if (!loading && totalPages > 1) { +
+ + Page {{ page }} / {{ totalPages }} + +
+ } +
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 new file mode 100644 index 0000000..fc191f7 --- /dev/null +++ b/src/app/features/website/catalog/components/search-results/search-results.component.scss @@ -0,0 +1,71 @@ +.catalog-search-results { + display: grid; + gap: 14px; +} + +.results-head { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} + +.results-head strong { + color: var(--text-primary); +} + +.results-head span { + color: var(--text-secondary); +} + +.results-skeletons { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 14px; +} + +.skeleton-card { + min-height: 220px; + border-radius: var(--radius-md); + background: linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%); + background-size: 200% 100%; + animation: shimmer 1.2s linear infinite; +} + +.empty-state { + min-height: 180px; + display: grid; + place-content: center; + gap: 6px; + text-align: center; + padding: 18px; +} + +.empty-state h3, +.empty-state p { + margin: 0; +} + +.empty-state p { + color: var(--text-secondary); +} + +.results-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.results-pager button { + min-height: 36px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + padding: 0 12px; +} + +@keyframes shimmer { + to { background-position: -200% 0; } +} 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 new file mode 100644 index 0000000..17e9824 --- /dev/null +++ b/src/app/features/website/catalog/components/search-results/search-results.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { Product } from '../../../../../core/products/models/product-domain.model'; +import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model'; +import { CatalogProductGridComponent } from '../product-grid/product-grid.component'; + +@Component({ + selector: 'app-catalog-search-results', + standalone: true, + imports: [CatalogProductGridComponent], + templateUrl: './search-results.component.html', + styleUrls: ['./search-results.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CatalogSearchResultsComponent { + @Input() products: Product[] = []; + @Input() total = 0; + @Input() page = 1; + @Input() pageSize = 24; + @Input() summary = ''; + @Input() loading = false; + @Input() layout: CatalogLayoutMode = 'grid'; + @Input() showRatings = true; + @Input() showDiscounts = true; + @Input() showAvailability = true; + + @Output() pageChange = new EventEmitter(); + @Output() productSelected = new EventEmitter(); + @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); + @Output() productPreview = new EventEmitter(); + + get totalPages(): number { + return Math.max(1, Math.ceil(this.total / Math.max(1, this.pageSize))); + } + + previous(): void { + if (this.page > 1) { + this.pageChange.emit(this.page - 1); + } + } + + next(): void { + if (this.page < this.totalPages) { + this.pageChange.emit(this.page + 1); + } + } +} diff --git a/src/app/features/website/catalog/components/sorting-control/sorting-control.component.html b/src/app/features/website/catalog/components/sorting-control/sorting-control.component.html new file mode 100644 index 0000000..24cfe59 --- /dev/null +++ b/src/app/features/website/catalog/components/sorting-control/sorting-control.component.html @@ -0,0 +1,10 @@ + diff --git a/src/app/features/website/catalog/components/sorting-control/sorting-control.component.scss b/src/app/features/website/catalog/components/sorting-control/sorting-control.component.scss new file mode 100644 index 0000000..109a954 --- /dev/null +++ b/src/app/features/website/catalog/components/sorting-control/sorting-control.component.scss @@ -0,0 +1,22 @@ +.catalog-sorting { + padding: 10px 12px; + min-height: 52px; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 8px; +} + +.catalog-sorting span { + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 700; +} + +.catalog-sorting select { + min-height: 34px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0 8px; + font: inherit; +} diff --git a/src/app/features/website/catalog/components/sorting-control/sorting-control.component.ts b/src/app/features/website/catalog/components/sorting-control/sorting-control.component.ts new file mode 100644 index 0000000..29db665 --- /dev/null +++ b/src/app/features/website/catalog/components/sorting-control/sorting-control.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SortDefinition } from '../../../../../core/products/models/catalog-experience.model'; + +@Component({ + selector: 'app-catalog-sorting-control', + standalone: true, + imports: [FormsModule], + templateUrl: './sorting-control.component.html', + styleUrls: ['./sorting-control.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CatalogSortingControlComponent { + @Input() options: SortDefinition[] = []; + @Input() selected = 'relevance'; + + @Output() selectedChange = new EventEmitter(); +} 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 8067f7e..01dd0c4 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.html +++ b/src/app/features/website/catalog/containers/catalog-container.component.html @@ -2,7 +2,7 @@
{{ 'catalog.title' | translate }} - @if (breadcrumb().length > 0) { + @if (catalogConfig().showBreadcrumbs && breadcrumb().length > 0) { } + +
@if (loading()) { @@ -37,9 +50,26 @@

{{ state().category?.title || ('catalog.allCategories' | translate) }}

-

{{ 'catalog.categoryHint' | translate }}

+

{{ 'catalog.categoryHint' | translate }} • {{ categories().length }} entries

+ @if (catalogConfig().showCategoryBanner) { +
+

{{ state().category?.title || ('catalog.allCategories' | translate) }}

+

Category banner placeholder for backend-driven media and description.

+
+ } + + @if (catalogConfig().showSubcategoryChips && subcategoryChips().length > 0) { +
+ @for (subcategory of subcategoryChips(); track subcategory.id) { + + } +
+ } + @if (categories().length > 0) { } @else { @@ -52,29 +82,51 @@ } @if (!loading() && !error() && viewMode() === 'products') { -
-
-

{{ state().category?.title || ('catalog.products' | translate) }}

-

{{ 'catalog.productHint' | translate }}

-
+
+ - @if (loadingProducts()) { -
{{ 'catalog.loadingProducts' | translate }}
- } +
+
+ - @if (!loadingProducts() && products().length > 0) { - +
+ + @if (catalogConfig().navigationMode !== 'default') { +
+ {{ catalogConfig().navigationMode }} +

Navigation layout placeholder prepared for backend/bootstrap-driven rendering.

+
+ } + + - } @else if (!loadingProducts()) { -
-

{{ 'catalog.emptyTitle' | translate }}

-

{{ 'catalog.emptyProducts' | translate }}

-
- } + (productPreview)="previewProduct($event)" /> +
} diff --git a/src/app/features/website/catalog/containers/catalog-container.component.scss b/src/app/features/website/catalog/containers/catalog-container.component.scss index b0960dc..6dc5879 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.scss +++ b/src/app/features/website/catalog/containers/catalog-container.component.scss @@ -8,8 +8,8 @@ .catalog-header { display: flex; flex-direction: column; - gap: 12px; - margin-bottom: 32px; + gap: 14px; + margin-bottom: 24px; } .catalog-root-link { @@ -47,6 +47,83 @@ gap: 24px; } +.catalog-products-section { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + align-items: start; + gap: 16px; +} + +.catalog-left-panel { + position: sticky; + top: 16px; +} + +.catalog-results-panel { + display: grid; + gap: 14px; +} + +.catalog-tools-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: space-between; +} + +.catalog-category-banner { + padding: 16px; + background: linear-gradient(120deg, color-mix(in srgb, var(--primary-color) 14%, white), #f8fbfb); +} + +.catalog-category-banner h3, +.catalog-category-banner p { + margin: 0; +} + +.catalog-category-banner p { + margin-top: 6px; + color: var(--text-secondary); +} + +.subcategory-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.subcategory-chip { + min-height: 32px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-primary); + color: var(--text-primary); + padding: 0 10px; + cursor: pointer; + transition: border-color 0.2s ease, transform 0.2s ease; +} + +.subcategory-chip:hover { + border-color: var(--primary-color); + transform: translateY(-1px); +} + +.catalog-navigation-placeholder { + padding: 14px; + display: grid; + gap: 6px; +} + +.catalog-navigation-placeholder strong, +.catalog-navigation-placeholder p { + margin: 0; +} + +.catalog-navigation-placeholder p { + color: var(--text-secondary); +} + .catalog-section-heading { display: flex; flex-direction: column; @@ -155,6 +232,14 @@ padding: 16px; } + .catalog-products-section { + grid-template-columns: 1fr; + } + + .catalog-left-panel { + position: static; + } + .catalog-root-link { font-size: 1.5rem; } diff --git a/src/app/features/website/catalog/containers/catalog-container.component.ts b/src/app/features/website/catalog/containers/catalog-container.component.ts index 56ec278..32132b2 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.ts +++ b/src/app/features/website/catalog/containers/catalog-container.component.ts @@ -1,10 +1,12 @@ -import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { combineLatest } from 'rxjs'; import { Subscription } from 'rxjs'; import { Category } from '../../../../core/categories/models/category-domain.model'; +import { CatalogLayoutMode, FilterDefinition, SearchCriteria, SearchResult, SortDefinition, toSearchResult } from '../../../../core/products/models/catalog-experience.model'; import { Product } from '../../../../core/products/models/product-domain.model'; +import { ConfigService } from '../../../../core/config/config.service'; import { CategoryFacade } from '../../../../facades/platform/category.facade'; import { ProductFacade } from '../../../../facades/platform/product.facade'; import { CartService } from '../../../../services'; @@ -12,16 +14,32 @@ 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 { DEFAULT_CATALOG_CONFIG } from '../../../../shared/models/config'; import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component'; -import { CatalogProductGridComponent } from '../components/product-grid/product-grid.component'; +import { CatalogFiltersPanelComponent, CatalogFilterStateValue } from '../components/filters-panel/filters-panel.component'; +import { CatalogLayoutSwitcherComponent } from '../components/layout-switcher/layout-switcher.component'; +import { CatalogSearchBoxComponent } from '../components/search-box/search-box.component'; +import { CatalogSearchResultsComponent } from '../components/search-results/search-results.component'; +import { CatalogSortingControlComponent } from '../components/sorting-control/sorting-control.component'; import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model'; +import { CatalogSearchHistoryService } from '../services/catalog-search-history.service'; type CatalogViewMode = 'categories' | 'products'; @Component({ selector: 'app-catalog-container', standalone: true, - imports: [RouterLink, LangRoutePipe, TranslatePipe, CatalogCategoryGridComponent, CatalogProductGridComponent], + imports: [ + RouterLink, + LangRoutePipe, + TranslatePipe, + CatalogSearchBoxComponent, + CatalogFiltersPanelComponent, + CatalogSortingControlComponent, + CatalogLayoutSwitcherComponent, + CatalogSearchResultsComponent, + CatalogCategoryGridComponent + ], templateUrl: './catalog-container.component.html', styleUrls: ['./catalog-container.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -30,33 +48,70 @@ export class CatalogContainerComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + private readonly configService = inject(ConfigService); private readonly categoryFacade = inject(CategoryFacade); private readonly productFacade = inject(ProductFacade); private readonly cartService = inject(CartService); private readonly prefetchService = inject(PrefetchService); private readonly languageService = inject(LanguageService); + private readonly searchHistoryService = inject(CatalogSearchHistoryService); + + readonly catalogConfig = signal(this.resolveCatalogConfig()); readonly state = signal(createInitialCatalogState()); readonly categories = signal([]); + readonly rawProducts = signal([]); readonly products = signal([]); + readonly subcategoryChips = signal([]); readonly breadcrumb = signal([]); + readonly searchSummary = signal(''); + readonly searchResult = signal(null); + readonly filterDefinitions = signal([]); + readonly filterState = signal({ values: {}, ranges: {}, toggles: {} }); + readonly searchSuggestions = signal([]); + readonly recentSearches = signal([]); + readonly searchHistory = signal(this.catalogConfig().searchHistoryEnabled ? this.searchHistoryService.getHistory() : []); readonly viewMode = signal('categories'); readonly loading = signal(true); readonly loadingProducts = signal(false); readonly error = signal(null); + readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0); + + readonly sortDefinitions = computed(() => { + const labels: Record = { + relevance: 'Relevance', + latest: 'Newest', + price_asc: 'Price Low -> High', + price_desc: 'Price High -> Low', + rating: 'Highest Rated', + popular: 'Most Popular', + discount: 'Discount' + }; + + return this.catalogConfig().availableSorts.map((id: string) => ({ id, label: labels[id] ?? id, enabled: true })); + }); + + readonly availableLayouts: CatalogLayoutMode[] = ['grid', 'large-grid', 'compact-grid', 'list']; readonly skeletonSlots = Array.from({ length: 8 }); private dataSubscription?: Subscription; + private readonly backendFetchSize = 200; constructor() { this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe()); + this.recentSearches.set(this.searchHistory().slice(0, 5)); - this.route.paramMap + combineLatest([this.route.paramMap, this.route.queryParamMap]) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(params => { + .subscribe(([params, queryParams]) => { const categoryId = Number(params.get('id')) || null; + const searchQuery = (queryParams.get('q') ?? '').trim(); this.enterCategory(categoryId); + + if (searchQuery.length > 0) { + this.submitSearch(searchQuery); + } }); } @@ -66,11 +121,20 @@ export class CatalogContainerComponent { this.loadingProducts.set(false); this.error.set(null); this.categories.set([]); + this.rawProducts.set([]); this.products.set([]); this.breadcrumb.set([]); + this.subcategoryChips.set([]); + this.searchSummary.set(''); + this.searchResult.set(null); + this.filterDefinitions.set([]); + this.filterState.set({ values: {}, ranges: {}, toggles: {} }); + this.searchSuggestions.set([]); this.categoryFacade.selectCategory(categoryId); this.state.set({ ...createInitialCatalogState(), + sort: this.catalogConfig().defaultSort, + layout: this.catalogConfig().layout, filters: { ...createInitialCatalogState().filters, categoryIds: categoryId == null ? [] : [categoryId], @@ -93,6 +157,7 @@ export class CatalogContainerComponent { ]).subscribe({ next: ([category, children, breadcrumb]) => { this.breadcrumb.set(breadcrumb); + this.subcategoryChips.set(children); this.state.update(current => ({ ...current, category: category ?? null })); if (children.length > 0) { @@ -128,6 +193,110 @@ export class CatalogContainerComponent { this.enterCategory(this.state().category?.id ?? null); } + onSearchQueryChange(query: string): void { + this.state.update(current => ({ ...current, search: query })); + + const normalized = query.trim().toLowerCase(); + if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) { + this.searchSuggestions.set([]); + return; + } + + const suggestions = this.rawProducts() + .map(product => product.name) + .filter(name => name.toLowerCase().includes(normalized)) + .slice(0, 6); + + this.searchSuggestions.set([...new Set(suggestions)]); + } + + submitSearch(query: string): void { + const normalized = query.trim(); + this.viewMode.set('products'); + this.state.update(current => ({ + ...current, + search: normalized, + pagination: { + ...current.pagination, + page: 1, + skip: 0 + } + })); + + if (this.catalogConfig().searchHistoryEnabled && normalized.length > 0) { + this.searchHistory.set(this.searchHistoryService.push(normalized)); + this.recentSearches.set(this.searchHistory().slice(0, 5)); + } + + this.loadCatalog(); + } + + useSuggestion(value: string): void { + this.onSearchQueryChange(value); + this.submitSearch(value); + } + + useRecentSearch(value: string): void { + this.onSearchQueryChange(value); + this.submitSearch(value); + } + + clearSearchHistory(): void { + this.searchHistoryService.clear(); + this.searchHistory.set([]); + this.recentSearches.set([]); + } + + onFilterStateChange(next: CatalogFilterStateValue): void { + this.filterState.set(next); + this.state.update(current => ({ + ...current, + pagination: { + ...current.pagination, + page: 1, + skip: 0 + } + })); + + this.recomputeResults(); + } + + resetFilters(): void { + this.filterState.set({ values: {}, ranges: {}, toggles: {} }); + this.recomputeResults(); + } + + changeSort(sortId: string): void { + this.state.update(current => ({ + ...current, + sort: sortId as CatalogState['sort'], + pagination: { + ...current.pagination, + page: 1, + skip: 0 + } + })); + + this.recomputeResults(); + } + + changeLayout(layout: CatalogLayoutMode): void { + this.state.update(current => ({ ...current, layout })); + } + + onResultsPageChange(page: number): void { + this.state.update(current => ({ + ...current, + pagination: { + ...current.pagination, + page, + skip: (page - 1) * current.pagination.count + } + })); + + this.recomputeResults(); + } + 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'); @@ -139,29 +308,344 @@ export class CatalogContainerComponent { } private loadProducts(categoryId: number): void { - const pagination = this.state().pagination; this.viewMode.set('products'); - this.loadingProducts.set(true); + this.loadCatalog(categoryId); + } - this.dataSubscription = this.productFacade.getProductsByCategory(categoryId, { count: pagination.count, skip: pagination.skip }) + private loadCatalog(categoryId?: number): void { + this.loadingProducts.set(true); + const categoryID = categoryId ?? this.state().category?.id ?? this.state().filters.categoryIds[0] ?? null; + const criteria = this.buildCriteria(categoryID); + + this.dataSubscription = this.productFacade.loadCatalog(criteria) .subscribe({ - next: result => { - this.products.set(result.items); - this.state.update(current => ({ - ...current, - pagination: { - ...current.pagination, - total: result.total, - hasMore: result.items.length >= current.pagination.count, - }, - })); - this.loading.set(false); - this.loadingProducts.set(false); - }, - error: () => this.setError('catalog.error'), + next: result => { + this.rawProducts.set(result.items); + this.buildFilterDefinitions(result.items); + this.recomputeResults(); + this.loading.set(false); + this.loadingProducts.set(false); + }, + error: () => this.setError('catalog.error') }); } + private buildCriteria(categoryId: number | null): SearchCriteria { + const state = this.state(); + + return { + keyword: state.search, + categoryIDs: categoryId == null ? [] : [categoryId], + sort: state.sort, + page: 1, + pageSize: this.backendFetchSize, + minPrice: this.filterState().ranges['price']?.min, + maxPrice: this.filterState().ranges['price']?.max, + discountOnly: this.filterState().toggles['discount'], + newOnly: this.filterState().toggles['new'] + }; + } + + private buildFilterDefinitions(products: Product[]): void { + const enabled = new Set(this.catalogConfig().enabledFilters); + const priceValues = products.map(product => product.price).filter(price => Number.isFinite(price)); + const brands = this.collectUnique(products.flatMap(product => product.tags ?? []).filter(tag => !tag.startsWith('new') && !tag.startsWith('sale'))); + const colors = this.collectUnique(products.flatMap(product => [product.colour, ...(product.itemDetails?.map(detail => detail.colour || detail.color) ?? [])]).filter(Boolean) as string[]); + const sizes = this.collectUnique(products.flatMap(product => [product.size, ...(product.itemDetails?.map(detail => detail.size) ?? [])]).filter(Boolean) as string[]); + const categories = this.collectUnique(products.map(product => String(product.categoryID)).filter(Boolean)); + const subcategories = this.collectUnique(products.map(product => product.subcategoryId ?? '').filter(Boolean)); + const attributes = this.collectUnique(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key))); + const ratings = [5, 4, 3, 2, 1]; + + const definitions: FilterDefinition[] = [ + { + id: 'price', + label: 'Price', + type: 'range', + min: priceValues.length ? Math.min(...priceValues) : 0, + max: priceValues.length ? Math.max(...priceValues) : 0, + enabled: enabled.has('price') + }, + { + id: 'availability', + label: 'Availability', + type: 'multi-select', + options: [ + { id: 'in-stock', label: 'In stock', value: 'in-stock' }, + { id: 'low-stock', label: 'Low stock', value: 'low-stock' }, + { id: 'out-of-stock', label: 'Out of stock', value: 'out-of-stock' } + ], + enabled: enabled.has('availability') + }, + { + id: 'rating', + label: 'Rating', + type: 'multi-select', + options: ratings.map(value => ({ id: `rating-${value}`, label: `${value} stars`, value: String(value) })), + enabled: enabled.has('rating') + }, + { + id: 'brand', + label: 'Brand', + type: 'multi-select', + options: brands.map(value => ({ id: `brand-${value}`, label: value, value })), + enabled: enabled.has('brand') + }, + { + id: 'category', + label: 'Category', + type: 'multi-select', + options: categories.map(value => ({ id: `cat-${value}`, label: `Category ${value}`, value })), + enabled: enabled.has('category') + }, + { + id: 'subcategory', + label: 'Subcategory', + type: 'multi-select', + options: subcategories.map(value => ({ id: `sub-${value}`, label: value, value })), + enabled: enabled.has('subcategory') + }, + { + id: 'discount', + label: 'Discount', + type: 'toggle', + enabled: enabled.has('discount') + }, + { + id: 'new', + label: 'New', + type: 'toggle', + enabled: enabled.has('new') + }, + { + id: 'color', + label: 'Color', + type: 'multi-select', + options: colors.map(value => ({ id: `color-${value}`, label: value, value })), + enabled: enabled.has('color') + }, + { + id: 'size', + label: 'Size', + type: 'multi-select', + options: sizes.map(value => ({ id: `size-${value}`, label: value, value })), + enabled: enabled.has('size') + }, + { + id: 'attributes', + label: 'Attributes', + type: 'multi-select', + options: attributes.map(value => ({ id: `attr-${value}`, label: value, value })), + enabled: enabled.has('attributes') + } + ]; + + this.filterDefinitions.set(definitions); + } + + private recomputeResults(): void { + if (this.rawProducts().length === 0) { + this.products.set([]); + this.state.update(current => ({ + ...current, + pagination: { + ...current.pagination, + total: 0, + hasMore: false, + skip: 0, + page: 1 + } + })); + this.searchSummary.set('No results'); + this.searchResult.set(null); + this.searchSuggestions.set([]); + return; + } + + const filtered = this.applyFilters(this.rawProducts()); + const sorted = this.applySort(filtered); + const pagination = this.state().pagination; + const page = Math.max(1, pagination.page); + const pageSize = Math.max(1, pagination.count); + const skip = (page - 1) * pageSize; + const paged = sorted.slice(skip, skip + pageSize); + + this.products.set(paged); + this.state.update(current => ({ + ...current, + pagination: { + ...current.pagination, + total: sorted.length, + skip, + hasMore: skip + pageSize < sorted.length + } + })); + + const result = toSearchResult({ + items: paged, + total: sorted.length, + count: pageSize, + skip + }, { + keyword: this.state().search, + page, + pageSize, + sort: this.state().sort + }); + + this.searchResult.set(result); + this.searchSummary.set(result.summary); + this.searchSuggestions.set(this.buildSuggestions(this.state().search, sorted)); + } + + private buildSuggestions(query: string, products: Product[]): string[] { + const normalized = query.trim().toLowerCase(); + if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) { + return []; + } + + return this.collectUnique( + products + .map(product => product.name) + .filter(name => name.toLowerCase().includes(normalized)) + .slice(0, 6) + ); + } + + private applyFilters(products: Product[]): Product[] { + const filterState = this.filterState(); + const query = this.state().search.trim().toLowerCase(); + + return products.filter(product => { + if (query.length > 0) { + const haystack = `${product.name} ${product.simpleDescription ?? ''}`.toLowerCase(); + if (!haystack.includes(query)) { + return false; + } + } + + const priceRange = filterState.ranges['price']; + if (priceRange?.min != null && product.price < priceRange.min) { + return false; + } + if (priceRange?.max != null && product.price > priceRange.max) { + return false; + } + + if (filterState.toggles['discount'] && !(product.discount > 0)) { + return false; + } + + if (filterState.toggles['new'] && !(product.badges ?? []).some(badge => badge.toLowerCase().includes('new'))) { + return false; + } + + const selectedRatings = filterState.values['rating'] ?? []; + if (selectedRatings.length > 0 && !selectedRatings.includes(String(Math.floor(product.rating || 0)))) { + return false; + } + + const selectedAvailability = filterState.values['availability'] ?? []; + if (selectedAvailability.length > 0) { + const availability = product.remainings === 'out' + ? 'out-of-stock' + : product.remainings === 'low' + ? 'low-stock' + : 'in-stock'; + + if (!selectedAvailability.includes(availability)) { + return false; + } + } + + const selectedBrands = filterState.values['brand'] ?? []; + if (selectedBrands.length > 0) { + const tags = product.tags ?? []; + if (!selectedBrands.some(brand => tags.includes(brand))) { + return false; + } + } + + const selectedCategories = filterState.values['category'] ?? []; + if (selectedCategories.length > 0 && !selectedCategories.includes(String(product.categoryID))) { + return false; + } + + const selectedSubcategories = filterState.values['subcategory'] ?? []; + if (selectedSubcategories.length > 0 && !selectedSubcategories.includes(product.subcategoryId ?? '')) { + return false; + } + + const selectedColors = filterState.values['color'] ?? []; + if (selectedColors.length > 0) { + const colors = this.collectUnique([product.colour ?? '', ...(product.itemDetails?.map(detail => detail.colour || detail.color || '') ?? [])]); + if (!selectedColors.some(color => colors.includes(color))) { + return false; + } + } + + const selectedSizes = filterState.values['size'] ?? []; + if (selectedSizes.length > 0) { + const sizes = this.collectUnique([product.size ?? '', ...(product.itemDetails?.map(detail => detail.size || '') ?? [])]); + if (!selectedSizes.some(size => sizes.includes(size))) { + return false; + } + } + + const selectedAttributes = filterState.values['attributes'] ?? []; + if (selectedAttributes.length > 0) { + const keys = (product.descriptionFields ?? []).map(field => field.key); + if (!selectedAttributes.some(attribute => keys.includes(attribute))) { + return false; + } + } + + return true; + }); + } + + private applySort(products: Product[]): Product[] { + const sort = this.state().sort; + const sorted = [...products]; + + switch (sort) { + case 'price_asc': + return sorted.sort((a, b) => a.price - b.price); + case 'price_desc': + return sorted.sort((a, b) => b.price - a.price); + case 'rating': + return sorted.sort((a, b) => (b.rating || 0) - (a.rating || 0)); + case 'popular': + return sorted.sort((a, b) => (b.callbacks?.length ?? 0) - (a.callbacks?.length ?? 0)); + case 'discount': + return sorted.sort((a, b) => (b.discount || 0) - (a.discount || 0)); + case 'latest': + case 'relevance': + default: + return sorted; + } + } + + private collectUnique(values: string[]): string[] { + return [...new Set(values.map(value => value.trim()).filter(Boolean))]; + } + + private resolveCatalogConfig() { + const snapshot = this.configService.getBootstrapSnapshot() as any; + const raw = snapshot?.catalog ?? {}; + + return { + ...DEFAULT_CATALOG_CONFIG, + ...raw, + availableSorts: Array.isArray(raw.availableSorts) && raw.availableSorts.length > 0 + ? raw.availableSorts + : DEFAULT_CATALOG_CONFIG.availableSorts, + enabledFilters: Array.isArray(raw.enabledFilters) && raw.enabledFilters.length > 0 + ? raw.enabledFilters + : DEFAULT_CATALOG_CONFIG.enabledFilters + }; + } + private setError(message: string): void { this.error.set(message); this.loading.set(false); diff --git a/src/app/features/website/catalog/models/catalog-state.model.ts b/src/app/features/website/catalog/models/catalog-state.model.ts index bc67a39..26ae36f 100644 --- a/src/app/features/website/catalog/models/catalog-state.model.ts +++ b/src/app/features/website/catalog/models/catalog-state.model.ts @@ -1,6 +1,7 @@ import { Category } from '../../../../core/categories/models/category-domain.model'; +import { CatalogLayoutMode } from '../../../../core/products/models/catalog-experience.model'; -export type CatalogSort = 'relevance' | 'price_asc' | 'price_desc' | 'popular' | 'rating' | 'latest'; +export type CatalogSort = 'relevance' | 'price_asc' | 'price_desc' | 'popular' | 'rating' | 'latest' | 'discount'; export interface CatalogPriceRange { min?: number; @@ -8,6 +9,7 @@ export interface CatalogPriceRange { } export interface CatalogPaginationState { + page: number; count: number; skip: number; total: number; @@ -18,12 +20,16 @@ export interface CatalogFilterState { categoryIds: number[]; priceRange: CatalogPriceRange; attributes: Record; + values: Record; + ranges: Record; + toggles: Record; } export interface CatalogState { category: Category | null; search: string; sort: CatalogSort; + layout: CatalogLayoutMode; priceRange: CatalogPriceRange; attributes: Record; pagination: CatalogPaginationState; @@ -39,7 +45,9 @@ export function createInitialCatalogState(): CatalogState { sort: 'relevance', priceRange: {}, attributes: {}, + layout: 'grid', pagination: { + page: 1, count: DEFAULT_CATALOG_PAGE_SIZE, skip: 0, total: 0, @@ -49,6 +57,9 @@ export function createInitialCatalogState(): CatalogState { categoryIds: [], priceRange: {}, attributes: {}, + values: {}, + ranges: {}, + toggles: {}, }, }; } diff --git a/src/app/features/website/catalog/services/catalog-search-history.service.ts b/src/app/features/website/catalog/services/catalog-search-history.service.ts new file mode 100644 index 0000000..c2e55a1 --- /dev/null +++ b/src/app/features/website/catalog/services/catalog-search-history.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; + +const STORAGE_KEY = 'marketplace.catalog.search.history'; + +@Injectable({ providedIn: 'root' }) +export class CatalogSearchHistoryService { + private readonly maxSize = 10; + + getHistory(): string[] { + if (typeof window === 'undefined') { + return []; + } + + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + : []; + } catch { + return []; + } + } + + push(term: string): string[] { + const normalized = term.trim(); + if (!normalized.length) { + return this.getHistory(); + } + + const history = [normalized, ...this.getHistory().filter(entry => entry.toLowerCase() !== normalized.toLowerCase())] + .slice(0, this.maxSize); + + this.save(history); + return history; + } + + clear(): void { + if (typeof window === 'undefined') { + return; + } + + localStorage.removeItem(STORAGE_KEY); + } + + private save(history: string[]): void { + if (typeof window === 'undefined') { + return; + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(history)); + } +} diff --git a/src/app/shared/models/config/bootstrap-config.model.ts b/src/app/shared/models/config/bootstrap-config.model.ts index 7474585..fd80e0c 100644 --- a/src/app/shared/models/config/bootstrap-config.model.ts +++ b/src/app/shared/models/config/bootstrap-config.model.ts @@ -1,5 +1,6 @@ import { ApiEndpointsConfig } from './api-endpoints.model'; import { BrandingConfig } from './branding.model'; +import { CatalogConfig } from './catalog-config.model'; import { CompanyConfig } from './company.model'; import { FeatureFlagsConfig } from './feature-flags.model'; import { FooterConfig } from './footer-config.model'; @@ -27,6 +28,7 @@ export interface BootstrapConfig { localization: LocalizationConfig; seo: SeoConfig; permissions: PermissionsConfig; + catalog?: CatalogConfig; layout?: PlatformLayoutConfig; navigation: NavigationConfig; footer?: FooterConfig; diff --git a/src/app/shared/models/config/catalog-config.model.ts b/src/app/shared/models/config/catalog-config.model.ts new file mode 100644 index 0000000..5a3c78b --- /dev/null +++ b/src/app/shared/models/config/catalog-config.model.ts @@ -0,0 +1,34 @@ +export type CatalogLayoutModeConfig = 'grid' | 'large-grid' | 'compact-grid' | 'list'; +export type CatalogNavigationModeConfig = 'default' | 'left-category-navigation' | 'mega-category-layout' | 'top-category-carousel'; + +export interface CatalogConfig { + layout?: CatalogLayoutModeConfig; + navigationMode?: CatalogNavigationModeConfig; + defaultSort?: 'relevance' | 'latest' | 'price_asc' | 'price_desc' | 'rating' | 'popular' | 'discount'; + availableSorts?: Array<'relevance' | 'latest' | 'price_asc' | 'price_desc' | 'rating' | 'popular' | 'discount'>; + enabledFilters?: string[]; + showBreadcrumbs?: boolean; + showCategoryBanner?: boolean; + showSubcategoryChips?: boolean; + showRatings?: boolean; + showDiscounts?: boolean; + showAvailability?: boolean; + suggestionsEnabled?: boolean; + searchHistoryEnabled?: boolean; +} + +export const DEFAULT_CATALOG_CONFIG: Required = { + layout: 'grid', + navigationMode: 'default', + defaultSort: 'relevance', + availableSorts: ['relevance', 'latest', 'price_asc', 'price_desc', 'rating', 'popular', 'discount'], + enabledFilters: ['price', 'availability', 'rating', 'brand', 'category', 'subcategory', 'discount', 'new', 'color', 'size', 'attributes'], + showBreadcrumbs: true, + showCategoryBanner: true, + showSubcategoryChips: true, + showRatings: true, + showDiscounts: true, + showAvailability: true, + suggestionsEnabled: true, + searchHistoryEnabled: true +}; diff --git a/src/app/shared/models/config/index.ts b/src/app/shared/models/config/index.ts index 79d1079..3288da9 100644 --- a/src/app/shared/models/config/index.ts +++ b/src/app/shared/models/config/index.ts @@ -1,6 +1,7 @@ export * from './api-endpoints.model'; export * from './bootstrap-config.model'; export * from './branding.model'; +export * from './catalog-config.model'; export * from './company.model'; export * from './feature-flags.model'; export * from './footer-config.model'; diff --git a/src/assets/mock/bootstrap/bootstrap.json b/src/assets/mock/bootstrap/bootstrap.json index ef6dc2e..1998043 100644 --- a/src/assets/mock/bootstrap/bootstrap.json +++ b/src/assets/mock/bootstrap/bootstrap.json @@ -246,6 +246,21 @@ }, "legalPageKeys": ["about-us", "privacy-policy", "terms-of-service"] }, + "catalog": { + "layout": "grid", + "navigationMode": "default", + "defaultSort": "relevance", + "availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"], + "enabledFilters": ["price", "availability", "rating", "brand", "category", "subcategory", "discount", "new", "color", "size", "attributes"], + "showBreadcrumbs": true, + "showCategoryBanner": true, + "showSubcategoryChips": true, + "showRatings": true, + "showDiscounts": true, + "showAvailability": true, + "suggestionsEnabled": true, + "searchHistoryEnabled": true + }, "productPage": { "rating": { "enabled": true