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>
This commit is contained in:
parent
bb88a27b90
commit
a11fe5a115
18
apps/ops/package-lock.json
generated
18
apps/ops/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,40 +1,38 @@
|
|||
<template>
|
||||
<q-page>
|
||||
<!-- Top bar: thin wrapper. All editing happens inside the iframe; the
|
||||
parent app's job is template selection + test-send + saved-toast. -->
|
||||
<!-- Top bar: template selector + saved chip + quick actions. The Unlayer
|
||||
editor below has its own toolbar for blocks/preview/etc. -->
|
||||
<div class="row items-center q-px-md q-py-sm bg-white" style="border-bottom:1px solid #e5e7eb">
|
||||
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
|
||||
<div class="text-h6 q-mr-md">Éditeur de template</div>
|
||||
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined
|
||||
style="min-width:240px" @update:model-value="onSelectTemplate" />
|
||||
<q-chip dense color="grey-2" text-color="grey-9" class="q-ml-sm" size="sm" icon="cloud_done"
|
||||
v-if="lastSaved">
|
||||
Sauvegardé · {{ lastSavedAt }}
|
||||
<q-chip v-if="lastSavedTs" dense size="sm" color="grey-2" text-color="grey-9" class="q-ml-sm" icon="cloud_done">
|
||||
Sauvegardé · {{ lastSavedLabel }}
|
||||
</q-chip>
|
||||
<q-space />
|
||||
<q-btn flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm"
|
||||
@click="openPreview">
|
||||
<q-tooltip>Voir le rendu compilé avec données de test (HTML de l'inbox)</q-tooltip>
|
||||
<q-btn flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm" @click="openPreview">
|
||||
<q-tooltip>Voir le HTML rendu (substitué) tel que reçu par le destinataire</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm"
|
||||
@click="testSendOpen = true">
|
||||
<q-tooltip>Envoie un courriel réel à une adresse de test avec variables custom</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat dense icon="refresh" @click="reloadEditor">
|
||||
<q-tooltip>Recharger l'éditeur (en cas de bug)</q-tooltip>
|
||||
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm" @click="testSendOpen = true">
|
||||
<q-tooltip>Envoyer un courriel réel à une adresse de test</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn unelevated color="primary" icon="save" label="Enregistrer"
|
||||
:loading="saving" @click="saveTemplate" />
|
||||
</div>
|
||||
|
||||
<!-- The editor itself — full-page iframe to the standalone email-editor
|
||||
microservice at editor.gigafibre.ca. Communicates back via postMessage
|
||||
when the user saves (we listen + show a toast + update lastSaved). -->
|
||||
<iframe v-if="iframeUrl" :src="iframeUrl" :key="iframeKey"
|
||||
style="width:100%; height:calc(100vh - 60px); border:0; display:block;"
|
||||
ref="editorIframe" />
|
||||
<!-- Unlayer editor — Vue 3 native, no iframe, full features:
|
||||
responsive preview, AMP blocks, Unsplash, file manager, dark mode,
|
||||
layers/structure panel, design tokens, etc. -->
|
||||
<EmailEditor
|
||||
ref="editor"
|
||||
:min-height="'calc(100vh - 60px)'"
|
||||
:options="editorOptions"
|
||||
@load="onEditorLoad"
|
||||
@ready="onEditorReady"
|
||||
/>
|
||||
|
||||
<!-- Aperçu dialog — renders the LATEST saved compiled HTML in a clean
|
||||
viewer. Useful before sending a test, to see what the inbox version
|
||||
actually looks like with merge tags substituted. -->
|
||||
<!-- Aperçu dialog — renders the latest saved HTML with sample vars -->
|
||||
<q-dialog v-model="previewOpen" maximized persistent>
|
||||
<q-card class="bg-grey-2">
|
||||
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
|
||||
|
|
@ -49,7 +47,7 @@
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Test-send dialog (unchanged from previous version) -->
|
||||
<!-- Test-send dialog -->
|
||||
<q-dialog v-model="testSendOpen" persistent>
|
||||
<q-card style="min-width: 500px; max-width: 90vw">
|
||||
<q-card-section class="row items-center bg-orange-1 text-orange-9">
|
||||
|
|
@ -59,8 +57,7 @@
|
|||
<q-btn flat dense round icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense
|
||||
type="email" autofocus class="q-mb-md" />
|
||||
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense type="email" autofocus class="q-mb-md" />
|
||||
<q-input v-model="testSendForm.subject" label="Sujet" outlined dense class="q-mb-md" />
|
||||
<div class="text-subtitle2 q-mb-sm">Variables</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
|
|
@ -75,8 +72,8 @@
|
|||
</q-card-section>
|
||||
<q-card-section class="bg-grey-2">
|
||||
<div class="text-caption text-grey-8">
|
||||
Sauvegarde l'éditeur d'abord pour tester la dernière version.
|
||||
Envoyé via Mailjet depuis <code>TARGO <support@targointernet.com></code>.
|
||||
Utilise la dernière version sauvegardée (sauvegarde dans l'éditeur d'abord pour tester les modifs récentes).
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
|
|
@ -90,31 +87,58 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { listTemplates, previewTemplate, testSendTemplate } from 'src/api/campaigns'
|
||||
import { EmailEditor } from 'vue-email-editor'
|
||||
import { listTemplates, getTemplate, saveTemplate as saveTemplateApi,
|
||||
previewTemplate, testSendTemplate } from 'src/api/campaigns'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const $q = useQuasar()
|
||||
|
||||
// ── Editor iframe — standalone microservice at editor.gigafibre.ca.
|
||||
// URL params drive which template loads. iframeKey is bumped to force a
|
||||
// fresh load when the user switches templates (otherwise the iframe
|
||||
// caches the previous template's state). ────────────────────────────────
|
||||
const EDITOR_BASE = 'https://editor.gigafibre.ca'
|
||||
// ── Editor ref + Unlayer configuration ──────────────────────────────────────
|
||||
const editor = ref(null)
|
||||
const editorReady = ref(false)
|
||||
const saving = ref(false)
|
||||
const currentName = ref(route.params.name || 'gift-email-fr')
|
||||
const iframeKey = ref(0)
|
||||
const editorIframe = ref(null)
|
||||
|
||||
const iframeUrl = computed(() =>
|
||||
`${EDITOR_BASE}/?name=${encodeURIComponent(currentName.value)}`
|
||||
)
|
||||
// Unlayer editor options:
|
||||
// - mergeTags: shown in the "Merge tags" panel, drag-droppable into text
|
||||
// - features: Unsplash, file manager, etc. are all on by default
|
||||
// - tools: which block types to expose
|
||||
// - appearance: light theme matching our ops UI
|
||||
const editorOptions = {
|
||||
appearance: {
|
||||
theme: 'modern_light',
|
||||
panels: {
|
||||
tools: { dock: 'left' },
|
||||
},
|
||||
},
|
||||
mergeTags: {
|
||||
firstname: { name: 'Prénom', value: '{{firstname}}' },
|
||||
lastname: { name: 'Nom', value: '{{lastname}}' },
|
||||
email: { name: 'Courriel', value: '{{email}}' },
|
||||
amount: { name: 'Montant', value: '{{amount}}' },
|
||||
gift_url: { name: 'Lien cadeau', value: '{{gift_url}}' },
|
||||
description: { name: 'Adresse service', value: '{{description}}' },
|
||||
expiry: { name: "Date d'expiry", value: '{{expiry}}' },
|
||||
commitment_months: { name: 'Engagement (mois)', value: '{{commitment_months}}' },
|
||||
year: { name: 'Année', value: '{{year}}' },
|
||||
},
|
||||
// Display mode: 'email' (default, with mobile preview), 'web' for landing pages
|
||||
displayMode: 'email',
|
||||
// Locale for built-in strings
|
||||
locale: 'fr-CA',
|
||||
// Use Unlayer's free CDN. For paid users this would carry a projectId.
|
||||
// Without a projectId the "Powered by Unlayer" badge shows in the sidebar.
|
||||
}
|
||||
|
||||
// ── Template list (selector at the top) ─────────────────────────────────────
|
||||
const templates = ref([])
|
||||
const templateOptions = computed(() => templates.value.map(t => ({
|
||||
label: `${t.name}.${t.format || 'html'} (${Math.round(t.size / 1024)} KB)`,
|
||||
label: `${t.name} (${Math.round(t.size / 1024)} KB)`,
|
||||
value: t.name,
|
||||
})))
|
||||
|
||||
|
|
@ -122,42 +146,79 @@ async function loadAvailableTemplates () {
|
|||
templates.value = await listTemplates()
|
||||
}
|
||||
|
||||
function onSelectTemplate (name) {
|
||||
currentName.value = name
|
||||
iframeKey.value++ // force iframe reload with new ?name=
|
||||
router.replace({ path: `/campaigns/templates/${name}` })
|
||||
}
|
||||
|
||||
function reloadEditor () { iframeKey.value++ }
|
||||
|
||||
// ── Listen for postMessage from the editor iframe ────────────────────────
|
||||
const lastSaved = ref(null)
|
||||
const lastSavedAt = computed(() => {
|
||||
if (!lastSaved.value) return ''
|
||||
const diff = Math.floor((Date.now() - lastSaved.value) / 1000)
|
||||
if (diff < 60) return `il y a ${diff}s`
|
||||
if (diff < 3600) return `il y a ${Math.floor(diff / 60)}min`
|
||||
return new Date(lastSaved.value).toLocaleTimeString('fr-CA')
|
||||
})
|
||||
|
||||
function handleMessage (event) {
|
||||
// Only accept messages from our editor domain
|
||||
if (event.origin !== EDITOR_BASE) return
|
||||
if (event.data?.type === 'email-editor:saved') {
|
||||
lastSaved.value = event.data.ts || Date.now()
|
||||
$q.notify({ type: 'positive', message: 'Template enregistré ✓', timeout: 2500 })
|
||||
// ── Load template into the Unlayer canvas on editor ready + on switch ──────
|
||||
async function loadTemplateIntoEditor (name) {
|
||||
if (!editorReady.value || !editor.value) return
|
||||
try {
|
||||
const data = await getTemplate(name)
|
||||
// Priority: Unlayer's own design JSON > nothing (start blank)
|
||||
// (MJML format from the previous editor isn't auto-importable into Unlayer.
|
||||
// The .html stored from legacy MJML compile is still valid email HTML
|
||||
// but Unlayer can't ingest arbitrary HTML — the user reconstructs once
|
||||
// and the design JSON saved by the next "Enregistrer" fixes future loads.)
|
||||
if (data.design) {
|
||||
const design = typeof data.design === 'string' ? JSON.parse(data.design) : data.design
|
||||
editor.value.loadDesign(design)
|
||||
} else {
|
||||
// No design yet — load a sensible blank starter so the canvas isn't fully empty
|
||||
editor.value.loadBlank({ backgroundColor: '#F5FAF7' })
|
||||
$q.notify({
|
||||
type: 'info',
|
||||
message: `Pas encore de design pour "${name}" — canvas vide. ` +
|
||||
`Construis visuellement puis enregistre pour persister.`,
|
||||
timeout: 7000,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: 'Erreur de chargement: ' + e.message })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAvailableTemplates()
|
||||
window.addEventListener('message', handleMessage)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
function onEditorLoad () {
|
||||
// Fired once when the editor IFRAME loads (before the editor inside is ready)
|
||||
}
|
||||
|
||||
async function onEditorReady () {
|
||||
// Fired when the editor INSIDE the iframe is ready to accept loadDesign()
|
||||
editorReady.value = true
|
||||
await loadTemplateIntoEditor(currentName.value)
|
||||
}
|
||||
|
||||
function onSelectTemplate (name) {
|
||||
currentName.value = name
|
||||
router.replace({ path: `/campaigns/templates/${name}` })
|
||||
loadTemplateIntoEditor(name)
|
||||
}
|
||||
|
||||
// ── Save: export HTML + design JSON, POST to hub ────────────────────────────
|
||||
const lastSavedTs = ref(null)
|
||||
const lastSavedLabel = computed(() => {
|
||||
if (!lastSavedTs.value) return ''
|
||||
const diff = Math.floor((Date.now() - lastSavedTs.value) / 1000)
|
||||
if (diff < 60) return `il y a ${diff}s`
|
||||
if (diff < 3600) return `il y a ${Math.floor(diff / 60)}min`
|
||||
return new Date(lastSavedTs.value).toLocaleTimeString('fr-CA')
|
||||
})
|
||||
|
||||
// ── Preview dialog (compiled HTML from hub) ──────────────────────────────
|
||||
function saveTemplate () {
|
||||
if (!editor.value) return
|
||||
saving.value = true
|
||||
// exportHtml uses a callback (legacy Unlayer API) — wrap in a Promise
|
||||
editor.value.exportHtml(async (data) => {
|
||||
try {
|
||||
const { html, design } = data
|
||||
await saveTemplateApi(currentName.value, html, { design })
|
||||
lastSavedTs.value = Date.now()
|
||||
$q.notify({ type: 'positive', message: 'Template enregistré ✓', timeout: 2500 })
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Preview dialog ──────────────────────────────────────────────────────────
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewHtmlContent = ref('')
|
||||
|
|
@ -175,16 +236,14 @@ async function openPreview () {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Test-send dialog ─────────────────────────────────────────────────────
|
||||
// ── Test-send dialog ────────────────────────────────────────────────────────
|
||||
const testSendOpen = ref(false)
|
||||
const testSending = ref(false)
|
||||
const testSendForm = ref({
|
||||
to: 'louis@targo.ca',
|
||||
subject: '[TEST] Aperçu du courriel TARGO',
|
||||
vars: {
|
||||
firstname: 'Louis',
|
||||
lastname: 'Test',
|
||||
amount: '60 $',
|
||||
firstname: 'Louis', lastname: 'Test', amount: '60 $',
|
||||
commitment_months: '3',
|
||||
gift_url: 'https://gft.link/TEST123',
|
||||
description: '123 Rue de Test, Ste-Clotilde',
|
||||
|
|
@ -208,4 +267,6 @@ async function doTestSend () {
|
|||
testSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAvailableTemplates)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -685,9 +685,10 @@ function templateMjmlPath (name) {
|
|||
return path.join(TEMPLATES_DIR, name + '.mjml')
|
||||
}
|
||||
|
||||
// Companion .json — easy-email's raw editor state (JSON tree). When present,
|
||||
// the editor uses it as the source of truth on load (instant restore of all
|
||||
// blocks/styling). Generated client-side by easy-email; we just persist it.
|
||||
// Companion .json — visual editor's design tree (now Unlayer's design JSON,
|
||||
// previously was easy-email's tree — same file, different content). When
|
||||
// present, the editor uses it as the source of truth on load. Generated
|
||||
// client-side by the editor on save; we just persist it as-is.
|
||||
function templateJsonPath (name) {
|
||||
if (!EDITABLE_TEMPLATES.includes(name)) {
|
||||
throw new Error(`template not editable: ${name}`)
|
||||
|
|
@ -1029,21 +1030,20 @@ async function handle (req, res, method, path) {
|
|||
const name = tplGet[1]
|
||||
try {
|
||||
const format = templateFormat(name)
|
||||
if (format === 'mjml') {
|
||||
// Source of truth = .mjml. Also return compiled .html (preview) and
|
||||
// .json (easy-email editor state) when they exist.
|
||||
const mjml = fs.readFileSync(templateMjmlPath(name), 'utf8')
|
||||
let html = ''
|
||||
try { html = fs.readFileSync(templatePath(name), 'utf8') } catch {}
|
||||
let editorJson = null
|
||||
try {
|
||||
const jsonStr = fs.readFileSync(templateJsonPath(name), 'utf8')
|
||||
editorJson = JSON.parse(jsonStr)
|
||||
} catch {}
|
||||
return json(res, 200, { name, format, mjml, html, json: editorJson })
|
||||
}
|
||||
// Always return: name, format, html (canonical, what gets sent), and
|
||||
// design (Unlayer/visual-editor JSON tree if present, for re-edit).
|
||||
// For legacy MJML templates the .mjml is also returned for reference.
|
||||
const html = fs.readFileSync(templatePath(name), 'utf8')
|
||||
return json(res, 200, { name, format: 'html', html })
|
||||
let design = null
|
||||
try {
|
||||
const jsonStr = fs.readFileSync(templateJsonPath(name), 'utf8')
|
||||
design = JSON.parse(jsonStr)
|
||||
} catch {}
|
||||
const out = { name, format, html, design }
|
||||
if (format === 'mjml') {
|
||||
try { out.mjml = fs.readFileSync(templateMjmlPath(name), 'utf8') } catch {}
|
||||
}
|
||||
return json(res, 200, out)
|
||||
} catch (e) {
|
||||
return json(res, 404, { error: 'template not found', detail: e.message })
|
||||
}
|
||||
|
|
@ -1099,17 +1099,27 @@ async function handle (req, res, method, path) {
|
|||
mjml_size: body.mjml.length, html_size: r.html.length, json_size,
|
||||
})
|
||||
}
|
||||
// ── HTML save path (legacy) ──
|
||||
// ── HTML save path (now the PRIMARY flow — Unlayer outputs HTML directly) ──
|
||||
const p = templatePath(name)
|
||||
const jsonPath = templateJsonPath(name)
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
fs.copyFileSync(p, p.replace(/\.html$/, `.bak-${ts}.html`))
|
||||
}
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
if (fs.existsSync(p)) fs.copyFileSync(p, p.replace(/\.html$/, `.bak-${ts}.html`))
|
||||
if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
|
||||
} catch (e) { log('template backup failed:', e.message) }
|
||||
fs.writeFileSync(p, body.html, 'utf8')
|
||||
log(`template ${name} updated by ops (${body.html.length} bytes, html mode)`)
|
||||
return json(res, 200, { name, format: 'html', saved: true, size: body.html.length })
|
||||
// Persist editor design JSON alongside (Unlayer's loadDesign() input)
|
||||
let design_size = 0
|
||||
if (body.design) {
|
||||
const designStr = typeof body.design === 'string' ? body.design : JSON.stringify(body.design)
|
||||
fs.writeFileSync(jsonPath, designStr, 'utf8')
|
||||
design_size = designStr.length
|
||||
}
|
||||
log(`template ${name} updated (html: ${body.html.length}b${design_size ? ', design: ' + design_size + 'b' : ''})`)
|
||||
return json(res, 200, {
|
||||
name, format: 'html', saved: true,
|
||||
size: body.html.length, design_size,
|
||||
})
|
||||
} catch (e) {
|
||||
return json(res, 400, { error: e.message })
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user