canina/frontend/admin-panel/src/pages/FAQManager.tsx
parsa aghaei 2c452215c3
All checks were successful
Deploy Canina / deploy (push) Successful in 1m41s
feat: resolve doctors query error, add FAQ, testimonials, blog preview, and full menu management
2026-08-19 17:20:01 +03:30

317 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';
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
onClick={() => openModal()}
className="bg-orange-500 hover:bg-orange-600 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-orange-200"
>
<Plus className="w-5 h-5" />
افزودن سوال جدید
</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-orange-500" />
</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-orange-500 font-bold hover:underline text-sm"
>
اولین سوال را اضافه کنید
</button>
</div>
) : (
faqs.map((faq, idx) => (
<div
key={faq.id}
className={`bg-white rounded-2xl border p-5 transition-all ${
faq.isActive ? 'border-gray-200' : 'border-dashed border-gray-300 opacity-60'
}`}
>
<div className="flex items-start gap-4">
<div className="flex items-center gap-2 shrink-0 mt-1">
<GripVertical className="w-4 h-4 text-gray-300" />
<span className="text-xs font-black text-gray-400 w-5 text-center">{idx + 1}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="font-black text-gray-900 text-sm">{faq.question}</span>
{!faq.isActive && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-500 text-[10px] font-bold rounded">
غیرفعال
</span>
)}
</div>
<p className="text-gray-600 text-sm leading-relaxed line-clamp-2">{faq.answer}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => toggleActive(faq)}
className={`p-2 rounded-lg transition-colors ${
faq.isActive
? 'text-green-600 hover:bg-green-50'
: 'text-gray-400 hover:bg-gray-50'
}`}
title={faq.isActive ? 'غیرفعال کردن' : 'فعال کردن'}
>
{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-lg transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteTargetId(faq.id)}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</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-2xl shadow-2xl overflow-hidden">
<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">
{editingFaq ? 'ویرایش سوال متداول' : 'افزودن سوال جدید'}
</h3>
<button onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-lg">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<form onSubmit={handleSave} className="p-6 space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">
سوال <span className="text-red-500">*</span>
</label>
<input
type="text"
required
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-orange-400 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-orange-400 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-orange-400 outline-none text-sm"
/>
</div>
<div className="flex items-end">
<label className="flex items-center justify-between p-3 bg-orange-50 rounded-xl border border-orange-200 cursor-pointer w-full">
<span className="text-xs font-bold text-orange-700">✅ فعال و قابل نمایش</span>
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded text-orange-500 focus:ring-orange-400"
/>
</label>
</div>
</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-orange-500 text-white font-bold hover:bg-orange-600 transition-colors shadow-md shadow-orange-200 flex items-center gap-2"
>
{isSaving && <Spinner size="sm" />}
ذخیره
</button>
</div>
</form>
</div>
</div>
)}
{deleteTargetId && (
<ConfirmModal
isOpen={true}
title="حذف سوال"
message="آیا از حذف این سوال متداول اطمینان دارید؟"
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
)}
</div>
);
}