feat(campaigns): support Giftbit Link Order CSV + add blank-canvas editor mode

Two issues spotted during first real-data test:

1. parseGiftbitCsv only handled the Campaign-export format (header row
   + columns firstname/lastname/email/gift_url/uuid/...). The Link Order
   product Giftbit ships when you pre-buy N links exports a different
   format: headerless, one URL per line. Detect this by checking the
   first non-empty line: if it starts with http(s):// and has no
   comma/pipe/tab separators, treat the whole file as bare URLs. Each
   URL maps to one recipient (row-order matching, same as before).

2. The template editor was hard-coded to load the existing
   gift-email-fr.html into GrapesJS on mount. Hand-crafted email HTML
   with deeply nested tables doesn't parse cleanly into GrapesJS
   components, so the visual canvas often renders blank. Two new
   toolbar actions to address this:

   • "Vide" — clears the canvas to a minimal table-based skeleton.
     For composing brand-new templates from scratch in the visual
     editor without inheriting the existing template's structure.
     Confirms before resetting, then sets dirty=true so the next Save
     overwrites the on-disk template (with hub-side backup).

   • "Réinitialiser" — reloads the last on-disk version, discarding
     any unsaved canvas state. Confirms if dirty.

   Plus an amber banner in visual mode (auto-hidden when blank-canvas
   is active) explaining that Visual mode is for new templates and
   the existing template should be edited in HTML mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-21 20:14:29 -04:00
parent 0f78fbe27e
commit ff629a6a85
2 changed files with 115 additions and 7 deletions

View File

@ -5,17 +5,33 @@
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
<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="loadTemplate" />
<q-btn flat dense icon="add" label="Vide" class="q-ml-sm" @click="startBlank">
<q-tooltip>Repartir d'un canevas vide (pour composer une nouvelle template depuis zéro)</q-tooltip>
</q-btn>
<q-btn flat dense icon="restore" label="Réinitialiser" class="q-ml-sm" @click="reloadFromDisk">
<q-tooltip>Recharger la dernière version sauvegardée sur disque</q-tooltip>
</q-btn>
<q-space />
<q-btn-toggle v-model="viewMode" :options="[
{ label: 'Visuel', value: 'visual' },
{ label: 'HTML', value: 'html' },
{ label: 'Aperçu', value: 'preview' },
]" dense unelevated toggle-color="primary" />
<q-btn flat icon="undo" label="Annuler" class="q-ml-sm" :disable="!dirty" @click="discardChanges" />
<q-btn flat icon="undo" label="Annuler" class="q-ml-sm" :disable="!dirty" @click="discardChanges">
<q-tooltip>Annuler les changements non sauvegardés</q-tooltip>
</q-btn>
<q-btn unelevated color="primary" icon="save" label="Enregistrer" class="q-ml-sm"
:loading="saving" :disable="!dirty" @click="save" />
</div>
<!-- Hint banner: visual mode is best for new templates, HTML for existing -->
<q-banner v-if="viewMode === 'visual' && !blankCanvas" class="bg-amber-1 text-amber-9 q-px-md q-py-xs" style="border-bottom:1px solid #fde68a">
<q-icon name="lightbulb" class="q-mr-xs" />
Le mode Visuel est idéal pour <strong>composer une nouvelle template depuis un canevas vide</strong>
(clique "Vide" ). Pour éditer la template existante (tables imbriquées hand-crafted),
<strong>passe en mode HTML</strong> GrapesJS ne parse pas toujours les structures complexes.
</q-banner>
<!-- Editor surface (one of three modes) -->
<div v-show="viewMode === 'visual'" ref="grapesContainer" style="height:calc(100vh - 110px)"></div>
@ -23,10 +39,16 @@
<q-banner class="bg-blue-1 text-blue-9 q-mb-sm" rounded>
<template v-slot:avatar><q-icon name="info" /></template>
Édition HTML brute. Les changements sauvegardés ici écrasent ceux de l'éditeur visuel.
Variables disponibles : <code>{{ '{{firstname}}' }}</code>, <code>{{ '{{amount}}' }}</code>,
<code>{{ '{{gift_url}}' }}</code>, <code>{{ '{{description}}' }}</code>,
<code>{{ '{{expiry}}' }}</code>, <code>{{ '{{commitment_months}}' }}</code>.
Blocs conditionnels : <code>{{ '{{#expiry}}...{{/expiry}}' }}</code>.
<!-- v-pre tells Vue NOT to compile child interpolations, so the {{var}}
text below is rendered literally instead of being parsed as Vue
template expressions. Without v-pre, Vue's parser chokes on the
nested curly braces inside `{{ '{{firstname}}' }}`. -->
<span v-pre>
Variables disponibles : <code>{{firstname}}</code>, <code>{{amount}}</code>,
<code>{{gift_url}}</code>, <code>{{description}}</code>,
<code>{{expiry}}</code>, <code>{{commitment_months}}</code>.
Blocs conditionnels : <code>{{#expiry}}...{{/expiry}}</code>.
</span>
</q-banner>
<q-input v-model="html" type="textarea" outlined dense filled
input-style="font-family:monospace; font-size:0.85rem; line-height:1.4; min-height:calc(100vh - 220px)"
@ -60,6 +82,7 @@ const previewHtml = ref('')
const dirty = ref(false)
const saving = ref(false)
const viewMode = ref('visual') // 'visual' | 'html' | 'preview'
const blankCanvas = ref(false) // true after clicking "Vide" hides the "use HTML" hint
let editor = null
let originalHtml = ''
@ -210,6 +233,55 @@ function discardChanges () {
html.value = originalHtml
if (editor) editor.setComponents(originalHtml)
dirty.value = false
blankCanvas.value = false
}
// Start from an empty canvas useful when composing a brand-new template
// without inheriting any structure from the existing on-disk template. The
// "saved" baseline is what's on disk, so clicking Annuler restores that.
// Saving while in blank mode OVERWRITES the disk template with the new
// composition (with a backup taken by the hub, see lib/campaigns.js).
function startBlank () {
$q.dialog({
title: 'Repartir d\'un canevas vide ?',
message: `Le canevas sera réinitialisé. Tes changements actuels sont conservés
sur disque tant que tu ne cliques pas "Enregistrer".`,
cancel: true, persistent: true,
}).onOk(() => {
const blank = `<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f7f8f7;font-family:-apple-system,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="padding:32px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;background:#fff;border-radius:14px;">
<tr><td style="padding:32px;">
<p>Bonjour {{firstname}},</p>
<p>Ton message ici</p>
</td></tr>
</table>
</td></tr></table>
</body></html>`
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 () => {

View File

@ -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 }