From d2f0f0de54fb3ff7c2fd673e27ba0173555ef376 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sun, 5 Jul 2026 01:24:54 +0400 Subject: [PATCH] CAtegory component making --- docs/Category-Domain-Report.md | 155 ++++++++++++++++++ .../categories/category-repository.token.ts | 20 +++ src/app/core/categories/category.service.ts | 48 ++++++ src/app/core/categories/dto/category.dto.ts | 23 +++ .../categories/mappers/category.mapper.ts | 94 +++++++++++ .../models/category-domain.model.ts | 15 ++ .../repositories/api-category.repository.ts | 22 +++ .../repositories/category.repository.ts | 6 + .../categories/utils/category-tree.utils.ts | 76 +++++++++ .../products/models/product-domain.model.ts | 3 +- .../providers/api-product-data.provider.ts | 8 +- .../runtime-provider-strategy.service.ts | 8 + src/app/facades/platform/category.facade.ts | 90 ++++++++++ src/app/pages/category/category.component.ts | 6 +- .../category/subcategories.component.html | 45 +---- .../pages/category/subcategories.component.ts | 130 ++++----------- src/app/pages/home/home.component.html | 21 +-- src/app/pages/home/home.component.ts | 81 +++------ 18 files changed, 636 insertions(+), 215 deletions(-) create mode 100644 docs/Category-Domain-Report.md create mode 100644 src/app/core/categories/category-repository.token.ts create mode 100644 src/app/core/categories/category.service.ts create mode 100644 src/app/core/categories/dto/category.dto.ts create mode 100644 src/app/core/categories/mappers/category.mapper.ts create mode 100644 src/app/core/categories/models/category-domain.model.ts create mode 100644 src/app/core/categories/repositories/api-category.repository.ts create mode 100644 src/app/core/categories/repositories/category.repository.ts create mode 100644 src/app/core/categories/utils/category-tree.utils.ts create mode 100644 src/app/facades/platform/category.facade.ts diff --git a/docs/Category-Domain-Report.md b/docs/Category-Domain-Report.md new file mode 100644 index 0000000..cadd6ab --- /dev/null +++ b/docs/Category-Domain-Report.md @@ -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. diff --git a/src/app/core/categories/category-repository.token.ts b/src/app/core/categories/category-repository.token.ts new file mode 100644 index 0000000..f73a084 --- /dev/null +++ b/src/app/core/categories/category-repository.token.ts @@ -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('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; + } + } +}); \ No newline at end of file diff --git a/src/app/core/categories/category.service.ts b/src/app/core/categories/category.service.ts new file mode 100644 index 0000000..d0a1027 --- /dev/null +++ b/src/app/core/categories/category.service.ts @@ -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 { + return this.categories$; + } + + getCategoryTree(): Observable { + return this.categories$.pipe(map(categories => CategoryTreeUtils.toTree(categories))); + } + + getRootCategories(): Observable { + return this.getCategoryTree(); + } + + getCategoryById(categoryId: number): Observable { + return this.getCategoryTree().pipe(map(tree => CategoryTreeUtils.findById(tree, categoryId))); + } + + getChildren(categoryId: number): Observable { + return this.categories$.pipe(map(categories => CategoryTreeUtils.getChildren(categories, categoryId))); + } + + getBreadcrumb(categoryId: number): Observable { + return this.categories$.pipe(map(categories => CategoryTreeUtils.getBreadcrumb(categories, categoryId))); + } + + getParent(categoryId: number): Observable { + return this.categories$.pipe(map(categories => CategoryTreeUtils.getParent(categories, categoryId))); + } + + isLeaf(category: Category): boolean { + return CategoryTreeUtils.isLeaf(category); + } +} \ No newline at end of file diff --git a/src/app/core/categories/dto/category.dto.ts b/src/app/core/categories/dto/category.dto.ts new file mode 100644 index 0000000..7be5867 --- /dev/null +++ b/src/app/core/categories/dto/category.dto.ts @@ -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[]; +} \ No newline at end of file diff --git a/src/app/core/categories/mappers/category.mapper.ts b/src/app/core/categories/mappers/category.mapper.ts new file mode 100644 index 0000000..2daa3c3 --- /dev/null +++ b/src/app/core/categories/mappers/category.mapper.ts @@ -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(); + + 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 { + return names.reduce>((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 { + 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; + } +} \ No newline at end of file diff --git a/src/app/core/categories/models/category-domain.model.ts b/src/app/core/categories/models/category-domain.model.ts new file mode 100644 index 0000000..0b007ae --- /dev/null +++ b/src/app/core/categories/models/category-domain.model.ts @@ -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; +} \ No newline at end of file diff --git a/src/app/core/categories/repositories/api-category.repository.ts b/src/app/core/categories/repositories/api-category.repository.ts new file mode 100644 index 0000000..ce4987d --- /dev/null +++ b/src/app/core/categories/repositories/api-category.repository.ts @@ -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 { + return this.http.get(`${environment.apiUrl}/category`) + .pipe(retry(this.retryConfig)); + } +} \ No newline at end of file diff --git a/src/app/core/categories/repositories/category.repository.ts b/src/app/core/categories/repositories/category.repository.ts new file mode 100644 index 0000000..d4db285 --- /dev/null +++ b/src/app/core/categories/repositories/category.repository.ts @@ -0,0 +1,6 @@ +import { Observable } from 'rxjs'; +import { CategoryDto } from '../dto/category.dto'; + +export interface CategoryRepository { + getCategories(): Observable; +} \ No newline at end of file diff --git a/src/app/core/categories/utils/category-tree.utils.ts b/src/app/core/categories/utils/category-tree.utils.ts new file mode 100644 index 0000000..7efea1a --- /dev/null +++ b/src/app/core/categories/utils/category-tree.utils.ts @@ -0,0 +1,76 @@ +import { Category } from '../models/category-domain.model'; + +export class CategoryTreeUtils { + static toTree(categories: Category[]): Category[] { + const byId = new Map(); + + 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)); + } +} \ No newline at end of file diff --git a/src/app/core/products/models/product-domain.model.ts b/src/app/core/products/models/product-domain.model.ts index 350b386..fd9de08 100644 --- a/src/app/core/products/models/product-domain.model.ts +++ b/src/app/core/products/models/product-domain.model.ts @@ -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; diff --git a/src/app/core/products/providers/api-product-data.provider.ts b/src/app/core/products/providers/api-product-data.provider.ts index e829670..20a3aa0 100644 --- a/src/app/core/products/providers/api-product-data.provider.ts +++ b/src/app/core/products/providers/api-product-data.provider.ts @@ -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 { return this.apiService.searchItems('', query.count, query.skip, this.toSearchOptions(query)) @@ -18,7 +22,7 @@ export class ApiProductDataProvider implements ProductDataProvider { } getCategories(): Observable { - return this.apiService.getCategories(); + return this.categoryService.getAllCategories(); } searchProducts(query: ProductSearchQuery): Observable { diff --git a/src/app/core/providers/runtime-provider-strategy.service.ts b/src/app/core/providers/runtime-provider-strategy.service.ts index 915cc18..1e4281d 100644 --- a/src/app/core/providers/runtime-provider-strategy.service.ts +++ b/src/app/core/providers/runtime-provider-strategy.service.ts @@ -28,4 +28,12 @@ export class RuntimeProviderStrategyService { return 'api'; } + + getCategoryProviderMode(): RuntimeProviderMode { + if (environment.useMockData) { + return 'mock'; + } + + return 'api'; + } } diff --git a/src/app/facades/platform/category.facade.ts b/src/app/facades/platform/category.facade.ts new file mode 100644 index 0000000..aec0760 --- /dev/null +++ b/src/app/facades/platform/category.facade.ts @@ -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(null); + + readonly allCategories = signal([]); + readonly categoryTree = signal([]); + readonly rootCategories = signal([]); + readonly selectedCategory = signal(null); + readonly breadcrumb = signal([]); + readonly children = signal([]); + readonly loading = signal(false); + readonly error = signal(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 { + return this.allCategories$; + } + + getCategoryTree(): Observable { + return this.categoryTree$; + } + + getRootCategories(): Observable { + return this.rootCategories$; + } + + getCategoryById(categoryId: number): Observable { + return this.categoryService.getCategoryById(categoryId); + } + + getBreadcrumb(categoryId: number): Observable { + return this.categoryService.getBreadcrumb(categoryId); + } + + getChildren(categoryId: number): Observable { + return this.categoryService.getChildren(categoryId); + } +} \ No newline at end of file diff --git a/src/app/pages/category/category.component.ts b/src/app/pages/category/category.component.ts index 9754e23..df6dad4 100644 --- a/src/app/pages/category/category.component.ts +++ b/src/app/pages/category/category.component.ts @@ -20,7 +20,7 @@ import { ProductCardComponent } from '../../components/product-card/product-card changeDetection: ChangeDetectionStrategy.OnPush }) export class CategoryComponent implements OnInit, OnDestroy { - categoryID = signal(0); + categoryId = signal(0); items = signal([]); loading = signal(false); error = signal(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 diff --git a/src/app/pages/category/subcategories.component.html b/src/app/pages/category/subcategories.component.html index fdb25e0..79c29dd 100644 --- a/src/app/pages/category/subcategories.component.html +++ b/src/app/pages/category/subcategories.component.html @@ -18,42 +18,10 @@

{{ parentName() }}

- - @if (nestedSubcategories().length > 0) { - - } - - @if (subcategories().length > 0) {
@for (cat of subcategories(); track trackByCategoryId($index, cat)) { - +
@if (cat.icon) { @@ -64,13 +32,16 @@

{{ categoryName(cat) }}

- @if ((cat.categoriesCount ?? 0) > 0) { - {{ 'subcategories.childrenCount' | translate:{ count: cat.categoriesCount ?? 0 } }} + @if (subcategoryChildCount(cat) > 0) { + {{ 'subcategories.childrenCount' | translate:{ count: subcategoryChildCount(cat) } }} } - @if ((cat.itemCount ?? 0) > 0) { - {{ 'subcategories.productsCount' | translate:{ count: cat.itemCount ?? 0 } }} + @if (subcategoryItemCount(cat) > 0) { + {{ 'subcategories.productsCount' | translate:{ count: subcategoryItemCount(cat) } }} }
+ @if (subcategoryItemCount(cat) > 0 && subcategoryChildCount(cat) > 0) { + {{ 'subcategories.includesProducts' | translate }} + }
} diff --git a/src/app/pages/category/subcategories.component.ts b/src/app/pages/category/subcategories.component.ts index 2e5396d..9571965 100644 --- a/src/app/pages/category/subcategories.component.ts +++ b/src/app/pages/category/subcategories.component.ts @@ -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([]); subcategories = signal([]); - /** Nested subcategories from API with hasItems support */ - nestedSubcategories = signal([]); /** Items belonging directly to this category (when hasItems is true) */ categoryItems = signal([]); 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; } } diff --git a/src/app/pages/home/home.component.html b/src/app/pages/home/home.component.html index 53ec627..7908823 100644 --- a/src/app/pages/home/home.component.html +++ b/src/app/pages/home/home.component.html @@ -62,8 +62,8 @@
} @else { } @else {
- @for (category of topLevelCategories(); track category.categoryID) { - + @for (category of topLevelCategories(); track category.id) { +
- @if (isWideCategory(category.categoryID) && category.wideBanner) { - - } @else if (category.icon) { + @if (category.icon) { } @else {
{{ categoryName(category).charAt(0) }}
@@ -165,7 +162,7 @@

{{ categoryName(category) }}

-

{{ 'home.itemsCount' | translate:{ count: getItemCount(category.categoryID) } }}

+

{{ 'home.itemsCount' | translate:{ count: getItemCount(category.id) } }}

} diff --git a/src/app/pages/home/home.component.ts b/src/app/pages/home/home.component.ts index bb4a0f9..6f99d7d 100644 --- a/src/app/pages/home/home.component.ts +++ b/src/app/pages/home/home.component.ts @@ -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([]); - wideCategories = signal>(new Set()); loading = signal(true); error = signal(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(); - 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(); 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(); - - 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 {