/** * api/campaigns.js — Client for Hub /campaigns endpoints. * * Mirrors services/targo-hub/lib/campaigns.js. All gift-campaign requests * go through the Hub which handles ERPNext auth + Mailjet send + SSE * progress broadcast. * * Functions: * parseCsvs({ map_csv, giftbit_csv, multi }) → preview matched send list * createCampaign({ name, params, recipients }) → save + return id * listCampaigns() → summaries * getCampaign(id) → full detail * updateCampaign(id, patch) → edit recipients/params * sendCampaign(id) → fire background worker * campaignSseUrl(id) → SSE URL for live updates */ import { HUB_URL } from 'src/config/hub' async function hubFetch (path, { method = 'GET', body } = {}) { const opts = { method, headers: { 'Content-Type': 'application/json' } } if (body) opts.body = JSON.stringify(body) const res = await fetch(`${HUB_URL}${path}`, opts) const text = await res.text() let data try { data = text ? JSON.parse(text) : {} } catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) } if (!res.ok) { const msg = data.error || `HTTP ${res.status}` const err = new Error(msg) err.status = res.status throw err } return data } export function parseCsvs ({ map_csv, giftbit_csv, multi = 'first' }) { return hubFetch('/campaigns/parse', { method: 'POST', body: { map_csv, giftbit_csv, multi }, }) } export function createCampaign ({ name, params, recipients }) { return hubFetch('/campaigns', { method: 'POST', body: { name, params, recipients }, }) } export function listCampaigns () { return hubFetch('/campaigns').then(r => r.campaigns || []) } export function getCampaign (id) { return hubFetch(`/campaigns/${encodeURIComponent(id)}`) } export function updateCampaign (id, patch) { return hubFetch(`/campaigns/${encodeURIComponent(id)}`, { method: 'PATCH', body: patch, }) } export function sendCampaign (id) { return hubFetch(`/campaigns/${encodeURIComponent(id)}/send`, { method: 'POST', }) } // Permanent deletion — removes the JSON on the hub. Used for clearing // test campaigns from the list. Giftbit shortlinks are unaffected. export function deleteCampaign (id) { return hubFetch(`/campaigns/${encodeURIComponent(id)}`, { method: 'DELETE', }) } // Inventory of every wrapper token across all campaigns, with status // (active / expired / revoked / redeemed / pending). Used by the // gifts inventory page to surface reassignable Giftbit shortlinks. export function listGifts () { return hubFetch('/campaigns/gifts').then(r => r.gifts || []) } // Kill switch — manually expire a single recipient's wrapper token so // the underlying Giftbit URL becomes reassignable before the natural // expiry date. export function revokeGift (campaignId, rowIndex) { return hubFetch( `/campaigns/${encodeURIComponent(campaignId)}/recipients/${rowIndex}/revoke`, { method: 'POST' }, ) } // Re-attempt a single failed recipient — resets status pending and // fires the worker. Used for one-off failures the auto-retry didn't // recover (rare transient Mailjet socket closes, etc.). export function retryRecipient (campaignId, rowIndex) { return hubFetch( `/campaigns/${encodeURIComponent(campaignId)}/recipients/${rowIndex}/retry`, { method: 'POST' }, ) } // Create a brand-new draft campaign targeted at the parent's non-clicked // recipients. Uses gift-email-reminder-* templates + an urgency subject. // The new campaign is returned but NOT auto-sent — the operator reviews // and clicks Lancer l'envoi when ready. export function createReminderCampaign (parentCampaignId) { return hubFetch( `/campaigns/${encodeURIComponent(parentCampaignId)}/reminder`, { method: 'POST' }, ) } // Build the URL the browser hits to download the per-recipient CSV report // (giftbit shortlink ↔ email ↔ name ↔ status). The hub serves it with the // proper Content-Disposition so an click triggers a save. export function campaignReportCsvUrl (id) { return `${HUB_URL}/campaigns/${encodeURIComponent(id)}/report.csv` } // ── Image assets (self-hosted on the hub, for GrapesJS asset manager) ─────── export function listAssets () { return hubFetch('/campaigns/assets').then(r => r.assets || []) } // Upload a File / Blob from the browser via base64-encoded JSON. Bypasses // multipart parsing on the hub side (zero new deps) at the cost of ~33% // payload overhead. Acceptable for the ≤5 MB images we permit. export async function uploadAsset (file) { const dataUrl = await new Promise((resolve, reject) => { const r = new FileReader() r.onload = () => resolve(r.result) r.onerror = () => reject(new Error('FileReader failed')) r.readAsDataURL(file) }) return hubFetch('/campaigns/assets/upload', { method: 'POST', body: { name: file.name, data: dataUrl }, }) } export function deleteAsset (filename) { return hubFetch(`/campaigns/assets/${encodeURIComponent(filename)}`, { method: 'DELETE', }) } // ── Template editing (used by the GrapesJS editor page) ───────────────────── export function listTemplates () { return hubFetch('/campaigns/templates').then(r => r.templates || []) } export function getTemplate (name) { return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`) } // saveTemplate(name, content, opts) — content is HTML by default. // Optional opts.design = Unlayer design JSON (persisted alongside HTML so the // editor can re-load the visual state on next open). // Legacy opts.format = 'mjml' still supported for older callers (sends mjml). export function saveTemplate (name, content, { format = 'html', design = null } = {}) { const body = format === 'mjml' ? { mjml: content } : { html: content } if (design) body.design = design return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`, { method: 'PUT', body, }) } export function previewTemplate (name, { html, vars } = {}) { return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/preview`, { method: 'POST', body: { html, vars }, }) } // Translate the source template to a target language via Gemini. // targetName must match the source's prefix (e.g. gift-email-fr → gift-email-en). // override=true required if the target already exists. export function translateTemplate (srcName, targetName, { override = false } = {}) { return hubFetch( `/campaigns/templates/${encodeURIComponent(srcName)}/translate-to/${encodeURIComponent(targetName)}`, { method: 'POST', body: { override } }, ) } // Send ONE rendered email to a specific address for visual QA. // Pass { to, vars, from?, subject? } — defaults filled in server-side. export function testSendTemplate (name, { to, vars, from, subject } = {}) { return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, { method: 'POST', body: { to, vars, from, subject }, }) } /** * Returns the URL for the SSE channel of one campaign. The Hub broadcasts on * topic `campaign:` so we subscribe to that single topic. Use with: * const es = new EventSource(campaignSseUrl(id)) * es.addEventListener('recipient-update', ev => { ... }) * es.addEventListener('campaign-done', ev => { ... }) */ export function campaignSseUrl (id) { return `${HUB_URL}/sse?topics=campaign:${encodeURIComponent(id)}` }