405 lines
11 KiB
TypeScript
405 lines
11 KiB
TypeScript
import { apiFetch } from './client';
|
|
import { API_ENDPOINTS } from './config';
|
|
|
|
export interface BrandResponse {
|
|
brand_id: string;
|
|
name: string;
|
|
slug: string;
|
|
logo_url: string | null;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface CategoryResponse {
|
|
category_id: string;
|
|
parent_category_id: string | null;
|
|
name: string;
|
|
slug: string;
|
|
description: string | null;
|
|
image_url: string | null;
|
|
sort_order: string;
|
|
is_parent_feature?: boolean;
|
|
is_active: boolean;
|
|
product_count?: number;
|
|
created_at: string;
|
|
}
|
|
|
|
// --- Parent Hierarchy Types ---
|
|
export interface HierarchyModelItem {
|
|
model_id: string;
|
|
name: string;
|
|
slug: string;
|
|
image_url: string | null;
|
|
}
|
|
|
|
export interface HierarchySeriesItem {
|
|
series_id: string;
|
|
name: string;
|
|
slug: string;
|
|
models: HierarchyModelItem[];
|
|
}
|
|
|
|
export interface HierarchyBrandItem {
|
|
brand_id: string;
|
|
name: string;
|
|
slug: string;
|
|
logo_url: string | null;
|
|
series: HierarchySeriesItem[];
|
|
}
|
|
|
|
export interface CategoryParentHierarchyResponse {
|
|
category_id: string;
|
|
brands: HierarchyBrandItem[];
|
|
}
|
|
|
|
export interface DeviceSeriesResponse {
|
|
series_id: string;
|
|
brand_id: string;
|
|
name: string;
|
|
slug: string;
|
|
sort_order: number;
|
|
is_active: boolean;
|
|
}
|
|
|
|
export interface VariantAttributeResponse {
|
|
id: string;
|
|
variant_id: string;
|
|
attribute_id: string;
|
|
attribute_value: string;
|
|
attribute_name?: string;
|
|
attribute_code?: string;
|
|
}
|
|
|
|
export interface VariantImageResponse {
|
|
image_id: string;
|
|
variant_id: string;
|
|
image_url: string;
|
|
sort_order: number;
|
|
is_primary: boolean;
|
|
}
|
|
|
|
export interface ProductVariantResponse {
|
|
variant_id: string;
|
|
product_id: string;
|
|
sku: string;
|
|
barcode: string | null;
|
|
/** API may return decimal strings like "199.00" */
|
|
price: number | string;
|
|
compare_price: number | string | null;
|
|
cost_price: number | string;
|
|
low_stock_threshold: number;
|
|
status: string;
|
|
available_stock?: number;
|
|
attributes?: VariantAttributeResponse[];
|
|
images?: VariantImageResponse[];
|
|
}
|
|
|
|
export interface ProductImageResponse {
|
|
image_id: string;
|
|
product_id: string;
|
|
image_url: string;
|
|
alt_text: string | null;
|
|
sort_order: number;
|
|
is_banner: boolean;
|
|
}
|
|
|
|
export interface ProductResponse {
|
|
product_id: string;
|
|
category_id: string;
|
|
brand_id: string | null;
|
|
device_series_id: string | null;
|
|
device_model_id: string | null;
|
|
name: string;
|
|
slug: string;
|
|
full_path: string;
|
|
description: string | null;
|
|
status: string;
|
|
created_at: string;
|
|
images: ProductImageResponse[];
|
|
variants: ProductVariantResponse[];
|
|
rating?: number;
|
|
review_count?: number;
|
|
badge?: 'HOT' | 'NEW' | 'SALE' | null;
|
|
/** Present on list cards; used for Sale / Hot highlight filters */
|
|
discount_percent?: number;
|
|
color?: string | null;
|
|
colors?: string[];
|
|
brand_name?: string | null;
|
|
}
|
|
|
|
export interface DeviceModelResponse {
|
|
model_id: string;
|
|
series_id: string;
|
|
brand_id: string;
|
|
name: string;
|
|
slug: string;
|
|
full_path: string;
|
|
release_year: number | null;
|
|
image_url: string | null;
|
|
is_active: boolean;
|
|
}
|
|
|
|
export interface ServiceTypeResponse {
|
|
service_type_id: string;
|
|
name: string;
|
|
slug: string;
|
|
icon_url: string | null;
|
|
description: string | null;
|
|
is_active: boolean;
|
|
}
|
|
|
|
export interface RepairServiceResponse {
|
|
repair_service_id: string;
|
|
model_id: string;
|
|
service_type_id: string;
|
|
slug: string;
|
|
full_path: string;
|
|
description: string | null;
|
|
}
|
|
|
|
export interface ProductCardResponse {
|
|
product_id: string;
|
|
category_id?: string;
|
|
slug: string;
|
|
name: string;
|
|
thumbnail_url: string | null;
|
|
/** List API returns decimal strings like "199.00" */
|
|
price: number | string;
|
|
compare_price: number | string | null;
|
|
discount_percent: number;
|
|
rating: number;
|
|
stock_count: number;
|
|
first_variant_id?: string | null;
|
|
badge: string | null;
|
|
brand_name: string | null;
|
|
color?: string | null;
|
|
colors?: string[];
|
|
}
|
|
|
|
export interface ProductPaginatedResponse {
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
cursor: string | null;
|
|
products: ProductCardResponse[];
|
|
}
|
|
|
|
export const catalogService = {
|
|
async getBrands(categoryId?: string): Promise<BrandResponse[]> {
|
|
try {
|
|
const url = categoryId
|
|
? `${API_ENDPOINTS.CATALOG_BRANDS_ALL}?category_id=${categoryId}`
|
|
: API_ENDPOINTS.CATALOG_BRANDS_ALL;
|
|
return await apiFetch<BrandResponse[]>(url);
|
|
} catch {
|
|
return MOCK_BRANDS;
|
|
}
|
|
},
|
|
|
|
async getCategories(): Promise<CategoryResponse[]> {
|
|
try {
|
|
return await apiFetch<CategoryResponse[]>(API_ENDPOINTS.CATALOG_CATEGORIES_ALL);
|
|
} catch {
|
|
return MOCK_CATEGORIES;
|
|
}
|
|
},
|
|
|
|
async getProducts(): Promise<ProductResponse[]> {
|
|
try {
|
|
const data = await apiFetch<any>(`${API_ENDPOINTS.CATALOG_PRODUCTS_ALL}?page=1&limit=48`);
|
|
const products = Array.isArray(data) ? data : data?.products || [];
|
|
return products;
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
|
|
async getProductsPaginated(params: Record<string, any> = {}): Promise<ProductPaginatedResponse> {
|
|
const query = new URLSearchParams();
|
|
Object.entries(params).forEach(([key, val]) => {
|
|
if (val !== undefined && val !== null && val !== '') {
|
|
query.append(key, String(val));
|
|
}
|
|
});
|
|
|
|
try {
|
|
const queryString = query.toString();
|
|
const endpoint = `/api/v1/catalog/products${queryString ? '?' + queryString : ''}`;
|
|
return await apiFetch<ProductPaginatedResponse>(endpoint);
|
|
} catch (e) {
|
|
const limit = Number(params.limit) || 24;
|
|
const page = Number(params.page) || 1;
|
|
let cards = MOCK_PRODUCTS.map((p) => {
|
|
const firstVar = p.variants[0];
|
|
const price = firstVar?.price ? Number(firstVar.price) : 0;
|
|
const compare_price = firstVar?.compare_price ? Number(firstVar.compare_price) : null;
|
|
let discount_percent = 0;
|
|
if (price && compare_price && compare_price > price) {
|
|
discount_percent = Math.round(((compare_price - price) / compare_price) * 100);
|
|
}
|
|
const colorAttr = firstVar?.attributes?.find((a) =>
|
|
`${a.attribute_code || ''} ${a.attribute_name || ''}`.toLowerCase().includes('color')
|
|
);
|
|
return {
|
|
product_id: p.product_id,
|
|
category_id: p.category_id,
|
|
slug: p.slug,
|
|
name: p.name,
|
|
thumbnail_url: p.images[0]?.image_url || null,
|
|
price,
|
|
compare_price,
|
|
discount_percent,
|
|
rating: p.rating || 4.8,
|
|
stock_count: 15,
|
|
badge: p.badge || null,
|
|
brand_name: MOCK_BRANDS.find((b) => b.brand_id === p.brand_id)?.name || 'Apple',
|
|
brand_id: p.brand_id,
|
|
color: colorAttr?.attribute_value || null,
|
|
colors: colorAttr?.attribute_value ? [colorAttr.attribute_value] : [],
|
|
};
|
|
});
|
|
|
|
if (params.category) {
|
|
cards = cards.filter((p) => p.category_id === params.category);
|
|
}
|
|
if (params.brand) {
|
|
cards = cards.filter(
|
|
(p) =>
|
|
p.brand_id === params.brand ||
|
|
String(p.brand_name).toLowerCase() === String(params.brand).toLowerCase()
|
|
);
|
|
}
|
|
if (params.search) {
|
|
const q = String(params.search).toLowerCase();
|
|
cards = cards.filter(
|
|
(p) =>
|
|
p.name.toLowerCase().includes(q) ||
|
|
String(p.color || '').toLowerCase().includes(q) ||
|
|
String(p.brand_name || '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
|
|
const total = cards.length;
|
|
const start = (page - 1) * limit;
|
|
return {
|
|
total,
|
|
page,
|
|
limit,
|
|
cursor: null,
|
|
products: cards.slice(start, start + limit),
|
|
};
|
|
}
|
|
},
|
|
|
|
async getProductDetail(slug: string): Promise<any> {
|
|
try {
|
|
return await apiFetch<any>(`/api/v1/catalog/products/detail/${slug}`);
|
|
} catch (e) {
|
|
const found = MOCK_PRODUCTS.find(p => p.slug === slug) || MOCK_PRODUCTS[0];
|
|
return {
|
|
product: found,
|
|
reviews: [
|
|
{ review_id: 'rev_1', author_name: 'Alex Morgan', rating: 5, verified_buyer: true, review_date: '2026-07-20', title: 'Exceptional Quality!', comment: 'Exceptional build quality and super-fast delivery!' }
|
|
],
|
|
related_products: MOCK_PRODUCTS.filter(p => p.product_id !== found.product_id),
|
|
compatible_devices: [
|
|
{ model_id: 'm1', name: 'iPhone 15 Pro Max', slug: 'iphone-15-pro-max' }
|
|
],
|
|
specifications: [
|
|
{ label: 'Manufacturer', value: 'Apple' },
|
|
{ label: 'Model Reference', value: found.name }
|
|
]
|
|
};
|
|
}
|
|
},
|
|
|
|
async getProductsBySlugs(slugs: string[]): Promise<ProductCardResponse[]> {
|
|
const unique = [...new Set(slugs.map((s) => String(s || '').trim()).filter(Boolean))];
|
|
if (!unique.length) return [];
|
|
|
|
const cards = await Promise.all(
|
|
unique.map(async (slug) => {
|
|
try {
|
|
const data = await apiFetch<any>(`/api/v1/catalog/products/detail/${slug}`);
|
|
const p = data?.product || data;
|
|
if (!p?.slug && !p?.product_id) return null;
|
|
const variant = Array.isArray(p.variants) ? p.variants[0] : null;
|
|
const thumb =
|
|
p.thumbnail_url ||
|
|
p.images?.[0]?.image_url ||
|
|
variant?.images?.[0]?.image_url ||
|
|
null;
|
|
return {
|
|
product_id: p.product_id || slug,
|
|
category_id: p.category_id,
|
|
slug: p.slug || slug,
|
|
name: p.name || slug,
|
|
thumbnail_url: thumb,
|
|
price: variant?.price ?? p.price ?? 0,
|
|
compare_price: variant?.compare_price ?? p.compare_price ?? null,
|
|
discount_percent: Number(p.discount_percent) || 0,
|
|
rating: Number(p.rating) || 5,
|
|
stock_count: Number(p.stock_count) || 0,
|
|
first_variant_id: variant?.variant_id || null,
|
|
badge: p.badge || null,
|
|
brand_name: p.brand_name || null,
|
|
} as ProductCardResponse;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
|
|
return cards.filter((c): c is ProductCardResponse => Boolean(c));
|
|
},
|
|
|
|
|
|
async getDeviceModels(): Promise<DeviceModelResponse[]> {
|
|
try {
|
|
return await apiFetch<DeviceModelResponse[]>(API_ENDPOINTS.CATALOG_DEVICE_MODELS_ALL);
|
|
} catch {
|
|
return MOCK_DEVICE_MODELS;
|
|
}
|
|
},
|
|
|
|
async getServiceTypes(): Promise<ServiceTypeResponse[]> {
|
|
try {
|
|
return await apiFetch<ServiceTypeResponse[]>(API_ENDPOINTS.CATALOG_SERVICE_TYPES_ALL);
|
|
} catch {
|
|
return MOCK_SERVICE_TYPES;
|
|
}
|
|
},
|
|
|
|
async getRepairServices(): Promise<RepairServiceResponse[]> {
|
|
try {
|
|
return await apiFetch<RepairServiceResponse[]>(API_ENDPOINTS.CATALOG_REPAIR_SERVICES_ALL);
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
|
|
async getParentHierarchy(categoryId: string): Promise<CategoryParentHierarchyResponse | null> {
|
|
try {
|
|
return await apiFetch<CategoryParentHierarchyResponse>(
|
|
`/api/v1/storefront/catalog/categories/${categoryId}/parent-hierarchy`
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async getDeviceSeries(): Promise<DeviceSeriesResponse[]> {
|
|
try {
|
|
return await apiFetch<DeviceSeriesResponse[]>('/api/v1/catalog/device-series/all');
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
};
|
|
|
|
// Fallback catalog mock dataset (empty by default to ensure only real API data renders)
|
|
export const MOCK_BRANDS: BrandResponse[] = [];
|
|
export const MOCK_CATEGORIES: CategoryResponse[] = [];
|
|
export const MOCK_PRODUCTS: ProductResponse[] = [];
|
|
export const MOCK_DEVICE_MODELS: DeviceModelResponse[] = [];
|
|
export const MOCK_SERVICE_TYPES: ServiceTypeResponse[] = [];
|