CAtegory component making

This commit is contained in:
sdarbinyan
2026-07-05 01:24:54 +04:00
parent b9f4103c8e
commit d2f0f0de54
18 changed files with 636 additions and 215 deletions

View 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;
}
}
});

View 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);
}
}

View 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[];
}

View 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;
}
}

View 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>;
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,6 @@
import { Observable } from 'rxjs';
import { CategoryDto } from '../dto/category.dto';
export interface CategoryRepository {
getCategories(): Observable<CategoryDto[]>;
}

View 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));
}
}

View File

@@ -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;

View File

@@ -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> {

View File

@@ -28,4 +28,12 @@ export class RuntimeProviderStrategyService {
return 'api';
}
getCategoryProviderMode(): RuntimeProviderMode {
if (environment.useMockData) {
return 'mock';
}
return 'api';
}
}