Pivot the template editor toward email-marketing-grade visual editing
by replacing grapesjs-preset-newsletter (permissive HTML, fails to parse
nested table structures) with grapesjs-mjml (the industry-standard
email markup language used by Mailchimp/Sendgrid/Twilio).
Why MJML: it was specifically designed to solve the "visual editor +
email-safe HTML" problem. You write semantic <mj-section>, <mj-column>,
<mj-button>, <mj-image> components — MJML compiles them to the gnarly
email-safe HTML with Outlook fallbacks + responsive media queries
auto-generated. Source is 3x more compact than hand-written HTML and
parses cleanly in visual editors.
Backend (lib/campaigns.js):
- Add `mjml` (v5, async) dependency. Compilation happens server-side
at SAVE time only; the send-worker reads pre-compiled .html (no
per-recipient compile cost).
- Each template can now be in 'mjml' or 'html' format. Detection by
file extension on disk: .mjml present → format='mjml', otherwise
format='html'. Source of truth for MJML templates = .mjml file;
.html is the auto-compiled output kept alongside for the send-worker.
- GET /campaigns/templates → returns { name, format, size } per template.
- GET /campaigns/templates/:name → returns { format, mjml?, html }
(mjml field present only when format=mjml; html always present).
- PUT /campaigns/templates/:name accepts:
{ mjml: "<mjml>..." } → compile to HTML, save both .mjml + .html
{ html: "..." } → save .html only (legacy path, unchanged)
Compilation errors return 400 with details (MJML validation soft mode).
Both files backed up as .bak-<ts>.<ext> before overwrite.
Frontend (TemplateEditorPage.vue):
- Detect format from API response on load.
- For format='mjml': swap grapesjs-preset-newsletter for grapesjs-mjml
plugin. Editor's getHtml() returns MJML source (not compiled HTML);
Save POSTs the MJML, hub compiles + persists both files.
- For format='html': existing behavior unchanged.
- Editor is destroyed + reinitialized when format changes (different
plugin sets).
- Custom variable blocks ({{firstname}}, {{amount}}, etc.) work for
both formats — they're text content, format-agnostic.
API client (apps/ops/src/api/campaigns.js):
- saveTemplate(name, content, { format }) routes to the right PUT body
shape based on format param.
Prototype: gift-email-fr-mjml — full MJML conversion of the simple
variant, ~7.5 KB MJML source compiling to ~32 KB email-safe HTML with
0 validation errors. All 6 Mustache variables preserved through
compilation (firstname, amount, gift_url, description, commitment_months,
year). User compares the MJML editor experience to the existing HTML
templates and decides whether to migrate the others.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
137 lines
4.4 KiB
JavaScript
137 lines
4.4 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) — content interpreted as HTML by default.
|
|
// Pass { format: 'mjml' } to send as MJML source (hub compiles to HTML).
|
|
export function saveTemplate (name, content, { format = 'html' } = {}) {
|
|
const body = format === 'mjml' ? { mjml: content } : { html: content }
|
|
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 },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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)}`
|
|
}
|