CAtegory component making
This commit is contained in:
155
docs/Category-Domain-Report.md
Normal file
155
docs/Category-Domain-Report.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Category Domain Report
|
||||
|
||||
## Scope
|
||||
|
||||
Sprint 4 added a complete Category Domain on top of the existing backend API contract. Backend endpoints and payload names were not changed.
|
||||
|
||||
Existing backend category fields remain isolated as DTO input:
|
||||
|
||||
- `categoryID`
|
||||
- `parentID`
|
||||
- `name`
|
||||
- `icon`
|
||||
- `priority`
|
||||
- `visible`
|
||||
- `categoriesCount`
|
||||
- `itemCount`
|
||||
- `names[]`
|
||||
|
||||
The UI now consumes category domain models rather than backend-shaped category responses.
|
||||
|
||||
## Implemented Files
|
||||
|
||||
### DTO
|
||||
|
||||
- `src/app/core/categories/dto/category.dto.ts`
|
||||
|
||||
Defines `CategoryDto` and `CategoryNameDto` for existing backend category payloads. Compatibility fields for current mock/API variants are accepted only at the DTO boundary.
|
||||
|
||||
### Domain Model
|
||||
|
||||
- `src/app/core/categories/models/category-domain.model.ts`
|
||||
|
||||
Frontend category model exposes:
|
||||
|
||||
- `id`
|
||||
- `parentId`
|
||||
- `title`
|
||||
- `icon`
|
||||
- `priority`
|
||||
- `visible`
|
||||
- `itemCount`
|
||||
- `children[]`
|
||||
- `translations`
|
||||
|
||||
No backend category naming is required by category UI consumers.
|
||||
|
||||
### Mapper
|
||||
|
||||
- `src/app/core/categories/mappers/category.mapper.ts`
|
||||
|
||||
Maps backend DTOs into domain categories, including:
|
||||
|
||||
- backend id normalization
|
||||
- parent id normalization
|
||||
- title fallback selection
|
||||
- `names[]` to `translations`
|
||||
- nested DTO flattening
|
||||
- visible-category filtering
|
||||
- priority sorting
|
||||
- duplicate id de-duplication
|
||||
|
||||
### Tree Utilities
|
||||
|
||||
- `src/app/core/categories/utils/category-tree.utils.ts`
|
||||
|
||||
Supports:
|
||||
|
||||
- flat list to tree
|
||||
- unlimited nesting
|
||||
- parent lookup
|
||||
- children lookup
|
||||
- breadcrumb generation
|
||||
- leaf detection
|
||||
- tree flattening for future lazy-loading compatibility
|
||||
|
||||
### Repository Abstraction
|
||||
|
||||
- `src/app/core/categories/repositories/category.repository.ts`
|
||||
- `src/app/core/categories/repositories/api-category.repository.ts`
|
||||
- `src/app/core/categories/category-repository.token.ts`
|
||||
|
||||
`CategoryRepository` returns DTOs from the existing `GET /category` API. The injection token uses the existing runtime provider strategy and remains compatible with both mock and API modes. Mock mode continues to work through the existing mock-data interceptor.
|
||||
|
||||
### Category Service
|
||||
|
||||
- `src/app/core/categories/category.service.ts`
|
||||
|
||||
Converts repository DTOs through the mapper and exposes domain methods:
|
||||
|
||||
- all categories
|
||||
- category tree
|
||||
- root categories
|
||||
- category by id
|
||||
- children
|
||||
- parent
|
||||
- breadcrumb
|
||||
- leaf detection
|
||||
|
||||
### Category Facade
|
||||
|
||||
- `src/app/facades/platform/category.facade.ts`
|
||||
|
||||
Exposes observable streams and state for:
|
||||
|
||||
- all categories
|
||||
- category tree
|
||||
- root categories
|
||||
- category by id
|
||||
- selected category
|
||||
- breadcrumb
|
||||
- children
|
||||
|
||||
### Product Compatibility
|
||||
|
||||
- `src/app/core/products/models/product-domain.model.ts`
|
||||
- `src/app/core/products/providers/api-product-data.provider.ts`
|
||||
|
||||
`ProductFacade.getCategories()` now resolves through `CategoryService`, so compatibility category access also returns the new category domain model.
|
||||
|
||||
## UI Migration
|
||||
|
||||
Updated category-facing UI consumers:
|
||||
|
||||
- `src/app/pages/home/home.component.ts`
|
||||
- `src/app/pages/home/home.component.html`
|
||||
- `src/app/pages/category/subcategories.component.ts`
|
||||
- `src/app/pages/category/subcategories.component.html`
|
||||
- `src/app/pages/category/category.component.ts`
|
||||
|
||||
The home page and subcategory page now consume `CategoryFacade` and `Category` domain models. Category route item loading still uses the existing product facade for product lists, without changing product/payment/auth contracts.
|
||||
|
||||
## Validation
|
||||
|
||||
Completed checks:
|
||||
|
||||
- DTOs are isolated under `core/categories/dto`.
|
||||
- Category mapper exists and is the only category DTO-to-domain conversion point.
|
||||
- Category UI uses `CategoryFacade` and category domain models.
|
||||
- Category backend field names are contained to the category DTO/mapper boundary and compatibility internals.
|
||||
- Components do not use `HttpClient` for category data.
|
||||
- No authentication changes were made.
|
||||
- No payment changes were made.
|
||||
- No bootstrap contract changes were made.
|
||||
- No backend API contract changes were made.
|
||||
- Mock/API compatibility is preserved through the repository token and existing mock interceptor.
|
||||
|
||||
Build validation passed:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Stop Point
|
||||
|
||||
Category Domain implementation is complete for Sprint 4. Stop here for approval before starting the next domain or any Builder/Backoffice work.
|
||||
20
src/app/core/categories/category-repository.token.ts
Normal file
20
src/app/core/categories/category-repository.token.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
|
||||
import { ApiCategoryRepository } from './repositories/api-category.repository';
|
||||
import { CategoryRepository } from './repositories/category.repository';
|
||||
|
||||
export const CATEGORY_REPOSITORY = new InjectionToken<CategoryRepository>('CATEGORY_REPOSITORY', {
|
||||
providedIn: 'root',
|
||||
factory: () => {
|
||||
const strategy = inject(RuntimeProviderStrategyService);
|
||||
const apiRepository = inject(ApiCategoryRepository);
|
||||
|
||||
switch (strategy.getCategoryProviderMode()) {
|
||||
case 'mock':
|
||||
case 'remote-config':
|
||||
case 'api':
|
||||
default:
|
||||
return apiRepository;
|
||||
}
|
||||
}
|
||||
});
|
||||
48
src/app/core/categories/category.service.ts
Normal file
48
src/app/core/categories/category.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, map, shareReplay } from 'rxjs';
|
||||
import { CATEGORY_REPOSITORY } from './category-repository.token';
|
||||
import { CategoryMapper } from './mappers/category.mapper';
|
||||
import { Category } from './models/category-domain.model';
|
||||
import { CategoryTreeUtils } from './utils/category-tree.utils';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CategoryService {
|
||||
private readonly repository = inject(CATEGORY_REPOSITORY);
|
||||
|
||||
private readonly categories$ = this.repository.getCategories().pipe(
|
||||
map(dtos => CategoryMapper.toDomainList(dtos)),
|
||||
shareReplay({ bufferSize: 1, refCount: true })
|
||||
);
|
||||
|
||||
getAllCategories(): Observable<Category[]> {
|
||||
return this.categories$;
|
||||
}
|
||||
|
||||
getCategoryTree(): Observable<Category[]> {
|
||||
return this.categories$.pipe(map(categories => CategoryTreeUtils.toTree(categories)));
|
||||
}
|
||||
|
||||
getRootCategories(): Observable<Category[]> {
|
||||
return this.getCategoryTree();
|
||||
}
|
||||
|
||||
getCategoryById(categoryId: number): Observable<Category | undefined> {
|
||||
return this.getCategoryTree().pipe(map(tree => CategoryTreeUtils.findById(tree, categoryId)));
|
||||
}
|
||||
|
||||
getChildren(categoryId: number): Observable<Category[]> {
|
||||
return this.categories$.pipe(map(categories => CategoryTreeUtils.getChildren(categories, categoryId)));
|
||||
}
|
||||
|
||||
getBreadcrumb(categoryId: number): Observable<Category[]> {
|
||||
return this.categories$.pipe(map(categories => CategoryTreeUtils.getBreadcrumb(categories, categoryId)));
|
||||
}
|
||||
|
||||
getParent(categoryId: number): Observable<Category | undefined> {
|
||||
return this.categories$.pipe(map(categories => CategoryTreeUtils.getParent(categories, categoryId)));
|
||||
}
|
||||
|
||||
isLeaf(category: Category): boolean {
|
||||
return CategoryTreeUtils.isLeaf(category);
|
||||
}
|
||||
}
|
||||
23
src/app/core/categories/dto/category.dto.ts
Normal file
23
src/app/core/categories/dto/category.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface CategoryNameDto {
|
||||
language?: string;
|
||||
value?: string;
|
||||
valuue?: string;
|
||||
}
|
||||
|
||||
export interface CategoryDto {
|
||||
categoryID?: number;
|
||||
parentID?: number;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
priority?: number;
|
||||
visible?: boolean;
|
||||
categoriesCount?: number;
|
||||
itemCount?: number;
|
||||
names?: CategoryNameDto[];
|
||||
|
||||
id?: string | number;
|
||||
categoryId?: string | number;
|
||||
parentId?: string | number;
|
||||
img?: string;
|
||||
subcategories?: CategoryDto[];
|
||||
}
|
||||
94
src/app/core/categories/mappers/category.mapper.ts
Normal file
94
src/app/core/categories/mappers/category.mapper.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { CategoryDto, CategoryNameDto } from '../dto/category.dto';
|
||||
import { Category, CategoryTranslation } from '../models/category-domain.model';
|
||||
|
||||
export class CategoryMapper {
|
||||
static toDomainList(dtos: CategoryDto[]): Category[] {
|
||||
const byId = new Map<number, Category>();
|
||||
|
||||
for (const dto of dtos) {
|
||||
for (const category of this.flattenDto(dto)) {
|
||||
byId.set(category.id, category);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byId.values())
|
||||
.filter(category => category.visible)
|
||||
.sort((a, b) => a.priority - b.priority || a.id - b.id);
|
||||
}
|
||||
|
||||
static toDomain(dto: CategoryDto, fallbackParentId: number | null = null): Category | null {
|
||||
const id = this.toNumber(dto.categoryID ?? dto.id ?? dto.categoryId);
|
||||
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentId = this.toNumber(dto.parentID ?? dto.parentId) ?? fallbackParentId;
|
||||
const translations = this.toTranslations(dto.names ?? []);
|
||||
const fallbackTitle = this.firstTranslatedTitle(translations) || dto.name || '';
|
||||
|
||||
return {
|
||||
id,
|
||||
parentId: parentId && parentId !== 0 ? parentId : null,
|
||||
title: fallbackTitle,
|
||||
icon: dto.icon ?? dto.img,
|
||||
priority: dto.priority ?? 0,
|
||||
visible: dto.visible ?? true,
|
||||
itemCount: dto.itemCount ?? 0,
|
||||
children: [],
|
||||
translations,
|
||||
};
|
||||
}
|
||||
|
||||
private static flattenDto(dto: CategoryDto, fallbackParentId: number | null = null): Category[] {
|
||||
const category = this.toDomain(dto, fallbackParentId);
|
||||
const currentParentId = category?.id ?? fallbackParentId;
|
||||
const nested = (dto.subcategories ?? []).flatMap(child => this.flattenDto(child, currentParentId));
|
||||
|
||||
return category ? [category, ...nested] : nested;
|
||||
}
|
||||
|
||||
private static toTranslations(names: CategoryNameDto[]): Record<string, CategoryTranslation> {
|
||||
return names.reduce<Record<string, CategoryTranslation>>((acc, entry) => {
|
||||
const language = this.normalizeLanguage(entry.language);
|
||||
const title = entry.value ?? entry.valuue ?? '';
|
||||
|
||||
if (language && title) {
|
||||
acc[language] = { title };
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private static normalizeLanguage(language?: string): string {
|
||||
const normalized = language?.toLowerCase();
|
||||
|
||||
if (normalized === 'am') {
|
||||
return 'hy';
|
||||
}
|
||||
|
||||
return normalized ?? '';
|
||||
}
|
||||
|
||||
private static firstTranslatedTitle(translations: Record<string, CategoryTranslation>): string {
|
||||
return translations['ru']?.title
|
||||
?? translations['en']?.title
|
||||
?? translations['hy']?.title
|
||||
?? Object.values(translations)[0]?.title
|
||||
?? '';
|
||||
}
|
||||
|
||||
private static toNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
15
src/app/core/categories/models/category-domain.model.ts
Normal file
15
src/app/core/categories/models/category-domain.model.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface CategoryTranslation {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
parentId: number | null;
|
||||
title: string;
|
||||
icon?: string;
|
||||
priority: number;
|
||||
visible: boolean;
|
||||
itemCount: number;
|
||||
children: Category[];
|
||||
translations: Record<string, CategoryTranslation>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, timer } from 'rxjs';
|
||||
import { retry } from 'rxjs/operators';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { CategoryDto } from '../dto/category.dto';
|
||||
import { CategoryRepository } from './category.repository';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiCategoryRepository implements CategoryRepository {
|
||||
private readonly retryConfig = {
|
||||
count: 2,
|
||||
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
|
||||
};
|
||||
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
getCategories(): Observable<CategoryDto[]> {
|
||||
return this.http.get<CategoryDto[]>(`${environment.apiUrl}/category`)
|
||||
.pipe(retry(this.retryConfig));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { CategoryDto } from '../dto/category.dto';
|
||||
|
||||
export interface CategoryRepository {
|
||||
getCategories(): Observable<CategoryDto[]>;
|
||||
}
|
||||
76
src/app/core/categories/utils/category-tree.utils.ts
Normal file
76
src/app/core/categories/utils/category-tree.utils.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Category } from '../models/category-domain.model';
|
||||
|
||||
export class CategoryTreeUtils {
|
||||
static toTree(categories: Category[]): Category[] {
|
||||
const byId = new Map<number, Category>();
|
||||
|
||||
for (const category of categories) {
|
||||
byId.set(category.id, { ...category, children: [] });
|
||||
}
|
||||
|
||||
const roots: Category[] = [];
|
||||
|
||||
for (const category of byId.values()) {
|
||||
if (category.parentId == null) {
|
||||
roots.push(category);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = byId.get(category.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(category);
|
||||
} else {
|
||||
roots.push(category);
|
||||
}
|
||||
}
|
||||
|
||||
this.sortTree(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
static flattenTree(categories: Category[]): Category[] {
|
||||
return categories.flatMap(category => [category, ...this.flattenTree(category.children)]);
|
||||
}
|
||||
|
||||
static findById(categories: Category[], categoryId: number): Category | undefined {
|
||||
return this.flattenTree(categories).find(category => category.id === categoryId);
|
||||
}
|
||||
|
||||
static getRootCategories(categories: Category[]): Category[] {
|
||||
return this.toTree(categories);
|
||||
}
|
||||
|
||||
static getChildren(categories: Category[], parentId: number): Category[] {
|
||||
const tree = this.toTree(categories);
|
||||
return this.findById(tree, parentId)?.children ?? [];
|
||||
}
|
||||
|
||||
static getParent(categories: Category[], categoryId: number): Category | undefined {
|
||||
const category = categories.find(item => item.id === categoryId);
|
||||
return category?.parentId == null
|
||||
? undefined
|
||||
: categories.find(item => item.id === category.parentId);
|
||||
}
|
||||
|
||||
static getBreadcrumb(categories: Category[], categoryId: number): Category[] {
|
||||
const byId = new Map(categories.map(category => [category.id, category]));
|
||||
const breadcrumb: Category[] = [];
|
||||
let current = byId.get(categoryId);
|
||||
|
||||
while (current) {
|
||||
breadcrumb.unshift(current);
|
||||
current = current.parentId == null ? undefined : byId.get(current.parentId);
|
||||
}
|
||||
|
||||
return breadcrumb;
|
||||
}
|
||||
|
||||
static isLeaf(category: Category): boolean {
|
||||
return category.children.length === 0;
|
||||
}
|
||||
|
||||
private static sortTree(categories: Category[]): void {
|
||||
categories.sort((a, b) => a.priority - b.priority || a.id - b.id);
|
||||
categories.forEach(category => this.sortTree(category.children));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Category, Item } from '../../../models';
|
||||
import { Item } from '../../../models';
|
||||
import { Category } from '../../categories/models/category-domain.model';
|
||||
|
||||
export type Product = Item;
|
||||
export type ProductCategory = Category;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { ApiService } from '../../../services';
|
||||
import { CategoryService } from '../../categories/category.service';
|
||||
import { ProductDataProvider } from './product-data-provider.interface';
|
||||
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiProductDataProvider implements ProductDataProvider {
|
||||
constructor(private readonly apiService: ApiService) {}
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly categoryService: CategoryService
|
||||
) {}
|
||||
|
||||
getProducts(query: ProductListQuery = {}): Observable<ProductListResult> {
|
||||
return this.apiService.searchItems('', query.count, query.skip, this.toSearchOptions(query))
|
||||
@@ -18,7 +22,7 @@ export class ApiProductDataProvider implements ProductDataProvider {
|
||||
}
|
||||
|
||||
getCategories(): Observable<ProductCategory[]> {
|
||||
return this.apiService.getCategories();
|
||||
return this.categoryService.getAllCategories();
|
||||
}
|
||||
|
||||
searchProducts(query: ProductSearchQuery): Observable<ProductListResult> {
|
||||
|
||||
@@ -28,4 +28,12 @@ export class RuntimeProviderStrategyService {
|
||||
|
||||
return 'api';
|
||||
}
|
||||
|
||||
getCategoryProviderMode(): RuntimeProviderMode {
|
||||
if (environment.useMockData) {
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
return 'api';
|
||||
}
|
||||
}
|
||||
|
||||
90
src/app/facades/platform/category.facade.ts
Normal file
90
src/app/facades/platform/category.facade.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, map, of, shareReplay, switchMap } from 'rxjs';
|
||||
import { CategoryService } from '../../core/categories/category.service';
|
||||
import { Category } from '../../core/categories/models/category-domain.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CategoryFacade {
|
||||
private readonly categoryService = inject(CategoryService);
|
||||
private readonly selectedCategoryId = new BehaviorSubject<number | null>(null);
|
||||
|
||||
readonly allCategories = signal<Category[]>([]);
|
||||
readonly categoryTree = signal<Category[]>([]);
|
||||
readonly rootCategories = signal<Category[]>([]);
|
||||
readonly selectedCategory = signal<Category | null>(null);
|
||||
readonly breadcrumb = signal<Category[]>([]);
|
||||
readonly children = signal<Category[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
readonly allCategories$ = this.categoryService.getAllCategories()
|
||||
.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
readonly categoryTree$ = this.categoryService.getCategoryTree()
|
||||
.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
readonly rootCategories$ = this.categoryService.getRootCategories()
|
||||
.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
readonly selectedCategory$ = this.selectedCategoryId.pipe(
|
||||
switchMap(categoryId => categoryId == null ? of(null) : this.categoryService.getCategoryById(categoryId)),
|
||||
map(category => category ?? null),
|
||||
shareReplay({ bufferSize: 1, refCount: true })
|
||||
);
|
||||
readonly breadcrumb$ = this.selectedCategoryId.pipe(
|
||||
switchMap(categoryId => categoryId == null ? of([]) : this.categoryService.getBreadcrumb(categoryId)),
|
||||
shareReplay({ bufferSize: 1, refCount: true })
|
||||
);
|
||||
readonly children$ = this.selectedCategoryId.pipe(
|
||||
switchMap(categoryId => categoryId == null ? of([]) : this.categoryService.getChildren(categoryId)),
|
||||
shareReplay({ bufferSize: 1, refCount: true })
|
||||
);
|
||||
|
||||
loadCategories(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.allCategories$.subscribe({
|
||||
next: (categories) => {
|
||||
this.allCategories.set(categories);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.allCategories.set([]);
|
||||
this.error.set('Failed to load categories');
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectCategory(categoryId: number | null): void {
|
||||
this.selectedCategoryId.next(categoryId);
|
||||
}
|
||||
|
||||
syncSelectedCategoryState(): void {
|
||||
this.selectedCategory$.subscribe(category => this.selectedCategory.set(category));
|
||||
this.breadcrumb$.subscribe(breadcrumb => this.breadcrumb.set(breadcrumb));
|
||||
this.children$.subscribe(children => this.children.set(children));
|
||||
}
|
||||
|
||||
getAllCategories(): Observable<Category[]> {
|
||||
return this.allCategories$;
|
||||
}
|
||||
|
||||
getCategoryTree(): Observable<Category[]> {
|
||||
return this.categoryTree$;
|
||||
}
|
||||
|
||||
getRootCategories(): Observable<Category[]> {
|
||||
return this.rootCategories$;
|
||||
}
|
||||
|
||||
getCategoryById(categoryId: number): Observable<Category | undefined> {
|
||||
return this.categoryService.getCategoryById(categoryId);
|
||||
}
|
||||
|
||||
getBreadcrumb(categoryId: number): Observable<Category[]> {
|
||||
return this.categoryService.getBreadcrumb(categoryId);
|
||||
}
|
||||
|
||||
getChildren(categoryId: number): Observable<Category[]> {
|
||||
return this.categoryService.getChildren(categoryId);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { ProductCardComponent } from '../../components/product-card/product-card
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CategoryComponent implements OnInit, OnDestroy {
|
||||
categoryID = signal<number>(0);
|
||||
categoryId = signal<number>(0);
|
||||
items = signal<Item[]>([]);
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
@@ -42,7 +42,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
this.routeSubscription = this.route.params.subscribe(params => {
|
||||
const id = parseInt(params['id'], 10);
|
||||
this.categoryID.set(id);
|
||||
this.categoryId.set(id);
|
||||
this.resetAndLoad();
|
||||
});
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
this.loading.set(true);
|
||||
this.isLoadingMore = true;
|
||||
|
||||
this.productFacade.getProductsByCategory(this.categoryID(), { count: this.count, skip: this.skip }).subscribe({
|
||||
this.productFacade.getProductsByCategory(this.categoryId(), { count: this.count, skip: this.skip }).subscribe({
|
||||
next: (result) => {
|
||||
const newItems = result.items;
|
||||
// Handle null or empty response
|
||||
|
||||
@@ -18,42 +18,10 @@
|
||||
<h2>{{ parentName() }}</h2>
|
||||
</header>
|
||||
|
||||
<!-- Nested subcategories from API (backOffice format with hasItems) -->
|
||||
@if (nestedSubcategories().length > 0) {
|
||||
<div class="categories-grid">
|
||||
@for (sub of nestedSubcategories(); track trackBySubId($index, sub)) {
|
||||
<a [routerLink]="['/category', sub.id] | langRoute" class="category-card">
|
||||
<div class="category-image">
|
||||
@if (sub.img) {
|
||||
<img [src]="sub.img" [alt]="sub.name" loading="lazy" decoding="async" />
|
||||
} @else {
|
||||
<div class="category-fallback">{{ sub.name.charAt(0) }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="category-info">
|
||||
<h3 class="category-name">{{ sub.name }}</h3>
|
||||
<div class="category-meta">
|
||||
@if (subcategoryChildCount(sub) > 0) {
|
||||
<span>{{ 'subcategories.childrenCount' | translate:{ count: subcategoryChildCount(sub) } }}</span>
|
||||
}
|
||||
@if (subcategoryItemCount(sub) > 0) {
|
||||
<span>{{ 'subcategories.productsCount' | translate:{ count: subcategoryItemCount(sub) } }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (sub.hasItems && subcategoryChildCount(sub) > 0) {
|
||||
<span class="category-mixed">{{ 'subcategories.includesProducts' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Legacy flat subcategories -->
|
||||
@if (subcategories().length > 0) {
|
||||
<div class="categories-grid">
|
||||
@for (cat of subcategories(); track trackByCategoryId($index, cat)) {
|
||||
<a [routerLink]="['/category', cat.categoryID] | langRoute" class="category-card">
|
||||
<a [routerLink]="['/category', cat.id] | langRoute" class="category-card">
|
||||
<div class="category-image">
|
||||
@if (cat.icon) {
|
||||
<img [src]="cat.icon" [alt]="categoryName(cat)" loading="lazy" decoding="async" />
|
||||
@@ -64,13 +32,16 @@
|
||||
<div class="category-info">
|
||||
<h3 class="category-name">{{ categoryName(cat) }}</h3>
|
||||
<div class="category-meta">
|
||||
@if ((cat.categoriesCount ?? 0) > 0) {
|
||||
<span>{{ 'subcategories.childrenCount' | translate:{ count: cat.categoriesCount ?? 0 } }}</span>
|
||||
@if (subcategoryChildCount(cat) > 0) {
|
||||
<span>{{ 'subcategories.childrenCount' | translate:{ count: subcategoryChildCount(cat) } }}</span>
|
||||
}
|
||||
@if ((cat.itemCount ?? 0) > 0) {
|
||||
<span>{{ 'subcategories.productsCount' | translate:{ count: cat.itemCount ?? 0 } }}</span>
|
||||
@if (subcategoryItemCount(cat) > 0) {
|
||||
<span>{{ 'subcategories.productsCount' | translate:{ count: subcategoryItemCount(cat) } }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (subcategoryItemCount(cat) > 0 && subcategoryChildCount(cat) > 0) {
|
||||
<span class="category-mixed">{{ 'subcategories.includesProducts' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject }
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { CartService, LanguageService } from '../../services';
|
||||
import { Category, Item, Subcategory } from '../../models';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Item } from '../../models';
|
||||
import { combineLatest, Subscription } from 'rxjs';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField, getTranslatedCategoryName } from '../../utils/item.utils';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||
import { ProductFacade } from '../../facades/platform/product.facade';
|
||||
|
||||
type CategoryNode = Category | Subcategory;
|
||||
import { CategoryFacade } from '../../facades/platform/category.facade';
|
||||
import { Category } from '../../core/categories/models/category-domain.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-subcategories',
|
||||
@@ -20,10 +20,7 @@ type CategoryNode = Category | Subcategory;
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
categories = signal<Category[]>([]);
|
||||
subcategories = signal<Category[]>([]);
|
||||
/** Nested subcategories from API with hasItems support */
|
||||
nestedSubcategories = signal<Subcategory[]>([]);
|
||||
/** Items belonging directly to this category (when hasItems is true) */
|
||||
categoryItems = signal<Item[]>([]);
|
||||
loading = signal(true);
|
||||
@@ -37,6 +34,7 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private categoryFacade: CategoryFacade,
|
||||
private productFacade: ProductFacade,
|
||||
private langService: LanguageService,
|
||||
private cartService: CartService
|
||||
@@ -53,44 +51,29 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
this.routeSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
private loadForParent(parentID: number): void {
|
||||
private loadForParent(parentId: number): void {
|
||||
this.loading.set(true);
|
||||
this.categoryItems.set([]);
|
||||
this.nestedSubcategories.set([]);
|
||||
this.subcategories.set([]);
|
||||
this.error.set(null);
|
||||
this.categoryFacade.selectCategory(parentId);
|
||||
|
||||
this.productFacade.getCategories().subscribe({
|
||||
next: (cats) => {
|
||||
this.categories.set(cats);
|
||||
const parent = this.findCategoryNode(cats, parentID);
|
||||
this.parentName.set(parent ? this.nodeName(parent) : this.i18n.t('home.categoriesTitle'));
|
||||
|
||||
// Check for nested subcategories from API response (backOffice format)
|
||||
const nested = parent?.subcategories || [];
|
||||
const visibleNested = nested
|
||||
.filter(s => this.isDisplayableNestedSubcategory(s))
|
||||
combineLatest([
|
||||
this.categoryFacade.getCategoryById(parentId),
|
||||
this.categoryFacade.getChildren(parentId),
|
||||
]).subscribe({
|
||||
next: ([parent, children]) => {
|
||||
this.parentName.set(parent ? this.categoryName(parent) : this.i18n.t('home.categoriesTitle'));
|
||||
const visibleChildren = children
|
||||
.filter(category => this.isDisplayableCategory(category))
|
||||
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
||||
|
||||
// Also check flat legacy subcategories
|
||||
const flatSubs = cats.filter(c => c.parentID === parentID && this.isDisplayableFlatSubcategory(c));
|
||||
|
||||
if (visibleNested.length > 0) {
|
||||
// Use nested subcategories from API
|
||||
this.nestedSubcategories.set(visibleNested);
|
||||
this.subcategories.set([]);
|
||||
|
||||
// If this category itself has items, load them too
|
||||
this.loadCategoryItems(parentID);
|
||||
} else if (flatSubs.length > 0) {
|
||||
// Legacy flat subcategories
|
||||
this.subcategories.set(flatSubs);
|
||||
this.nestedSubcategories.set([]);
|
||||
|
||||
// Also load items for this category in case it has direct items
|
||||
this.loadCategoryItems(parentID);
|
||||
if (visibleChildren.length > 0) {
|
||||
this.subcategories.set(visibleChildren);
|
||||
this.loadCategoryItems(parentId);
|
||||
} else {
|
||||
// No subcategories: redirect to items list for this category
|
||||
const lang = this.langService.currentLanguage();
|
||||
this.router.navigate([`/${lang}/category`, parentID, 'items'], { replaceUrl: true });
|
||||
this.router.navigate([`/${lang}/category`, parentId, 'items'], { replaceUrl: true });
|
||||
}
|
||||
|
||||
this.loading.set(false);
|
||||
@@ -104,8 +87,8 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/** Load items that belong directly to this category */
|
||||
private loadCategoryItems(categoryID: number): void {
|
||||
this.productFacade.getProductsByCategory(categoryID, { count: 50, skip: 0 }).subscribe({
|
||||
private loadCategoryItems(categoryId: number): void {
|
||||
this.productFacade.getProductsByCategory(categoryId, { count: 50, skip: 0 }).subscribe({
|
||||
next: (result) => {
|
||||
this.categoryItems.set(result.items);
|
||||
},
|
||||
@@ -115,58 +98,13 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private isDisplayableFlatSubcategory(category: Category): boolean {
|
||||
private isDisplayableCategory(category: Category): boolean {
|
||||
return category.visible !== false
|
||||
&& ((category.itemCount ?? 0) > 0 || (category.subcategories?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
private isDisplayableNestedSubcategory(subcategory: Subcategory): boolean {
|
||||
return subcategory.visible !== false
|
||||
&& (
|
||||
(subcategory.itemCount ?? 0) > 0
|
||||
|| subcategory.hasItems === true
|
||||
|| (subcategory.subcategories?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
private findCategoryNode(categories: Category[], categoryID: number): CategoryNode | undefined {
|
||||
for (const category of categories) {
|
||||
if (category.categoryID === categoryID || Number(category.id) === categoryID) {
|
||||
return category;
|
||||
}
|
||||
|
||||
const child = this.findSubcategoryNode(category.subcategories ?? [], categoryID);
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private findSubcategoryNode(subcategories: Subcategory[], categoryID: number): Subcategory | undefined {
|
||||
for (const subcategory of subcategories) {
|
||||
if (Number(subcategory.id) === categoryID || Number(subcategory.categoryId) === categoryID) {
|
||||
return subcategory;
|
||||
}
|
||||
|
||||
const child = this.findSubcategoryNode(subcategory.subcategories ?? [], categoryID);
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private nodeName(node: CategoryNode): string {
|
||||
return 'categoryID' in node
|
||||
? getTranslatedCategoryName(node, this.langService.currentLanguage())
|
||||
: node.name;
|
||||
&& ((category.itemCount ?? 0) > 0 || category.children.length > 0);
|
||||
}
|
||||
|
||||
hasSubcategories(): boolean {
|
||||
return this.subcategories().length > 0 || this.nestedSubcategories().length > 0;
|
||||
return this.subcategories().length > 0;
|
||||
}
|
||||
|
||||
addToCart(itemID: number, event: Event): void {
|
||||
@@ -177,18 +115,14 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
|
||||
// TrackBy function for performance optimization
|
||||
trackByCategoryId(_index: number, category: Category): number {
|
||||
return category.categoryID;
|
||||
return category.id;
|
||||
}
|
||||
|
||||
trackBySubId(_index: number, sub: Subcategory): string {
|
||||
return sub.id;
|
||||
subcategoryChildCount(subcategory: Category): number {
|
||||
return subcategory.children.length;
|
||||
}
|
||||
|
||||
subcategoryChildCount(subcategory: Subcategory): number {
|
||||
return subcategory.subcategories?.length ?? 0;
|
||||
}
|
||||
|
||||
subcategoryItemCount(subcategory: Subcategory): number {
|
||||
subcategoryItemCount(subcategory: Category): number {
|
||||
return subcategory.itemCount ?? 0;
|
||||
}
|
||||
|
||||
@@ -199,5 +133,5 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
|
||||
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
||||
|
||||
categoryName(cat: Category): string { return getTranslatedCategoryName(cat, this.langService.currentLanguage()); }
|
||||
categoryName(cat: Category): string { return cat.translations[this.langService.currentLanguage()]?.title ?? cat.title; }
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="novo-categories-grid">
|
||||
@for (category of topLevelCategories(); track category.categoryID) {
|
||||
<a [routerLink]="['/category', category.categoryID] | langRoute" class="novo-category-card">
|
||||
@for (category of topLevelCategories(); track category.id) {
|
||||
<a [routerLink]="['/category', category.id] | langRoute" class="novo-category-card">
|
||||
<div class="novo-category-image">
|
||||
@if (category.icon) {
|
||||
<img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" />
|
||||
@@ -75,8 +75,8 @@
|
||||
</div>
|
||||
<div class="novo-category-info">
|
||||
<h3>{{ categoryName(category) }}</h3>
|
||||
@if (getItemCount(category.categoryID)) {
|
||||
<p class="novo-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.categoryID) } }}</p>
|
||||
@if (getItemCount(category.id)) {
|
||||
<p class="novo-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.id) } }}</p>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
@@ -150,14 +150,11 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="dexar-categories-grid">
|
||||
@for (category of topLevelCategories(); track category.categoryID) {
|
||||
<a [routerLink]="['/category', category.categoryID] | langRoute"
|
||||
class="dexar-category-card"
|
||||
[class.dexar-category-card--wide]="isWideCategory(category.categoryID)">
|
||||
@for (category of topLevelCategories(); track category.id) {
|
||||
<a [routerLink]="['/category', category.id] | langRoute"
|
||||
class="dexar-category-card">
|
||||
<div class="dexar-category-image">
|
||||
@if (isWideCategory(category.categoryID) && category.wideBanner) {
|
||||
<img [src]="category.wideBanner" [alt]="categoryName(category)" loading="lazy" decoding="async" />
|
||||
} @else if (category.icon) {
|
||||
@if (category.icon) {
|
||||
<img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" decoding="async" />
|
||||
} @else {
|
||||
<div class="dexar-category-fallback">{{ categoryName(category).charAt(0) }}</div>
|
||||
@@ -165,7 +162,7 @@
|
||||
</div>
|
||||
<div class="dexar-category-info">
|
||||
<h3 class="dexar-category-name">{{ categoryName(category) }}</h3>
|
||||
<p class="dexar-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.categoryID) } }}</p>
|
||||
<p class="dexar-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.id) } }}</p>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Component, OnInit, OnDestroy, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Component, OnInit, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { LanguageService } from '../../services';
|
||||
import { Category } from '../../models';
|
||||
import { getTranslatedCategoryName } from '../../utils/item.utils';
|
||||
import { ItemsCarouselComponent } from '../../components/items-carousel/items-carousel.component';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
||||
import { ProductFacade } from '../../facades/platform/product.facade';
|
||||
import { CategoryFacade } from '../../facades/platform/category.facade';
|
||||
import { Category } from '../../core/categories/models/category-domain.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
@@ -16,16 +15,15 @@ import { ProductFacade } from '../../facades/platform/product.facade';
|
||||
styleUrls: ['./home.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class HomeComponent implements OnInit, OnDestroy {
|
||||
export class HomeComponent implements OnInit {
|
||||
constructor(
|
||||
private router: Router,
|
||||
private langService: LanguageService,
|
||||
private readonly uiRuntime: UiRuntimeFacade,
|
||||
private readonly productFacade: ProductFacade
|
||||
private readonly categoryFacade: CategoryFacade
|
||||
) {}
|
||||
|
||||
categories = signal<Category[]>([]);
|
||||
wideCategories = signal<Set<number>>(new Set());
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
readonly skeletonSlots = Array.from({ length: 6 });
|
||||
@@ -33,7 +31,6 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
// Memoized computed values for performance
|
||||
topLevelCategories = computed(() => {
|
||||
return this.categories()
|
||||
.filter(cat => cat.parentID === 0)
|
||||
.filter(cat => this.isDisplayableTopLevelCategory(cat))
|
||||
.sort((a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity));
|
||||
});
|
||||
@@ -41,7 +38,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
// Memoized item count lookup
|
||||
private itemCountMap = computed(() => {
|
||||
const map = new Map<number, number>();
|
||||
this.categories().forEach(cat => map.set(cat.categoryID, cat.itemCount || 0));
|
||||
this.categories().forEach(cat => map.set(cat.id, cat.itemCount || 0));
|
||||
return map;
|
||||
});
|
||||
|
||||
@@ -49,11 +46,9 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
private subcategoriesCache = computed(() => {
|
||||
const cache = new Map<number, Category[]>();
|
||||
this.categories().forEach(cat => {
|
||||
if (cat.parentID !== 0 && this.isDisplayableFlatSubcategory(cat)) {
|
||||
if (!cache.has(cat.parentID)) {
|
||||
cache.set(cat.parentID, []);
|
||||
}
|
||||
cache.get(cat.parentID)!.push(cat);
|
||||
const children = cat.children.filter(child => this.isDisplayableFlatSubcategory(child));
|
||||
if (children.length > 0) {
|
||||
cache.set(cat.id, children);
|
||||
}
|
||||
});
|
||||
return cache;
|
||||
@@ -71,21 +66,13 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.loadCategories();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pendingImages.forEach(img => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
});
|
||||
this.pendingImages.clear();
|
||||
}
|
||||
|
||||
loadCategories(): void {
|
||||
this.loading.set(true);
|
||||
this.productFacade.getCategories().subscribe({
|
||||
this.error.set(null);
|
||||
this.categoryFacade.getRootCategories().subscribe({
|
||||
next: (categories) => {
|
||||
this.categories.set(categories);
|
||||
this.loading.set(false);
|
||||
this.detectWideImages(categories);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load categories');
|
||||
@@ -95,65 +82,35 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
getItemCount(categoryID: number): number {
|
||||
return this.itemCountMap().get(categoryID) || 0;
|
||||
getItemCount(categoryId: number): number {
|
||||
return this.itemCountMap().get(categoryId) || 0;
|
||||
}
|
||||
|
||||
getSubCategories(parentID: number): Category[] {
|
||||
return this.subcategoriesCache().get(parentID) || [];
|
||||
getSubCategories(parentId: number): Category[] {
|
||||
return this.subcategoriesCache().get(parentId) || [];
|
||||
}
|
||||
|
||||
private isDisplayableFlatSubcategory(category: Category): boolean {
|
||||
return category.visible !== false
|
||||
&& ((category.itemCount ?? 0) > 0 || (category.subcategories?.length ?? 0) > 0);
|
||||
&& ((category.itemCount ?? 0) > 0 || category.children.length > 0);
|
||||
}
|
||||
|
||||
private isDisplayableTopLevelCategory(category: Category): boolean {
|
||||
return category.visible !== false
|
||||
&& (
|
||||
(category.itemCount ?? 0) > 0
|
||||
|| (category.categoriesCount ?? 0) > 0
|
||||
|| (category.subcategories?.length ?? 0) > 0
|
||||
|| this.getSubCategories(category.categoryID).length > 0
|
||||
|| category.children.length > 0
|
||||
|| this.getSubCategories(category.id).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
isWideCategory(categoryID: number): boolean {
|
||||
return this.wideCategories().has(categoryID);
|
||||
}
|
||||
|
||||
private pendingImages = new Set<HTMLImageElement>();
|
||||
|
||||
private detectWideImages(categories: Category[]): void {
|
||||
const topLevel = categories.filter(c => c.parentID === 0);
|
||||
topLevel.forEach(cat => {
|
||||
if (!cat.wideBanner) return;
|
||||
|
||||
const img = new Image();
|
||||
this.pendingImages.add(img);
|
||||
img.onload = () => {
|
||||
this.pendingImages.delete(img);
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
if (ratio > 2) {
|
||||
this.wideCategories.update(set => {
|
||||
const next = new Set(set);
|
||||
next.add(cat.categoryID);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
img.onerror = () => this.pendingImages.delete(img);
|
||||
img.src = cat.wideBanner;
|
||||
});
|
||||
}
|
||||
|
||||
navigateToSearch(): void {
|
||||
const lang = this.langService.currentLanguage();
|
||||
this.router.navigate([`/${lang}/search`]);
|
||||
}
|
||||
|
||||
categoryName(cat: Category): string {
|
||||
return getTranslatedCategoryName(cat, this.langService.currentLanguage());
|
||||
return cat.translations[this.langService.currentLanguage()]?.title ?? cat.title;
|
||||
}
|
||||
|
||||
scrollToCatalog(): void {
|
||||
|
||||
Reference in New Issue
Block a user