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>
|
||||
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined
|
||||
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">
|
||||
Sauvegardé · {{ lastSavedLabel }}
|
||||
</q-chip>
|
||||
|
|
@ -60,6 +63,40 @@
|
|||
</q-card>
|
||||
</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 -->
|
||||
<q-dialog v-model="testSendOpen" persistent>
|
||||
<q-card style="min-width: 500px; max-width: 90vw">
|
||||
|
|
@ -165,6 +202,20 @@ const editorOptions = {
|
|||
displayMode: 'email',
|
||||
// Locale for built-in strings
|
||||
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.
|
||||
// Without a projectId the "Powered by Unlayer" badge shows in the sidebar.
|
||||
}
|
||||
|
|
@ -180,6 +231,60 @@ async function loadAvailableTemplates () {
|
|||
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 ──────
|
||||
async function loadTemplateIntoEditor (name) {
|
||||
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
|
||||
// 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.
|
||||
// Canonical templates — both now backed by .mjml source (compiled to .html
|
||||
// on save for the send-worker). Legacy HTML-only variants were dropped
|
||||
// after the MJML migration (see git history for rollback).
|
||||
const EDITABLE_TEMPLATES = ['gift-email-fr', 'gift-email-en']
|
||||
// Templates allow-list — moved from a hardcoded array to a regex-validated
|
||||
// + disk-scan approach so the user can create new templates from the editor
|
||||
// UI without us re-deploying the hub. The prefix list bounds what names are
|
||||
// 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
|
||||
// the French version (most of the customer base) if the language-specific
|
||||
|
|
@ -669,8 +691,8 @@ function templateForLanguage (lang) {
|
|||
}
|
||||
|
||||
function templatePath (name) {
|
||||
if (!EDITABLE_TEMPLATES.includes(name)) {
|
||||
throw new Error(`template not editable: ${name}`)
|
||||
if (!isValidTemplateName(name)) {
|
||||
throw new Error(`template name invalid or not allowed: ${name}`)
|
||||
}
|
||||
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
|
||||
// recompile to .html. If only the .html exists, classic HTML mode.
|
||||
function templateMjmlPath (name) {
|
||||
if (!EDITABLE_TEMPLATES.includes(name)) {
|
||||
throw new Error(`template not editable: ${name}`)
|
||||
if (!isValidTemplateName(name)) {
|
||||
throw new Error(`template name invalid or not allowed: ${name}`)
|
||||
}
|
||||
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
|
||||
// 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}`)
|
||||
if (!isValidTemplateName(name)) {
|
||||
throw new Error(`template name invalid or not allowed: ${name}`)
|
||||
}
|
||||
return path.join(TEMPLATES_DIR, name + '.json')
|
||||
}
|
||||
|
|
@ -704,7 +726,7 @@ function templateFormat (name) {
|
|||
}
|
||||
|
||||
function listEditableTemplates () {
|
||||
return EDITABLE_TEMPLATES.map(name => {
|
||||
return scanEditableTemplates().map(name => {
|
||||
const format = templateFormat(name)
|
||||
const p = format === 'mjml' ? templateMjmlPath(name) : path.join(TEMPLATES_DIR, name + '.html')
|
||||
let size = 0, modified = null
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user