canina/frontend/admin-panel/src/pages/FAQManager.tsx

338 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}