diff --git a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
index c37c5cb..dac085a 100644
--- a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
+++ b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
@@ -7,6 +7,9 @@
Éditeur de template
+
+ Créer un nouveau template (vide ou copié depuis un existant)
+
Sauvegardé · {{ lastSavedLabel }}
@@ -60,6 +63,40 @@
+
+
+
+
+
+ Nouveau template
+
+
+
+
+
+
+
+
+
+ Le template sera créé sous le nom {{ newTemplateFinal }}
+
+ · contenu copié depuis {{ newTemplateForm.starter }}
+
+
+
+
+
+
+
+
+
+
@@ -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 = 'Nouveau template — drag des blocs depuis la sidebar pour commencer.
'
+ 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
diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js
index 8b25e85..09ea6b2 100644
--- a/services/targo-hub/lib/campaigns.js
+++ b/services/targo-hub/lib/campaigns.js
@@ -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