window.pageBoot = async (me) => {
/* ---------------- shared state ---------------- */
let OPT = null; // wizard options
let mode = "list"; // list | monitor
let editingId = null; // editing existing campaign
const ST_BADGE = { draft: "b-gray", queued: "b-blue", running: "b-amber",
paused: "b-violet", completed: "b-green",
cancelled: "b-gray", failed: "b-red" };
const C = {}; // wizard state (CampaignIn mirror)
const DEF = () => ({
name: "", recipient_list_id: null, subject_set_ids: [], template_ids: [],
message_type: "html", attach_mode: "none", attach_pdf: false,
attach_pdf_template_id: null, attachment_id: null, variable_config: {},
account_ids: [], rotation_strategy: "sequential", rotation_weights: {},
subject_rotation: "round_robin", template_sequence_mode: "repeat",
phone_selection: "round_robin", max_per_account: 200,
send_interval_seconds: 1.0, rate_per_minute: 60, max_retries: 3,
retry_delay_seconds: 60, unsubscribe_enabled: true, preview_sample_size: 5,
});
Object.assign(C, DEF());
let step = 1;
const STEPS = [
"Campaign name", "Recipient list", "Subject lines", "Templates", "Attachments",
"Variables & amounts", "Gmail accounts", "Rotation & weights", "Limits & pacing",
"Company phone", "Unsubscribe", "Confirm & start",
];
const STEPS_KEYS = ["name", "recipients", "subjects", "templates", "attachments",
"variables", "accounts", "rotation", "limits", "phone",
"unsubscribe", "confirm"];
async function loadOptions() {
const d = await GET("/campaigns/wizard/options");
OPT = d;
return d;
}
function stBadge(s) { return `${esc(s || "—")}`; }
/* ==================== LIST PANE ==================== */
let lst = { page: 1, status: "", q: "" };
async function loadList() {
const box = $("#campTable");
box.innerHTML = `
`;
try {
const p = new URLSearchParams({ status: lst.status, search: lst.q,
page: lst.page, page_size: 20 });
const d = await GET("/campaigns/list?" + p);
const rows = d.campaigns || [];
box.innerHTML = rows.length ? `
| ID | Name | Status | Recipients | Sent | Failed | Suppressed | Skipped | Started | Actions |
${rows.map(c => `
| ${c.id} |
${esc(c.name)} |
${stBadge(c.status)} |
${c.total_recipients ?? 0} |
${c.sent_count ?? 0} |
${c.failed_count ?? 0} |
${c.suppressed_count ?? 0} |
${c.skipped_count ?? 0} |
${fmtDT(c.started_at)} |
${["draft", "paused"].includes(c.status) ? `` : ""}
|
`).join("")}
`
: `✦
No campaigns yet. Use the wizard to create your first campaign.
`;
const pages = Math.max(1, Math.ceil(d.total / 20));
$("#campPager").innerHTML = pages > 1 ?
`
Page ${lst.page} of ${pages} · ${d.total} campaigns
` : "";
$$("#campPager [data-pg]").forEach(b => b.onclick = () => { lst.page = +b.dataset.pg; loadList(); });
$$("#campTable [data-mon]").forEach(b => b.onclick = () => openMonitor(+b.dataset.mon));
$$("#campTable [data-edit]").forEach(b => b.onclick = () => openWizard(+b.dataset.edit));
$$("#campTable [data-prev]").forEach(b => b.onclick = () => campaignPreview(+b.dataset.prev));
$$("#campTable [data-del]").forEach(b => b.onclick = () =>
confirmedAction(`Delete campaign #${b.dataset.del}? This removes the campaign and its recipient assignments. Logs are kept.`,
async () => { await POST(`/campaigns/${b.dataset.del}/delete`); loadList(); },
{ title: "Delete campaign", confirmLabel: "Delete" }));
} catch (e) { box.innerHTML = ``; }
}
/* ==================== MONITOR PANE ==================== */
let MON = { id: null, es: null, page: 1, statusF: "", q: "" };
function openMonitor(cid) {
MON.id = cid;
setMode("monitor");
}
function setMode(m) {
mode = m;
$$("#modeTabs .mode-tab").forEach(t => t.classList.toggle("active", t.dataset.mode === m));
$("#listPane").classList.toggle("hidden", m !== "list");
$("#monPane").classList.toggle("hidden", m !== "monitor");
if (m === "list") { stopStream(); }
if (m === "monitor" && MON.id) { refreshMonSel(); startMonitor(); }
}
async function refreshMonSel() {
try {
const d = await GET("/campaigns/list?page_size=100");
$("#monSel").innerHTML = (d.campaigns || []).map(c =>
``).join("");
} catch {}
$("#monSel").onchange = () => { MON.id = +$("#monSel").value; startMonitor(); };
}
function stopStream() {
if (MON.es) { MON.es.close(); MON.es = null; }
$("#liveInd").classList.add("hidden");
}
async function startMonitor() {
if (!MON.id) return;
stopStream();
const body = $("#monBody");
body.innerHTML = ``;
await drawProgress(); // initial paint via REST
await drawRecipients();
drawControls();
// SSE
MON.es = new EventSource(`/campaigns/${MON.id}/stream`);
MON.es.addEventListener("connected", () => $("#liveInd").classList.remove("hidden"));
MON.es.addEventListener("progress", ev => { try { paintProgress(JSON.parse(ev.data)); } catch {} });
MON.es.addEventListener("done", ev => {
try { paintProgress(JSON.parse(ev.data)); } catch {}
stopStream();
toast(`Campaign finished — status: ${(JSON.parse(ev.data) || {}).status || "?"}`, "info", 6000);
drawControls();
});
MON.es.onerror = () => { $("#liveInd").classList.add("hidden"); };
}
async function drawProgress() {
try { const p = await GET(`/campaigns/${MON.id}/progress`); paintProgress(p); }
catch (e) { $("#monBody").innerHTML = ``; }
}
function paintProgress(p) {
let box = $("#progBox");
if (!box) {
box = el("div", { id: "progBox" });
$("#monBody").prepend(box);
}
const meterSegs = Math.min(p.total || 0, 400);
const seg = (cls, n) => ``;
const sum = p.sent + p.perm_failed + p.suppressed + p.skipped;
box.innerHTML = `
#${p.campaign_id}
${stBadge(p.status)}
${p.started_at ? "started " + fmtDT(p.started_at) : "not started"}
${p.finished_at ? `· finished ${fmtDT(p.finished_at)}` : ""}
${p.progress_pct}% complete
${meterSegs("sent", p.sent)}${meterSegs("fail", p.perm_failed)}${meterSegs("supp", p.suppressed)}${meterSegs("skip", p.skipped)}${meterSegs("proc", (p.total || 0) - sum)}
${p.temp_failed}
Temp failed
${p.perm_failed}
Perm failed
${p.suppressed}
Suppressed
${p.jobs_total} send jobs · delivery attempts are retried with backoff; temporary failures never masquerade as success.
`;
drawControls();
}
function drawControls() {
const ctl = $("#monCtl");
if (!ctl) return;
fetch(`/campaigns/${MON.id}`).then(r => r.json()).then(c => {
ctl.innerHTML = "";
const mk = (label, cls, fn, confirmMsg) => {
const b = el("button", { class: "btn sm " + cls, onclick: () =>
confirmMsg ? confirmedAction(confirmMsg, fn, { title: label }) : fn() }, label);
ctl.append(b);
};
if (c.status === "draft") mk("Start", "", async () => { await POST(`/campaigns/${MON.id}/start`); toast("Campaign started", "ok"); startMonitor(); });
if (c.status === "running") mk("Pause", "secondary", async () => { await POST(`/campaigns/${MON.id}/pause`); toast("Campaign paused", "ok"); startMonitor(); });
if (c.status === "paused") mk("Resume", "", async () => { await POST(`/campaigns/${MON.id}/resume`); toast("Campaign resumed", "ok"); startMonitor(); });
if (["running", "paused", "queued", "draft"].includes(c.status))
mk("Cancel", "danger-o", async () => { await POST(`/campaigns/${MON.id}/cancel`); toast("Campaign cancelled", "warn"); startMonitor(); },
`Cancel campaign #${MON.id}? In-flight messages already accepted by Gmail cannot be recalled.`);
const ex = el("button", { class: "btn sm secondary", onclick: () => { window.location = `/campaigns/${MON.id}/export`; } }, "Export CSV");
const pv = el("button", { class: "btn sm secondary", onclick: () => campaignPreview(MON.id) }, "Preview");
ctl.append(ex, pv);
}).catch(() => {});
}
async function drawRecipients() {
let box = $("#recBox");
if (!box) { box = el("div", { id: "recBox" }); $("#monBody").append(box); }
await loadRecipients(box);
}
async function loadRecipients(box) {
const p = new URLSearchParams({ status: MON.statusF, search: MON.q,
page: MON.page, page_size: 50 });
let d;
try { d = await GET(`/campaigns/${MON.id}/recipients?` + p); }