diff --git a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
index 672025d..7c09f48 100644
--- a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
+++ b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
@@ -5,17 +5,33 @@
Éditeur de template
+
+ Repartir d'un canevas vide (pour composer une nouvelle template depuis zéro)
+
+
+ Recharger la dernière version sauvegardée sur disque
+
-
+
+ Annuler les changements non sauvegardés
+
+
+
+
+ Le mode Visuel est idéal pour composer une nouvelle template depuis un canevas vide
+ (clique "Vide" ↑). Pour éditer la template existante (tables imbriquées hand-crafted),
+ passe en mode HTML — GrapesJS ne parse pas toujours les structures complexes.
+
+
@@ -23,10 +39,16 @@
Édition HTML brute. Les changements sauvegardés ici écrasent ceux de l'éditeur visuel.
- Variables disponibles : {{ '{{firstname}}' }}, {{ '{{amount}}' }},
- {{ '{{gift_url}}' }}, {{ '{{description}}' }},
- {{ '{{expiry}}' }}, {{ '{{commitment_months}}' }}.
- Blocs conditionnels : {{ '{{#expiry}}...{{/expiry}}' }}.
+
+
+ Variables disponibles : {{firstname}}, {{amount}},
+ {{gift_url}}, {{description}},
+ {{expiry}}, {{commitment_months}}.
+ Blocs conditionnels : {{#expiry}}...{{/expiry}}.
+
{
+ const blank = `
+
+
+
+ |
+ Bonjour {{firstname}},
+ Ton message ici…
+ |
+
+ |
+`
+ html.value = blank
+ if (editor) editor.setComponents(blank)
+ dirty.value = true
+ blankCanvas.value = true
+ })
+}
+
+// Force-reload the on-disk version of the current template, discarding any
+// unsaved edits. Useful after experimenting in the canvas to get back to
+// known-good state without leaving the editor.
+async function reloadFromDisk () {
+ if (dirty.value) {
+ const ok = await new Promise(resolve => {
+ $q.dialog({
+ title: 'Réinitialiser ?',
+ message: 'Les changements non sauvegardés seront perdus.',
+ cancel: true, persistent: true,
+ }).onOk(() => resolve(true)).onCancel(() => resolve(false))
+ })
+ if (!ok) return
+ }
+ await loadTemplate(currentName.value)
+ blankCanvas.value = false
}
onMounted(async () => {
diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js
index ef7d3fb..3c64640 100644
--- a/services/targo-hub/lib/campaigns.js
+++ b/services/targo-hub/lib/campaigns.js
@@ -188,10 +188,47 @@ function parseMapCsv (text, multi = 'first') {
}
// ── Giftbit CSV parsing ──────────────────────────────────────────────────────
+// Two supported formats:
+//
+// 1. "Link Order" export (headerless, one URL per line):
+// http://gft.link/4kpZMApLK4B
+// http://gft.link/Dn2cb27xYJ8
+//
+// This is what Giftbit ships when you pre-buy N gift links upfront without
+// targeting specific recipients. Each line = one redeemable shortlink. The
+// recipient mapping is done entirely on our side (Map CSV ↔ link by row).
+//
+// 2. "Campaign" export (with header row, multiple columns):
+// firstname,lastname,email,gift_url,giftbit_uuid,gift_value_cents,internal_id
+// Alice,Tremblay,alice@x.com,https://...,uuid-001,5000,ACC-1
+//
+// This is what create_giftbit_campaign.js produces (and what Giftbit
+// sends when you use their Campaign API with delivery_type=SHORTLINK).
function parseGiftbitCsv (text) {
+ const cleaned = text.replace(/^/, '').trim()
+ if (!cleaned) return []
+
+ // Detect headerless one-URL-per-line format: first non-empty line starts
+ // with http:// or https:// and has no comma/pipe/tab separators.
+ const firstLine = cleaned.split(/\r?\n/, 1)[0].trim()
+ if (/^https?:\/\/\S+$/.test(firstLine) && !firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')) {
+ return cleaned.split(/\r?\n/)
+ .map(l => l.trim())
+ .filter(l => /^https?:\/\//.test(l))
+ .map(url => ({
+ gift_url: url,
+ giftbit_uuid: '',
+ gift_value_cents: 0,
+ email: '',
+ firstname: '',
+ lastname: '',
+ internal_id: '',
+ }))
+ }
+
+ // Otherwise treat as CSV with header row
const rows = parseCsv(text)
if (!rows.length) return []
- // Detect URL column from common names
const keys = Object.keys(rows[0])
const urlCandidates = ['gift_url','gift link','gift_link','url','link','shortlink',
'redemption_url','gift','giftbit_link','campaign_link']
@@ -200,7 +237,6 @@ function parseGiftbitCsv (text) {
const hit = keys.find(k => k.toLowerCase().replace(/\s+/g, '_') === c)
if (hit) { urlCol = hit; break }
}
- // Fallback: any column with http(s)://
if (!urlCol) {
for (const k of keys) {
if ((rows[0][k] || '').match(/^https?:\/\//)) { urlCol = k; break }