gigafibre-fsm/apps/ops/src/api/campaigns.js
louispaulb 0cd99b6d44 feat(ops/campaigns): pivot template editor to Unlayer (vue-email-editor)
After honest acknowledgment that easy-email-standard is abandoned and
limited (Chrome-only, no responsive preview, no AMP, no Unsplash, no
file manager), pivoted to Unlayer's vue-email-editor — a Vue 3 native
component giving all the features the user listed for free (internal
use; a small "Powered by Unlayer" badge shows in the sidebar but NOT
in sent emails).

Why drop MJML alongside:
  • MJML was our SERVER-SIDE compilation step because we hand-wrote
    templates. With a visual editor that outputs email-safe HTML
    directly (responsive media queries, Outlook MSO fallbacks, AMP
    where used), the compilation step is redundant.
  • One fewer dependency on the hub (mjml package no longer needed).
  • One fewer file format to persist (.mjml dropped, only .html
    canonical + .json design).

Storage simplification:
  Before: .mjml (source) + .html (compiled) + .json (editor state)
  After:  .html (canonical) + .json (Unlayer design tree)

The hub's send-worker reads .html as before — no changes to send
logic.

Architecture wins:
  • Vue 3 native — zero iframe friction, no postMessage choreography
  • No separate microservice — easy-email container decommissioned
    (docker compose down, code kept under /opt/email-editor/ in case
    of rollback)
  • DNS editor.gigafibre.ca retained but unused — can be removed via
    Cloudflare API cleanup later
  • The editor's mergeTags option exposes our {{firstname}}, {{amount}},
    {{gift_url}}, etc. in Unlayer's native "Merge tags" panel — same
    pattern, more polished UI
  • Features now native: responsive preview (mobile/tablet/desktop
    breakpoints), Unsplash search, file manager, dark mode, design
    history, undo/redo, layers panel, content blocks library

Frontend (TemplateEditorPage.vue):
  • Imports EmailEditor from vue-email-editor
  • onReady() callback: fetch template + loadDesign() to restore canvas
  • saveTemplate(): exportHtml() → PUT { html, design } to hub
  • Top bar kept: template selector, saved chip, preview, test-send,
    save button
  • Removed: iframe-related glue (postMessage listener, iframeKey,
    EDITOR_BASE constant, Cmd-S handling that lived in the iframe)

API client (apps/ops/src/api/campaigns.js):
  • saveTemplate() now accepts opts.design (Unlayer JSON tree) alongside
    content. Legacy opts.format='mjml' still works for backward compat.

Hub (services/targo-hub/lib/campaigns.js):
  • GET /campaigns/templates/:name unconditionally returns
    { name, format, html, design } (+ mjml when format=mjml for
    legacy templates). The design field is null when no .json file
    exists yet.
  • PUT /campaigns/templates/:name HTML save path now accepts
    body.design alongside body.html and persists both with backups.
  • MJML save path (legacy) preserved for any callers using the old
    contract.

Container decommissioned on prod: email-editor container stopped +
removed. The Vue editor lives inside the ops SPA, served from
erp.gigafibre.ca/ops as a normal route.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:14:06 -04:00

149 lines
5.0 KiB
JavaScript

/**
* 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',
})
}
// ── 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 },
})
}
// 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:<id>` 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)}`
}