feat(products): remove mock static fallbacks and add dynamic specialist recommendation management in database and admin panel
Some checks failed
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 7s
Deploy Canina / deploy (push) Has been cancelled

This commit is contained in:
parsa aghaei 2026-09-02 15:50:01 +03:30
parent 1ca06d5740
commit 00839017ff
7 changed files with 216 additions and 66 deletions

View File

@ -476,6 +476,7 @@ export class AdminService {
...(data.expectedResults !== undefined
? { expectedResults: data.expectedResults }
: {}),
...(data.specialist !== undefined ? { specialist: data.specialist } : {}),
...(data.isPreorder !== undefined ? { isPreorder: data.isPreorder } : {}),
...(data.preorderDeposit !== undefined
? { preorderDeposit: data.preorderDeposit }
@ -616,6 +617,7 @@ export class AdminService {
...(data.expectedResults !== undefined
? { expectedResults: data.expectedResults }
: {}),
...(data.specialist !== undefined ? { specialist: data.specialist } : {}),
...(data.isPreorder !== undefined ? { isPreorder: data.isPreorder } : {}),
...(data.preorderDeposit !== undefined
? { preorderDeposit: data.preorderDeposit }

View File

@ -332,6 +332,15 @@ export class ProductDto {
@IsArray()
expectedResults?: Array<{ icon: string; text: string }>;
@ApiPropertyOptional({ description: 'توصیه و نظر پزشک متخصص (Specialist Endorsement)' })
@IsOptional()
specialist?: {
name?: string;
title?: string;
image?: string;
message?: string;
};
@ApiPropertyOptional({ description: 'پیکربندی جامع دوز و داده‌های تکمیلی' })
@IsOptional()
dosageConfig?: Record<string, any>;

View File

@ -232,7 +232,7 @@ export default function Products() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [previewImage, setPreviewImage] = useState<{ url: string; title: string; subtitle?: string; artNo?: string } | null>(null);
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
const [mediaTargetField, setMediaTargetField] = useState<'imageUrl' | 'ogImage' | 'podcastUrl' | 'podcastCover' | 'videoUrl' | 'videoCover' | 'pdfUrl' | 'pdfCover' | 'gallery'>('imageUrl');
const [mediaTargetField, setMediaTargetField] = useState<'imageUrl' | 'ogImage' | 'podcastUrl' | 'podcastCover' | 'videoUrl' | 'videoCover' | 'pdfUrl' | 'pdfCover' | 'specialistImage' | 'gallery'>('imageUrl');
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('general');
@ -297,6 +297,12 @@ export default function Products() {
preorderDeposit: '' as number | string,
roundingStep: '' as number | string,
roundingMode: '' as string,
specialist: {
name: '',
title: '',
image: '',
message: '',
},
});
// Smart Pricing & Rounding state
@ -482,6 +488,14 @@ export default function Products() {
? (Array.isArray((product as unknown as { contraindications?: any }).contraindications) ? (product as unknown as { contraindications?: any }).contraindications.join('\n') : String((product as unknown as { contraindications?: any }).contraindications))
: '');
const rawSpecialist = dosageConfig.specialist || (product as unknown as { specialist?: any }).specialist;
const initialSpecialist = rawSpecialist && typeof rawSpecialist === 'object' ? {
name: rawSpecialist.name || '',
title: rawSpecialist.title || '',
image: rawSpecialist.image || '',
message: rawSpecialist.message || '',
} : { name: '', title: '', image: '', message: '' };
setFormData({
artNo: product.artNo || '',
nameFa: product.nameFa || '',
@ -542,6 +556,7 @@ export default function Products() {
preorderDeposit: product.preorderDeposit !== undefined && product.preorderDeposit !== null ? product.preorderDeposit : '',
roundingStep: product.roundingStep !== undefined && product.roundingStep !== null ? product.roundingStep : '',
roundingMode: product.roundingMode || '',
specialist: initialSpecialist,
});
} else {
setEditingProduct(null);
@ -574,7 +589,8 @@ export default function Products() {
podcastUrl: '', podcastTitle: '', podcastDescription: '', podcastCover: '',
videoUrl: '', videoTitle: '', videoDescription: '', videoCover: '',
pdfUrl: '', pdfTitle: '', pdfDescription: '', pdfCover: '',
symptoms: [], isPreorder: false, preorderDeposit: ''
symptoms: [], isPreorder: false, preorderDeposit: '',
specialist: { name: '', title: '', image: '', message: '' }
});
}
setIsModalOpen(true);
@ -916,7 +932,15 @@ export default function Products() {
slug: derivedSlug,
symptoms: formData.symptoms || [],
isPreorder: Boolean(formData.isPreorder),
preorderDeposit: formData.preorderDeposit ? String(formData.preorderDeposit) : undefined
preorderDeposit: formData.preorderDeposit ? String(formData.preorderDeposit) : undefined,
specialist: formData.specialist && (formData.specialist.name?.trim() || formData.specialist.message?.trim())
? {
name: (formData.specialist.name || '').trim(),
title: (formData.specialist.title || '').trim(),
image: (formData.specialist.image || '').trim(),
message: (formData.specialist.message || '').trim(),
}
: null,
};
if (editingProduct) {
@ -1822,6 +1846,103 @@ export default function Products() {
</div>
</div>
</div>
{/* Specialist Endorsement Card */}
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
<div className="flex items-center justify-between border-b pb-2">
<h4 className="text-sm font-black text-gray-800 flex items-center gap-2">
<Activity className="w-4 h-4 text-canina-blue" />
<span>توصیه و نظر پزشک متخصص (Specialist Endorsement)</span>
</h4>
{formData.specialist?.name && (
<button
type="button"
onClick={() => setFormData({
...formData,
specialist: { name: '', title: '', image: '', message: '' }
})}
className="text-xs text-red-600 hover:underline font-bold"
>
پاک کردن نظر پزشک
</button>
)}
</div>
<p className="text-[11px] text-gray-500 font-medium">
در صورت تکمیل این بخش، کادر نظر و توصیه بالینی پزشک در کاتالوگ و صفحه محصول نمایش داده میشود. در صورت خالی ماندن، هیچ کادری نمایش داده نخواهد شد.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">نام پزشک / متخصص</label>
<input
type="text"
value={formData.specialist?.name || ''}
onChange={(e) => setFormData({
...formData,
specialist: { ...(formData.specialist || { name: '', title: '', image: '', message: '' }), name: e.target.value }
})}
placeholder="مثال: دکتر علیرضا رضایی یا Dr. Klaus Henning"
className="w-full px-3 py-2 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-bold font-vazir"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">عنوان / تخصص پزشک</label>
<input
type="text"
value={formData.specialist?.title || ''}
onChange={(e) => setFormData({
...formData,
specialist: { ...(formData.specialist || { name: '', title: '', image: '', message: '' }), title: e.target.value }
})}
placeholder="مثال: متخصص جراحی استخوان و مفاصل دامپزشکی"
className="w-full px-3 py-2 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-bold font-vazir"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">تصویر پزشک (اختیاری)</label>
<div className="flex gap-2 items-center">
<input
type="text"
value={formData.specialist?.image || ''}
onChange={(e) => setFormData({
...formData,
specialist: { ...(formData.specialist || { name: '', title: '', image: '', message: '' }), image: e.target.value }
})}
placeholder="آدرس تصویر یا انتخاب از رسانه‌ها (/uploads/doctor.jpg)"
className="flex-1 px-3 py-2 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
<button
type="button"
onClick={() => {
setMediaTargetField('specialistImage');
setIsMediaSelectorOpen(true);
}}
className="px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl text-xs font-bold whitespace-nowrap cursor-pointer flex items-center gap-1"
>
<ImageIcon className="w-3.5 h-3.5" />
انتخاب تصویر
</button>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">متن توصیه / نظر بالینی پزشک</label>
<textarea
rows={3}
value={formData.specialist?.message || ''}
onChange={(e) => setFormData({
...formData,
specialist: { ...(formData.specialist || { name: '', title: '', image: '', message: '' }), message: e.target.value }
})}
placeholder="متن نظر پزشک درباره اثربخشی، کیفیت مواد اولیه یا پروتکل درمانی مکمل..."
className="w-full px-3 py-2 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs leading-relaxed font-vazir resize-none"
/>
</div>
</div>
</div>
{/* Benefits & Analysis Tab */}
@ -4030,6 +4151,14 @@ export default function Products() {
if (!formData.images.includes(url)) {
setFormData({ ...formData, images: [...formData.images, url] });
}
} else if (mediaTargetField === 'specialistImage') {
setFormData({
...formData,
specialist: {
...(formData.specialist || { name: '', title: '', image: '', message: '' }),
image: url,
}
});
} else {
setFormData({ ...formData, [mediaTargetField]: url });
if (mediaTargetField === 'imageUrl') setMediaImageError(false);
@ -4038,9 +4167,11 @@ export default function Products() {
selectedUrl={
(mediaTargetField as string) === 'gallery'
? undefined
: formData[mediaTargetField]?.startsWith('http')
? formData[mediaTargetField]
: formData[mediaTargetField]
: mediaTargetField === 'specialistImage'
? (formData.specialist?.image?.startsWith('http') ? formData.specialist?.image : formData.specialist?.image ? `${BASE_DOMAIN}${formData.specialist?.image}` : undefined)
: (formData[mediaTargetField] as string)?.startsWith('http')
? (formData[mediaTargetField] as string)
: (formData[mediaTargetField] as string)
? `${BASE_DOMAIN}${formData[mediaTargetField]}`
: undefined
}

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect } from "react";
import FlipbookCatalog from "../../components/catalog/FlipbookCatalog";
import { productService } from "../../lib/services/productService";
import { Product, PRODUCTS } from "../../lib/data/products";
import { Product } from "../../lib/data/products";
export default function CatalogClient() {
const [products, setProducts] = useState<Product[]>([]);
@ -15,12 +15,12 @@ export default function CatalogClient() {
if (res.data && res.data.length > 0) {
setProducts(res.data);
} else {
setProducts(PRODUCTS);
setProducts([]);
}
})
.catch(err => {
console.error("Catalog fetch error, fallback to static products:", err);
setProducts(PRODUCTS);
console.error("Catalog fetch error:", err);
setProducts([]);
})
.finally(() => setLoading(false));
}, []);

View File

@ -495,19 +495,34 @@ export default function CatalogPageSpread({
)}
{/* Specialist Endorsement */}
{product.specialist && (
{product.specialist && product.specialist.name && (
<div className="bg-white border border-slate-200/80 rounded-xl p-2 flex items-center gap-2 shrink-0 shadow-3xs">
<div className="w-7 h-7 rounded-full bg-canina-blue/10 border border-canina-blue/20 text-canina-blue flex items-center justify-center font-black text-xs font-mono shadow-3xs shrink-0">
<Stethoscope className="w-3.5 h-3.5" />
<div className="w-7 h-7 rounded-full bg-canina-blue/10 border border-canina-blue/20 text-canina-blue flex items-center justify-center font-black text-xs font-mono shadow-3xs shrink-0 overflow-hidden">
{product.specialist.image ? (
<img
src={product.specialist.image}
alt={product.specialist.name}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLElement).style.display = 'none';
}}
/>
) : (
<Stethoscope className="w-3.5 h-3.5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 truncate">
<span className="text-[8.5px] font-black text-slate-900 truncate">{product.specialist.name}</span>
<span className="text-[7.5px] text-slate-500 truncate font-medium">({product.specialist.title})</span>
{product.specialist.title && (
<span className="text-[7.5px] text-slate-500 truncate font-medium">({product.specialist.title})</span>
)}
</div>
<p className="text-[8px] text-slate-600 italic truncate mt-0.2">
«{product.specialist.message}»
</p>
{product.specialist.message && (
<p className="text-[8px] text-slate-600 italic truncate mt-0.2">
«{product.specialist.message}»
</p>
)}
</div>
</div>
)}

View File

@ -58,7 +58,7 @@ export interface Product {
calculateDosage?: (weight: number, isYoung: boolean) => DosageResult;
analysis: Record<string, string>;
feedingAdvice: string;
specialist: Specialist;
specialist?: Specialist;
image: string;
images?: string[];
podcastUrl?: string;

View File

@ -1,5 +1,5 @@
import api from './api';
import { PRODUCTS, Product, PetType } from "../data/products";
import { Product, PetType } from "../data/products";
import { Banner } from "../types";
import { getMediaUrl } from "../media";
@ -85,13 +85,8 @@ export class ProductService {
private mapBackendToFrontend(inputData: Record<string, unknown>): Product {
const data = inputData as unknown as BackendProduct;
// Find the local static product to inherit functions like calculateDosage
const local = PRODUCTS.find(p =>
p.artNo === data.artNo ||
p.id === data.productGroup ||
(typeof data.slug === 'string' && data.slug.includes(p.id))
);
const dosageConfig = (data.dosageConfig as Record<string, any>) || {};
const safeParse = <T>(val: unknown, fallback: T): T => {
if (!val) return fallback;
if (typeof val === 'string') {
@ -106,12 +101,15 @@ export class ProductService {
const priceVal = Number(data.priceValue || 0);
const defaultSpecialist = local?.specialist || {
name: 'تیم تخصصی کنینا',
title: 'مشاور علمی و دارویی',
image: '/images/vets/vet1.webp',
message: 'تمامی محصولات مکمل کنینا دارای استانداردهای دارویی آلمان و تاییدیه کلینیکی هستند.'
};
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 || '',
@ -132,48 +130,43 @@ export class ProductService {
benefits: data.benefits || '',
suitableFor: (data.suitableFor as PetType) || 'سگ',
requiresRx: Boolean(data.requiresRx),
storage: (data.dosageConfig as Record<string, any>)?.storageInfo || data.storage,
storage: dosageConfig.storageInfo || data.storage,
optimisticTemplate: data.optimisticTemplate,
feedingAdvice: (data.dosageConfig as Record<string, any>)?.feedingAdvice || data.dosageLogic || data.feedingAdvice || '',
showFaqs: (data.dosageConfig as Record<string, any>)?.showFaqs !== undefined ? Boolean((data.dosageConfig as Record<string, any>).showFaqs) : true,
faqs: (data.dosageConfig as Record<string, any>)?.faqs && Array.isArray((data.dosageConfig as Record<string, any>).faqs) && (data.dosageConfig as Record<string, any>).faqs.length > 0
? (data.dosageConfig as Record<string, any>).faqs
: (local?.faqs || []),
paoMonths: (data.dosageConfig as Record<string, any>)?.paoMonths || undefined,
storageInfo: (data.dosageConfig as Record<string, any>)?.storageInfo || data.storage || undefined,
precautions: (data.dosageConfig as Record<string, any>)?.precautions || undefined,
lifeStage: (data.dosageConfig as Record<string, any>)?.lifeStage || undefined,
keyHighlights: (data.dosageConfig as Record<string, any>)?.keyHighlights || undefined,
dosageConfig: (data.dosageConfig as Record<string, any>) || undefined,
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: (() => {
if (!data.imageUrl) return local?.image || '/assets/images/hero-section-image.png';
if (data.imageUrl.startsWith('/products/')) return local?.image || '/assets/images/hero-section-image.png';
return getMediaUrl(data.imageUrl) || local?.image || '/assets/images/hero-section-image.png';
})(),
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)
: (local?.images || []),
: [],
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: (data.dosageConfig as Record<string, any>)?.keyBenefits && Array.isArray((data.dosageConfig as Record<string, any>).keyBenefits) && (data.dosageConfig as Record<string, any>).keyBenefits.length > 0
? (data.dosageConfig as Record<string, any>).keyBenefits
: safeParse<Product['keyBenefits']>(data.keyBenefits, local?.keyBenefits || []),
expectedResults: (data.dosageConfig as Record<string, any>)?.expectedResults && Array.isArray((data.dosageConfig as Record<string, any>).expectedResults) && (data.dosageConfig as Record<string, any>).expectedResults.length > 0
? (data.dosageConfig as Record<string, any>).expectedResults
: safeParse<Product['expectedResults']>(data.expectedResults, local?.expectedResults || []),
analysis: (data.dosageConfig as Record<string, any>)?.analysis && typeof (data.dosageConfig as Record<string, any>).analysis === 'object' && Object.keys((data.dosageConfig as Record<string, any>).analysis).length > 0
? (data.dosageConfig as Record<string, any>).analysis
: safeParse<Record<string, string>>(data.compositionTable, local?.analysis || {}),
onSetOfAction: (data.dosageConfig as Record<string, any>)?.onSetOfAction || data.onSetOfAction || local?.onSetOfAction || undefined,
specialBadge: (data.dosageConfig as Record<string, any>)?.specialBadge || data.specialBadge || local?.specialBadge || undefined,
contraindications: (data.dosageConfig as Record<string, any>)?.contraindications
? (Array.isArray((data.dosageConfig as Record<string, any>).contraindications) ? (data.dosageConfig as Record<string, any>).contraindications : [(data.dosageConfig as Record<string, any>).contraindications])
: (local?.contraindications || undefined),
specialist: safeParse<Product['specialist']>(data.specialist, defaultSpecialist),
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,
calculateDosage: local?.calculateDosage,
podcastUrl: data.podcastUrl ? getMediaUrl(data.podcastUrl) : undefined,
podcastTitle: data.podcastTitle || undefined,
podcastDescription: data.podcastDescription || undefined,