Add product data domain layer

This commit is contained in:
sdarbinyan
2026-07-05 01:02:16 +04:00
parent 9cf508d319
commit 05d75421f5
8 changed files with 266 additions and 2 deletions

View File

@@ -0,0 +1,37 @@
import { Category, Item } from '../../../models';
export type Product = Item;
export type ProductCategory = Category;
export type ProductSort = 'relevance' | 'price_asc' | 'price_desc' | 'popular' | 'rating' | 'latest';
export interface ProductFilters {
categoryIDs?: number[];
minPrice?: number;
maxPrice?: number;
tag?: string;
badges?: string[];
inStockOnly?: boolean;
}
export interface ProductListQuery extends ProductFilters {
count?: number;
skip?: number;
sort?: ProductSort;
}
export interface ProductSearchQuery extends ProductListQuery {
search: string;
}
export interface ProductListResult {
items: Product[];
total: number;
count: number;
skip: number;
}
export interface RelatedProductsQuery {
productID: number;
categoryID?: number;
count?: number;
}

View File

@@ -0,0 +1,20 @@
import { InjectionToken, inject } from '@angular/core';
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
import { ApiProductDataProvider } from './providers/api-product-data.provider';
import { ProductDataProvider } from './providers/product-data-provider.interface';
export const PRODUCT_DATA_PROVIDER = new InjectionToken<ProductDataProvider>('PRODUCT_DATA_PROVIDER', {
providedIn: 'root',
factory: () => {
const strategy = inject(RuntimeProviderStrategyService);
const apiProvider = inject(ApiProductDataProvider);
switch (strategy.getProductProviderMode()) {
case 'mock':
case 'remote-config':
case 'api':
default:
return apiProvider;
}
}
});

View File

@@ -0,0 +1,41 @@
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { PRODUCT_DATA_PROVIDER } from './product-data-provider.token';
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from './models/product-domain.model';
@Injectable({ providedIn: 'root' })
export class ProductDataService {
private readonly provider = inject(PRODUCT_DATA_PROVIDER);
getProducts(query?: ProductListQuery): Observable<ProductListResult> {
return this.provider.getProducts(query);
}
getProduct(productID: number): Observable<Product> {
return this.provider.getProduct(productID);
}
getCategories(): Observable<ProductCategory[]> {
return this.provider.getCategories();
}
searchProducts(query: ProductSearchQuery): Observable<ProductListResult> {
return this.provider.searchProducts(query);
}
getFeaturedProducts(query?: ProductListQuery): Observable<ProductListResult> {
return this.provider.getFeaturedProducts(query);
}
getLatestProducts(query?: ProductListQuery): Observable<ProductListResult> {
return this.provider.getLatestProducts(query);
}
getProductsByCategory(categoryID: number, query?: ProductListQuery): Observable<ProductListResult> {
return this.provider.getProductsByCategory(categoryID, query);
}
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult> {
return this.provider.getRelatedProducts(query);
}
}

View File

