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:
parent
0f78fbe27e
commit
ff629a6a85
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user