Add product data domain layer
This commit is contained in:
37
src/app/core/products/models/product-domain.model.ts
Normal file
37
src/app/core/products/models/product-domain.model.ts
Normal 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;
|
||||||
|
}
|
||||||
20
src/app/core/products/product-data-provider.token.ts
Normal file
20
src/app/core/products/product-data-provider.token.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
41
src/app/core/products/product-data.service.ts
Normal file
41
src/app/core/products/product-data.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/core/products/providers/api-product-data.provider.ts
Normal file
75
src/app/core/products/providers/api-product-data.provider.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -20,4 +20,12 @@ export class RuntimeProviderStrategyService {
|
|||||||
|
|
||||||
return 'api';
|
return 'api';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProductProviderMode(): RuntimeProviderMode {
|
||||||
|
if (environment.useMockData) {
|
||||||
|
return 'mock';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'api';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
70
src/app/facades/platform/product.facade.ts
Normal file
70
src/app/facades/platform/product.facade.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user