352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
import api from './api';
|
|
import { Product, PetType } from "../data/products";
|
|
import { Banner } from "../types";
|
|
import { getMediaUrl } from "../media";
|
|
|
|
const CATEGORY_SLUG_TO_NAME: Record<string, string> = {
|
|
'joints': 'مفاصل و استخوان',
|
|
'immune': 'تقویت سیستم ایمنی و گوارش',
|
|
'energy': 'ویتامینها و انرژیبخشها',
|
|
'special-care': 'مراقبتهای ویژه (پوست، دندان و چشم)',
|
|
'general': 'تقویت عمومی',
|
|
'nutrition': 'تغذیه تخصصی',
|
|
'supplements': 'مکملهای غذایی و درمانی',
|
|
};
|
|
|
|
interface BackendProduct {
|
|
id?: string;
|
|
artNo?: string;
|
|
nameFa?: string;
|
|
name?: string;
|
|
nameEn?: string;
|
|
scientificTagline?: string;
|
|
description?: string;
|
|
shortDescription?: string;
|
|
priceDisplay?: string;
|
|
priceValue?: number;
|
|
category?: { name?: string } | string;
|
|
categorySlug?: string;
|
|
unit?: string;
|
|
packageSize?: number;
|
|
dosageLogic?: string;
|
|
benefits?: string;
|
|
suitableFor?: string;
|
|
requiresRx?: boolean;
|
|
storage?: string;
|
|
specialBadge?: string;
|
|
onSetOfAction?: string;
|
|
optimisticTemplate?: string;
|
|
feedingAdvice?: string;
|
|
dosageConfig?: Record<string, any>;
|
|
faqs?: Array<{ question: string; answer: string }>;
|
|
slug?: string;
|
|
imageUrl?: string;
|
|
image?: string;
|
|
images?: string[];
|
|
podcastUrl?: string;
|
|
podcastTitle?: string;
|
|
podcastDescription?: string;
|
|
podcastCover?: string;
|
|
videoUrl?: string;
|
|
videoTitle?: string;
|
|
videoDescription?: string;
|
|
videoCover?: string;
|
|
pdfUrl?: string;
|
|
pdfTitle?: string;
|
|
pdfDescription?: string;
|
|
pdfCover?: string;
|
|
productGroup?: string;
|
|
ingredientList?: Array<{ ingredient: string }>;
|
|
ingredients?: string;
|
|
symptoms?: Array<{ symptom?: string } | string>;
|
|
keyBenefits?: unknown;
|
|
expectedResults?: unknown;
|
|
scientificValidation?: unknown;
|
|
doctorNotes?: unknown;
|
|
scientificArticles?: unknown;
|
|
compositionTable?: unknown;
|
|
analyticalConstituents?: unknown;
|
|
additivesPerKg?: unknown;
|
|
faqList?: unknown;
|
|
specialist?: unknown;
|
|
}
|
|
|
|
export class ProductService {
|
|
private static instance: ProductService;
|
|
|
|
private constructor() {}
|
|
|
|
public static getInstance(): ProductService {
|
|
if (!ProductService.instance) {
|
|
ProductService.instance = new ProductService();
|
|
}
|
|
return ProductService.instance;
|
|
}
|
|
|
|
private mapBackendToFrontend(inputData: Record<string, unknown>): Product {
|
|
const data = inputData as unknown as BackendProduct;
|
|
const dosageConfig = (data.dosageConfig as Record<string, any>) || {};
|
|
|
|
const safeParse = <T>(val: unknown, fallback: T): T => {
|
|
if (!val) return fallback;
|
|
if (typeof val === 'string') {
|
|
try { return JSON.parse(val) as T; } catch { return fallback; }
|
|
}
|
|
return val as T;
|
|
};
|
|
|
|
const categoryName = typeof data.category === 'object' && data.category !== null
|
|
? data.category.name
|
|
: (typeof data.category === 'string' ? data.category : (CATEGORY_SLUG_TO_NAME[data.categorySlug || ''] || data.categorySlug || ''));
|
|
|
|
const priceVal = Number(data.priceValue || 0);
|
|
|
|
const specialistData = dosageConfig.specialist || safeParse<Product['specialist'] | undefined>(data.specialist, undefined);
|
|
const validSpecialist = specialistData && typeof specialistData === 'object' && (specialistData.name || specialistData.message)
|
|
? {
|
|
name: specialistData.name || '',
|
|
title: specialistData.title || '',
|
|
image: specialistData.image ? getMediaUrl(specialistData.image) : '',
|
|
message: specialistData.message || ''
|
|
}
|
|
: undefined;
|
|
|
|
return {
|
|
id: data.id || '',
|
|
artNo: data.artNo || '',
|
|
name: data.nameFa || data.name || '',
|
|
nameFa: data.nameFa || data.name || '',
|
|
nameEn: data.nameEn || '',
|
|
scientificTagline: data.scientificTagline || '',
|
|
description: data.description || '',
|
|
shortDescription: data.shortDescription || '',
|
|
price: data.priceDisplay || `${priceVal.toLocaleString('fa-IR')} تومان`,
|
|
priceValue: priceVal,
|
|
category: categoryName || '',
|
|
categorySlug: data.categorySlug || '',
|
|
unit: data.unit || 'عدد',
|
|
packageSize: data.packageSize !== undefined && data.packageSize !== null ? Number(data.packageSize) : 100,
|
|
dosage_logic: data.dosageLogic || '',
|
|
benefits: data.benefits || '',
|
|
suitableFor: (data.suitableFor as PetType) || 'سگ',
|
|
requiresRx: Boolean(data.requiresRx),
|
|
storage: dosageConfig.storageInfo || data.storage,
|
|
optimisticTemplate: data.optimisticTemplate,
|
|
feedingAdvice: dosageConfig.feedingAdvice || data.dosageLogic || data.feedingAdvice || '',
|
|
showFaqs: dosageConfig.showFaqs !== undefined ? Boolean(dosageConfig.showFaqs) : true,
|
|
faqs: dosageConfig.faqs && Array.isArray(dosageConfig.faqs) && dosageConfig.faqs.length > 0
|
|
? dosageConfig.faqs
|
|
: (data.faqs || []),
|
|
paoMonths: dosageConfig.paoMonths || undefined,
|
|
storageInfo: dosageConfig.storageInfo || data.storage || undefined,
|
|
precautions: dosageConfig.precautions || undefined,
|
|
lifeStage: dosageConfig.lifeStage || undefined,
|
|
keyHighlights: dosageConfig.keyHighlights || undefined,
|
|
dosageConfig: dosageConfig,
|
|
slug: data.slug,
|
|
image: data.imageUrl ? getMediaUrl(data.imageUrl) : '/assets/images/hero-section-image.png',
|
|
images: Array.isArray(data.images) && data.images.length > 0
|
|
? data.images.map((img: string) => getMediaUrl(img)).filter(Boolean)
|
|
: [],
|
|
|
|
main_ingredients: data.ingredientList?.map(i => i.ingredient) || data.ingredients?.split(/[،,-]/).map(s => s.trim()).filter(Boolean) || [],
|
|
symptoms: data.symptoms?.map(s => typeof s === 'string' ? s : s.symptom || '').filter(Boolean) || [],
|
|
keyBenefits: dosageConfig.keyBenefits && Array.isArray(dosageConfig.keyBenefits) && dosageConfig.keyBenefits.length > 0
|
|
? dosageConfig.keyBenefits
|
|
: safeParse<Product['keyBenefits']>(data.keyBenefits, []),
|
|
expectedResults: dosageConfig.expectedResults && Array.isArray(dosageConfig.expectedResults) && dosageConfig.expectedResults.length > 0
|
|
? dosageConfig.expectedResults
|
|
: safeParse<Product['expectedResults']>(data.expectedResults, []),
|
|
analysis: dosageConfig.analysis && typeof dosageConfig.analysis === 'object' && Object.keys(dosageConfig.analysis).length > 0
|
|
? dosageConfig.analysis
|
|
: safeParse<Record<string, string>>(data.compositionTable, {}),
|
|
onSetOfAction: dosageConfig.onSetOfAction || data.onSetOfAction || undefined,
|
|
specialBadge: dosageConfig.specialBadge || data.specialBadge || undefined,
|
|
contraindications: dosageConfig.contraindications
|
|
? (Array.isArray(dosageConfig.contraindications) ? dosageConfig.contraindications : [dosageConfig.contraindications])
|
|
: undefined,
|
|
specialist: validSpecialist,
|
|
|
|
podcastUrl: data.podcastUrl ? getMediaUrl(data.podcastUrl) : undefined,
|
|
podcastTitle: data.podcastTitle || undefined,
|
|
podcastDescription: data.podcastDescription || undefined,
|
|
podcastCover: data.podcastCover ? getMediaUrl(data.podcastCover) : undefined,
|
|
|
|
videoUrl: data.videoUrl ? getMediaUrl(data.videoUrl) : undefined,
|
|
videoTitle: data.videoTitle || undefined,
|
|
videoDescription: data.videoDescription || undefined,
|
|
videoCover: data.videoCover ? getMediaUrl(data.videoCover) : undefined,
|
|
|
|
pdfUrl: data.pdfUrl ? getMediaUrl(data.pdfUrl) : undefined,
|
|
pdfTitle: data.pdfTitle || undefined,
|
|
pdfDescription: data.pdfDescription || undefined,
|
|
pdfCover: data.pdfCover ? getMediaUrl(data.pdfCover) : undefined,
|
|
barcode: (inputData.barcode as string) || undefined,
|
|
metaTitle: (inputData.metaTitle as string) || undefined,
|
|
metaDescription: (inputData.metaDescription as string) || undefined,
|
|
canonicalUrl: (inputData.canonicalUrl as string) || undefined,
|
|
keywords: typeof inputData.keywords === 'string' ? inputData.keywords : undefined,
|
|
stockStatus: (inputData.stockStatus as 'IN_STOCK' | 'OUT_OF_STOCK' | 'PRE_ORDER' | 'DISCONTINUED') || 'IN_STOCK',
|
|
noIndex: Boolean(inputData.noIndex),
|
|
noFollow: Boolean(inputData.noFollow),
|
|
ogImage: inputData.ogImage ? getMediaUrl(inputData.ogImage as string) : undefined,
|
|
featuredImageAlt: (inputData.featuredImageAlt as string) || undefined,
|
|
updatedAt: (inputData.updatedAt as string) || (inputData.createdAt as string) || undefined,
|
|
reviews: Array.isArray(inputData.reviews) ? inputData.reviews as Product['reviews'] : undefined,
|
|
};
|
|
}
|
|
|
|
private async serverFetch(endpoint: string, tags: string[] = ['products'], revalidate = 3600) {
|
|
const apiUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4001/api';
|
|
const apiBase = apiUrl.endsWith('/api') ? apiUrl : `${apiUrl.replace(/\/$/, '')}/api`;
|
|
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
const res = await fetch(`${apiBase}${cleanEndpoint}`, {
|
|
next: { tags, revalidate },
|
|
});
|
|
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
public async getProducts(filters?: {
|
|
category?: string;
|
|
petType?: PetType | "all";
|
|
query?: string;
|
|
symptom?: string;
|
|
page?: number;
|
|
limit?: number;
|
|
sortBy?: string;
|
|
sortOrder?: 'asc' | 'desc';
|
|
}): Promise<{ data: Product[]; meta: { total: number; page: number; lastPage: number; limit: number } }> {
|
|
const params = new URLSearchParams();
|
|
if (filters?.category && filters.category !== "all") params.append('category', filters.category);
|
|
if (filters?.petType && filters.petType !== "all") params.append('petType', filters.petType);
|
|
if (filters?.query) params.append('search', filters.query);
|
|
if (filters?.symptom) params.append('symptom', filters.symptom);
|
|
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
|
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
|
params.append('page', String(filters?.page || 1));
|
|
params.append('limit', String(filters?.limit || 12));
|
|
|
|
try {
|
|
if (typeof window === 'undefined') {
|
|
const json = await this.serverFetch(`/products?${params.toString()}`, ['products'], 3600);
|
|
return {
|
|
data: json.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item)),
|
|
meta: json.meta,
|
|
};
|
|
}
|
|
const response = await api.get(`/products?${params.toString()}`);
|
|
return {
|
|
data: response.data.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item)),
|
|
meta: response.data.meta,
|
|
};
|
|
} catch (error) {
|
|
console.error("[ProductService] Failed to fetch products:", error);
|
|
return { data: [], meta: { total: 0, page: 1, lastPage: 0, limit: 12 } };
|
|
}
|
|
}
|
|
|
|
public async getProductById(id: string): Promise<Product | null> {
|
|
try {
|
|
if (typeof window === 'undefined') {
|
|
const data = await this.serverFetch(`/products/${id}`, ['products'], 3600);
|
|
return this.mapBackendToFrontend(data);
|
|
}
|
|
const response = await api.get(`/products/${id}`);
|
|
return this.mapBackendToFrontend(response.data);
|
|
} catch (error) {
|
|
console.error(`[ProductService] Failed to fetch product ${id}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async getProductBySlug(slug: string): Promise<Product | null> {
|
|
try {
|
|
if (typeof window === 'undefined') {
|
|
const data = await this.serverFetch(`/products/${encodeURIComponent(slug)}`, ['products', `product-${slug}`], 3600);
|
|
return this.mapBackendToFrontend(data);
|
|
}
|
|
const response = await api.get(`/products/${slug}`);
|
|
return this.mapBackendToFrontend(response.data);
|
|
} catch (error) {
|
|
console.error(`[ProductService] Failed to fetch product ${slug}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async getFeaturedProducts(): Promise<Product[]> {
|
|
try {
|
|
const response = await api.get('/products?limit=30');
|
|
const all = response.data.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item));
|
|
const featured: Product[] = [];
|
|
const categoriesSeen = new Set<string>();
|
|
|
|
for (const p of all) {
|
|
if (p.category && !categoriesSeen.has(p.category)) {
|
|
featured.push(p);
|
|
categoriesSeen.add(p.category);
|
|
}
|
|
if (featured.length === 4) break;
|
|
}
|
|
|
|
if (featured.length < 4) {
|
|
for (const p of all) {
|
|
if (!featured.some(f => f.id === p.id)) {
|
|
featured.push(p);
|
|
}
|
|
if (featured.length === 4) break;
|
|
}
|
|
}
|
|
return featured;
|
|
} catch (error) {
|
|
console.error("[ProductService] Failed to fetch featured products:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public async getActiveFilters(): Promise<{
|
|
categories: { id: string; name: string; slug: string }[];
|
|
symptoms: string[];
|
|
petTypes: string[];
|
|
}> {
|
|
try {
|
|
const response = await api.get('/products/filters');
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error("[ProductService] Failed to fetch active filters:", error);
|
|
return { categories: [], symptoms: [], petTypes: [] };
|
|
}
|
|
}
|
|
|
|
public async getNavigationFilters(): Promise<{
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
symptoms: string[];
|
|
}[]> {
|
|
try {
|
|
const response = await api.get('/products/navigation-filters');
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error("[ProductService] Failed to fetch navigation filters:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public async getBanners(): Promise<Banner[]> {
|
|
try {
|
|
const response = await api.get('/banners');
|
|
const data = response.data || [];
|
|
return data.map((b: Banner) => ({
|
|
...b,
|
|
imageUrl: getMediaUrl(b.imageUrl),
|
|
}));
|
|
} catch (error) {
|
|
console.error("[ProductService] Failed to fetch banners:", error);
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
export const productService = ProductService.getInstance();
|