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

467 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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