467 lines
19 KiB
TypeScript
467 lines
19 KiB
TypeScript
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 Button from '../components/ui/Button';
|
||
import Modal from '../components/ui/Modal';
|
||
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 {
|
||
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(() => {
|
||
let isMounted = true;
|
||
api.get('/banners')
|
||
.then(res => {
|
||
if (!isMounted) return;
|
||
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(() => {
|
||
if (isMounted) setIsLoading(false);
|
||
});
|
||
return () => {
|
||
isMounted = false;
|
||
};
|
||
}, []);
|
||
|
||
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
|
||
variant="primary"
|
||
size="sm"
|
||
startIcon={Plus}
|
||
onClick={() => openModal()}
|
||
>
|
||
افزودن بنر جدید
|
||
</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={() => handleReorder(index, 'up')}
|
||
className="p-1 text-gray-400 hover:text-purple-600 disabled:opacity-30 disabled:hover:text-gray-400 cursor-pointer"
|
||
>
|
||
<MoveUp className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
disabled={index === banners.length - 1}
|
||
onClick={() => handleReorder(index, 'down')}
|
||
className="p-1 text-gray-400 hover:text-purple-600 disabled:opacity-30 disabled:hover:text-gray-400 cursor-pointer"
|
||
>
|
||
<MoveDown className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
{banner.imageUrl ? (
|
||
<img
|
||
src={getImageFullUrl(banner.imageUrl)}
|
||
alt={banner.title || 'Banner'}
|
||
className="w-20 h-10 object-cover rounded-lg border border-gray-100 shadow-xs"
|
||
/>
|
||
) : (
|
||
<div className="w-20 h-10 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400">
|
||
<ImageIcon className="w-4 h-4" />
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<div className="font-bold text-gray-900">{banner.title || '-'}</div>
|
||
<div className="text-xs text-gray-400 mt-0.5">{banner.subtitle || '-'}</div>
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<span className="px-2.5 py-1 rounded-full text-xs font-bold bg-purple-50 text-purple-700">
|
||
{banner.position === 'home_hero' ? 'اسلایدر اصلی' : banner.position === 'home_promo' ? 'بنر تبلیغاتی میانی' : banner.position}
|
||
</span>
|
||
</td>
|
||
<td className="py-4 px-6 text-sm text-gray-500 font-mono" dir="ltr">
|
||
{banner.linkUrl || '-'}
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<button
|
||
onClick={() => handleToggleActive(banner)}
|
||
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold transition-colors cursor-pointer ${
|
||
banner.isActive
|
||
? 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||
: 'bg-rose-50 text-rose-700 hover:bg-rose-100'
|
||
}`}
|
||
>
|
||
{banner.isActive ? (
|
||
<>
|
||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||
فعال
|
||
</>
|
||
) : (
|
||
<>
|
||
<XCircle className="w-3.5 h-3.5" />
|
||
غیرفعال
|
||
</>
|
||
)}
|
||
</button>
|
||
</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 cursor-pointer"
|
||
>
|
||
<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 cursor-pointer"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modal */}
|
||
{isModalOpen && (
|
||
<Modal
|
||
isOpen={isModalOpen}
|
||
onClose={() => setIsModalOpen(false)}
|
||
title={editingBanner ? 'ویرایش بنر' : 'افزودن بنر جدید'}
|
||
icon={ImageIcon}
|
||
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="bannerForm"
|
||
isLoading={isSaving}
|
||
>
|
||
ذخیره تغییرات
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-6 text-right font-vazir">
|
||
<form id="bannerForm" onSubmit={handleSave} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-bold text-gray-700 mb-1">عنوان بنر</label>
|
||
<input
|
||
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 className="grid grid-cols-1 md: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 bg-white"
|
||
>
|
||
<option value="home_hero">اسلایدر اصلی (Home Hero)</option>
|
||
<option value="home_promo">بنر تبلیغاتی میانی (Home Promo)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-bold text-gray-700 mb-1">لینک هدف (URL)</label>
|
||
<input
|
||
type="text"
|
||
value={formData.linkUrl}
|
||
onChange={(e) => setFormData({ ...formData, linkUrl: e.target.value })}
|
||
placeholder="/shop یا https://..."
|
||
dir="ltr"
|
||
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-mono"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-bold text-gray-700 mb-1">تصویر بنر *</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.imageUrl}
|
||
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
|
||
placeholder="آدرس تصویر یا انتخاب از رسانهها..."
|
||
dir="ltr"
|
||
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-mono"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
startIcon={ImageIcon}
|
||
onClick={() => setIsMediaSelectorOpen(true)}
|
||
>
|
||
انتخاب تصویر
|
||
</Button>
|
||
</div>
|
||
{formData.imageUrl && (
|
||
<div className="mt-3">
|
||
<img
|
||
src={getImageFullUrl(formData.imageUrl)}
|
||
alt="Preview"
|
||
className="h-28 w-full object-cover rounded-xl border border-gray-200"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-center">
|
||
<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 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>
|
||
</form>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
|
||
{/* 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>
|
||
);
|
||
}
|