Phase 2.5 — close the load/save loop so the editor isn't broken by a page
refresh.
Problem: easy-email doesn't ship an MJML→JSON parser, so loading an
existing MJML template into the editor canvas isn't possible. First-time
load = empty canvas. Without this fix, every page reload would also reset
to empty (even after saving), making the editor useless past one session.
Solution: persist easy-email's raw JSON tree (editor state) as a third
companion file alongside .mjml + .html. Editor reads .json on load when
present, falls back to empty otherwise.
Three files per template now:
gift-email-fr.mjml — MJML source (rendered by send-worker → already done)
gift-email-fr.html — compiled HTML (cached output, sent to recipients)
gift-email-fr.json — easy-email editor state (UI restoration only)
Backend (lib/campaigns.js):
- New templateJsonPath() helper + EDITABLE_TEMPLATES checks
- GET /campaigns/templates/:name returns { format, mjml, html, json }
when format=mjml (json null until first easy-email save)
- PUT /campaigns/templates/:name now accepts body.json alongside body.mjml
(writes both .mjml + .html [compiled] + .json [editor state])
- Backup hook extended to also backup .json before overwrite
Editor (EmailEditorApp.tsx):
- On load: prefer data.json → parse and seed initialValues. If json
missing but mjml present, show explanatory error banner + empty canvas
(user reconstructs once; that save fixes future loads).
- On save: send BOTH mjml (compiled via JsonToMjml) AND raw values
object as json. Hub persists all three artifacts.
First UX flow on next user visit:
1. Open editor → empty canvas + banner "MJML exists but no JSON
editor-state yet; reconstruct once to save a JSON snapshot"
2. User drag-drops blocks to rebuild the template visually
3. Click save → MJML + HTML + JSON all persist
4. Subsequent reloads load from JSON instantly with full editor state
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
235 lines
8.4 KiB
TypeScript
235 lines
8.4 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react'
|
|
import { EmailEditorProvider, EmailEditor, IEmailTemplate } from 'easy-email-editor'
|
|
import { ExtensionProps, StandardLayout } from 'easy-email-extensions'
|
|
import { BasicType, AdvancedType, JsonToMjml } from 'easy-email-core'
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Targo email editor — wraps easy-email-editor with our hub integration:
|
|
//
|
|
// 1. On mount: read ?name=<template-name> from URL, GET its MJML from the hub
|
|
// 2. Render easy-email with the loaded MJML
|
|
// 3. On save (Cmd-S or button): convert easy-email JSON → MJML, PUT to hub
|
|
// 4. postMessage to parent window so the wrapping ops UI knows we saved
|
|
//
|
|
// Hub URL is read from VITE_HUB_URL env (defaults to msg.gigafibre.ca in prod).
|
|
// The hub does the MJML → HTML compilation server-side; we just send the MJML.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const HUB_URL = (import.meta as any).env?.VITE_HUB_URL || 'https://msg.gigafibre.ca'
|
|
|
|
// Merge tags exposed to the editor's "Variables" panel. These map to the
|
|
// Mustache variables the hub renders at send time.
|
|
const MERGE_TAGS = {
|
|
firstname: '{{firstname}}',
|
|
lastname: '{{lastname}}',
|
|
email: '{{email}}',
|
|
amount: '{{amount}}',
|
|
gift_url: '{{gift_url}}',
|
|
description: '{{description}}',
|
|
expiry: '{{expiry}}',
|
|
commitment_months: '{{commitment_months}}',
|
|
year: '{{year}}',
|
|
}
|
|
|
|
// Minimal initial template returned when the hub has no content yet (rare —
|
|
// since we always pre-create gift-email-fr.mjml). Kept defensive.
|
|
function emptyTemplate(): IEmailTemplate {
|
|
return {
|
|
subject: 'Une offre exclusive de TARGO',
|
|
subTitle: 'Comme toi, on aime les connexions stables et les relations durables.',
|
|
content: {
|
|
type: BasicType.PAGE,
|
|
data: {
|
|
value: {
|
|
breakpoint: '480px',
|
|
headAttributes: '',
|
|
'font-size': '16px',
|
|
'line-height': '1.5',
|
|
'font-family': "'Plus Jakarta Sans', Helvetica, Arial, sans-serif",
|
|
},
|
|
},
|
|
attributes: {
|
|
'background-color': '#F5FAF7',
|
|
width: '600px',
|
|
},
|
|
children: [],
|
|
} as any,
|
|
}
|
|
}
|
|
|
|
export function EmailEditorApp() {
|
|
const [templateName, setTemplateName] = useState<string>('')
|
|
const [initialValues, setInitialValues] = useState<IEmailTemplate | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Read template name from URL and fetch its MJML content from the hub on mount
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search)
|
|
const name = params.get('name') || 'gift-email-fr'
|
|
setTemplateName(name)
|
|
|
|
;(async () => {
|
|
try {
|
|
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(name)}`)
|
|
if (!res.ok) throw new Error(`Hub returned ${res.status}`)
|
|
const data = await res.json()
|
|
// Three sources of truth, in priority order:
|
|
// 1. .json file → easy-email JSON tree (fast, full restore)
|
|
// 2. .mjml file → MJML source (no auto-importer, start blank)
|
|
// 3. nothing → empty page
|
|
if (data.json) {
|
|
try {
|
|
const parsed = typeof data.json === 'string' ? JSON.parse(data.json) : data.json
|
|
setInitialValues(parsed as IEmailTemplate)
|
|
} catch (e: any) {
|
|
setError(`Stored JSON is invalid (${e.message}) — starting blank`)
|
|
setInitialValues(emptyTemplate())
|
|
}
|
|
} else if (data.mjml) {
|
|
setError(`Existing MJML (${(data.mjml || '').length}b) cannot be auto-imported into easy-email. ` +
|
|
`Reconstructing this template once with the drag-drop blocks here will save an editable JSON snapshot for next time.`)
|
|
setInitialValues(emptyTemplate())
|
|
} else {
|
|
setInitialValues(emptyTemplate())
|
|
}
|
|
} catch (e: any) {
|
|
setError(`Could not load template "${name}": ${e.message}`)
|
|
setInitialValues(emptyTemplate())
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
})()
|
|
}, [])
|
|
|
|
// Save → convert easy-email's JSON tree to MJML, PUT to hub
|
|
const onSave = useCallback(async (values: IEmailTemplate) => {
|
|
if (!templateName) return
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
const mjmlSource = JsonToMjml({
|
|
data: values.content as any,
|
|
mode: 'production',
|
|
context: values.content as any,
|
|
})
|
|
// Send BOTH the compiled MJML (for send-worker) AND the raw easy-email
|
|
// JSON tree (for next-load restore). Hub persists .mjml + .html + .json
|
|
// — the JSON file is the canonical editing source going forward.
|
|
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(templateName)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mjml: mjmlSource, json: values }),
|
|
})
|
|
if (!res.ok) {
|
|
const errBody = await res.json().catch(() => ({}))
|
|
throw new Error(errBody.error || `Hub returned ${res.status}`)
|
|
}
|
|
// Notify parent window (the ops UI iframing us) that we saved
|
|
if (window.parent !== window) {
|
|
window.parent.postMessage(
|
|
{ type: 'email-editor:saved', template: templateName, ts: Date.now() },
|
|
'*',
|
|
)
|
|
}
|
|
// Visual confirmation (toast handled by easy-email's own UI)
|
|
} catch (e: any) {
|
|
setError(`Save failed: ${e.message}`)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [templateName])
|
|
|
|
if (loading) {
|
|
return <div style={{ padding: 32, textAlign: 'center' }}>Loading template…</div>
|
|
}
|
|
if (!initialValues) {
|
|
return <div style={{ padding: 32, color: 'red' }}>{error}</div>
|
|
}
|
|
|
|
return (
|
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
{/* Top bar — shows template name + save state + parent communication */}
|
|
<div style={{
|
|
padding: '8px 16px',
|
|
background: '#1B2E24',
|
|
color: '#fff',
|
|
fontSize: 14,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
}}>
|
|
<strong>TARGO Email Editor</strong>
|
|
<span style={{ opacity: 0.7 }}>· {templateName}</span>
|
|
{saving && <span style={{ color: '#00C853' }}>· Saving…</span>}
|
|
{error && (
|
|
<span style={{ color: '#fbbf24', fontSize: 12, marginLeft: 'auto', maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{error}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* Editor */}
|
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
|
<EmailEditorProvider
|
|
data={initialValues}
|
|
height="100%"
|
|
autoComplete
|
|
dashed={false}
|
|
mergeTags={MERGE_TAGS}
|
|
mergeTagGenerate={(tag: string) => `{{${tag}}}`}
|
|
onSubmit={onSave}
|
|
>
|
|
{() => (
|
|
<StandardLayout
|
|
showSourceCode
|
|
categories={DEFAULT_CATEGORIES}
|
|
/>
|
|
)}
|
|
</EmailEditorProvider>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Block categories shown in the left sidebar — same set easy-email uses by
|
|
// default, organized for email composition.
|
|
const DEFAULT_CATEGORIES: ExtensionProps['categories'] = [
|
|
{
|
|
label: 'Content',
|
|
active: true,
|
|
blocks: [
|
|
{ type: AdvancedType.TEXT },
|
|
{ type: AdvancedType.BUTTON },
|
|
{ type: AdvancedType.IMAGE },
|
|
{ type: AdvancedType.DIVIDER },
|
|
{ type: AdvancedType.SPACER },
|
|
{ type: AdvancedType.HERO },
|
|
{ type: AdvancedType.WRAPPER },
|
|
],
|
|
},
|
|
{
|
|
label: 'Layout',
|
|
active: true,
|
|
displayType: 'column',
|
|
blocks: [
|
|
{
|
|
title: '1 column',
|
|
payload: [['100%']],
|
|
},
|
|
{
|
|
title: '2 columns',
|
|
payload: [['50%', '50%']],
|
|
},
|
|
{
|
|
title: '3 columns',
|
|
payload: [['33%', '33%', '33%']],
|
|
},
|
|
{
|
|
title: '4 columns',
|
|
payload: [['25%', '25%', '25%', '25%']],
|
|
},
|
|
],
|
|
},
|
|
]
|