} function fmtMoney(n) { if (n == null) return "—"; return "$" + Number(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function plural(n, w) { return `${n} ${w}${n === 1 ? "" : "s"}`; } /* ---------- API client ---------- */ const CSRF = { get() { return getCookie("mailer_csrf"); }, }; function getCookie(name) { return document.cookie.split("; ").find(r => r.startsWith(name + "="))?.split("=").slice(1).join("=") || ""; } async function api(path, opts = {}) { const headers = Object.assign({}, opts.headers || {}); if (opts.json !== undefined) { headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(opts.json); delete opts.json; } else if (opts.body && typeof opts.body === "object" && !(opts.body instanceof FormData) && !(opts.body instanceof Blob) && !(opts.body instanceof URLSearchParams) && !(opts.body instanceof ArrayBuffer)) { // plain object → JSON body (guards against accidental "[object Object]" sends) headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(opts.body); } if ((opts.method || "GET").toUpperCase() !== "GET") headers["X-CSRF-Token"] = CSRF.get(); const res = await fetch(path, Object.assign({}, opts, { headers, credentials: "same-origin" })); let data = null; try { data = await res.json(); } catch { /* no body */ } if (res.status === 401 && !location.pathname.startsWith("/login")) { location.href = "/login?next=" + encodeURIComponent(location.pathname); throw new Error("Not authenticated"); } if (!res.ok) { let detail = data && (data.detail?.message || data.detail || data.error); // Pydantic 422: detail is [{loc:[...], msg:'...'}] — surface the msg if (Array.isArray(detail)) { detail = detail.map(e => { let field = Array.isArray(e?.loc) ? e.loc.filter(x => typeof x === "string").join(".") : ""; field = field.replace(/^body\./, ""); let msg = e?.msg || String(e); msg = msg.replace(/^Value error,\s*/i, ""); return (field ? field + ": " : "") + msg; }).join("; "); } if (detail && typeof detail === "object") detail = JSON.stringify(detail); const err = new Error(typeof detail === "string" && detail ? detail : (res.status + " " + res.statusText)); err.status = res.status; err.data = data; throw err; } return data; } const GET = p => api(p); const POST = (p, json) => api(p, { method: "POST", json }); function b64Blob(b64, mime) { const bin = atob(b64); const arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return new Blob([arr], { type: mime }); } function dlBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1500); } const PATCH = (p, json) => api(p, { method: "PATCH", json }); const PUT = (p, json) => api(p, { method: "PUT", json }); const DEL = p => api(p, { method: "DELETE" });