feat(catalog): implement sprint 10 advanced search experience
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
This commit is contained in:
133
Sprint-10-Advanced-Search-Report.md
Normal file
133
Sprint-10-Advanced-Search-Report.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,9 +2,27 @@
|
||||
<a [routerLink]="['/product', item.itemID] | langRoute" class="product-link" (click)="onSelected($event)">
|
||||
<div class="product-image">
|
||||
<img [src]="getMainImage(item)" [alt]="title || item.name" loading="lazy" decoding="async" width="300" height="300" />
|
||||
@if (item.discount > 0) {
|
||||
@if (showDiscountBadge && item.discount > 0) {
|
||||
<div class="discount-badge">-{{ item.discount }}%</div>
|
||||
}
|
||||
@if (showStock && item.remainings) {
|
||||
<span class="stock-badge" [class.out]="item.remainings === 'out'">{{ item.remainings }}</span>
|
||||
}
|
||||
|
||||
@if (showFavoritePlaceholder || showComparePlaceholder || showQuickViewPlaceholder) {
|
||||
<div class="product-actions-overlay">
|
||||
@if (showFavoritePlaceholder) {
|
||||
<button type="button" (click)="onFavoritePlaceholder($event)">Fav</button>
|
||||
}
|
||||
@if (showComparePlaceholder) {
|
||||
<button type="button" (click)="onComparePlaceholder($event)">Compare</button>
|
||||
}
|
||||
@if (showQuickViewPlaceholder) {
|
||||
<button type="button" (click)="onQuickViewPlaceholder($event)">Quick</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (item.badges && item.badges.length > 0) {
|
||||
<div class="product-badges-overlay">
|
||||
@for (badge of item.badges; track badge) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<number>();
|
||||
@Output() selected = new EventEmitter<{ itemID: number; event: Event }>();
|
||||
@Output() favoritePlaceholder = new EventEmitter<number>();
|
||||
@Output() comparePlaceholder = new EventEmitter<number>();
|
||||
@Output() quickViewPlaceholder = new EventEmitter<number>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
86
src/app/core/products/models/catalog-experience.model.ts
Normal file
86
src/app/core/products/models/catalog-experience.model.ts
Normal file
@@ -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<string, string[]>;
|
||||
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}`
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
return this.productData.submitQuestion(productID, input);
|
||||
}
|
||||
|
||||
search(criteria: SearchCriteria): Observable<ProductListResult> {
|
||||
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<ProductListResult> {
|
||||
return this.search(criteria);
|
||||
}
|
||||
|
||||
sort(criteria: SearchCriteria): Observable<ProductListResult> {
|
||||
return this.search({ ...criteria, sort: criteria.sort ?? 'relevance' });
|
||||
}
|
||||
|
||||
loadCatalog(criteria: SearchCriteria): Observable<ProductListResult> {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<aside class="catalog-filters card">
|
||||
<div class="filters-head">
|
||||
<h3>Filters</h3>
|
||||
<button type="button" (click)="resetFilters.emit()">Reset</button>
|
||||
</div>
|
||||
|
||||
@for (filter of definitions; track filter.id) {
|
||||
@if (filter.enabled !== false) {
|
||||
<section class="filter-group">
|
||||
<h4>{{ filter.label }}</h4>
|
||||
|
||||
@if (filter.type === 'multi-select') {
|
||||
<div class="options">
|
||||
@for (option of filter.options ?? []; track option.id) {
|
||||
<label class="option-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(filter.id, option.value)"
|
||||
(change)="toggleOption(filter.id, option.value)" />
|
||||
<span>{{ option.label }}</span>
|
||||
@if (option.count != null) {
|
||||
<small>{{ option.count }}</small>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filter.type === 'range') {
|
||||
<div class="range-inputs">
|
||||
<input
|
||||
type="number"
|
||||
[value]="state.ranges[filter.id]?.min ?? ''"
|
||||
(input)="updateRange(filter.id, 'min', $any($event.target).value)"
|
||||
[placeholder]="'Min ' + (filter.min ?? '')" />
|
||||
<input
|
||||
type="number"
|
||||
[value]="state.ranges[filter.id]?.max ?? ''"
|
||||
(input)="updateRange(filter.id, 'max', $any($event.target).value)"
|
||||
[placeholder]="'Max ' + (filter.max ?? '')" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filter.type === 'toggle') {
|
||||
<label class="option-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="state.toggles[filter.id]"
|
||||
(change)="updateToggle(filter.id, $any($event.target).checked)" />
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</aside>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string[]>;
|
||||
ranges: Record<string, { min?: number; max?: number }>;
|
||||
toggles: Record<string, boolean>;
|
||||
}
|
||||
|
||||
@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<CatalogFilterStateValue>();
|
||||
@Output() resetFilters = new EventEmitter<void>();
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="catalog-layout-switcher card">
|
||||
@for (layout of available; track layout) {
|
||||
<button
|
||||
type="button"
|
||||
class="layout-btn"
|
||||
[class.active]="layout === active"
|
||||
(click)="activeChange.emit(layout)">
|
||||
{{ labels[layout] }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<CatalogLayoutMode>();
|
||||
|
||||
readonly labels: Record<CatalogLayoutMode, string> = {
|
||||
grid: 'Grid',
|
||||
'large-grid': 'Large Grid',
|
||||
'compact-grid': 'Compact Grid',
|
||||
list: 'List'
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
<div class="catalog-product-grid grid grid-4">
|
||||
<div [class]="'catalog-product-grid ' + layoutClass()">
|
||||
@for (product of products; track trackByItemId($index, product)) {
|
||||
<div class="catalog-product-shell">
|
||||
<app-product-card
|
||||
[item]="product"
|
||||
[title]="productTitle(product)"
|
||||
[description]="productDescription(product)"
|
||||
[showDescription]="true"
|
||||
[showStock]="true"
|
||||
[showRating]="true"
|
||||
[appearance]="layout === 'compact-grid' ? 'compact' : 'standard'"
|
||||
[showDescription]="layout !== 'compact-grid'"
|
||||
[showStock]="showAvailability"
|
||||
[showRating]="showRatings"
|
||||
[showDiscountBadge]="showDiscounts"
|
||||
[showFavoritePlaceholder]="showActionsPlaceholder"
|
||||
[showComparePlaceholder]="showActionsPlaceholder"
|
||||
[showQuickViewPlaceholder]="showActionsPlaceholder"
|
||||
[addToCartLabel]="'catalog.addToCart' | translate"
|
||||
(selected)="productSelected.emit(product)"
|
||||
(addToCart)="onAddToCart(product, $event.event)"
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.catalog-layout-large-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.catalog-layout-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.catalog-layout-list .catalog-product-shell {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.catalog-product-shell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output } from '@angular/core';
|
||||
import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { ProductCardComponent } from '../../../../../components/product-card/product-card.component';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
@@ -15,6 +16,11 @@ import { getTranslatedField, trackByItemId } from '../../../../../utils/item.uti
|
||||
})
|
||||
export class CatalogProductGridComponent {
|
||||
@Input({ required: true }) products: Product[] = [];
|
||||
@Input() layout: CatalogLayoutMode = 'grid';
|
||||
@Input() showRatings = true;
|
||||
@Input() showDiscounts = true;
|
||||
@Input() showAvailability = true;
|
||||
@Input() showActionsPlaceholder = false;
|
||||
|
||||
@Output() productSelected = new EventEmitter<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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<section class="catalog-search-box card">
|
||||
<form class="search-form" (submit)="onSubmit($event)">
|
||||
<input
|
||||
type="search"
|
||||
[ngModel]="query"
|
||||
(ngModelChange)="queryChange.emit($event)"
|
||||
name="query"
|
||||
placeholder="Search products, brands, categories"
|
||||
autocomplete="off" />
|
||||
<button type="submit" [disabled]="loading">Search</button>
|
||||
</form>
|
||||
|
||||
@if (suggestions.length > 0) {
|
||||
<div class="suggestions">
|
||||
<strong>Suggestions</strong>
|
||||
<div class="chip-list">
|
||||
@for (item of suggestions; track item) {
|
||||
<button type="button" class="chip" (click)="selectSuggestion(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recentSearches.length > 0) {
|
||||
<div class="recent">
|
||||
<strong>Recent searches</strong>
|
||||
<div class="chip-list">
|
||||
@for (item of recentSearches; track item) {
|
||||
<button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (searchHistory.length > 0) {
|
||||
<div class="history">
|
||||
<div class="history-head">
|
||||
<strong>Search history</strong>
|
||||
<button type="button" (click)="historyCleared.emit()">Clear</button>
|
||||
</div>
|
||||
<div class="chip-list">
|
||||
@for (item of searchHistory; track item) {
|
||||
<button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (noResults && !loading) {
|
||||
<p class="no-results">No results found for this query. Try broader keywords.</p>
|
||||
}
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
@Output() searchSubmit = new EventEmitter<string>();
|
||||
@Output() suggestionSelected = new EventEmitter<string>();
|
||||
@Output() recentSelected = new EventEmitter<string>();
|
||||
@Output() historyCleared = new EventEmitter<void>();
|
||||
|
||||
onSubmit(event: Event): void {
|
||||
event.preventDefault();
|
||||
this.searchSubmit.emit(this.query.trim());
|
||||
}
|
||||
|
||||
selectSuggestion(value: string): void {
|
||||
this.suggestionSelected.emit(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<section class="catalog-search-results section">
|
||||
<div class="results-head">
|
||||
<strong>{{ summary }}</strong>
|
||||
<span>{{ total }} items</span>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="results-skeletons">
|
||||
@for (_ of [1,2,3,4,5,6,7,8]; track $index) {
|
||||
<div class="skeleton-card"></div>
|
||||
}
|
||||
</div>
|
||||
} @else if (products.length > 0) {
|
||||
<app-catalog-product-grid
|
||||
[products]="products"
|
||||
[layout]="layout"
|
||||
[showRatings]="showRatings"
|
||||
[showDiscounts]="showDiscounts"
|
||||
[showAvailability]="showAvailability"
|
||||
[showActionsPlaceholder]="true"
|
||||
(productSelected)="productSelected.emit($event)"
|
||||
(addToCart)="addToCart.emit($event)"
|
||||
(productPreview)="productPreview.emit($event)" />
|
||||
} @else {
|
||||
<div class="empty-state card">
|
||||
<h3>No results found</h3>
|
||||
<p>Try changing filters, sorting, or search keywords.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && totalPages > 1) {
|
||||
<div class="results-pager">
|
||||
<button type="button" (click)="previous()" [disabled]="page <= 1">Previous</button>
|
||||
<span>Page {{ page }} / {{ totalPages }}</span>
|
||||
<button type="button" (click)="next()" [disabled]="page >= totalPages">Next</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<number>();
|
||||
@Output() productSelected = new EventEmitter<Product>();
|
||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||
@Output() productPreview = new EventEmitter<number>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<label class="catalog-sorting card">
|
||||
<span>Sort by</span>
|
||||
<select [ngModel]="selected" (ngModelChange)="selectedChange.emit($event)">
|
||||
@for (option of options; track option.id) {
|
||||
@if (option.enabled !== false) {
|
||||
<option [value]="option.id">{{ option.label }}</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<header class="catalog-header">
|
||||
<a [routerLink]="'/catalog' | langRoute" class="catalog-root-link">{{ 'catalog.title' | translate }}</a>
|
||||
|
||||
@if (breadcrumb().length > 0) {
|
||||
@if (catalogConfig().showBreadcrumbs && breadcrumb().length > 0) {
|
||||
<nav class="catalog-breadcrumb" [attr.aria-label]="'catalog.breadcrumb' | translate">
|
||||
<a [routerLink]="'/catalog' | langRoute">{{ 'catalog.allCategories' | translate }}</a>
|
||||
@for (category of breadcrumb(); track category.id) {
|
||||
@@ -11,6 +11,19 @@
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
|
||||
<app-catalog-search-box
|
||||
[query]="state().search"
|
||||
[loading]="loadingProducts()"
|
||||
[suggestions]="searchSuggestions()"
|
||||
[recentSearches]="recentSearches()"
|
||||
[searchHistory]="searchHistory()"
|
||||
[noResults]="noResults()"
|
||||
(queryChange)="onSearchQueryChange($event)"
|
||||
(searchSubmit)="submitSearch($event)"
|
||||
(suggestionSelected)="useSuggestion($event)"
|
||||
(recentSelected)="useRecentSearch($event)"
|
||||
(historyCleared)="clearSearchHistory()" />
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
@@ -37,9 +50,26 @@
|
||||
<section class="catalog-section">
|
||||
<div class="catalog-section-heading">
|
||||
<h1>{{ state().category?.title || ('catalog.allCategories' | translate) }}</h1>
|
||||
<p>{{ 'catalog.categoryHint' | translate }}</p>
|
||||
<p>{{ 'catalog.categoryHint' | translate }} • {{ categories().length }} entries</p>
|
||||
</div>
|
||||
|
||||
@if (catalogConfig().showCategoryBanner) {
|
||||
<div class="catalog-category-banner card">
|
||||
<h3>{{ state().category?.title || ('catalog.allCategories' | translate) }}</h3>
|
||||
<p>Category banner placeholder for backend-driven media and description.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (catalogConfig().showSubcategoryChips && subcategoryChips().length > 0) {
|
||||
<div class="subcategory-chips">
|
||||
@for (subcategory of subcategoryChips(); track subcategory.id) {
|
||||
<button type="button" class="subcategory-chip" (click)="selectCategory(subcategory)">
|
||||
{{ subcategory.title }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (categories().length > 0) {
|
||||
<app-catalog-category-grid [categories]="categories()" (categorySelected)="selectCategory($event)" />
|
||||
} @else {
|
||||
@@ -52,29 +82,51 @@
|
||||
}
|
||||
|
||||
@if (!loading() && !error() && viewMode() === 'products') {
|
||||
<section class="catalog-section">
|
||||
<div class="catalog-section-heading">
|
||||
<h1>{{ state().category?.title || ('catalog.products' | translate) }}</h1>
|
||||
<p>{{ 'catalog.productHint' | translate }}</p>
|
||||
<section class="catalog-section catalog-products-section">
|
||||
<aside class="catalog-left-panel">
|
||||
<app-catalog-filters-panel
|
||||
[definitions]="filterDefinitions()"
|
||||
[state]="filterState()"
|
||||
(stateChange)="onFilterStateChange($event)"
|
||||
(resetFilters)="resetFilters()" />
|
||||
</aside>
|
||||
|
||||
<div class="catalog-results-panel">
|
||||
<div class="catalog-tools-row">
|
||||
<app-catalog-sorting-control
|
||||
[options]="sortDefinitions()"
|
||||
[selected]="state().sort"
|
||||
(selectedChange)="changeSort($event)" />
|
||||
|
||||
<app-catalog-layout-switcher
|
||||
[active]="state().layout"
|
||||
[available]="availableLayouts"
|
||||
(activeChange)="changeLayout($event)" />
|
||||
</div>
|
||||
|
||||
@if (loadingProducts()) {
|
||||
<div class="catalog-inline-loading">{{ 'catalog.loadingProducts' | translate }}</div>
|
||||
@if (catalogConfig().navigationMode !== 'default') {
|
||||
<div class="catalog-navigation-placeholder card">
|
||||
<strong>{{ catalogConfig().navigationMode }}</strong>
|
||||
<p>Navigation layout placeholder prepared for backend/bootstrap-driven rendering.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loadingProducts() && products().length > 0) {
|
||||
<app-catalog-product-grid
|
||||
<app-catalog-search-results
|
||||
[products]="products()"
|
||||
[total]="state().pagination.total"
|
||||
[page]="state().pagination.page"
|
||||
[pageSize]="state().pagination.count"
|
||||
[summary]="searchSummary()"
|
||||
[loading]="loadingProducts()"
|
||||
[layout]="state().layout"
|
||||
[showRatings]="catalogConfig().showRatings"
|
||||
[showDiscounts]="catalogConfig().showDiscounts"
|
||||
[showAvailability]="catalogConfig().showAvailability"
|
||||
(pageChange)="onResultsPageChange($event)"
|
||||
(productSelected)="selectProduct($event)"
|
||||
(addToCart)="addToCart($event)"
|
||||
(productPreview)="previewProduct($event)"
|
||||
/>
|
||||
} @else if (!loadingProducts()) {
|
||||
<div class="catalog-message">
|
||||
<h2>{{ 'catalog.emptyTitle' | translate }}</h2>
|
||||
<p>{{ 'catalog.emptyProducts' | translate }}</p>
|
||||
(productPreview)="previewProduct($event)" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<CatalogState>(createInitialCatalogState());
|
||||
readonly categories = signal<Category[]>([]);
|
||||
readonly rawProducts = signal<Product[]>([]);
|
||||
readonly products = signal<Product[]>([]);
|
||||
readonly subcategoryChips = signal<Category[]>([]);
|
||||
readonly breadcrumb = signal<Category[]>([]);
|
||||
readonly searchSummary = signal('');
|
||||
readonly searchResult = signal<SearchResult | null>(null);
|
||||
readonly filterDefinitions = signal<FilterDefinition[]>([]);
|
||||
readonly filterState = signal<CatalogFilterStateValue>({ values: {}, ranges: {}, toggles: {} });
|
||||
readonly searchSuggestions = signal<string[]>([]);
|
||||
readonly recentSearches = signal<string[]>([]);
|
||||
readonly searchHistory = signal<string[]>(this.catalogConfig().searchHistoryEnabled ? this.searchHistoryService.getHistory() : []);
|
||||
readonly viewMode = signal<CatalogViewMode>('categories');
|
||||
readonly loading = signal(true);
|
||||
readonly loadingProducts = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
|
||||
|
||||
readonly sortDefinitions = computed<SortDefinition[]>(() => {
|
||||
const labels: Record<string, string> = {
|
||||
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,27 +308,342 @@ 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.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: result.total,
|
||||
hasMore: result.items.length >= current.pagination.count,
|
||||
},
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
skip: 0,
|
||||
page: 1
|
||||
}
|
||||
}));
|
||||
this.loading.set(false);
|
||||
this.loadingProducts.set(false);
|
||||
},
|
||||
error: () => this.setError('catalog.error'),
|
||||
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 {
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
values: Record<string, string[]>;
|
||||
ranges: Record<string, CatalogPriceRange>;
|
||||
toggles: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface CatalogState {
|
||||
category: Category | null;
|
||||
search: string;
|
||||
sort: CatalogSort;
|
||||
layout: CatalogLayoutMode;
|
||||
priceRange: CatalogPriceRange;
|
||||
attributes: Record<string, string[]>;
|
||||
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: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
34
src/app/shared/models/config/catalog-config.model.ts
Normal file
34
src/app/shared/models/config/catalog-config.model.ts
Normal file
@@ -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<CatalogConfig> = {
|
||||
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
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user