feat(campaigns): create new templates from UI + enable Unlayer template library
Two improvements to the template editor:
1. "+ Nouveau" button + creation dialog
Users can now create new templates from the editor UI without us
re-deploying the hub. Click "Nouveau" next to the template selector,
pick a name + prefix + starter (blank or copy from existing), submit.
The hub PUTs the new template (existing endpoint, no new code needed
on the backend — just relaxed validation).
Form:
• Type (prefix): gift-email / newsletter / transactional
• Name suffix: lowercase letters/digits/dashes (e.g. summer-2026)
• Starter: "Vide" or "Copier depuis <existing template>"
On submit:
• If starter != blank: GET source template's html + design
• PUT new template name with that content
• Refresh templates list + switch editor to the new one
2. Backend: replace hardcoded EDITABLE_TEMPLATES allow-list with
regex-validated prefix matching + disk scan
• EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-',
'transactional-'] — bounds what categories users can create
• TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ — prevents path traversal
• isValidTemplateName() validates both regex + prefix membership
• scanEditableTemplates() returns all matching .html/.mjml files
currently on disk (excludes .bak-* and .legacy-* variants)
• listEditableTemplates() now scans disk instead of a static list,
so newly-created templates appear automatically in the dropdown
3. Enable Unlayer's built-in panels
• templates: true — exposes Unlayer's template library (limited
free-tier selection but ~10-20 starters available without a
projectId)
• stockImages: true — Unsplash search built into image picker
• imageEditor: true — basic crop/resize on inserted images
• undoRedo: true — history navigation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
448e62177e
commit
73e4118901
|
|
@ -7,6 +7,9 @@
|
||||||
<div class="text-h6 q-mr-md">Éditeur de template</div>
|
<div class="text-h6 q-mr-md">Éditeur de template</div>
|
||||||
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined
|
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined
|
||||||
style="min-width:240px" @update:model-value="onSelectTemplate" />
|
style="min-width:240px" @update:model-value="onSelectTemplate" />
|
||||||
|
<q-btn flat dense icon="add" label="Nouveau" color="primary" class="q-ml-sm" @click="newTemplateOpen = true">
|
||||||
|
<q-tooltip>Créer un nouveau template (vide ou copié depuis un existant)</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
<q-chip v-if="lastSavedTs" dense size="sm" color="grey-2" text-color="grey-9" class="q-ml-sm" icon="cloud_done">
|
<q-chip v-if="lastSavedTs" dense size="sm" color="grey-2" text-color="grey-9" class="q-ml-sm" icon="cloud_done">
|
||||||
Sauvegardé · {{ lastSavedLabel }}
|
Sauvegardé · {{ lastSavedLabel }}
|
||||||
</q-chip>
|
</q-chip>
|
||||||
|
|
@ -60,6 +63,40 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- New template dialog — name + starter -->
|
||||||
|
<q-dialog v-model="newTemplateOpen" persistent>
|
||||||
|
<q-card style="min-width: 500px; max-width: 90vw">
|
||||||
|
<q-card-section class="row items-center bg-blue-1 text-blue-9">
|
||||||
|
<q-icon name="add" class="q-mr-sm" />
|
||||||
|
<div class="text-h6">Nouveau template</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat dense round icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<q-input v-model="newTemplateForm.suffix" outlined dense autofocus
|
||||||
|
label="Nom (suffixe après gift-email-)" :prefix="newTemplateForm.prefix + '-'"
|
||||||
|
:rules="[v => /^[a-z0-9-]+$/.test(v) || 'Lettres minuscules, chiffres et tirets seulement']"
|
||||||
|
hint="Exemple: summer-2026, automne-promo, anniversaire" class="q-mb-md" />
|
||||||
|
<q-select v-model="newTemplateForm.prefix" :options="['gift-email','newsletter','transactional']"
|
||||||
|
label="Type" outlined dense class="q-mb-md" />
|
||||||
|
<q-select v-model="newTemplateForm.starter" :options="starterOptions" emit-value map-options
|
||||||
|
label="Démarrer depuis" outlined dense class="q-mb-md" />
|
||||||
|
<q-banner v-if="newTemplateFinal" class="bg-grey-2 text-grey-8 q-mt-sm" rounded dense>
|
||||||
|
<q-icon name="info" class="q-mr-xs" />
|
||||||
|
Le template sera créé sous le nom <strong>{{ newTemplateFinal }}</strong>
|
||||||
|
<span v-if="newTemplateForm.starter !== 'blank'">
|
||||||
|
· contenu copié depuis <em>{{ newTemplateForm.starter }}</em>
|
||||||
|
</span>
|
||||||
|
</q-banner>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Annuler" v-close-popup />
|
||||||
|
<q-btn unelevated color="primary" icon="add" label="Créer le template"
|
||||||
|
:loading="creating" :disable="!newTemplateValid" @click="createTemplate" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
<!-- Test-send dialog -->
|
<!-- Test-send dialog -->
|
||||||
<q-dialog v-model="testSendOpen" persistent>
|
<q-dialog v-model="testSendOpen" persistent>
|
||||||
<q-card style="min-width: 500px; max-width: 90vw">
|
<q-card style="min-width: 500px; max-width: 90vw">
|
||||||
|
|
@ -165,6 +202,20 @@ const editorOptions = {
|
||||||
displayMode: 'email',
|
displayMode: 'email',
|
||||||
// Locale for built-in strings
|
// Locale for built-in strings
|
||||||
locale: 'fr-CA',
|
locale: 'fr-CA',
|
||||||
|
// Enable optional sidebar features (free tier limits some — see Unlayer docs)
|
||||||
|
features: {
|
||||||
|
// Built-in Unlayer template library — limited selection without projectId
|
||||||
|
// but still gives the user some pre-built starts to pick from.
|
||||||
|
templates: true,
|
||||||
|
// Unsplash image search panel
|
||||||
|
stockImages: true,
|
||||||
|
// Image upload (uses Unlayer's CDN by default; can be wired to our hub
|
||||||
|
// /campaigns/assets/upload endpoint via customJS later if we want to
|
||||||
|
// self-host uploads — for now their CDN is fine for ad-hoc images)
|
||||||
|
imageEditor: true,
|
||||||
|
// Undo/redo + history
|
||||||
|
undoRedo: true,
|
||||||
|
},
|
||||||
// Use Unlayer's free CDN. For paid users this would carry a projectId.
|
// 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.
|
// Without a projectId the "Powered by Unlayer" badge shows in the sidebar.
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +231,60 @@ async function loadAvailableTemplates () {
|
||||||
templates.value = await listTemplates()
|
templates.value = await listTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── New template creation ───────────────────────────────────────────────────
|
||||||
|
const newTemplateOpen = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const newTemplateForm = ref({
|
||||||
|
prefix: 'gift-email',
|
||||||
|
suffix: '',
|
||||||
|
starter: 'blank', // 'blank' | template name to copy from
|
||||||
|
})
|
||||||
|
|
||||||
|
const newTemplateFinal = computed(() => {
|
||||||
|
const { prefix, suffix } = newTemplateForm.value
|
||||||
|
if (!suffix || !/^[a-z0-9-]+$/.test(suffix)) return ''
|
||||||
|
return `${prefix}-${suffix}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const newTemplateValid = computed(() =>
|
||||||
|
!!newTemplateFinal.value &&
|
||||||
|
!templates.value.find(t => t.name === newTemplateFinal.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const starterOptions = computed(() => [
|
||||||
|
{ label: 'Vide (canevas blanc)', value: 'blank' },
|
||||||
|
...templates.value.map(t => ({ label: `Copier depuis ${t.name}`, value: t.name })),
|
||||||
|
])
|
||||||
|
|
||||||
|
async function createTemplate () {
|
||||||
|
if (!newTemplateValid.value) return
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const newName = newTemplateFinal.value
|
||||||
|
let html = '<div style="padding:32px;text-align:center;color:#64748B;font-family:sans-serif;">Nouveau template — drag des blocs depuis la sidebar pour commencer.</div>'
|
||||||
|
let design = null
|
||||||
|
if (newTemplateForm.value.starter !== 'blank') {
|
||||||
|
// Copy html + design from the chosen source template
|
||||||
|
const src = await getTemplate(newTemplateForm.value.starter)
|
||||||
|
html = src.html || html
|
||||||
|
design = src.design || null
|
||||||
|
}
|
||||||
|
await saveTemplateApi(newName, html, { design })
|
||||||
|
await loadAvailableTemplates()
|
||||||
|
newTemplateOpen.value = false
|
||||||
|
// Switch to the new template
|
||||||
|
currentName.value = newName
|
||||||
|
router.replace({ path: `/campaigns/templates/${newName}` })
|
||||||
|
loadTemplateIntoEditor(newName)
|
||||||
|
newTemplateForm.value.suffix = '' // reset for next time
|
||||||
|
$q.notify({ type: 'positive', message: `Template "${newName}" créé`, timeout: 3000 })
|
||||||
|
} catch (e) {
|
||||||
|
$q.notify({ type: 'negative', message: 'Erreur création: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Load template into the Unlayer canvas on editor ready + on switch ──────
|
// ── Load template into the Unlayer canvas on editor ready + on switch ──────
|
||||||
async function loadTemplateIntoEditor (name) {
|
async function loadTemplateIntoEditor (name) {
|
||||||
if (!editorReady.value || !editor.value) return
|
if (!editorReady.value || !editor.value) return
|
||||||
|
|
|
||||||
|
|
@ -652,10 +652,32 @@ const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')
|
||||||
// gets used at send time. `-simple` variants are flat-structured copies
|
// gets used at send time. `-simple` variants are flat-structured copies
|
||||||
// designed to parse cleanly in GrapesJS visual editor (no nested tables).
|
// designed to parse cleanly in GrapesJS visual editor (no nested tables).
|
||||||
// Adding a new lang or variant = drop a new .html here + add to this list.
|
// Adding a new lang or variant = drop a new .html here + add to this list.
|
||||||
// Canonical templates — both now backed by .mjml source (compiled to .html
|
// Templates allow-list — moved from a hardcoded array to a regex-validated
|
||||||
// on save for the send-worker). Legacy HTML-only variants were dropped
|
// + disk-scan approach so the user can create new templates from the editor
|
||||||
// after the MJML migration (see git history for rollback).
|
// UI without us re-deploying the hub. The prefix list bounds what names are
|
||||||
const EDITABLE_TEMPLATES = ['gift-email-fr', 'gift-email-en']
|
// allowed (prevents arbitrary file writes outside the campaign domain).
|
||||||
|
const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-']
|
||||||
|
const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only
|
||||||
|
|
||||||
|
function isValidTemplateName (name) {
|
||||||
|
if (typeof name !== 'string' || !TEMPLATE_NAME_RE.test(name)) return false
|
||||||
|
return EDITABLE_TEMPLATE_PREFIXES.some(p => name.startsWith(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan the templates dir and return every file matching our allow-list
|
||||||
|
// prefixes (.html OR .mjml — either format makes it editable). The .json
|
||||||
|
// design file alone doesn't count (it's a companion).
|
||||||
|
function scanEditableTemplates () {
|
||||||
|
if (!fs.existsSync(TEMPLATES_DIR)) return []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const f of fs.readdirSync(TEMPLATES_DIR)) {
|
||||||
|
const m = f.match(/^([a-z0-9-]+)\.(html|mjml)$/)
|
||||||
|
if (!m) continue
|
||||||
|
if (m[1].includes('.bak-') || m[1].includes('.legacy-')) continue // skip backups
|
||||||
|
if (isValidTemplateName(m[1])) seen.add(m[1])
|
||||||
|
}
|
||||||
|
return [...seen].sort()
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the template path for a given recipient language. Falls back to
|
// Resolve the template path for a given recipient language. Falls back to
|
||||||
// the French version (most of the customer base) if the language-specific
|
// the French version (most of the customer base) if the language-specific
|
||||||
|
|
@ -669,8 +691,8 @@ function templateForLanguage (lang) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function templatePath (name) {
|
function templatePath (name) {
|
||||||
if (!EDITABLE_TEMPLATES.includes(name)) {
|
if (!isValidTemplateName(name)) {
|
||||||
throw new Error(`template not editable: ${name}`)
|
throw new Error(`template name invalid or not allowed: ${name}`)
|
||||||
}
|
}
|
||||||
return path.join(TEMPLATES_DIR, name + '.html')
|
return path.join(TEMPLATES_DIR, name + '.html')
|
||||||
}
|
}
|
||||||
|
|
@ -679,8 +701,8 @@ function templatePath (name) {
|
||||||
// is in MJML mode — the editor loads the .mjml source and on save we
|
// is in MJML mode — the editor loads the .mjml source and on save we
|
||||||
// recompile to .html. If only the .html exists, classic HTML mode.
|
// recompile to .html. If only the .html exists, classic HTML mode.
|
||||||
function templateMjmlPath (name) {
|
function templateMjmlPath (name) {
|
||||||
if (!EDITABLE_TEMPLATES.includes(name)) {
|
if (!isValidTemplateName(name)) {
|
||||||
throw new Error(`template not editable: ${name}`)
|
throw new Error(`template name invalid or not allowed: ${name}`)
|
||||||
}
|
}
|
||||||
return path.join(TEMPLATES_DIR, name + '.mjml')
|
return path.join(TEMPLATES_DIR, name + '.mjml')
|
||||||
}
|
}
|
||||||
|
|
@ -690,8 +712,8 @@ function templateMjmlPath (name) {
|
||||||
// present, the editor uses it as the source of truth on load. Generated
|
// 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.
|
// client-side by the editor on save; we just persist it as-is.
|
||||||
function templateJsonPath (name) {
|
function templateJsonPath (name) {
|
||||||
if (!EDITABLE_TEMPLATES.includes(name)) {
|
if (!isValidTemplateName(name)) {
|
||||||
throw new Error(`template not editable: ${name}`)
|
throw new Error(`template name invalid or not allowed: ${name}`)
|
||||||
}
|
}
|
||||||
return path.join(TEMPLATES_DIR, name + '.json')
|
return path.join(TEMPLATES_DIR, name + '.json')
|
||||||
}
|
}
|
||||||
|
|
@ -704,7 +726,7 @@ function templateFormat (name) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function listEditableTemplates () {
|
function listEditableTemplates () {
|
||||||
return EDITABLE_TEMPLATES.map(name => {
|
return scanEditableTemplates().map(name => {
|
||||||
const format = templateFormat(name)
|
const format = templateFormat(name)
|
||||||
const p = format === 'mjml' ? templateMjmlPath(name) : path.join(TEMPLATES_DIR, name + '.html')
|
const p = format === 'mjml' ? templateMjmlPath(name) : path.join(TEMPLATES_DIR, name + '.html')
|
||||||
let size = 0, modified = null
|
let size = 0, modified = null
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user