@@ -0,0 +1,75 @@
import { Injectable } from '@angular/core';
import { Observable, map } from 'rxjs';
import { ApiService } from '../../../services';
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) {}
getProducts(query: ProductListQuery = {}): Observable<ProductListResult> {
return this.apiService.searchItems('', query.count, query.skip, this.toSearchOptions(query))
.pipe(map(result => this.toResult(result.items, result.total, query)));
}
getProduct(productID: number): Observable<Product> {
return this.apiService.getItem(productID);
}
getCategories(): Observable<ProductCategory[]> {
return this.apiService.getCategories();
}
searchProducts(query: ProductSearchQuery): Observable<ProductListResult> {
return this.apiService.searchItems(query.search, query.count, query.skip, this.toSearchOptions(query))
.pipe(map(result => this.toResult(result.items, result.total, query)));
}
getFeaturedProducts(query: ProductListQuery = {}): Observable<ProductListResult> {
return this.apiService.getRandomItems(query.count ?? 10)
.pipe(map(items => this.toResult(items, items.length, query)));
}
getLatestProducts(query: ProductListQuery = {}): Observable<ProductListResult> {
return this.apiService.searchItems('', query.count, query.skip, { ...this.toSearchOptions(query), sort: 'popular' })
.pipe(map(result => this.toResult(result.items, result.total, query)));
}
getProductsByCategory(categoryID: number, query: ProductListQuery = {}): Observable<ProductListResult> {
return this.apiService.getCategoryItems(categoryID, query.count, query.skip)
.pipe(map(items => this.toResult(items, items.length, query)));
}
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult> {
if (query.categoryID == null) {
return this.getFeaturedProducts({ count: query.count ?? 8 });
}
return this.getProductsByCategory(query.categoryID, { count: query.count ?? 8 }).pipe(
map(result => ({
...result,
items: result.items.filter(item => item.itemID !== query.productID)
}))
);
}
private toSearchOptions(query: ProductListQuery) {
return {
categoryIDs: query.categoryIDs,
minPrice: query.minPrice,
maxPrice: query.maxPrice,
tag: query.tag,
sort: query.sort === 'latest' ? 'popular' : query.sort
};
}
private toResult(items: Product[], total: number, query: ProductListQuery): ProductListResult {
return {
items,
total,
count: query.count ?? items.length,
skip: query.skip ?? 0
};
}
}

View File

@@ -0,0 +1,13 @@
import { Observable } from 'rxjs';
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';
export interface ProductDataProvider {
getProducts(query?: ProductListQuery): Observable<ProductListResult>;
getProduct(productID: number): Observable<Product>;
getCategories(): Observable<ProductCategory[]>;
searchProducts(query: ProductSearchQuery): Observable<ProductListResult>;
getFeaturedProducts(query?: ProductListQuery): Observable<ProductListResult>;
getLatestProducts(query?: ProductListQuery): Observable<ProductListResult>;
getProductsByCategory(categoryID: number, query?: ProductListQuery): Observable<ProductListResult>;
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult>;
}

View File

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

View File

@@ -0,0 +1,70 @@
import { Injectable, signal } from '@angular/core';
import { ProductDataService } from '../../core/products/product-data.service';
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../../core/products/models/product-domain.model';
@Injectable({ providedIn: 'root' })
export class ProductFacade {
readonly categories = signal<ProductCategory[]>([]);
readonly products = signal<Product[]>([]);
readonly selectedProduct = signal<Product | null>(null);
readonly relatedProducts = signal<Product[]>([]);
readonly total = signal(0);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
constructor(private readonly productData: ProductDataService) {}
loadCategories(): void {
this.loading.set(true);
this.error.set(null);
this.productData.getCategories().subscribe({
next: (categories) => {
this.categories.set(categories);
this.loading.set(false);
},
error: () => {
this.categories.set([]);
this.error.set('Failed to load categories');
this.loading.set(false);
}
});
}
getCategories() {
return this.productData.getCategories();
}
getProducts(query?: ProductListQuery) {
return this.productData.getProducts(query);
}
getProduct(productID: number) {
return this.productData.getProduct(productID);
}
searchProducts(query: ProductSearchQuery) {
return this.productData.searchProducts(query);
}
getFeaturedProducts(query?: ProductListQuery) {
return this.productData.getFeaturedProducts(query);
}
getLatestProducts(query?: ProductListQuery) {
return this.productData.getLatestProducts(query);
}
getProductsByCategory(categoryID: number, query?: ProductListQuery) {
return this.productData.getProductsByCategory(categoryID, query);
}
getRelatedProducts(query: RelatedProductsQuery) {
return this.productData.getRelatedProducts(query);
}
setListResult(result: ProductListResult): void {
this.products.set(result.items);
this.total.set(result.total);
}
}

View File

@@ -779,8 +779,8 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
return respond(items); return respond(items);
} }
// ── GET /item/:id // ── GET /items/:id
const itemMatch = url.match(/\/item\/([^/?]+)$/); const itemMatch = url.match(/\/items?\/([^/?]+)$/);
if (itemMatch && req.method === 'GET') { if (itemMatch && req.method === 'GET') {
const raw = itemMatch[1]; const raw = itemMatch[1];
const num = Number(raw); const num = Number(raw);