gigafibre-fsm/services/email-editor/src/EmailEditorApp.tsx
louispaulb bb88a27b90 feat(email-editor): persist easy-email JSON state for instant restore on reload
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>
2026-05-22 06:04:48 -04:00

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%']],
},
],
},
]