feat(admin): implement Phase B management modules based on frontend integration contract
Some checks failed
Deploy Canina / deploy (push) Failing after 28s

This commit is contained in:
پارسا آقایی 2026-08-06 23:58:10 +03:30
parent 8dc2b1e586
commit f70c1fe3c2
12 changed files with 2998 additions and 13 deletions

View File

@ -1,6 +1,6 @@
import { useState, useRef } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall } from 'lucide-react';
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall, Sparkles, MessageSquareQuote, FlaskConical, Building2, Globe, DollarSign, Sliders } from 'lucide-react';
import api from '../services/api';
import { useAdminAuthStore } from '../store/adminAuthStore';
@ -13,36 +13,44 @@ const menuGroups = [
]
},
{
title: 'فروشگاه',
title: 'فروشگاه و تخصصی',
items: [
{ icon: ShoppingCart, label: 'سفارشات', path: '/orders' },
{ icon: Package, label: 'محصولات', path: '/products' },
{ icon: FolderTree, label: 'دسته‌بندی‌ها', path: '/categories' },
{ icon: Tag, label: 'کدهای تخفیف', path: '/coupons' },
{ icon: Sparkles, label: 'مشاور هوشمند', path: '/smart-advisor' },
{ icon: FileText, label: 'نسخه‌های پزشکی', path: '/prescriptions' },
]
},
{
title: 'مدیریت و کاربران',
title: 'مدیریت و همکاران B2B',
items: [
{ icon: Users, label: 'کاربران', path: '/users' },
{ icon: Heart, label: 'حیوانات (Pets)', path: '/pets' },
{ icon: Package, label: 'درخواست‌های B2B', path: '/wholesale' },
{ icon: Building2, label: 'مدیریت B2B & عمده', path: '/b2b' },
{ icon: PhoneCall, label: 'تماس با ما & اطلاعات', path: '/contact' },
]
},
{
title: 'محتوا',
title: 'محتوا و دانشنامه',
items: [
{ icon: Image, label: 'بنرها و اسلایدرها', path: '/banners' },
{ icon: FlaskConical, label: 'دانشنامه ترکیبات', path: '/ingredients' },
{ icon: MessageSquareQuote, label: 'نظرات و گواهی‌ها', path: '/testimonials' },
{ icon: FileText, label: 'وبلاگ', path: '/blogs' },
{ icon: BookOpen, label: 'دانشنامه', path: '/wiki' },
{ icon: BookOpen, label: 'دانشنامه عمومی', path: '/wiki' },
{ icon: Video, label: 'مدیریت ویدئوها', path: '/videos' },
{ icon: Image, label: 'بنرها و نظرات (CMS)', path: '/cms' },
{ icon: Image, label: 'مدیریت CMS قدیم', path: '/cms' },
]
},
{
title: 'سیستم',
title: 'سیستم و تنظیمات',
items: [
{ icon: Settings, label: 'تنظیمات', path: '/settings' },
{ icon: Settings, label: 'تنظیمات کلی', path: '/settings' },
{ icon: Globe, label: 'تنظیمات سئو', path: '/settings/seo' },
{ icon: DollarSign, label: 'تنظیمات مالی & ارسال', path: '/settings/financial' },
{ icon: Sliders, label: 'تنظیمات سیستمی', path: '/settings/system' },
{ icon: Languages, label: 'متون رابط کاربری', path: '/ui-texts' },
{ icon: Image, label: 'مدیریت رسانه', path: '/media' },
]
@ -150,7 +158,5 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
</>
);
}
function useEffect(arg0: () => () => void, arg1: undefined[]) {
throw new Error('Function not implemented.');
}

View File

