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>
149 lines
5.0 KiB
JavaScript
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)}`
|
|
}
|