feat(admin): implement Phase B management modules based on frontend integration contract
Some checks failed
Deploy Canina / deploy (push) Failing after 28s
Some checks failed
Deploy Canina / deploy (push) Failing after 28s
This commit is contained in:
parent
8dc2b1e586
commit
f70c1fe3c2
@ -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.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
499
frontend/admin-panel/src/pages/B2BManager.tsx
Normal file
499
frontend/admin-panel/src/pages/B2BManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
417
frontend/admin-panel/src/pages/BannersManager.tsx
Normal file
417
frontend/admin-panel/src/pages/BannersManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
frontend/admin-panel/src/pages/FinancialSettingsPage.tsx
Normal file
187
frontend/admin-panel/src/pages/FinancialSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
416
frontend/admin-panel/src/pages/IngredientsManager.tsx
Normal file
416
frontend/admin-panel/src/pages/IngredientsManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
295
frontend/admin-panel/src/pages/PrescriptionsManager.tsx
Normal file
295
frontend/admin-panel/src/pages/PrescriptionsManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
frontend/admin-panel/src/pages/SeoSettingsPage.tsx
Normal file
171
frontend/admin-panel/src/pages/SeoSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
frontend/admin-panel/src/pages/SmartAdvisorManager.tsx
Normal file
310
frontend/admin-panel/src/pages/SmartAdvisorManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
frontend/admin-panel/src/pages/SystemSettingsPage.tsx
Normal file
157
frontend/admin-panel/src/pages/SystemSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
393
frontend/admin-panel/src/pages/TestimonialsManager.tsx
Normal file
393
frontend/admin-panel/src/pages/TestimonialsManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 /> },
|
||||
],
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user