@ -0,0 +1,499 @@
import { useState, useEffect } from 'react';
import { Building2, CheckCircle2, XCircle, Clock, PhoneCall, Filter, X, CreditCard, Plus, ShieldCheck } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import type { B2BInquiry, PartnerAccount } from '../types/admin';
export default function B2BManager() {
const [activeTab, setActiveTab] = useState<'inquiries' | 'partners'>('inquiries');
// Inquiries State
const [inquiries, setInquiries] = useState<B2BInquiry[]>([]);
const [isInquiriesLoading, setIsInquiriesLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string>('ALL');
// Inquiry Review Modal State
const [selectedInquiry, setSelectedInquiry] = useState<B2BInquiry | null>(null);
const [inquiryStatus, setInquiryStatus] = useState<'PENDING' | 'CONTACTED' | 'APPROVED' | 'REJECTED'>('CONTACTED');
const [inquiryAdminNotes, setInquiryAdminNotes] = useState('');
const [isInquirySubmitting, setIsInquirySubmitting] = useState(false);
// Partners State
const [partners, setPartners] = useState<PartnerAccount[]>([]);
const [isPartnersLoading, setIsPartnersLoading] = useState(false);
const [isPartnerModalOpen, setIsPartnerModalOpen] = useState(false);
const [partnerFormData, setPartnerFormData] = useState({
userId: '',
companyName: '',
taxId: '',
creditLimit: '100000000',
discountTier: 'tier1',
status: 'ACTIVE',
});
const [isPartnerSubmitting, setIsPartnerSubmitting] = useState(false);
const fetchInquiries = async () => {
try {
setIsInquiriesLoading(true);
const res = await api.get('/b2b/inquiries');
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setInquiries(data);
} catch (err) {
console.error('Failed to fetch B2B inquiries:', err);
toast.error('خطا در دریافت لیست درخواست‌های B2B');
} finally {
setIsInquiriesLoading(false);
}
};
const fetchPartners = async () => {
try {
setIsPartnersLoading(true);
const res = await api.get('/admin/b2b/partners');
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setPartners(data);
} catch (err) {
console.warn('Failed to fetch partner accounts:', err);
// Fallback empty if backend endpoint not populated
setPartners([]);
} finally {
setIsPartnersLoading(false);
}
};
useEffect(() => {
if (activeTab === 'inquiries') {
fetchInquiries();
} else {
fetchPartners();
}
}, [activeTab]);
const openInquiryReview = (inquiry: B2BInquiry) => {
setSelectedInquiry(inquiry);
setInquiryStatus(inquiry.status || 'CONTACTED');
setInquiryAdminNotes(inquiry.adminNotes || '');
};
const handleInquiryReviewSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedInquiry) return;
try {
setIsInquirySubmitting(true);
await api.patch(`/b2b/inquiries/${selectedInquiry.id}`, {
status: inquiryStatus,
adminNotes: inquiryAdminNotes.trim() || undefined,
});
toast.success('وضعیت درخواست B2B بروزرسانی شد');
setSelectedInquiry(null);
fetchInquiries();
} catch (err) {
console.error('Failed to review B2B inquiry:', err);
toast.error('خطا در بروزرسانی وضعیت درخواست B2B');
} finally {
setIsInquirySubmitting(false);
}
};
const handlePartnerSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!partnerFormData.userId.trim() || !partnerFormData.companyName.trim()) {
toast.error('شناسه کاربر و نام شرکت اجباری هستند');
return;
}
try {
setIsPartnerSubmitting(true);
await api.post('/admin/b2b/partners', partnerFormData);
toast.success('حساب همکار B2B جدید ایجاد شد');
setIsPartnerModalOpen(false);
fetchPartners();
} catch (err) {
console.error('Failed to create partner account:', err);
toast.error('خطا در ثبت حساب همکار B2B');
} finally {
setIsPartnerSubmitting(false);
}
};
const filteredInquiries = inquiries.filter(inq => {
if (statusFilter === 'ALL') return true;
return inq.status === statusFilter;
});
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<Building2 className="w-6 h-6 text-purple-600" />
مدیریت همکاران عمدهفروشی و B2B
</h2>
<p className="text-gray-500 font-medium mt-1">بررسی درخواستهای نمایندگی، تنظیم سقف اعتبار و اعطای کدهای تخفیف همکار</p>
</div>
{/* Tab Toggle */}
<div className="flex items-center gap-2 bg-white p-1.5 rounded-2xl border border-gray-200 shadow-sm">
<button
onClick={() => setActiveTab('inquiries')}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all ${
activeTab === 'inquiries' ? 'bg-purple-600 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-50'
}`}
>
درخواستهای دریافت نمایندگی ({inquiries.length})
</button>
<button
onClick={() => setActiveTab('partners')}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all ${
activeTab === 'partners' ? 'bg-purple-600 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-50'
}`}
>
حسابهای تاییدشده همکار (Partners)
</button>
</div>
</div>
{activeTab === 'inquiries' ? (
<div className="space-y-4">
<div className="flex justify-end">
<div className="flex items-center gap-2 bg-white p-1.5 rounded-2xl border border-gray-200 shadow-sm">
<Filter className="w-4 h-4 text-gray-400 mr-2" />
{[
{ id: 'ALL', label: 'همه' },
{ id: 'PENDING', label: 'جدید' },
{ id: 'CONTACTED', label: 'تماس گرفته شده' },
{ id: 'APPROVED', label: 'تایید شده' },
{ id: 'REJECTED', label: 'رد شده' },
].map(f => (
<button
key={f.id}
onClick={() => setStatusFilter(f.id)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
statusFilter === f.id ? 'bg-purple-50 text-purple-700 border border-purple-200' : 'text-gray-600 hover:bg-gray-50'
}`}
>
{f.label}
</button>
))}
</div>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="py-4 px-6 text-sm font-bold text-gray-500">شرکت / رابط</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">اطلاعات تماس</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">نوع کسبوکار / حجم متقاضی</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">متن پیام</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">وضعیت</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-28">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isInquiriesLoading ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<Spinner size="lg" className="mx-auto text-purple-600" />
</td>
</tr>
) : filteredInquiries.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-gray-500 font-medium">درخواستی یافت نشد</td>
</tr>
) : (
filteredInquiries.map((inq) => (
<tr key={inq.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-bold text-gray-900">{inq.companyName}</span>
<span className="text-xs text-gray-500">{inq.contactName}</span>
</div>
</td>
<td className="py-4 px-6">
<div className="flex flex-col font-mono text-xs text-gray-600" dir="ltr">
<span>{inq.phone}</span>
<span>{inq.email}</span>
</div>
</td>
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="inline-block px-2.5 py-0.5 bg-purple-50 text-purple-700 text-xs font-bold rounded-lg border border-purple-100 w-max">
{inq.businessType}
</span>
{inq.estimatedVolume && <span className="text-[11px] text-gray-400 mt-1">{inq.estimatedVolume}</span>}
</div>
</td>
<td className="py-4 px-6 text-gray-600 text-xs max-w-xs line-clamp-2">
{inq.message}
</td>
<td className="py-4 px-6">
{inq.status === 'APPROVED' ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-50 text-green-700 text-xs font-bold rounded-full border border-green-200">
<CheckCircle2 className="w-3.5 h-3.5" /> تایید شده
</span>
) : inq.status === 'CONTACTED' ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-700 text-xs font-bold rounded-full border border-blue-200">
<PhoneCall className="w-3.5 h-3.5" /> تماس گرفته شده
</span>
) : inq.status === 'REJECTED' ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-red-50 text-red-700 text-xs font-bold rounded-full border border-red-200">
<XCircle className="w-3.5 h-3.5" /> رد شده
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-amber-50 text-amber-700 text-xs font-bold rounded-full border border-amber-200">
<Clock className="w-3.5 h-3.5" /> در انتظار بررسی
</span>
)}
</td>
<td className="py-4 px-6">
<button
onClick={() => openInquiryReview(inq)}
className="px-3 py-1.5 bg-purple-600 text-white hover:bg-purple-700 rounded-lg text-xs font-bold transition-all shadow-sm"
>
تغییر وضعیت
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-bold text-gray-800">فهرست حسابهای تایید شده همکار</h3>
<button
onClick={() => setIsPartnerModalOpen(true)}
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-xl flex items-center gap-2 font-bold text-xs shadow-md shadow-purple-200"
>
<Plus className="w-4 h-4" />
افزودن همکار B2B جدید
</button>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="py-4 px-6 text-sm font-bold text-gray-500">نام شرکت / موسسه</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">User ID</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">کد اقتصادی (Tax ID)</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">سقف اعتبار (تومان)</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">سطح تخفیف</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">وضعیت</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isPartnersLoading ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<Spinner size="lg" className="mx-auto text-purple-600" />
</td>
</tr>
) : partners.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-gray-500 font-medium">هیچ حساب همکار B2B به صورت دستی تعریف نشده است</td>
</tr>
) : (
partners.map((partner) => (
<tr key={partner.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6 font-bold text-gray-900">{partner.companyName}</td>
<td className="py-4 px-6 font-mono text-xs text-gray-500">{partner.userId}</td>
<td className="py-4 px-6 font-mono text-xs text-gray-500">{partner.taxId || '-'}</td>
<td className="py-4 px-6 font-bold text-purple-700">
{Number(partner.creditLimit).toLocaleString()} تومان
</td>
<td className="py-4 px-6">
<span className="px-2.5 py-1 bg-purple-50 text-purple-700 text-xs font-bold rounded-lg border border-purple-100">
{partner.discountTier}
</span>
</td>
<td className="py-4 px-6">
<span className="inline-flex items-center gap-1 text-green-600 text-xs font-bold">
<ShieldCheck className="w-4 h-4" /> {partner.status}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Inquiry Review Modal */}
{selectedInquiry && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-lg font-bold text-gray-900">
بررسی درخواست B2B ({selectedInquiry.companyName})
</h3>
<button onClick={() => setSelectedInquiry(null)} className="text-gray-400 hover:text-red-500 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleInquiryReviewSubmit} className="p-6 space-y-4">
<div className="p-4 bg-purple-50/50 rounded-xl border border-purple-100 space-y-1 text-xs">
<div><span className="font-bold text-gray-600">شخص رابط: </span>{selectedInquiry.contactName} ({selectedInquiry.phone})</div>
<div><span className="font-bold text-gray-600">پیام درخواست: </span>{selectedInquiry.message}</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">تغییر وضعیت درخواست *</label>
<select
value={inquiryStatus}
onChange={(e) => setInquiryStatus(e.target.value as any)}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold bg-white"
>
<option value="PENDING">در انتظار بررسی (PENDING)</option>
<option value="CONTACTED">تماس حاصل شد (CONTACTED)</option>
<option value="APPROVED">تایید شده (APPROVED)</option>
<option value="REJECTED">رد شده (REJECTED)</option>
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">یادداشت ادمین (adminNotes)</label>
<textarea
rows={4}
value={inquiryAdminNotes}
onChange={(e) => setInquiryAdminNotes(e.target.value)}
placeholder="مذاکره انجام شد. لیست قیمت عمده ارسال گردید."
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={() => setSelectedInquiry(null)}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold hover:bg-gray-50 transition-colors"
>
انصراف
</button>
<button
type="submit"
disabled={isInquirySubmitting}
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2"
>
{isInquirySubmitting && <Spinner size="sm" />}
بروزرسانی وضعیت
</button>
</div>
</form>
</div>
</div>
)}
{/* Partner Account Setup Modal */}
{isPartnerModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-lg font-bold text-gray-900">
تعریف حساب همکار B2B جدید
</h3>
<button onClick={() => setIsPartnerModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handlePartnerSave} className="p-6 space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">شناسه کاربر (User ID) *</label>
<input
required
type="text"
value={partnerFormData.userId}
onChange={(e) => setPartnerFormData({ ...partnerFormData, userId: e.target.value })}
placeholder="uuid-string-of-user"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">نام شرکت / سازمان *</label>
<input
required
type="text"
value={partnerFormData.companyName}
onChange={(e) => setPartnerFormData({ ...partnerFormData, companyName: e.target.value })}
placeholder="کلینیک دامپزشکی پارت"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">کد اقتصادی (Tax ID)</label>
<input
type="text"
value={partnerFormData.taxId}
onChange={(e) => setPartnerFormData({ ...partnerFormData, taxId: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">سقف اعتبار (تومان)</label>
<input
type="number"
value={partnerFormData.creditLimit}
onChange={(e) => setPartnerFormData({ ...partnerFormData, creditLimit: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold"
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">سطح تخفیف (Discount Tier)</label>
<select
value={partnerFormData.discountTier}
onChange={(e) => setPartnerFormData({ ...partnerFormData, discountTier: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold bg-white"
>
<option value="tier1">سطح ۱ (۱۵٪ تخفیف)</option>
<option value="tier2">سطح ۲ (۲۵٪ تخفیف)</option>
<option value="tier3">سطح ۳ (۳۵٪ تخفیف)</option>
</select>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={() => setIsPartnerModalOpen(false)}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold hover:bg-gray-50 transition-colors"
>
انصراف
</button>
<button
type="submit"
disabled={isPartnerSubmitting}
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2"
>
{isPartnerSubmitting && <Spinner size="sm" />}
ایجاد حساب همکار
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,417 @@
import { useState, useEffect } from 'react';
import { Image as ImageIcon, Plus, Edit2, Trash2, X, MoveUp, MoveDown, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api, { BASE_DOMAIN } from '../services/api';
import Spinner from '../components/ui/Spinner';
import ConfirmModal from '../components/ui/ConfirmModal';
import MediaSelector from '../components/ui/MediaSelector';
import type { Banner } from '../types/admin';
export default function BannersManager() {
const [banners, setBanners] = useState<Banner[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
const [editingBanner, setEditingBanner] = useState<Banner | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({
title: '',
subtitle: '',
imageUrl: '',
linkUrl: '',
position: 'home_hero',
isActive: true,
order: 0,
});
const fetchBanners = async () => {
try {
setIsLoading(true);
const res = await api.get('/banners');
// res.data can be direct array or wrapped in response object
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setBanners(data.sort((a: Banner, b: Banner) => a.order - b.order));
} catch (err) {
console.error('Failed to fetch banners:', err);
toast.error('خطا در دریافت لیست بنرها');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchBanners();
}, []);
const openModal = (banner: Banner | null = null) => {
if (banner) {
setEditingBanner(banner);
setFormData({
title: banner.title || '',
subtitle: banner.subtitle || '',
imageUrl: banner.imageUrl || '',
linkUrl: banner.linkUrl || '',
position: banner.position || 'home_hero',
isActive: banner.isActive ?? true,
order: banner.order || 0,
});
} else {
setEditingBanner(null);
setFormData({
title: '',
subtitle: '',
imageUrl: '',
linkUrl: '',
position: 'home_hero',
isActive: true,
order: banners.length,
});
}
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.title.trim() || !formData.imageUrl.trim()) {
toast.error('عنوان و لینک تصویر اجباری هستند');
return;
}
try {
setIsSaving(true);
if (editingBanner) {
await api.patch(`/banners/${editingBanner.id}`, formData);
toast.success('بنر با موفقیت بروزرسانی شد');
} else {
await api.post('/banners', formData);
toast.success('بنر جدید با موفقیت ایجاد شد');
}
setIsModalOpen(false);
fetchBanners();
} catch (err) {
console.error('Failed to save banner:', err);
toast.error('خطا در ذخیره اطلاعات بنر');
} finally {
setIsSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTargetId) return;
try {
await api.delete(`/banners/${deleteTargetId}`);
toast.success('بنر با موفقیت حذف شد');
fetchBanners();
} catch (err) {
console.error('Failed to delete banner:', err);
toast.error('خطا در حذف بنر');
} finally {
setDeleteTargetId(null);
}
};
const handleMoveOrder = async (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= banners.length) return;
const newBanners = [...banners];
const temp = newBanners[index];
newBanners[index] = newBanners[targetIndex];
newBanners[targetIndex] = temp;
// Update order numbers
const reorderedPayload = newBanners.map((b, i) => ({ id: b.id, order: i }));
setBanners(newBanners.map((b, i) => ({ ...b, order: i })));
try {
await api.put('/banners/reorder', reorderedPayload);
toast.success('ترتیب بنرها به روز شد');
} catch (err) {
console.error('Failed to reorder banners:', err);
toast.error('خطا در تغییر ترتیب بنرها');
fetchBanners();
}
};
const getImageFullUrl = (url: string) => {
if (!url) return '';
if (url.startsWith('http')) return url;
return url.startsWith('/') ? `${BASE_DOMAIN}${url}` : `${BASE_DOMAIN}/${url}`;
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<ImageIcon className="w-6 h-6 text-purple-600" />
مدیریت بنرها و اسلایدرها
</h2>
<p className="text-gray-500 font-medium mt-1">مدیریت اسلایدر صفحه اصلی و تبلیغات بنری</p>
</div>
<button
onClick={() => openModal()}
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
>
<Plus className="w-5 h-5" />
افزودن بنر جدید
</button>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="py-4 px-6 text-sm font-bold text-gray-500">ترتیب</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">تصویر</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">عنوان و زیرعنوان</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">موقعیت</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">لینک هدف</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">وضعیت</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-28">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isLoading ? (
<tr>
<td colSpan={7} className="py-12 text-center">
<Spinner size="lg" className="mx-auto text-purple-600" />
</td>
</tr>
) : banners.length === 0 ? (
<tr>
<td colSpan={7} className="py-12 text-center text-gray-500 font-medium">هیچ بنری ثبت نشده است</td>
</tr>
) : (
banners.map((banner, index) => (
<tr key={banner.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6">
<div className="flex items-center gap-1">
<button
disabled={index === 0}
onClick={() => handleMoveOrder(index, 'up')}
className="p-1 text-gray-400 hover:text-purple-600 disabled:opacity-30"
>
<MoveUp className="w-4 h-4" />
</button>
<span className="font-mono text-sm font-bold text-gray-600 px-1">{index + 1}</span>
<button
disabled={index === banners.length - 1}
onClick={() => handleMoveOrder(index, 'down')}
className="p-1 text-gray-400 hover:text-purple-600 disabled:opacity-30"
>
<MoveDown className="w-4 h-4" />
</button>
</div>
</td>
<td className="py-3 px-6">
<div className="w-20 h-12 rounded-xl bg-gray-100 border border-gray-200 overflow-hidden flex items-center justify-center">
{banner.imageUrl ? (
<img src={getImageFullUrl(banner.imageUrl)} alt={banner.title} className="w-full h-full object-cover" />
) : (
<ImageIcon className="w-6 h-6 text-gray-400" />
)}
</div>
</td>
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-bold text-gray-900">{banner.title}</span>
{banner.subtitle && <span className="text-xs text-gray-500 mt-0.5">{banner.subtitle}</span>}
</div>
</td>
<td className="py-4 px-6">
<span className="inline-block px-3 py-1 bg-purple-50 text-purple-700 text-xs font-bold rounded-lg border border-purple-100">
{banner.position === 'home_hero' ? 'اسلایدر هیرو اصلی' : banner.position === 'category_top' ? 'بالای دسته‌بندی' : 'سایدبار'}
</span>
</td>
<td className="py-4 px-6 text-gray-500 font-mono text-xs dir-ltr text-right">
{banner.linkUrl || '-'}
</td>
<td className="py-4 px-6">
{banner.isActive ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-50 text-green-700 text-xs font-bold rounded-full border border-green-200">
<CheckCircle2 className="w-3.5 h-3.5" /> فعال
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-gray-100 text-gray-600 text-xs font-bold rounded-full border border-gray-200">
<XCircle className="w-3.5 h-3.5" /> غیرفعال
</span>
)}
</td>
<td className="py-4 px-6">
<div className="flex items-center gap-2">
<button onClick={() => openModal(banner)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => setDeleteTargetId(banner.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Form Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-lg font-bold text-gray-900">
{editingBanner ? 'ویرایش بنر' : 'افزودن بنر جدید'}
</h3>
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSave} className="p-6 space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">عنوان بنر *</label>
<input
required
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="مثال: تخصص دارویی از آلمان"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">زیرعنوان (اختیاری)</label>
<input
type="text"
value={formData.subtitle}
onChange={(e) => setFormData({ ...formData, subtitle: e.target.value })}
placeholder="مثال: بیش از ۴۰ سال تجربه"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">آدرس تصویر (imageUrl) *</label>
<div className="flex gap-2">
<input
required
type="text"
value={formData.imageUrl}
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
placeholder="https://... یا /uploads/..."
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
<button
type="button"
onClick={() => setIsMediaSelectorOpen(true)}
className="px-3 py-2.5 bg-purple-50 text-purple-600 hover:bg-purple-100 rounded-xl font-bold text-xs shrink-0"
>
انتخاب از رسانه
</button>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">لینک هدف (linkUrl)</label>
<input
type="text"
value={formData.linkUrl}
onChange={(e) => setFormData({ ...formData, linkUrl: e.target.value })}
placeholder="مثال: /products/joints"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">موقعیت نمایش</label>
<select
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold bg-white"
>
<option value="home_hero">اسلایدر هیرو اصلی (home_hero)</option>
<option value="category_top">بالای دستهبندی (category_top)</option>
<option value="sidebar">سایدبار (sidebar)</option>
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">ترتیب نمایش (order)</label>
<input
type="number"
value={formData.order}
onChange={(e) => setFormData({ ...formData, order: Number(e.target.value) })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold"
/>
</div>
</div>
<div className="pt-2 flex items-center justify-between">
<span className="text-sm font-bold text-gray-700">وضعیت بنر</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold hover:bg-gray-50 transition-colors"
>
انصراف
</button>
<button
type="submit"
disabled={isSaving}
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2"
>
{isSaving && <Spinner size="sm" />}
ذخیره تغییرات
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteTargetId && (
<ConfirmModal
isOpen={Boolean(deleteTargetId)}
title="حذف بنر"
message="آیا از حذف این بنر اطمینان دارید؟ این عملیات قابل بازگشت نیست."
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
)}
{/* Media Selector */}
{isMediaSelectorOpen && (
<MediaSelector
isOpen={isMediaSelectorOpen}
onClose={() => setIsMediaSelectorOpen(false)}
onSelect={(url) => {
setFormData({ ...formData, imageUrl: url });
setIsMediaSelectorOpen(false);
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,187 @@
import { useState, useEffect } from 'react';
import { DollarSign, Save, Plus, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import type { FinancialSettings } from '../types/admin';
export default function FinancialSettingsPage() {
const [financial, setFinancial] = useState<FinancialSettings>({
taxPercentage: 10,
freeShippingThreshold: 2000000,
standardShippingFee: 85000,
charityDonationOptions: [10000, 20000, 50000],
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [donationInput, setDonationInput] = useState('');
useEffect(() => {
let isSubscribed = true;
api.get('/settings/financial')
.then(res => {
if (!isSubscribed) return;
const data = res.data?.data || res.data;
if (data) {
setFinancial({
taxPercentage: Number(data.taxPercentage ?? 10),
freeShippingThreshold: Number(data.freeShippingThreshold ?? 2000000),
standardShippingFee: Number(data.standardShippingFee ?? 85000),
charityDonationOptions: Array.isArray(data.charityDonationOptions)
? data.charityDonationOptions.map(Number)
: [10000, 20000, 50000],
});
}
})
.catch(err => {
console.warn('Failed to fetch Financial settings, using default state', err);
})
.finally(() => {
if (isSubscribed) setIsLoading(false);
});
return () => {
isSubscribed = false;
};
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsSaving(true);
await api.patch('/settings/financial', financial);
toast.success('تنظیمات مالی و ارسال با موفقیت بروزرسانی شد');
} catch (err) {
console.error('Failed to update financial settings:', err);
toast.error('خطا در بروزرسانی تنظیمات مالی');
} finally {
setIsSaving(false);
}
};
const addDonationOption = () => {
const val = Number(donationInput);
if (val > 0 && !financial.charityDonationOptions.includes(val)) {
setFinancial({
...financial,
charityDonationOptions: [...financial.charityDonationOptions, val].sort((a, b) => a - b),
});
setDonationInput('');
}
};
const removeDonationOption = (amount: number) => {
setFinancial({
...financial,
charityDonationOptions: financial.charityDonationOptions.filter(a => a !== amount),
});
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<DollarSign className="w-6 h-6 text-purple-600" />
تنظیمات مالی و ارسال (Financial & Shipping Settings)
</h2>
<p className="text-gray-500 font-medium mt-1">تنظیم درصد مالیات، آستانه ارسال رایگان و گزینههای کمک به خیریه</p>
</div>
</div>
{isLoading ? (
<div className="flex justify-center p-12"><Spinner size="lg" className="text-purple-600" /></div>
) : (
<form onSubmit={handleSave} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">درصد مالیات ارزش افزوده (taxPercentage) *</label>
<div className="relative">
<input
required
type="number"
min="0"
max="100"
value={financial.taxPercentage}
onChange={(e) => setFinancial({ ...financial, taxPercentage: Number(e.target.value) })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-bold text-sm"
dir="ltr"
/>
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 font-bold">%</span>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">هزینه ارسال استاندارد (standardShippingFee - تومان) *</label>
<input
required
type="number"
min="0"
value={financial.standardShippingFee}
onChange={(e) => setFinancial({ ...financial, standardShippingFee: Number(e.target.value) })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-bold text-sm"
dir="ltr"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-bold text-gray-700 mb-1">آستانه ارسال رایگان (freeShippingThreshold - تومان) *</label>
<input
required
type="number"
min="0"
value={financial.freeShippingThreshold}
onChange={(e) => setFinancial({ ...financial, freeShippingThreshold: Number(e.target.value) })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-bold text-sm"
dir="ltr"
/>
<p className="text-xs text-gray-500 mt-1">سبدهای خرید با مبلغ بالاتر از این مقدار، ارسال رایگان خواهند داشت.</p>
</div>
<div className="md:col-span-2 space-y-2">
<label className="block text-sm font-bold text-gray-700 mb-1">گزینههای کمک به خیریه ردپای مهربانی (charityDonationOptions)</label>
<div className="flex gap-2 max-w-md">
<input
type="number"
placeholder="مبلغ جدید (مثال: 100000)"
value={donationInput}
onChange={(e) => setDonationInput(e.target.value)}
className="flex-1 px-4 py-2 rounded-xl border border-gray-200 text-xs outline-none font-bold"
dir="ltr"
/>
<button
type="button"
onClick={addDonationOption}
className="px-4 py-2 bg-purple-600 text-white rounded-xl text-xs font-bold flex items-center gap-1"
>
<Plus className="w-4 h-4" /> افزودن
</button>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{financial.charityDonationOptions.map(amount => (
<span key={amount} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-purple-50 text-purple-700 text-xs font-bold rounded-xl border border-purple-200">
{amount.toLocaleString('fa-IR')} تومان
<button type="button" onClick={() => removeDonationOption(amount)} className="hover:text-red-500">
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
</div>
</div>
<div className="flex justify-end pt-4 border-t border-gray-100">
<button
type="submit"
disabled={isSaving}
className="bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-8 rounded-xl transition-colors flex items-center gap-2 shadow-md shadow-purple-200"
>
{isSaving ? <Spinner size="sm" /> : <Save className="w-5 h-5" />}
ذخیره تنظیمات مالی
</button>
</div>
</form>
)}
</div>
);
}

View File

@ -0,0 +1,416 @@
import { useState, useEffect } from 'react';
import { FlaskConical, Plus, Edit2, Trash2, X, Tag } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api, { BASE_DOMAIN } from '../services/api';
import Spinner from '../components/ui/Spinner';
import ConfirmModal from '../components/ui/ConfirmModal';
import MediaSelector from '../components/ui/MediaSelector';
import type { Ingredient } from '../types/admin';
export default function IngredientsManager() {
const [ingredients, setIngredients] = useState<Ingredient[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
const [editingIngredient, setEditingIngredient] = useState<Ingredient | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Benefits tags handling
const [benefitInput, setBenefitInput] = useState('');
const [formData, setFormData] = useState({
nameFa: '',
nameEn: '',
slug: '',
scientificName: '',
description: '',
imageUrl: '',
benefits: [] as string[],
});
const fetchIngredients = async () => {
try {
setIsLoading(true);
const res = await api.get('/ingredients');
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setIngredients(data);
} catch (err) {
console.error('Failed to fetch ingredients:', err);
toast.error('خطا در دریافت لیست ترکیبات دانشنامه');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchIngredients();
}, []);
const openModal = (item: Ingredient | null = null) => {
if (item) {
setEditingIngredient(item);
setFormData({
nameFa: item.nameFa || '',
nameEn: item.nameEn || '',
slug: item.slug || '',
scientificName: item.scientificName || '',
description: item.description || '',
imageUrl: item.imageUrl || '',
benefits: Array.isArray(item.benefits) ? item.benefits : [],
});
} else {
setEditingIngredient(null);
setFormData({
nameFa: '',
nameEn: '',
slug: '',
scientificName: '',
description: '',
imageUrl: '',
benefits: [],
});
}
setBenefitInput('');
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.nameFa.trim() || !formData.nameEn.trim()) {
toast.error('نام فارسی و انگلیسی الزامی است');
return;
}
try {
setIsSaving(true);
const slugVal = formData.slug.trim() || formData.nameEn.toLowerCase().replace(/\s+/g, '-');
const payload = {
...formData,
slug: slugVal,
};
if (editingIngredient) {
await api.patch(`/ingredients/${editingIngredient.id}`, payload);
toast.success('ترکیب با موفقیت بروزرسانی شد');
} else {
await api.post('/ingredients', payload);
toast.success('ترکیب جدید با موفقیت ایجاد شد');
}
setIsModalOpen(false);
fetchIngredients();
} catch (err) {
console.error('Failed to save ingredient:', err);
toast.error('خطا در ذخیره اطلاعات ترکیب');
} finally {
setIsSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTargetId) return;
try {
await api.delete(`/ingredients/${deleteTargetId}`);
toast.success('ترکیب با موفقیت حذف شد');
fetchIngredients();
} catch (err) {
console.error('Failed to delete ingredient:', err);
toast.error('خطا در حذف ترکیب');
} finally {
setDeleteTargetId(null);
}
};
const addBenefitTag = () => {
const val = benefitInput.trim();
if (val && !formData.benefits.includes(val)) {
setFormData({ ...formData, benefits: [...formData.benefits, val] });
setBenefitInput('');
}
};
const removeBenefitTag = (tag: string) => {
setFormData({ ...formData, benefits: formData.benefits.filter(b => b !== tag) });
};
const getImageFullUrl = (url?: string) => {
if (!url) return '';
if (url.startsWith('http')) return url;
return url.startsWith('/') ? `${BASE_DOMAIN}${url}` : `${BASE_DOMAIN}/${url}`;
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<FlaskConical className="w-6 h-6 text-purple-600" />
مدیریت ترکیبات و دانشنامه علمی (Scientific Wiki)
</h2>
<p className="text-gray-500 font-medium mt-1">مدیریت مواد موثره، ترکیبات دارویی و خواص درمانی محصولات کانینا</p>
</div>
<button
onClick={() => openModal()}
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
>
<Plus className="w-5 h-5" />
افزودن ترکیب جدید
</button>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="py-4 px-6 text-sm font-bold text-gray-500">تصویر</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">نام فارسی / انگلیسی</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">نام علمی (Scientific Name)</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">خواص و مزایا</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-28">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isLoading ? (
<tr>
<td colSpan={5} className="py-12 text-center">
<Spinner size="lg" className="mx-auto text-purple-600" />
</td>
</tr>
) : ingredients.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-gray-500 font-medium">هیچ ترکیبی ثبت نشده است</td>
</tr>
) : (
ingredients.map((item) => (
<tr key={item.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-6">
<div className="w-12 h-12 rounded-xl bg-gray-100 border border-gray-200 overflow-hidden flex items-center justify-center">
{item.imageUrl ? (
<img src={getImageFullUrl(item.imageUrl)} alt={item.nameFa} className="w-full h-full object-cover" />
) : (
<FlaskConical className="w-5 h-5 text-gray-400" />
)}
</div>
</td>
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-bold text-gray-900">{item.nameFa}</span>
<span className="text-xs font-mono text-gray-400" dir="ltr">{item.nameEn}</span>
</div>
</td>
<td className="py-4 px-6 font-mono text-xs text-purple-700 italic" dir="ltr">
{item.scientificName || '-'}
</td>
<td className="py-4 px-6">
<div className="flex flex-wrap gap-1">
{item.benefits && item.benefits.map((b, i) => (
<span key={i} className="px-2 py-0.5 bg-purple-50 text-purple-700 text-[11px] font-bold rounded-full border border-purple-100">
{b}
</span>
))}
</div>
</td>
<td className="py-4 px-6">
<div className="flex items-center gap-2">
<button onClick={() => openModal(item)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => setDeleteTargetId(item.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Form Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in zoom-in duration-200 max-h-[90vh] flex flex-col">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-lg font-bold text-gray-900">
{editingIngredient ? 'ویرایش ترکیب دانشنامه' : 'افزودن ترکیب جدید'}
</h3>
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSave} className="p-6 space-y-4 overflow-y-auto flex-1">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">نام فارسی *</label>
<input
required
type="text"
value={formData.nameFa}
onChange={(e) => setFormData({ ...formData, nameFa: e.target.value })}
placeholder="مثال: صدف لب‌سبز"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">نام انگلیسی (nameEn) *</label>
<input
required
type="text"
value={formData.nameEn}
onChange={(e) => setFormData({ ...formData, nameEn: e.target.value })}
placeholder="Green-Lipped Mussel"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">شناسه یکتا (slug)</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="green-lipped-mussel"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">نام علمی (scientificName)</label>
<input
type="text"
value={formData.scientificName}
onChange={(e) => setFormData({ ...formData, scientificName: e.target.value })}
placeholder="Perna canaliculus"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono italic"
dir="ltr"
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">تصویر ترکیب (imageUrl)</label>
<div className="flex gap-2">
<input
type="text"
value={formData.imageUrl}
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
placeholder="https://... یا /uploads/..."
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
<button
type="button"
onClick={() => setIsMediaSelectorOpen(true)}
className="px-3 py-2.5 bg-purple-50 text-purple-600 hover:bg-purple-100 rounded-xl font-bold text-xs shrink-0"
>
رسانه
</button>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">خواص و مزایا (benefits array)</label>
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={benefitInput}
onChange={(e) => setBenefitInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addBenefitTag();
}
}}
placeholder="تایپ کنید و اینتر بزنید (مثال: ترمیم غضروف)"
className="flex-1 px-4 py-2 rounded-xl border border-gray-200 text-xs outline-none"
/>
<button
type="button"
onClick={addBenefitTag}
className="px-3 py-2 bg-purple-600 text-white rounded-xl text-xs font-bold"
>
+ افزودن
</button>
</div>
<div className="flex flex-wrap gap-1.5 p-2 bg-gray-50 rounded-xl border border-gray-100 min-h-[40px]">
{formData.benefits.map((b, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 bg-purple-100 text-purple-800 text-xs font-bold rounded-lg">
<Tag className="w-3 h-3" />
{b}
<button type="button" onClick={() => removeBenefitTag(b)} className="hover:text-red-500">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">توضیحات جامع علمی</label>
<textarea
rows={4}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="توضیحات بیوشیمیایی و خواص بالینی ماده را وارد کنید..."
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold hover:bg-gray-50 transition-colors"
>
انصراف
</button>
<button
type="submit"
disabled={isSaving}
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2"
>
{isSaving && <Spinner size="sm" />}
ذخیره تغییرات
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteTargetId && (
<ConfirmModal
isOpen={Boolean(deleteTargetId)}
title="حذف ترکیب"
message="آیا از حذف این ترکیب دانشنامه اطمینان دارید؟"
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
)}
{/* Media Selector */}
{isMediaSelectorOpen && (
<MediaSelector
isOpen={isMediaSelectorOpen}
onClose={() => setIsMediaSelectorOpen(false)}
onSelect={(url) => {
setFormData({ ...formData, imageUrl: url });
setIsMediaSelectorOpen(false);
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,295 @@
import { useState, useEffect } from 'react';
import { FileText, Eye, CheckCircle2, XCircle, Clock, Filter, X, ExternalLink } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api, { BASE_DOMAIN } from '../services/api';
import Spinner from '../components/ui/Spinner';
import type { Prescription } from '../types/admin';
export default function PrescriptionsManager() {
const [prescriptions, setPrescriptions] = useState<Prescription[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string>('ALL');
// Review Modal State
const [selectedRx, setSelectedRx] = useState<Prescription | null>(null);
const [reviewStatus, setReviewStatus] = useState<'APPROVED' | 'REJECTED'>('APPROVED');
const [adminNotes, setAdminNotes] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const fetchPrescriptions = async () => {
try {
setIsLoading(true);
const res = await api.get('/prescriptions');
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setPrescriptions(data);
} catch (err) {
console.error('Failed to fetch prescriptions:', err);
toast.error('خطا در دریافت لیست نسخه‌های پزشکی');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchPrescriptions();
}, []);
const openReviewModal = (rx: Prescription) => {
setSelectedRx(rx);
setReviewStatus(rx.status === 'REJECTED' ? 'REJECTED' : 'APPROVED');
setAdminNotes(rx.adminNotes || '');
};
const handleReviewSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRx) return;
try {
setIsSubmitting(true);
await api.patch(`/prescriptions/${selectedRx.id}/review`, {
status: reviewStatus,
adminNotes: adminNotes.trim() || undefined,
});
toast.success(`نسخه پزشکی با موفقیت ${reviewStatus === 'APPROVED' ? 'تایید' : 'رد'} شد`);
setSelectedRx(null);
fetchPrescriptions();
} catch (err) {
console.error('Failed to review prescription:', err);
toast.error('خطا در تغییر وضعیت نسخه پزشکی');
} finally {
setIsSubmitting(false);
}
};
const getFileFullUrl = (url: string) => {
if (!url) return '';
if (url.startsWith('http')) return url;
return url.startsWith('/') ? `${BASE_DOMAIN}${url}` : `${BASE_DOMAIN}/${url}`;
};
const filteredPrescriptions = prescriptions.filter(rx => {
if (statusFilter === 'ALL') return true;
return rx.status === statusFilter;
});
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<FileText className="w-6 h-6 text-purple-600" />
داشبورد بررسی نسخههای پزشکی (Prescriptions)
</h2>
<p className="text-gray-500 font-medium mt-1">بررسی آنلاین نسخههای ارسال شده توسط کاربران و تایید دستور مصرف</p>
</div>
{/* Status Filters */}
<div className="flex items-center gap-2 bg-white p-1.5 rounded-2xl border border-gray-200 shadow-sm">
<Filter className="w-4 h-4 text-gray-400 mr-2" />
{[
{ id: 'ALL', label: 'همه' },
{ id: 'PENDING', label: 'در انتظار بررسی' },
{ id: 'APPROVED', label: 'تایید شده' },
{ id: 'REJECTED', label: 'رد شده' },
].map(f => (
<button
key={f.id}
onClick={() => setStatusFilter(f.id)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
statusFilter === f.id
? 'bg-purple-600 text-white shadow-sm'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
{f.label}
</button>
))}
</div>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="py-4 px-6 text-sm font-bold text-gray-500">شناسه / تاریخ</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">کاربر / حیوان خانگی</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">فایل نسخه</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">یادداشت کاربر</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">وضعیت</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-28">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isLoading ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<Spinner size="lg" className="mx-auto text-purple-600" />
</td>
</tr>
) : filteredPrescriptions.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-gray-500 font-medium">هیچ نسخهای با این فیلتر یافت نشد</td>
</tr>
) : (
filteredPrescriptions.map((rx) => (
<tr key={rx.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-mono text-xs font-bold text-gray-800">{rx.id.slice(0, 8)}...</span>
<span className="text-[11px] text-gray-400 mt-0.5">
{rx.createdAt ? new Date(rx.createdAt).toLocaleDateString('fa-IR') : '-'}
</span>
</div>
</td>
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-mono text-xs text-gray-600">User ID: {rx.userId.slice(0, 8)}</span>
{rx.petId && <span className="text-xs text-purple-600 font-medium">Pet ID: {rx.petId.slice(0, 8)}</span>}
</div>
</td>
<td className="py-4 px-6">
<a
href={getFileFullUrl(rx.fileUrl)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1 bg-purple-50 text-purple-700 text-xs font-bold rounded-lg border border-purple-100 hover:bg-purple-100 transition-colors"
>
<Eye className="w-3.5 h-3.5" />
مشاهده فایل نسخه
<ExternalLink className="w-3 h-3" />
</a>
</td>
<td className="py-4 px-6 text-gray-600 text-xs max-w-xs line-clamp-2">
{rx.notes || '-'}
</td>
<td className="py-4 px-6">
{rx.status === 'APPROVED' ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-50 text-green-700 text-xs font-bold rounded-full border border-green-200">
<CheckCircle2 className="w-3.5 h-3.5" /> تایید شده
</span>
) : rx.status === 'REJECTED' ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-red-50 text-red-700 text-xs font-bold rounded-full border border-red-200">
<XCircle className="w-3.5 h-3.5" /> رد شده
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-amber-50 text-amber-700 text-xs font-bold rounded-full border border-amber-200">
<Clock className="w-3.5 h-3.5" /> در انتظار بررسی
</span>
)}
</td>
<td className="py-4 px-6">
<button
onClick={() => openReviewModal(rx)}
className="px-3 py-1.5 bg-purple-600 text-white hover:bg-purple-700 rounded-lg text-xs font-bold transition-all shadow-sm"
>
بررسی / تغییر وضعیت
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Review Modal */}
{selectedRx && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-lg font-bold text-gray-900">
بررسی نسخه پزشکی ({selectedRx.id.slice(0, 8)})
</h3>
<button onClick={() => setSelectedRx(null)} className="text-gray-400 hover:text-red-500 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleReviewSubmit} className="p-6 space-y-4">
<div className="p-4 bg-purple-50/50 rounded-xl border border-purple-100 space-y-2 text-xs">
<div className="flex justify-between">
<span className="font-bold text-gray-600">فایل نسخه:</span>
<a
href={getFileFullUrl(selectedRx.fileUrl)}
target="_blank"
rel="noreferrer"
className="text-purple-600 font-bold underline flex items-center gap-1"
>
باز کردن در پنجره جدید <ExternalLink className="w-3 h-3" />
</a>
</div>
{selectedRx.notes && (
<div>
<span className="font-bold text-gray-600">یادداشت کاربر: </span>
<span className="text-gray-800">{selectedRx.notes}</span>
</div>
)}
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">تغییر وضعیت بررسی *</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setReviewStatus('APPROVED')}
className={`py-3 px-4 rounded-xl border font-bold text-xs flex items-center justify-center gap-2 transition-all ${
reviewStatus === 'APPROVED'
? 'border-green-500 bg-green-50 text-green-700 shadow-sm'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<CheckCircle2 className="w-4 h-4" /> تایید نسخه (APPROVED)
</button>
<button
type="button"
onClick={() => setReviewStatus('REJECTED')}
className={`py-3 px-4 rounded-xl border font-bold text-xs flex items-center justify-center gap-2 transition-all ${
reviewStatus === 'REJECTED'
? 'border-red-500 bg-red-50 text-red-700 shadow-sm'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<XCircle className="w-4 h-4" /> عدم تایید / رد (REJECTED)
</button>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">یادداشت کارشناس ادمین (adminNotes)</label>
<textarea
rows={4}
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
placeholder="مثال: نسخه تایید شد. دوز مصرفی مطابق دستور پزشک است."
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={() => setSelectedRx(null)}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold hover:bg-gray-50 transition-colors"
>
انصراف
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2"
>
{isSubmitting && <Spinner size="sm" />}
ثبت وضعیت بررسی
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,171 @@
import { useState, useEffect } from 'react';
import { Globe, Save, Image as ImageIcon } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import MediaSelector from '../components/ui/MediaSelector';
import type { SeoSettings } from '../types/admin';
export default function SeoSettingsPage() {
const [seo, setSeo] = useState<SeoSettings>({
defaultMetaTitle: 'کانینا ایران | مکمل‌های دارویی آلمانی حیوانات خانگی',
defaultMetaDescription: 'نمایندگی رسمی محصولات کانینا فارما آلمان در ایران',
keywords: 'مکمل سگ, مکمل گربه, کنهیدروکس, کانینا',
canonicalBaseUrl: 'https://caninairan.com',
ogImageUrl: 'https://caninairan.com/og-default.jpg',
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
useEffect(() => {
let isSubscribed = true;
api.get('/settings/seo')
.then(res => {
if (!isSubscribed) return;
const data = res.data?.data || res.data;
if (data) {
setSeo({
defaultMetaTitle: data.defaultMetaTitle || seo.defaultMetaTitle,
defaultMetaDescription: data.defaultMetaDescription || seo.defaultMetaDescription,
keywords: data.keywords || seo.keywords,
canonicalBaseUrl: data.canonicalBaseUrl || seo.canonicalBaseUrl,
ogImageUrl: data.ogImageUrl || seo.ogImageUrl,
});
}
})
.catch(err => {
console.warn('Failed to fetch SEO settings, using default state', err);
})
.finally(() => {
if (isSubscribed) setIsLoading(false);
});
return () => {
isSubscribed = false;
};
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsSaving(true);
await api.patch('/settings/seo', seo);
toast.success('تنظیمات سئو با موفقیت بروزرسانی شد');
} catch (err) {
console.error('Failed to update SEO settings:', err);
toast.error('خطا در بروزرسانی تنظیمات سئو');
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<Globe className="w-6 h-6 text-purple-600" />
تنظیمات عمومی سئو (SEO Settings)
</h2>
<p className="text-gray-500 font-medium mt-1">تنظیم متا دیتاهای پیشفرض، OpenGraph و کلمات کلیدی اصلی فروشگاه</p>
</div>
</div>
{isLoading ? (
<div className="flex justify-center p-12"><Spinner size="lg" className="text-purple-600" /></div>
) : (
<form onSubmit={handleSave} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">عنوان متای پیشفرض (defaultMetaTitle) *</label>
<input
required
type="text"
value={seo.defaultMetaTitle}
onChange={(e) => setSeo({ ...seo, defaultMetaTitle: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-bold text-sm"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">توضیحات متای پیشفرض (defaultMetaDescription) *</label>
<textarea
required
rows={3}
value={seo.defaultMetaDescription}
onChange={(e) => setSeo({ ...seo, defaultMetaDescription: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">کلمات کلیدی اصلی (keywords)</label>
<input
type="text"
value={seo.keywords}
onChange={(e) => setSeo({ ...seo, keywords: e.target.value })}
placeholder="با کاما جدا کنید (مثال: مکمل سگ, مکمل گربه)"
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">آدرس پایه Canonical (canonicalBaseUrl)</label>
<input
type="text"
value={seo.canonicalBaseUrl}
onChange={(e) => setSeo({ ...seo, canonicalBaseUrl: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">تصویر پیشفرض شبکه اجتماعی (ogImageUrl)</label>
<div className="flex gap-2">
<input
type="text"
value={seo.ogImageUrl}
onChange={(e) => setSeo({ ...seo, ogImageUrl: e.target.value })}
className="flex-1 px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
<button
type="button"
onClick={() => setIsMediaSelectorOpen(true)}
className="px-3 py-3 bg-purple-50 text-purple-600 hover:bg-purple-100 rounded-xl font-bold text-xs shrink-0"
>
رسانه
</button>
</div>
</div>
</div>
</div>
<div className="flex justify-end pt-4 border-t border-gray-100">
<button
type="submit"
disabled={isSaving}
className="bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-8 rounded-xl transition-colors flex items-center gap-2 shadow-md shadow-purple-200"
>
{isSaving ? <Spinner size="sm" /> : <Save className="w-5 h-5" />}
ذخیره تنظیمات سئو
</button>
</div>
</form>
)}
{isMediaSelectorOpen && (
<MediaSelector
isOpen={isMediaSelectorOpen}
onClose={() => setIsMediaSelectorOpen(false)}
onSelect={(url) => {
setSeo({ ...seo, ogImageUrl: url });
setIsMediaSelectorOpen(false);
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,310 @@
import { useState, useEffect } from 'react';
import { HelpCircle, Plus, Edit2, Trash2, X, Search, Sparkles } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import ConfirmModal from '../components/ui/ConfirmModal';
import type { SmartAdvisorRule, Product } from '../types/admin';
export default function SmartAdvisorManager() {
const [rules, setRules] = useState<SmartAdvisorRule[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<SmartAdvisorRule | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Search product inside modal dropdown
const [productSearch, setProductSearch] = useState('');
const [formData, setFormData] = useState({
condition: '',
targetPetType: 'سگ',
recommendedProduct: '',
reason: '',
});
const fetchData = async () => {
try {
setIsLoading(true);
const [rulesRes, prodRes] = await Promise.all([
api.get('/smart-advisor/rules'),
api.get('/admin/products', { params: { limit: 100 } }),
]);
const rulesData = Array.isArray(rulesRes.data) ? rulesRes.data : (rulesRes.data?.data || []);
const productsData = prodRes.data?.data || (Array.isArray(prodRes.data) ? prodRes.data : []);
setRules(rulesData);
setProducts(productsData);
} catch (err) {
console.error('Failed to fetch smart advisor rules:', err);
toast.error('خطا در دریافت قوانین مشاور هوشمند');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const openModal = (rule: SmartAdvisorRule | null = null) => {
if (rule) {
setEditingRule(rule);
setFormData({
condition: rule.condition || '',
targetPetType: rule.targetPetType || 'سگ',
recommendedProduct: rule.recommendedProduct || '',
reason: rule.reason || '',
});
} else {
setEditingRule(null);
setFormData({
condition: '',
targetPetType: 'سگ',
recommendedProduct: products[0]?.id || '',
reason: '',
});
}
setProductSearch('');
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.condition.trim() || !formData.recommendedProduct || !formData.reason.trim()) {
toast.error('تکمیل تمامی فیلدهای اجباری الزامی است');
return;
}
try {
setIsSaving(true);
if (editingRule) {
await api.patch(`/smart-advisor/rules/${editingRule.id}`, formData);
toast.success('قانون مشاور هوشمند بروزرسانی شد');
} else {
await api.post('/smart-advisor/rules', formData);
toast.success('قانون جدید با موفقیت ایجاد شد');
}
setIsModalOpen(false);
fetchData();
} catch (err) {
console.error('Failed to save rule:', err);
toast.error('خطا در ذخیره قانون مشاور هوشمند');
} finally {
setIsSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTargetId) return;
try {
await api.delete(`/smart-advisor/rules/${deleteTargetId}`);
toast.success('قانون با موفقیت حذف شد');
fetchData();
} catch (err) {
console.error('Failed to delete rule:', err);
toast.error('خطا در حذف قانون');
} finally {
setDeleteTargetId(null);
}
};
const filteredProducts = products.filter(p =>
(p.nameFa || p.name || '').includes(productSearch) ||
(p.artNo || '').includes(productSearch)
);
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<Sparkles className="w-6 h-6 text-purple-600" />
مدیریت موتور مشاور هوشمند (Smart Advisor)
</h2>
<p className="text-gray-500 font-medium mt-1">تعریف قوانین تجویز محصولات پیشنهادی بر اساس عوارض و علائم پت</p>
</div>
<button
onClick={() => openModal()}
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
>
<Plus className="w-5 h-5" />
تعریف قانون جدید
</button>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="py-4 px-6 text-sm font-bold text-gray-500">شرط / عارضه (Condition)</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">گونه هدف</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">محصول پیشنهادی</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">دلیل و توصیه تخصصی</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-28">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isLoading ? (
<tr>
<td colSpan={5} className="py-12 text-center">
<Spinner size="lg" className="mx-auto text-purple-600" />
</td>
</tr>
) : rules.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-gray-500 font-medium">هیچ قانونی ثبت نشده است</td>
</tr>
) : (
rules.map((rule) => (
<tr key={rule.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6 font-bold text-gray-900">{rule.condition}</td>
<td className="py-4 px-6">
<span className="inline-block px-3 py-1 bg-purple-50 text-purple-700 text-xs font-bold rounded-lg border border-purple-100">
{rule.targetPetType || 'هر دو'}
</span>
</td>
<td className="py-4 px-6">
<span className="font-bold text-purple-700">
{rule.product?.nameFa || rule.recommendedProduct}
</span>
</td>
<td className="py-4 px-6 text-gray-600 text-sm max-w-md line-clamp-2">
{rule.reason}
</td>
<td className="py-4 px-6">
<div className="flex items-center gap-2">
<button onClick={() => openModal(rule)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => setDeleteTargetId(rule.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Form Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-lg font-bold text-gray-900">
{editingRule ? 'ویرایش قانون مشاور هوشمند' : 'تعریف قانون جدید'}
</h3>
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSave} className="p-6 space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">شرط / علائم عارضه (condition) *</label>
<input
required
type="text"
value={formData.condition}
onChange={(e) => setFormData({ ...formData, condition: e.target.value })}
placeholder="مثال: مشکلات مفصلی و لنگش"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">گونه حیوان هدف (targetPetType)</label>
<select
value={formData.targetPetType}
onChange={(e) => setFormData({ ...formData, targetPetType: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold bg-white"
>
<option value="سگ">سگ</option>
<option value="گربه">گربه</option>
<option value="هر دو">هر دو (سگ و گربه)</option>
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">محصول پیشنهادی *</label>
<div className="space-y-2">
<div className="relative">
<Search className="w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="جستجوی سریع محصول..."
value={productSearch}
onChange={(e) => setProductSearch(e.target.value)}
className="w-full pl-3 pr-9 py-2 rounded-lg border border-gray-200 text-xs outline-none"
/>
</div>
<select
required
value={formData.recommendedProduct}
onChange={(e) => setFormData({ ...formData, recommendedProduct: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold bg-white"
>
{filteredProducts.map(p => (
<option key={p.id} value={p.id}>
{p.nameFa || p.name} ({p.artNo})
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">علت و توضیح علمی پیشنهاد (reason) *</label>
<textarea
required
rows={4}
value={formData.reason}
onChange={(e) => setFormData({ ...formData, reason: e.target.value })}
placeholder="مثال: حاوی ۱۰٪ پودر صدف لب‌سبز برای بازسازی غضروف مفاصل..."
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold hover:bg-gray-50 transition-colors"
>
انصراف
</button>
<button
type="submit"
disabled={isSaving}
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2"
>
{isSaving && <Spinner size="sm" />}
ذخیره قانون
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteTargetId && (
<ConfirmModal
isOpen={Boolean(deleteTargetId)}
title="حذف قانون"
message="آیا از حذف این قانون مشاور هوشمند اطمینان دارید؟"
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
import { Sliders, Save, AlertTriangle } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import type { SystemSettings } from '../types/admin';
export default function SystemSettingsPage() {
const [system, setSystem] = useState<SystemSettings>({
maintenanceMode: false,
allowGuestCheckout: true,
b2bRegistrationOpen: true,
supportPhone: '021-12345678',
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
let isSubscribed = true;
api.get('/settings/system')
.then(res => {
if (!isSubscribed) return;
const data = res.data?.data || res.data;
if (data) {
setSystem({
maintenanceMode: Boolean(data.maintenanceMode),
allowGuestCheckout: Boolean(data.allowGuestCheckout ?? true),
b2bRegistrationOpen: Boolean(data.b2bRegistrationOpen ?? true),
supportPhone: data.supportPhone || system.supportPhone,
});
}
})
.catch(err => {
console.warn('Failed to fetch System settings, using default state', err);
})
.finally(() => {
if (isSubscribed) setIsLoading(false);
});
return () => {
isSubscribed = false;
};
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsSaving(true);
await api.patch('/settings/system', system);
toast.success('تنظیمات سیستمی با موفقیت بروزرسانی شد');
} catch (err) {
console.error('Failed to update system settings:', err);
toast.error('خطا در بروزرسانی تنظیمات سیستمی');
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<Sliders className="w-6 h-6 text-purple-600" />
تنظیمات سیستمی و کنترل حالتها (System Settings)
</h2>
<p className="text-gray-500 font-medium mt-1">مدیریت حالت تعمیرات، خرید مهمان و ثبتنام همکاران B2B</p>
</div>
</div>
{isLoading ? (
<div className="flex justify-center p-12"><Spinner size="lg" className="text-purple-600" /></div>
) : (
<form onSubmit={handleSave} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6">
<div className="space-y-4">
{/* Maintenance Mode */}
<div className="flex items-center justify-between p-4 bg-amber-50/60 rounded-xl border border-amber-200">
<div className="flex items-center gap-3">
<AlertTriangle className="w-6 h-6 text-amber-600" />
<div>
<h4 className="font-bold text-gray-900 text-sm">حالت تعمیرات (maintenanceMode)</h4>
<p className="text-xs text-gray-600 mt-0.5">غیرفعالسازی موقت فرانتاند جهت بروزرسانی دیتابیس یا سرور</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={system.maintenanceMode}
onChange={(e) => setSystem({ ...system, maintenanceMode: e.target.checked })}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-600"></div>
</label>
</div>
{/* Guest Checkout */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-200">
<div>
<h4 className="font-bold text-gray-900 text-sm">اجازه خرید کاربر مهمان (allowGuestCheckout)</h4>
<p className="text-xs text-gray-500 mt-0.5">امکان تسویهحساب سریع بدون نیاز به ایجاد حساب کاربری قبل از ثبت سفارش</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={system.allowGuestCheckout}
onChange={(e) => setSystem({ ...system, allowGuestCheckout: e.target.checked })}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
{/* B2B Registration Open */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-200">
<div>
<h4 className="font-bold text-gray-900 text-sm">ثبتنام متقاضیان همکار باز است (b2bRegistrationOpen)</h4>
<p className="text-xs text-gray-500 mt-0.5">فعال بودن فرم پذیرش درخواستهای جدید عمدهفروشی در سایت</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={system.b2bRegistrationOpen}
onChange={(e) => setSystem({ ...system, b2bRegistrationOpen: e.target.checked })}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
{/* Support Phone */}
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">شماره تلفن پشتیبانی اصلی (supportPhone) *</label>
<input
required
type="text"
value={system.supportPhone}
onChange={(e) => setSystem({ ...system, supportPhone: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold"
dir="ltr"
/>
</div>
</div>
<div className="flex justify-end pt-4 border-t border-gray-100">
<button
type="submit"
disabled={isSaving}
className="bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-8 rounded-xl transition-colors flex items-center gap-2 shadow-md shadow-purple-200"
>
{isSaving ? <Spinner size="sm" /> : <Save className="w-5 h-5" />}
ذخیره تنظیمات سیستمی
</button>
</div>
</form>
)}
</div>
);
}

View File

@ -0,0 +1,393 @@
import { useState, useEffect } from 'react';
import { MessageSquareQuote, Plus, Edit2, Trash2, X, Star, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api, { BASE_DOMAIN } from '../services/api';
import Spinner from '../components/ui/Spinner';
import ConfirmModal from '../components/ui/ConfirmModal';
import MediaSelector from '../components/ui/MediaSelector';
import type { Testimonial } from '../types/admin';
export default function TestimonialsManager() {
const [testimonials, setTestimonials] = useState<Testimonial[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
const [editingTestimonial, setEditingTestimonial] = useState<Testimonial | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({
authorName: '',
roleTitle: '',
avatarUrl: '',
content: '',
rating: 5,
isFeatured: true,
isActive: true,
order: 0,
});
const fetchTestimonials = async () => {
try {
setIsLoading(true);
const res = await api.get('/testimonials');
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setTestimonials(data.sort((a: Testimonial, b: Testimonial) => a.order - b.order));
} catch (err) {
console.error('Failed to fetch testimonials:', err);
toast.error('خطا در دریافت لیست نظرات و گواهی‌ها');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchTestimonials();
}, []);
const openModal = (item: Testimonial | null = null) => {
if (item) {
setEditingTestimonial(item);
setFormData({
authorName: item.authorName || '',
roleTitle: item.roleTitle || '',
avatarUrl: item.avatarUrl || '',
content: item.content || '',
rating: item.rating || 5,
isFeatured: item.isFeatured ?? true,
isActive: item.isActive ?? true,
order: item.order || 0,
});
} else {
setEditingTestimonial(null);
setFormData({
authorName: '',
roleTitle: '',
avatarUrl: '',
content: '',
rating: 5,
isFeatured: true,
isActive: true,
order: testimonials.length,
});
}
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.authorName.trim() || !formData.content.trim()) {
toast.error('نام نویسنده و متن نظر اجباری هستند');
return;
}
try {
setIsSaving(true);
if (editingTestimonial) {
await api.patch(`/testimonials/${editingTestimonial.id}`, formData);
toast.success('نظر با موفقیت بروزرسانی شد');
} else {
await api.post('/testimonials', formData);
toast.success('نظر جدید با موفقیت ایجاد شد');
}
setIsModalOpen(false);
fetchTestimonials();
} catch (err) {
console.error('Failed to save testimonial:', err);
toast.error('خطا در ذخیره نظر');
} finally {
setIsSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTargetId) return;
try {
await api.delete(`/testimonials/${deleteTargetId}`);
toast.success('نظر با موفقیت حذف شد');
fetchTestimonials();
} catch (err) {
console.error('Failed to delete testimonial:', err);
toast.error('خطا در حذف نظر');
} finally {
setDeleteTargetId(null);
}
};
const getAvatarFullUrl = (url?: string) => {
if (!url) return '';
if (url.startsWith('http')) return url;
return url.startsWith('/') ? `${BASE_DOMAIN}${url}` : `${BASE_DOMAIN}/${url}`;
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<MessageSquareQuote className="w-6 h-6 text-purple-600" />
مدیریت نظرات دامپزشکان و مشتریان (Testimonials)
</h2>
<p className="text-gray-500 font-medium mt-1">مدیریت توصیه نامهها و نظرات تایید شده جهت نمایش در صفحه اصلی و گالری دامپزشکان</p>
</div>
<button
onClick={() => openModal()}
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
>
<Plus className="w-5 h-5" />
افزودن نظر جدید
</button>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="py-4 px-6 text-sm font-bold text-gray-500">تصویر</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">نویسنده و سمت</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">متن نظر</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">امتیاز</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">ویژه / فعال</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-28">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isLoading ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<Spinner size="lg" className="mx-auto text-purple-600" />
</td>
</tr>
) : testimonials.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-gray-500 font-medium">هیچ نظری ثبت نشده است</td>
</tr>
) : (
testimonials.map((item) => (
<tr key={item.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-6">
<div className="w-10 h-10 rounded-full bg-gray-100 border border-gray-200 overflow-hidden flex items-center justify-center">
{item.avatarUrl ? (
<img src={getAvatarFullUrl(item.avatarUrl)} alt={item.authorName} className="w-full h-full object-cover" />
) : (
<span className="font-bold text-gray-400 text-xs">{item.authorName.slice(0, 2)}</span>
)}
</div>
</td>
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-bold text-gray-900">{item.authorName}</span>
{item.roleTitle && <span className="text-xs text-purple-600 font-medium">{item.roleTitle}</span>}
</div>
</td>
<td className="py-4 px-6 text-gray-600 text-sm max-w-md line-clamp-2">
{item.content}
</td>
<td className="py-4 px-6">
<div className="flex items-center gap-1 text-amber-500">
{Array.from({ length: item.rating || 5 }).map((_, i) => (
<Star key={i} className="w-4 h-4 fill-amber-400" />
))}
</div>
</td>
<td className="py-4 px-6">
<div className="flex items-center gap-2">
{item.isFeatured && (
<span className="px-2 py-0.5 bg-amber-50 text-amber-700 text-[11px] font-bold rounded border border-amber-200">
ویژه (Featured)
</span>
)}
{item.isActive ? (
<span className="inline-flex items-center gap-0.5 text-green-600 text-xs font-bold">
<CheckCircle2 className="w-3.5 h-3.5" /> فعال
</span>
) : (
<span className="inline-flex items-center gap-0.5 text-gray-400 text-xs font-bold">
<XCircle className="w-3.5 h-3.5" /> غیرفعال
</span>
)}
</div>
</td>
<td className="py-4 px-6">
<div className="flex items-center gap-2">
<button onClick={() => openModal(item)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => setDeleteTargetId(item.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Form Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-lg font-bold text-gray-900">
{editingTestimonial ? 'ویرایش نظر' : 'افزودن نظر جدید'}
</h3>
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSave} className="p-6 space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">نام و نام خانوادگی نویسنده *</label>
<input
required
type="text"
value={formData.authorName}
onChange={(e) => setFormData({ ...formData, authorName: e.target.value })}
placeholder="مثال: دکتر علیرضا محمدی"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">سمت / عنوان تخصص (roleTitle)</label>
<input
type="text"
value={formData.roleTitle}
onChange={(e) => setFormData({ ...formData, roleTitle: e.target.value })}
placeholder="مثال: دامپزشک و متخصص جراحی"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">تصویر آواتار (avatarUrl)</label>
<div className="flex gap-2">
<input
type="text"
value={formData.avatarUrl}
onChange={(e) => setFormData({ ...formData, avatarUrl: e.target.value })}
placeholder="https://... یا /uploads/..."
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
<button
type="button"
onClick={() => setIsMediaSelectorOpen(true)}
className="px-3 py-2.5 bg-purple-50 text-purple-600 hover:bg-purple-100 rounded-xl font-bold text-xs shrink-0"
>
رسانه
</button>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">متن نظر / رضایتنامه *</label>
<textarea
required
rows={4}
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="متن نظر یا توصیه بالینی را بنویسید..."
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">امتیاز (۱ تا ۵)</label>
<select
value={formData.rating}
onChange={(e) => setFormData({ ...formData, rating: Number(e.target.value) })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold bg-white"
>
{[5, 4, 3, 2, 1].map(r => (
<option key={r} value={r}>{r} ستاره</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">ترتیب نمایش</label>
<input
type="number"
value={formData.order}
onChange={(e) => setFormData({ ...formData, order: Number(e.target.value) })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold"
/>
</div>
</div>
<div className="pt-2 grid grid-cols-2 gap-4">
<label className="flex items-center justify-between p-3 bg-gray-50 rounded-xl border border-gray-200 cursor-pointer">
<span className="text-xs font-bold text-gray-700">نمایش ویژه (Featured)</span>
<input
type="checkbox"
checked={formData.isFeatured}
onChange={(e) => setFormData({ ...formData, isFeatured: e.target.checked })}
className="rounded text-purple-600 focus:ring-purple-500"
/>
</label>
<label className="flex items-center justify-between p-3 bg-gray-50 rounded-xl border border-gray-200 cursor-pointer">
<span className="text-xs font-bold text-gray-700">وضعیت انتشار (Active)</span>
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded text-purple-600 focus:ring-purple-500"
/>
</label>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold hover:bg-gray-50 transition-colors"
>
انصراف
</button>
<button
type="submit"
disabled={isSaving}
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2"
>
{isSaving && <Spinner size="sm" />}
ذخیره تغییرات
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteTargetId && (
<ConfirmModal
isOpen={Boolean(deleteTargetId)}
title="حذف نظر"
message="آیا از حذف این نظر اطمینان دارید؟"
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
)}
{/* Media Selector */}
{isMediaSelectorOpen && (
<MediaSelector
isOpen={isMediaSelectorOpen}
onClose={() => setIsMediaSelectorOpen(false)}
onSelect={(url) => {
setFormData({ ...formData, avatarUrl: url });
setIsMediaSelectorOpen(false);
}}
/>
)}
</div>
);
}

View File

@ -24,6 +24,15 @@ const CMS = lazy(() => import('../pages/CMS'));
const WholesaleApplications = lazy(() => import('../pages/WholesaleApplications'));
const Videos = lazy(() => import('../pages/Videos'));
const ContactSubmissions = lazy(() => import('../pages/ContactSubmissions'));
const BannersManager = lazy(() => import('../pages/BannersManager'));
const SmartAdvisorManager = lazy(() => import('../pages/SmartAdvisorManager'));
const TestimonialsManager = lazy(() => import('../pages/TestimonialsManager'));
const IngredientsManager = lazy(() => import('../pages/IngredientsManager'));
const PrescriptionsManager = lazy(() => import('../pages/PrescriptionsManager'));
const B2BManager = lazy(() => import('../pages/B2BManager'));
const SeoSettingsPage = lazy(() => import('../pages/SeoSettingsPage'));
const FinancialSettingsPage = lazy(() => import('../pages/FinancialSettingsPage'));
const SystemSettingsPage = lazy(() => import('../pages/SystemSettingsPage'));
export interface AdminRouteConfig {
path: string;
@ -49,6 +58,9 @@ export const router = createBrowserRouter([
{ path: 'orders/*', element: <Orders /> },
{ path: 'coupons/*', element: <Coupons /> },
{ path: 'settings/*', element: <Settings /> },
{ path: 'settings/seo', element: <SeoSettingsPage /> },
{ path: 'settings/financial', element: <FinancialSettingsPage /> },
{ path: 'settings/system', element: <SystemSettingsPage /> },
{ path: 'reports/*', element: <Reports /> },
{ path: 'categories/*', element: <Categories /> },
{ path: 'blogs/*', element: <Blogs /> },
@ -58,6 +70,12 @@ export const router = createBrowserRouter([
{ path: 'ui-texts/*', element: <UITexts /> },
{ path: 'media/*', element: <Media /> },
{ path: 'cms/*', element: <CMS /> },
{ path: 'banners/*', element: <BannersManager /> },
{ path: 'smart-advisor/*', element: <SmartAdvisorManager /> },
{ path: 'testimonials/*', element: <TestimonialsManager /> },
{ path: 'ingredients/*', element: <IngredientsManager /> },
{ path: 'prescriptions/*', element: <PrescriptionsManager /> },
{ path: 'b2b/*', element: <B2BManager /> },
{ path: 'wholesale/*', element: <WholesaleApplications /> },
{ path: 'contact/*', element: <ContactSubmissions /> },
],

View File

@ -66,3 +66,119 @@ export interface PetSummary {
age?: number;
weight?: number;
}
export interface Banner {
id: string;
title: string;
subtitle?: string;
imageUrl: string;
linkUrl?: string;
position: string;
isActive: boolean;
order: number;
createdAt: string;
updatedAt: string;
}
export interface SmartAdvisorRule {
id: string;
condition: string;
targetPetType?: string;
recommendedProduct: string;
reason: string;
createdAt: string;
product?: {
id: string;
nameFa: string;
slug: string;
};
}
export interface Testimonial {
id: string;
authorName: string;
roleTitle?: string;
avatarUrl?: string;
content: string;
rating: number;
isFeatured: boolean;
isActive: boolean;
order: number;
createdAt: string;
updatedAt: string;
}
export interface Ingredient {
id: string;
nameFa: string;
nameEn: string;
slug: string;
description?: string;
scientificName?: string;
imageUrl?: string;
benefits: string[];
createdAt: string;
updatedAt: string;
}
export interface Prescription {
id: string;
userId: string;
petId?: string;
fileUrl: string;
status: 'PENDING' | 'APPROVED' | 'REJECTED';
notes?: string;
adminNotes?: string;
createdAt: string;
updatedAt: string;
}
export interface B2BInquiry {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
businessType: string;
estimatedVolume?: string;
message: string;
status: 'PENDING' | 'CONTACTED' | 'APPROVED' | 'REJECTED';
adminNotes?: string;
createdAt: string;
updatedAt: string;
}
export interface PartnerAccount {
id: string;
userId: string;
companyName: string;
taxId?: string;
creditLimit: string | number;
discountTier: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface SeoSettings {
defaultMetaTitle: string;
defaultMetaDescription: string;
keywords: string;
canonicalBaseUrl: string;
ogImageUrl: string;
}
export interface FinancialSettings {
taxPercentage: number;
freeShippingThreshold: number;
standardShippingFee: number;
charityDonationOptions: number[];
}
export interface SystemSettings {
maintenanceMode: boolean;
allowGuestCheckout: boolean;
b2bRegistrationOpen: boolean;
supportPhone: string;
}