canina/frontend/admin-panel/src/pages/Coupons.tsx
parsa aghaei 9d63f9a7a4
All checks were successful
Deploy Canina / deploy (push) Successful in 31s
feat(admin): standardize UI components, modal viewports, URL syncing, and add live monitoring
2026-08-23 08:40:37 +03:30

517 lines
26 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 { Tag, Plus, Edit2, Trash2, Search, XCircle, CheckCircle2, User, Heart, ShoppingBag, FolderTree, Shield, HelpCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import Pagination from '../components/ui/Pagination';
import ConfirmModal from '../components/ui/ConfirmModal';
import PriceInput from '../components/ui/PriceInput';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
export interface CouponTarget {
targetType: string;
targetId: string;
modifierType: string;
modifierValue: number | string;
}
export interface Coupon {
id: string;
code: string;
type: string;
value: number;
minCartValue?: number;
maxCartValue?: number;
maxUses?: number;
usedCount?: number;
expiresAt?: string;
isActive: boolean;
targets?: CouponTarget[];
}
export interface CouponFormData {
code: string;
type: string;
value: number | string;
minCartValue: string;
maxCartValue: string;
maxUses: string;
expiresAt: string;
isActive: boolean;
targets: CouponTarget[];
}
export default function Coupons() {
const [coupons, setCoupons] = useState<Coupon[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const limit = 10;
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCoupon, setEditingCoupon] = useState<Coupon | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [formData, setFormData] = useState<CouponFormData>({
code: '', type: 'percent', value: 0, minCartValue: '', maxCartValue: '', maxUses: '', expiresAt: '', isActive: true,
targets: []
});
const fetchData = useCallback(async () => {
try {
setIsLoading(true);
const res = await api.get('/admin/coupons', { params: { page, limit, search } });
if (res.data?.data) {
setCoupons(res.data.data);
setTotalPages(res.data.meta?.lastPage || 1);
}
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
}, [page, search]);
useEffect(() => {
const timer = setTimeout(() => fetchData(), 500);
return () => clearTimeout(timer);
}, [fetchData]);
const openModal = (coupon: Coupon | null = null) => {
if (coupon) {
setEditingCoupon(coupon);
setFormData({
code: coupon.code,
type: coupon.type,
value: Number(coupon.value),
minCartValue: coupon.minCartValue ? String(coupon.minCartValue) : '',
maxCartValue: coupon.maxCartValue ? String(coupon.maxCartValue) : '',
maxUses: coupon.maxUses ? String(coupon.maxUses) : '',
expiresAt: coupon.expiresAt ? new Date(coupon.expiresAt).toISOString().split('T')[0] : '',
isActive: coupon.isActive,
targets: coupon.targets || []
});
} else {
setEditingCoupon(null);
setFormData({
code: '', type: 'percent', value: 0, minCartValue: '', maxCartValue: '', maxUses: '', expiresAt: '', isActive: true, targets: []
});
}
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
const payload = {
...formData,
value: Number(formData.value),
minCartValue: formData.minCartValue ? Number(formData.minCartValue) : null,
maxCartValue: formData.maxCartValue ? Number(formData.maxCartValue) : null,
maxUses: formData.maxUses ? Number(formData.maxUses) : null,
expiresAt: formData.expiresAt || null,
targets: formData.targets.map(t => ({
...t, modifierValue: t.modifierValue ? Number(t.modifierValue) : null
}))
};
if (editingCoupon) {
await api.put(`/admin/coupons/${editingCoupon.id}`, payload);
toast.success('کد تخفیف با موفقیت ویرایش شد');
} else {
await api.post('/admin/coupons', payload);
toast.success('کد تخفیف با موفقیت ایجاد شد');
}
setIsModalOpen(false);
fetchData();
} catch (err) {
console.error('Save error', err);
toast.error('خطا در ذخیره کد تخفیف');
}
};
const confirmDelete = async () => {
if (!deleteTargetId) return;
try {
await api.delete(`/admin/coupons/${deleteTargetId}`);
toast.success('کد تخفیف با موفقیت حذف شد');
fetchData();
} catch {
toast.error('خطا در حذف کد تخفیف');
} finally {
setDeleteTargetId(null);
}
};
const handleToggle = async (id: string, currentStatus: boolean) => {
try {
await api.put(`/admin/coupons/${id}/toggle`, { isActive: !currentStatus });
fetchData();
} catch {
toast.error('خطا در تغییر وضعیت کد تخفیف');
}
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<Tag className="w-6 h-6 text-purple-600" />
تخفیف‌های پلیمورفیک (Polymorphic)
</h2>
<p className="text-gray-500 font-medium mt-1">مدیریت کدهای تخفیف با اهداف چندگانه (کاربران، حیوانات، محصولات و ...)</p>
</div>
<button onClick={() => openModal()} className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200">
<Plus className="w-5 h-5" />
کد تخفیف جدید
</button>
</div>
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input type="text" placeholder="جستجوی کد..." value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all" />
</div>
</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 w-24">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isLoading ? (
<tr><td colSpan={6} className="py-12 text-center"><Spinner size="lg" className="mx-auto text-purple-600" /></td></tr>
) : coupons.length === 0 ? (
<tr><td colSpan={6} className="py-12 text-center text-gray-500 font-medium">هیچ کدی یافت نشد</td></tr>
) : (
coupons.map((c) => (
<tr key={c.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6 font-mono font-bold text-lg text-gray-900">{c.code}</td>
<td className="py-4 px-6 font-bold text-purple-700">
{c.type === 'percent' ? `${c.value}٪` : `${Number(c.value).toLocaleString()} تومان`}
</td>
<td className="py-4 px-6">
<span className="inline-flex items-center justify-center px-3 py-1 bg-purple-100 text-purple-700 rounded-full font-bold text-sm">
{c.targets?.length || 0} هدف
</span>
</td>
<td className="py-4 px-6">
<span className="font-bold text-gray-800">{c.usedCount}</span>
{c.maxUses && <span className="text-gray-400 text-xs mr-1">/ {c.maxUses}</span>}
</td>
<td className="py-4 px-6">
<button onClick={() => handleToggle(c.id, c.isActive)} className={`px-3 py-1 text-xs font-bold rounded-lg transition-colors flex items-center gap-1 ${c.isActive ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{c.isActive ? <CheckCircle2 className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}
{c.isActive ? 'فعال' : 'غیرفعال'}
</button>
</td>
<td className="py-4 px-6">
<div className="flex items-center gap-2">
<button onClick={() => openModal(c)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => setDeleteTargetId(c.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{!isLoading && totalPages > 1 && (
<div className="p-4 border-t border-gray-100 flex justify-center bg-gray-50/50">
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div>
)}
</div>
<ConfirmModal
isOpen={!!deleteTargetId}
title="حذف کد تخفیف"
message="آیا از حذف این کد تخفیف مطمئن هستید؟ این عملیات قابل بازگشت نیست."
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
{isModalOpen && <CouponModal formData={formData} setFormData={setFormData} onSave={handleSave} onClose={() => setIsModalOpen(false)} isEditing={!!editingCoupon} />}
</div>
);
}
interface CouponModalProps {
formData: CouponFormData;
setFormData: React.Dispatch<React.SetStateAction<CouponFormData>>;
onSave: (e: React.FormEvent) => Promise<void>;
onClose: () => void;
isEditing: boolean;
}
// Sub-component for Modal to keep code clean
function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: CouponModalProps) {
const addTarget = () => {
setFormData({
...formData,
targets: [...formData.targets, { targetType: 'USER', targetId: '', modifierType: 'override', modifierValue: '' }]
});
};
const removeTarget = (index: number) => {
const nt = [...formData.targets];
nt.splice(index, 1);
setFormData({ ...formData, targets: nt });
};
const updateTarget = (index: number, key: keyof CouponTarget, val: string | number) => {
const nt = [...formData.targets];
nt[index] = { ...nt[index], [key]: val };
setFormData({ ...formData, targets: nt });
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'USER': return <User className="w-4 h-4 text-blue-500" />;
case 'PET': return <Heart className="w-4 h-4 text-pink-500" />;
case 'PRODUCT': return <ShoppingBag className="w-4 h-4 text-orange-500" />;
case 'CATEGORY': return <FolderTree className="w-4 h-4 text-emerald-500" />;
case 'ROLE': return <Shield className="w-4 h-4 text-indigo-500" />;
default: return null;
}
};
return (
<Modal
isOpen={true}
onClose={onClose}
title={isEditing ? 'ویرایش تخفیف' : 'ساخت تخفیف جدید'}
icon={Tag}
maxWidth="4xl"
footer={
<div className="flex justify-end gap-2 w-full">
<Button
variant="outline"
size="sm"
onClick={onClose}
>
انصراف
</Button>
<Button
variant="primary"
size="sm"
type="submit"
form="couponForm"
>
ثبت تخفیف
</Button>
</div>
}
>
<form id="couponForm" onSubmit={onSave} className="space-y-6 text-xs font-vazir">
{/* Base Settings */}
<div className="space-y-4">
<h4 className="font-bold text-gray-800 border-b border-gray-100 pb-2">تنظیمات پایه</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1.5">
<span>کد تخفیف *</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
عبارت یکتا جهت وارد کردن توسط کاربر (مثال: CANINA20). حروف به طور خودکار بزرگ می‌شوند.
</div>
</div>
</label>
<input required type="text" value={formData.code} onChange={e => setFormData({...formData, code: e.target.value.toUpperCase()})} dir="ltr" className="w-full px-3 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-center uppercase font-bold" />
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1.5">
<span>نوع محاسبه *</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
درصدی (کسر درصد از کل مبلغ) یا مبلغ ثابت (کسر مقدار ریالی مشخص).
</div>
</div>
</label>
<select required value={formData.type} onChange={e => setFormData({...formData, type: e.target.value})} className="w-full px-3 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 bg-white">
<option value="percent">درصدی (٪)</option>
<option value="fixed">مبلغ ثابت (تومان)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1.5">
<span>مقدار پایه *</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
عدد درصد یا مبلغ ثابت تخفیف به تومان.
</div>
</div>
</label>
{formData.type === 'fixed' ? (
<PriceInput
required
value={formData.value}
onChange={(val) => setFormData({ ...formData, value: val })}
placeholder="مثال: ۵۰,۰۰۰"
className="px-3 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 text-xs"
/>
) : (
<div className="relative">
<input
required
type="number"
min="0"
max="100"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
dir="ltr"
placeholder="مثال: ۲۰"
className="w-full px-3 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 font-bold"
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 font-bold">%</span>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
<span>حداقل خرید (تومان)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
حداقل مبلغ سبد خرید برای فعال شدن این کد.
</div>
</div>
</label>
<PriceInput
value={formData.minCartValue}
onChange={(val) => setFormData({ ...formData, minCartValue: val })}
placeholder="۰ = بدون محدودیت"
className="px-3 py-2 rounded-xl border border-gray-200 focus:border-purple-500 text-xs"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
<span>سقف تخفیف (تومان)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
حداکثر سقف ریالی کسر شده در تخفیف‌های درصدی.
</div>
</div>
</label>
<PriceInput
value={formData.maxCartValue}
onChange={(val) => setFormData({ ...formData, maxCartValue: val })}
placeholder="بدون سقف"
className="px-3 py-2 rounded-xl border border-gray-200 focus:border-purple-500 text-xs"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
<span>دفعات مجاز استفاده</span>
<div className="group relative inline-block">
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
تعداد کل دفات قابل استفاده توسط کلیه کاربران.
</div>
</div>
</label>
<input type="number" min="1" value={formData.maxUses} onChange={e => setFormData({...formData, maxUses: e.target.value})} dir="ltr" className="w-full px-3 py-2 rounded-xl border border-gray-200 focus:border-purple-500 text-xs" />
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
<span>تاریخ انقضا</span>
<div className="group relative inline-block">
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
آخرین مهلت اعتبار کد تخفیف.
</div>
</div>
</label>
<input type="date" value={formData.expiresAt} onChange={e => setFormData({...formData, expiresAt: e.target.value})} className="w-full px-3 py-2 rounded-xl border border-gray-200 focus:border-purple-500 text-xs" />
</div>
</div>
</div>
{/* Target Builder */}
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-gray-100 pb-2">
<h4 className="font-bold text-gray-800">اهداف اختصاصی (Targets & Modifiers)</h4>
<button type="button" onClick={addTarget} className="text-xs font-bold text-purple-600 bg-purple-50 px-3 py-1.5 rounded-xl hover:bg-purple-100 transition-colors flex items-center gap-1 cursor-pointer">
<Plus className="w-4 h-4" /> افزودن هدف
</button>
</div>
{formData.targets.length === 0 ? (
<div className="bg-gray-50 border border-dashed border-gray-300 rounded-2xl p-6 text-center text-gray-500 text-xs">
هیچ هدف اختصاصی تعریف نشده است. این کد تخفیف با مقادیر پایه برای همه اعمال می‌شود (مگر محدودیت دیگری وجود داشته باشد).
</div>
) : (
<div className="space-y-3">
{formData.targets.map((t: CouponTarget, i: number) => (
<div key={i} className="flex flex-col sm:flex-row gap-3 items-end bg-white border border-gray-200 p-3.5 rounded-2xl shadow-xs hover:border-purple-300 transition-colors relative">
<div className="space-y-1 w-full sm:w-1/5">
<label className="text-[11px] font-bold text-gray-500 flex items-center gap-1">{getTypeIcon(t.targetType)} نوع هدف</label>
<select value={t.targetType} onChange={(e) => updateTarget(i, 'targetType', e.target.value)} className="w-full px-2.5 py-2 rounded-xl border border-gray-200 text-xs bg-gray-50 focus:bg-white focus:border-purple-500 outline-none">
<option value="USER">کاربر خاص</option>
<option value="PET">پت (حیوان)</option>
<option value="PRODUCT">محصول خاص</option>
<option value="CATEGORY">دسته‌بندی خاص</option>
<option value="ROLE">نقش کاربری</option>
</select>
</div>
<div className="space-y-1 w-full sm:w-2/5">
<label className="text-[11px] font-bold text-gray-500">شناسه (ID / Slug / موبایل)</label>
<input type="text" placeholder="مقدار شناسه هدف..." value={t.targetId} onChange={(e) => updateTarget(i, 'targetId', e.target.value)} className="w-full px-2.5 py-2 rounded-xl border border-gray-200 text-xs outline-none focus:border-purple-500" />
</div>
<div className="space-y-1 w-full sm:w-1/5">
<label className="text-[11px] font-bold text-gray-500">نوع تغییردهنده (Modifier)</label>
<select value={t.modifierType} onChange={(e) => updateTarget(i, 'modifierType', e.target.value)} className="w-full px-2.5 py-2 rounded-xl border border-gray-200 text-xs bg-gray-50 focus:bg-white focus:border-purple-500 outline-none">
<option value="override">جایگزین مقدار پایه (Override)</option>
<option value="add">افزایش به پایه (+)</option>
<option value="subtract">کاهش از پایه (-)</option>
</select>
</div>
<div className="space-y-1 w-full sm:w-1/5">
<label className="text-[11px] font-bold text-gray-500">مقدار جدید (اختیاری)</label>
<input type="number" placeholder="مقدار پایه" value={t.modifierValue} onChange={(e) => updateTarget(i, 'modifierValue', e.target.value)} dir="ltr" className="w-full px-2.5 py-2 rounded-xl border border-gray-200 text-xs outline-none focus:border-purple-500" />
</div>
<button type="button" onClick={() => removeTarget(i)} className="p-2.5 text-red-500 bg-red-50 hover:bg-red-100 rounded-xl transition-colors shrink-0 cursor-pointer">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
<div className="flex items-center">
<label className="flex items-center gap-3 cursor-pointer p-3 border border-green-200 bg-green-50/50 rounded-2xl w-full">
<input type="checkbox" checked={formData.isActive} onChange={(e) => setFormData({...formData, isActive: e.target.checked})} className="w-4 h-4 text-green-600 rounded" />
<span className="font-bold text-green-800 text-xs">کد تخفیف در سیستم فعال باشد</span>
</label>
</div>
</form>
</Modal>
);
}