Files
flatrender/src/components/admin/MarketingAdmin.tsx
T
soroush.asadi 6dbb14d146
Build backend images / build content-svc (push) Failing after 14s
Build backend images / build file-svc (push) Failing after 22s
Build backend images / build gateway (push) Failing after 1m21s
Build backend images / build identity-svc (push) Failing after 1m43s
Build backend images / build notification-svc (push) Failing after 1m6s
Build backend images / build render-svc (push) Failing after 53s
Build backend images / build studio-svc (push) Failing after 1m5s
feat(notifications+admin): marketing campaigns
- campaigns table (migration 19) + CRUD + send endpoint in notification-svc
- audience resolution reads cross-schema from identity.users (all / verified /
  with_plan); send dispatches via the SMS or Email channel and logs deliveries
- endpoints: GET/POST /v1/campaigns, POST /v1/campaigns/:id/send, DELETE
- gateway route /v1/campaigns/* → notification
- /admin/marketing: create campaign (channel, audience, template/subject/body),
  list with status + sent counts, send, delete

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:17:19 +03:30

151 lines
7.7 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.
"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>
);
}