From 05d75421f54baee8693da61077d54b8090b7caf5 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sun, 5 Jul 2026 01:02:16 +0400 Subject: [PATCH] Add product data domain layer --- .../products/models/product-domain.model.ts | 37 +++++++++ .../products/product-data-provider.token.ts | 20 +++++ src/app/core/products/product-data.service.ts | 41 ++++++++++ .../providers/api-product-data.provider.ts | 75 +++++++++++++++++++ .../product-data-provider.interface.ts | 13 ++++ .../runtime-provider-strategy.service.ts | 8 ++ src/app/facades/platform/product.facade.ts | 70 +++++++++++++++++ src/app/interceptors/mock-data.interceptor.ts | 4 +- 8 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 src/app/core/products/models/product-domain.model.ts create mode 100644 src/app/core/products/product-data-provider.token.ts create mode 100644 src/app/core/products/product-data.service.ts create mode 100644 src/app/core/products/providers/api-product-data.provider.ts create mode 100644 src/app/core/products/providers/product-data-provider.interface.ts create mode 100644 src/app/facades/platform/product.facade.ts diff --git a/src/app/core/products/models/product-domain.model.ts b/src/app/core/products/models/product-domain.model.ts new file mode 100644 index 0000000..350b386 --- /dev/null +++ b/src/app/core/products/models/product-domain.model.ts @@ -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; +} diff --git a/src/app/core/products/product-data-provider.token.ts b/src/app/core/products/product-data-provider.token.ts new file mode 100644 index 0000000..7178288 --- /dev/null +++ b/src/app/core/products/product-data-provider.token.ts @@ -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('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; + } + } +}); diff --git a/src/app/core/products/product-data.service.ts b/src/app/core/products/product-data.service.ts new file mode 100644 index 0000000..568e5d2 --- /dev/null +++ b/src/app/core/products/product-data.service.ts @@ -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 { + return this.provider.getProducts(query); + } + + getProduct(productID: number): Observable { + return this.provider.getProduct(productID); + } + + getCategories(): Observable { + return this.provider.getCategories(); + } + + searchProducts(query: ProductSearchQuery): Observable { + return this.provider.searchProducts(query); + } + + getFeaturedProducts(query?: ProductListQuery): Observable { + return this.provider.getFeaturedProducts(query); + } + + getLatestProducts(query?: ProductListQuery): Observable { + return this.provider.getLatestProducts(query); + } + + getProductsByCategory(categoryID: number, query?: ProductListQuery): Observable { + return this.provider.getProductsByCategory(categoryID, query); + } + + getRelatedProducts(query: RelatedProductsQuery): Observable { + return this.provider.getRelatedProducts(query); + } +} diff --git a/src/app/core/products/providers/api-product-data.provider.ts b/src/app/core/products/providers/api-product-data.provider.ts new file mode 100644 index 0000000..e829670 --- /dev/null +++ b/src/app/core/products/providers/api-product-data.provider.ts @@ -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 { + 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 { + return this.apiService.getItem(productID); + } + + getCategories(): Observable { + return this.apiService.getCategories(); + } + + searchProducts(query: ProductSearchQuery): Observable { + 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 { + return this.apiService.getRandomItems(query.count ?? 10) + .pipe(map(items => this.toResult(items, items.length, query))); + } + + getLatestProducts(query: ProductListQuery = {}): Observable { + 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 { + return this.apiService.getCategoryItems(categoryID, query.count, query.skip) + .pipe(map(items => this.toResult(items, items.length, query))); + } + + getRelatedProducts(query: RelatedProductsQuery): Observable { + 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 + }; + } +} diff --git a/src/app/core/products/providers/product-data-provider.interface.ts b/src/app/core/products/providers/product-data-provider.interface.ts new file mode 100644 index 0000000..f7183ab --- /dev/null +++ b/src/app/core/products/providers/product-data-provider.interface.ts @@ -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; + getProduct(productID: number): Observable; + getCategories(): Observable; + searchProducts(query: ProductSearchQuery): Observable; + getFeaturedProducts(query?: ProductListQuery): Observable; + getLatestProducts(query?: ProductListQuery): Observable; + getProductsByCategory(categoryID: number, query?: ProductListQuery): Observable; + getRelatedProducts(query: RelatedProductsQuery): Observable; +} diff --git a/src/app/core/providers/runtime-provider-strategy.service.ts b/src/app/core/providers/runtime-provider-strategy.service.ts index be19d49..915cc18 100644 --- a/src/app/core/providers/runtime-provider-strategy.service.ts +++ b/src/app/core/providers/runtime-provider-strategy.service.ts @@ -20,4 +20,12 @@ export class RuntimeProviderStrategyService { return 'api'; } + + getProductProviderMode(): RuntimeProviderMode { + if (environment.useMockData) { + return 'mock'; + } + + return 'api'; + } } diff --git a/src/app/facades/platform/product.facade.ts b/src/app/facades/platform/product.facade.ts new file mode 100644 index 0000000..4485925 --- /dev/null +++ b/src/app/facades/platform/product.facade.ts @@ -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([]); + readonly products = signal([]); + readonly selectedProduct = signal(null); + readonly relatedProducts = signal([]); + readonly total = signal(0); + readonly loading = signal(false); + readonly error = signal(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); + } +} diff --git a/src/app/interceptors/mock-data.interceptor.ts b/src/app/interceptors/mock-data.interceptor.ts index 5b737b6..34b4d71 100644 --- a/src/app/interceptors/mock-data.interceptor.ts +++ b/src/app/interceptors/mock-data.interceptor.ts @@ -779,8 +779,8 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => { return respond(items); } - // ── GET /item/:id - const itemMatch = url.match(/\/item\/([^/?]+)$/); + // ── GET /items/:id + const itemMatch = url.match(/\/items?\/([^/?]+)$/); if (itemMatch && req.method === 'GET') { const raw = itemMatch[1]; const num = Number(raw);