From a11fe5a1152c68457556d39e9256038f81756af9 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 22 May 2026 06:14:06 -0400 Subject: [PATCH] feat(ops/campaigns): pivot template editor to Unlayer (vue-email-editor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/ops/package-lock.json | 18 ++ apps/ops/package.json | 1 + apps/ops/src/api/campaigns.js | 9 +- .../campaigns/pages/TemplateEditorPage.vue | 211 +++++++++++------- services/targo-hub/lib/campaigns.js | 58 +++-- 5 files changed, 195 insertions(+), 102 deletions(-) diff --git a/apps/ops/package-lock.json b/apps/ops/package-lock.json index 225624c..4b0a620 100644 --- a/apps/ops/package-lock.json +++ b/apps/ops/package-lock.json @@ -22,6 +22,7 @@ "sip.js": "^0.21.2", "vue": "^3.4.21", "vue-chartjs": "^5.3.3", + "vue-email-editor": "^2.2.0", "vue-router": "^4.3.0", "vuedraggable": "^4.1.0" }, @@ -2909,6 +2910,12 @@ "dev": true, "license": "ISC" }, + "node_modules/@unlayer/types": { + "version": "1.413.0", + "resolved": "https://registry.npmjs.org/@unlayer/types/-/types-1.413.0.tgz", + "integrity": "sha512-pOE9lKvP7ofnmfWZN+PTizw2GrwZNtePiMH3Yl8OSt/nYQL52X7N4SHd7dDd2c7ecJwWVWo8MfPY8QTon+44lw==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-vue": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.3.4.tgz", @@ -9606,6 +9613,17 @@ } } }, + "node_modules/vue-email-editor": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/vue-email-editor/-/vue-email-editor-2.2.0.tgz", + "integrity": "sha512-aEXm0OHjZgeQqGsssfukqJm7kubfGBOPo9ddwGHMXLbzegJDZ0ou2h7NmRvPR+XaoRGYHdZXf9p7zVae5ACgWA==", + "dependencies": { + "@unlayer/types": "^1.394.0" + }, + "peerDependencies": { + "vue": "^3.2.13" + } + }, "node_modules/vue-eslint-parser": { "version": "9.4.3", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", diff --git a/apps/ops/package.json b/apps/ops/package.json index 08ea24d..bfec0b7 100644 --- a/apps/ops/package.json +++ b/apps/ops/package.json @@ -24,6 +24,7 @@ "sip.js": "^0.21.2", "vue": "^3.4.21", "vue-chartjs": "^5.3.3", + "vue-email-editor": "^2.2.0", "vue-router": "^4.3.0", "vuedraggable": "^4.1.0" }, diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js index 979c2ac..bdaae66 100644 --- a/apps/ops/src/api/campaigns.js +++ b/apps/ops/src/api/campaigns.js @@ -107,10 +107,13 @@ 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' } = {}) { +// 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, diff --git a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue index 443f00d..31fdc7c 100644 --- a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue +++ b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue @@ -1,40 +1,38 @@