338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { HelpCircle, Plus, Edit2, Trash2, X, GripVertical, ToggleLeft, ToggleRight } 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 Button from '../components/ui/Button';
|
||
import Modal from '../components/ui/Modal';
|
||
|
||
|
||
interface FAQ {
|
||
id: string;
|
||
question: string;
|
||
answer: string;
|
||
order: number;
|
||
isActive: boolean;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
export default function FAQManager() {
|
||
const [faqs, setFaqs] = useState<FAQ[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [editingFaq, setEditingFaq] = useState<FAQ | null>(null);
|
||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
|
||
const [formData, setFormData] = useState({
|
||
question: '',
|
||
answer: '',
|
||
order: 0,
|
||
isActive: true,
|
||
});
|
||
|
||
const fetchFaqs = useCallback(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const res = await api.get('/faq/admin');
|
||
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||
setFaqs(data);
|
||
} catch (err) {
|
||
console.error('Failed to fetch FAQs:', err);
|
||
toast.error('خطا در دریافت سوالات متداول');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchFaqs();
|
||
}, [fetchFaqs]);
|
||
|
||
const openModal = (item: FAQ | null = null) => {
|
||
if (item) {
|
||
setEditingFaq(item);
|
||
setFormData({
|
||
question: item.question,
|
||
answer: item.answer,
|
||
order: item.order,
|
||
isActive: item.isActive,
|
||
});
|
||
} else {
|
||
setEditingFaq(null);
|
||
setFormData({
|
||
question: '',
|
||
answer: '',
|
||
order: faqs.length,
|
||
isActive: true,
|
||
});
|
||
}
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!formData.question.trim() || !formData.answer.trim()) {
|
||
toast.error('سوال و جواب اجباری هستند');
|
||
return;
|
||
}
|
||
try {
|
||
setIsSaving(true);
|
||
if (editingFaq) {
|
||
await api.put(`/faq/${editingFaq.id}`, formData);
|
||
toast.success('سوال با موفقیت بروزرسانی شد');
|
||
} else {
|
||
await api.post('/faq', formData);
|
||
toast.success('سوال جدید اضافه شد');
|
||
}
|
||
setIsModalOpen(false);
|
||
fetchFaqs();
|
||
} catch (err) {
|
||
console.error('Failed to save FAQ:', err);
|
||
toast.error('خطا در ذخیره سوال');
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const toggleActive = async (item: FAQ) => {
|
||
try {
|
||
await api.put(`/faq/${item.id}`, { isActive: !item.isActive });
|
||
toast.success(item.isActive ? 'غیرفعال شد' : 'فعال شد');
|
||
fetchFaqs();
|
||
} catch {
|
||
toast.error('خطا در تغییر وضعیت');
|
||
}
|
||
};
|
||
|
||
const confirmDelete = async () => {
|
||
if (!deleteTargetId) return;
|
||
try {
|
||
await api.delete(`/faq/${deleteTargetId}`);
|
||
toast.success('سوال حذف شد');
|
||
fetchFaqs();
|
||
} catch {
|
||
toast.error('خطا در حذف');
|
||
} finally {
|
||
setDeleteTargetId(null);
|
||
}
|
||
};
|
||
|
||
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">
|
||
<HelpCircle className="w-6 h-6 text-orange-500" />
|
||
سوالات متداول (FAQ)
|
||
</h2>
|
||
<p className="text-gray-500 font-medium mt-1">
|
||
سوالات و پاسخهایی که در صفحه اصلی سایت نمایش داده میشوند — با Schema.org FAQPage برای SEO
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
startIcon={Plus}
|
||
onClick={() => openModal()}
|
||
>
|
||
افزودن سوال جدید
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Info Box */}
|
||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4 text-sm text-orange-800">
|
||
<strong>💡 نکته SEO:</strong> سوالات فعال به صورت خودکار با markup <code className="bg-orange-100 px-1 rounded">FAQPage</code> در صفحه اصلی نمایش داده میشوند و در نتایج گوگل به عنوان Rich Snippet ظاهر میشوند.
|
||
</div>
|
||
|
||
{/* List */}
|
||
<div className="space-y-3">
|
||
{isLoading ? (
|
||
<div className="flex justify-center py-12">
|
||
<Spinner size="lg" className="text-purple-600" />
|
||
</div>
|
||
) : faqs.length === 0 ? (
|
||
<div className="bg-white rounded-2xl border border-gray-200 py-16 text-center">
|
||
<HelpCircle className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||
<p className="text-gray-500 font-medium">هیچ سوالی ثبت نشده است</p>
|
||
<button
|
||
onClick={() => openModal()}
|
||
className="mt-4 text-purple-600 font-bold hover:underline text-sm cursor-pointer"
|
||
>
|
||
اولین سوال را اضافه کنید
|
||
</button>
|
||
</div>
|
||
) : (
|
||
faqs.map((faq) => (
|
||
<div
|
||
key={faq.id}
|
||
className={`bg-white rounded-2xl border p-5 transition-all ${
|
||
faq.isActive ? 'border-gray-200 shadow-sm' : 'border-gray-100 opacity-60 bg-gray-50'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex items-start gap-3 flex-1">
|
||
<div className="p-2 bg-orange-50 text-orange-600 rounded-xl mt-0.5 shrink-0">
|
||
<HelpCircle className="w-5 h-5" />
|
||
</div>
|
||
<div className="space-y-2 flex-1">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs font-bold px-2 py-0.5 bg-gray-100 text-gray-600 rounded-md">
|
||
ترتیب: {faq.order}
|
||
</span>
|
||
<span
|
||
className={`text-xs font-bold px-2 py-0.5 rounded-md ${
|
||
faq.isActive
|
||
? 'bg-green-100 text-green-700'
|
||
: 'bg-gray-100 text-gray-500'
|
||
}`}
|
||
>
|
||
{faq.isActive ? 'فعال' : 'غیرفعال'}
|
||
</span>
|
||
</div>
|
||
<h3 className="font-bold text-gray-900 text-base">{faq.question}</h3>
|
||
<p className="text-gray-600 text-sm leading-relaxed whitespace-pre-line">
|
||
{faq.answer}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<button
|
||
onClick={() => toggleActive(faq)}
|
||
title={faq.isActive ? 'غیرفعالسازی' : 'فعالسازی'}
|
||
className={`p-2 rounded-xl transition-colors cursor-pointer ${
|
||
faq.isActive
|
||
? 'text-green-600 hover:bg-green-50'
|
||
: 'text-gray-400 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
{faq.isActive ? (
|
||
<ToggleRight className="w-5 h-5" />
|
||
) : (
|
||
<ToggleLeft className="w-5 h-5" />
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={() => openModal(faq)}
|
||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-xl transition-colors cursor-pointer"
|
||
>
|
||
<Edit2 className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => setDeleteTargetId(faq.id)}
|
||
className="p-2 text-red-600 hover:bg-red-50 rounded-xl transition-colors cursor-pointer"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Modal */}
|
||
{isModalOpen && (
|
||
<Modal
|
||
isOpen={isModalOpen}
|
||
onClose={() => setIsModalOpen(false)}
|
||
title={editingFaq ? 'ویرایش سوال متداول' : 'افزودن سوال جدید'}
|
||
icon={HelpCircle}
|
||
maxWidth="2xl"
|
||
footer={
|
||
<div className="flex justify-end gap-2.5 w-full">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
type="button"
|
||
onClick={() => setIsModalOpen(false)}
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
type="submit"
|
||
form="faqForm"
|
||
isLoading={isSaving}
|
||
>
|
||
ذخیره سوال
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-6 text-right font-vazir">
|
||
<form id="faqForm" onSubmit={handleSave} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-bold text-gray-700 mb-1">
|
||
سوال <span className="text-red-500">*</span>
|
||
</label>
|
||
<input
|
||
required
|
||
type="text"
|
||
value={formData.question}
|
||
onChange={(e) => setFormData({ ...formData, question: 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">
|
||
پاسخ <span className="text-red-500">*</span>
|
||
</label>
|
||
<textarea
|
||
required
|
||
rows={5}
|
||
value={formData.answer}
|
||
onChange={(e) => setFormData({ ...formData, answer: 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 resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<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"
|
||
/>
|
||
</div>
|
||
<div className="flex items-end">
|
||
<label className="flex items-center justify-between p-3 bg-purple-50 rounded-xl border border-purple-200 cursor-pointer w-full">
|
||
<span className="text-xs font-bold text-purple-700">✅ فعال و قابل نمایش</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>
|
||
</form>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
|
||
|
||
{deleteTargetId && (
|
||
<ConfirmModal
|
||
isOpen={true}
|
||
title="حذف سوال"
|
||
message="آیا از حذف این سوال متداول اطمینان دارید؟"
|
||
onConfirm={confirmDelete}
|
||
onCancel={() => setDeleteTargetId(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|