151 lines
7.7 KiB
TypeScript
151 lines
7.7 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useCallback, useEffect, useState } from "react";
|
|||
|
|
|
|||
|
|
interface Campaign {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
channel: string;
|
|||
|
|
audience: string;
|
|||
|
|
subject?: string | null;
|
|||
|
|
body_html?: string | null;
|
|||
|
|
template_code?: string | null;
|
|||
|
|
status: string;
|
|||
|
|
total_count: number;
|
|||
|
|
sent_count: number;
|
|||
|
|
failed_count: number;
|
|||
|
|
created_at: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120] p-5";
|
|||
|
|
const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
|||
|
|
const ghost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
|
|||
|
|
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
|||
|
|
const lbl = "mb-1 block text-xs font-medium text-gray-400";
|
|||
|
|
|
|||
|
|
const empty = { name: "", channel: "email", audience: "all", subject: "", body_html: "", template_code: "" };
|
|||
|
|
|
|||
|
|
export function MarketingAdmin() {
|
|||
|
|
const [rows, setRows] = useState<Campaign[]>([]);
|
|||
|
|
const [form, setForm] = useState({ ...empty });
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|||
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
const reload = useCallback(async () => {
|
|||
|
|
const r = await fetch("/api/admin/resource/campaigns", { cache: "no-store" }).then((x) => x.json()).catch(() => null);
|
|||
|
|
setRows(r?.data ?? (Array.isArray(r) ? r : []));
|
|||
|
|
}, []);
|
|||
|
|
useEffect(() => { reload(); }, [reload]);
|
|||
|
|
|
|||
|
|
const create = async () => {
|
|||
|
|
setSaving(true); setMsg(null);
|
|||
|
|
const res = await fetch("/api/admin/resource/campaigns", {
|
|||
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
name: form.name, channel: form.channel, audience: form.audience,
|
|||
|
|
subject: form.subject || null, body_html: form.body_html || null,
|
|||
|
|
template_code: form.template_code || null,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
const d = await res.json().catch(() => null);
|
|||
|
|
setMsg(res.ok ? "کمپین ساخته شد ✓" : (d?.error ?? "خطا"));
|
|||
|
|
setSaving(false);
|
|||
|
|
if (res.ok) { setForm({ ...empty }); reload(); }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const send = async (camp: Campaign) => {
|
|||
|
|
if (!confirm(`ارسال کمپین «${camp.name}» به مخاطبان؟`)) return;
|
|||
|
|
setBusy(camp.id); setMsg(null);
|
|||
|
|
const res = await fetch(`/api/admin/resource/campaigns/${camp.id}/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
|||
|
|
const d = await res.json().catch(() => null);
|
|||
|
|
setMsg(res.ok ? `ارسال شد: ${d.sent}/${d.total} (ناموفق ${d.failed})` : (d?.error ?? "ارسال ناموفق"));
|
|||
|
|
setBusy(null); reload();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const remove = async (camp: Campaign) => {
|
|||
|
|
if (!confirm(`حذف کمپین «${camp.name}»؟`)) return;
|
|||
|
|
const res = await fetch(`/api/admin/resource/campaigns/${camp.id}`, { method: "DELETE" });
|
|||
|
|
if (res.ok) reload();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const statusBadge = (s: string) => {
|
|||
|
|
const map: Record<string, string> = {
|
|||
|
|
Sent: "bg-emerald-500/15 text-emerald-300",
|
|||
|
|
Sending: "bg-amber-500/15 text-amber-300",
|
|||
|
|
Failed: "bg-red-500/15 text-red-300",
|
|||
|
|
Draft: "bg-gray-500/15 text-gray-400",
|
|||
|
|
};
|
|||
|
|
return <span className={`rounded px-1.5 py-0.5 text-[11px] ${map[s] ?? map.Draft}`}>{s}</span>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<div>
|
|||
|
|
<h1 className="text-xl font-semibold text-white">Marketing — Campaigns</h1>
|
|||
|
|
<p className="mt-1 text-sm text-gray-400">ارسال پیامک/ایمیل به گروهی از کاربران از طریق کانالهای پیکربندیشده.</p>
|
|||
|
|
</div>
|
|||
|
|
{msg && <p className="rounded-lg bg-[#12152a] px-3 py-2 text-sm text-gray-300">{msg}</p>}
|
|||
|
|
|
|||
|
|
<section className={card}>
|
|||
|
|
<h2 className="text-sm font-semibold text-white">کمپین جدید</h2>
|
|||
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|||
|
|
<div><label className={lbl}>نام کمپین</label><input className={inp} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
|
|||
|
|
<div>
|
|||
|
|
<label className={lbl}>کانال</label>
|
|||
|
|
<select className={inp} value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value })}>
|
|||
|
|
<option value="email">ایمیل</option><option value="sms">پیامک</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className={lbl}>مخاطبان</label>
|
|||
|
|
<select className={inp} value={form.audience} onChange={(e) => setForm({ ...form, audience: e.target.value })}>
|
|||
|
|
<option value="all">همه کاربران</option>
|
|||
|
|
<option value="verified">کاربران تأییدشده</option>
|
|||
|
|
<option value="with_plan">دارای پلن فعال</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
{form.channel === "email" && (
|
|||
|
|
<div><label className={lbl}>قالب ایمیل (اختیاری)</label><input className={inp} placeholder="promotion" value={form.template_code} onChange={(e) => setForm({ ...form, template_code: e.target.value })} /></div>
|
|||
|
|
)}
|
|||
|
|
{form.channel === "email" && (
|
|||
|
|
<div className="sm:col-span-2"><label className={lbl}>موضوع (اگر قالب انتخاب نشده)</label><input className={inp} value={form.subject} onChange={(e) => setForm({ ...form, subject: e.target.value })} /></div>
|
|||
|
|
)}
|
|||
|
|
<div className="sm:col-span-2">
|
|||
|
|
<label className={lbl}>{form.channel === "sms" ? "متن پیامک" : "بدنه HTML (اگر قالب انتخاب نشده)"}</label>
|
|||
|
|
<textarea className={`${inp} min-h-[90px]`} value={form.body_html} onChange={(e) => setForm({ ...form, body_html: e.target.value })} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="mt-3"><button className={btn} onClick={create} disabled={saving || !form.name}>{saving ? "..." : "ساخت کمپین"}</button></div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<div className={`${card} !p-0 overflow-hidden`}>
|
|||
|
|
<table className="w-full text-sm">
|
|||
|
|
<thead><tr className="border-b border-[#1e2235] text-left text-xs text-gray-500">
|
|||
|
|
<th className="px-4 py-3">نام</th><th className="px-4 py-3">کانال</th><th className="px-4 py-3">مخاطب</th><th className="px-4 py-3">وضعیت</th><th className="px-4 py-3">ارسال</th><th className="px-4 py-3 text-right">عملیات</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody>
|
|||
|
|
{rows.length === 0 ? (
|
|||
|
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">کمپینی وجود ندارد.</td></tr>
|
|||
|
|
) : rows.map((c) => (
|
|||
|
|
<tr key={c.id} className="border-b border-[#161a2e] hover:bg-[#12152a]">
|
|||
|
|
<td className="px-4 py-3 text-gray-200">{c.name}</td>
|
|||
|
|
<td className="px-4 py-3 text-gray-400">{c.channel === "sms" ? "پیامک" : "ایمیل"}</td>
|
|||
|
|
<td className="px-4 py-3 text-gray-400">{c.audience}</td>
|
|||
|
|
<td className="px-4 py-3">{statusBadge(c.status)}</td>
|
|||
|
|
<td className="px-4 py-3 text-gray-400">{c.sent_count}/{c.total_count}</td>
|
|||
|
|
<td className="px-4 py-3">
|
|||
|
|
<div className="flex items-center justify-end gap-2">
|
|||
|
|
<button className={ghost} disabled={busy === c.id} onClick={() => send(c)}>{busy === c.id ? "در حال ارسال…" : "ارسال"}</button>
|
|||
|
|
<button className="rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10" onClick={() => remove(c)}>حذف</button>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|