gigafibre-fsm/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue

501 lines
22 KiB
Vue

<template>
<q-page>
<!-- Top bar: template selector + saved chip + quick actions. The Unlayer
editor below has its own toolbar for blocks/preview/etc. -->
<div class="row items-center q-px-md q-py-sm bg-white" style="border-bottom:1px solid #e5e7eb">
<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="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>
<q-btn flat dense icon="code" class="q-ml-sm" color="grey-7">
<q-tooltip max-width="320px">
<strong>9 variables disponibles</strong> (Client / Offre / Système).
Insertion : clic dans un texte → barre flottante → icône <code>{}</code> Merge Tags.
Marche aussi dans les champs URL (boutons, images, mailto).
</q-tooltip>
</q-btn>
<q-space />
<q-btn flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm" @click="openPreview">
<q-tooltip>Voir le HTML rendu (substitué) tel que reçu par le destinataire</q-tooltip>
</q-btn>
<q-btn flat color="purple-7" icon="translate" label="Traduire (AI)" class="q-mr-sm"
:disable="!aiTranslateTargetName" @click="aiTranslateOpen = true">
<q-tooltip>{{ aiTranslateTargetName ? `Traduire vers ${aiTranslateTargetName} via Gemini` : 'Disponible pour les templates avec suffixe -fr ou -en' }}</q-tooltip>
</q-btn>
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm" @click="testSendOpen = true">
<q-tooltip>Envoyer un courriel réel à une adresse de test</q-tooltip>
</q-btn>
<q-btn unelevated color="primary" icon="save" label="Enregistrer"
:loading="saving" @click="saveTemplate" />
</div>
<!-- Unlayer editor — Vue 3 native, no iframe, full features:
responsive preview, AMP blocks, Unsplash, file manager, dark mode,
layers/structure panel, design tokens, etc.
Wrapped in an explicit-sized container so the inner iframe gets
enough height/width (Quasar's q-page doesn't propagate dimensions
that easyEditor's nested iframe can pick up automatically). -->
<div style="height: calc(100vh - 60px); width: 100%; overflow: hidden; position: relative;">
<EmailEditor
ref="editor"
:options="editorOptions"
:min-height="'100%'"
style="height: 100%; width: 100%;"
@load="onEditorLoad"
@ready="onEditorReady"
/>
</div>
<!-- Aperçu dialog — renders the latest saved HTML with sample vars -->
<q-dialog v-model="previewOpen" maximized persistent>
<q-card class="bg-grey-2">
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
<q-icon name="visibility" class="q-mr-sm" />
<q-toolbar-title>Aperçu inbox · {{ currentName }}</q-toolbar-title>
<q-btn flat dense round icon="close" @click="previewOpen = false" />
</q-toolbar>
<q-banner v-if="previewLoading" class="bg-blue-1 text-blue-9"><q-spinner size="sm" /> Rendu en cours…</q-banner>
<q-card-section style="height: calc(100vh - 60px); overflow:hidden">
<iframe :srcdoc="previewHtmlContent" style="width:100%; height:100%; border:1px solid #e5e7eb; background:#fff;" />
</q-card-section>
</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>
<!-- AI translate dialog -->
<q-dialog v-model="aiTranslateOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
<q-card-section class="row items-center bg-purple-1 text-purple-9">
<q-icon name="translate" class="q-mr-sm" />
<div class="text-h6">Traduire avec Gemini AI</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div class="q-mb-md">
Le contenu de <strong>{{ currentName }}</strong> sera traduit vers
<strong>{{ aiTranslateTargetName }}</strong> via Gemini Flash.
</div>
<q-banner class="bg-grey-2 text-grey-8 q-mb-md" rounded dense>
<q-icon name="info" class="q-mr-xs" />
<!-- v-pre on this span so Vue doesn't try to compile the literal
{{...}} braces inside the explanatory <code> tag. -->
<span v-pre>
Le AI préserve la structure HTML, les variables <code>{{...}}</code>,
les URLs, les noms de marque (TARGO, Giftbit) et les emojis.
Il traduit seulement le texte visible.
</span>
</q-banner>
<q-banner v-if="targetTemplateExists" class="bg-amber-1 text-amber-9 q-mb-md" rounded dense>
<q-icon name="warning" class="q-mr-xs" />
Le template <strong>{{ aiTranslateTargetName }}</strong> existe déjà.
La traduction va l'écraser (backup automatique avant).
<q-toggle v-model="aiTranslateOverride" label="Confirmer l'écrasement" class="q-mt-sm" />
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="purple-7" icon="translate" label="Traduire maintenant"
:loading="aiTranslating"
:disable="targetTemplateExists && !aiTranslateOverride"
@click="doAiTranslate" />
</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">
<q-card-section class="row items-center bg-orange-1 text-orange-9">
<q-icon name="send" class="q-mr-sm" />
<div class="text-h6">Envoyer un test du courriel</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense type="email" autofocus class="q-mb-md" />
<q-input v-model="testSendForm.subject" label="Sujet" outlined dense class="q-mb-md" />
<div class="text-subtitle2 q-mb-sm">Variables</div>
<div class="row q-col-gutter-sm">
<q-input v-model="testSendForm.vars.firstname" label="firstname" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.lastname" label="lastname" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.amount" label="amount" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.commitment_months" label="commitment_months" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.gift_url" label="gift_url" outlined dense class="col-12" />
<q-input v-model="testSendForm.vars.description" label="description" outlined dense class="col-12" />
<q-input v-model="testSendForm.vars.expiry" label="expiry" outlined dense class="col-12" />
</div>
</q-card-section>
<q-card-section class="bg-grey-2">
<div class="text-caption text-grey-8">
Sauvegarde l'éditeur d'abord pour tester la dernière version.
Envoyé via Mailjet depuis <code>TARGO &lt;support@targointernet.com&gt;</code>.
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="primary" icon-right="send" label="Envoyer le test"
:loading="testSending" :disable="!testSendForm.to" @click="doTestSend" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { EmailEditor } from 'vue-email-editor'
import { listTemplates, getTemplate, saveTemplate as saveTemplateApi,
previewTemplate, testSendTemplate, translateTemplate } from 'src/api/campaigns'
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
// ── Editor ref + Unlayer configuration ──────────────────────────────────────
const editor = ref(null)
const editorReady = ref(false)
const saving = ref(false)
const currentName = ref(route.params.name || 'gift-email-fr')
// Unlayer editor options:
// - mergeTags: shown in the "Merge tags" panel, drag-droppable into text
// - features: Unsplash, file manager, etc. are all on by default
// - tools: which block types to expose
// - appearance: light theme matching our ops UI
const editorOptions = {
appearance: {
theme: 'modern_light',
panels: {
tools: { dock: 'left' },
},
},
// Merge tags organized by category — Unlayer shows these in a dropdown
// when editing a text block (click into text → toolbar → {} icon) and
// ALSO in URL fields (Button "Action URL", Image "Source", mailto links).
// The `sample` field is what Unlayer shows as a preview (so the user sees
// realistic content while editing); on send, the hub's Mustache renderer
// substitutes the actual value.
mergeTags: [
{
name: 'Client',
mergeTags: [
{ name: 'Prénom', value: '{{firstname}}', sample: 'Louis' },
{ name: 'Nom de famille', value: '{{lastname}}', sample: 'Tremblay' },
{ name: 'Courriel', value: '{{email}}', sample: 'louis@targo.ca' },
{ name: 'Adresse service', value: '{{description}}', sample: '123 Rue de la Rivière, Ste-Clotilde' },
],
},
{
name: 'Offre',
mergeTags: [
{ name: 'Montant', value: '{{amount}}', sample: '60 $' },
{ name: 'Lien cadeau (URL)', value: '{{gift_url}}', sample: 'https://gft.link/abc' },
{ name: "Date d'expiration", value: '{{expiry}}', sample: '31 décembre 2026' },
{ name: 'Engagement (mois)', value: '{{commitment_months}}', sample: '3' },
],
},
{
name: 'Système',
mergeTags: [
{ name: 'Année courante', value: '{{year}}', sample: '2026' },
],
},
],
// Display mode: 'email' (default, with mobile preview), 'web' for landing pages
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.
}
// ── Template list (selector at the top) ─────────────────────────────────────
const templates = ref([])
const templateOptions = computed(() => templates.value.map(t => ({
label: `${t.name} (${Math.round(t.size / 1024)} KB)`,
value: t.name,
})))
async function loadAvailableTemplates () {
templates.value = await listTemplates()
}
// ── AI translation (Gemini via hub) ─────────────────────────────────────────
// Auto-detect source language from the template name suffix (-fr / -en) and
// compute the target name with the OPPOSITE suffix.
const aiTranslateOpen = ref(false)
const aiTranslating = ref(false)
const aiTranslateOverride = ref(false)
const aiTranslateTargetName = computed(() => {
const m = (currentName.value || '').match(/^(.+)-(fr|en)$/)
if (!m) return ''
const opposite = m[2] === 'fr' ? 'en' : 'fr'
return `${m[1]}-${opposite}`
})
const targetTemplateExists = computed(() =>
!!aiTranslateTargetName.value && !!templates.value.find(t => t.name === aiTranslateTargetName.value),
)
async function doAiTranslate () {
if (!aiTranslateTargetName.value) return
aiTranslating.value = true
try {
const r = await translateTemplate(currentName.value, aiTranslateTargetName.value, {
override: aiTranslateOverride.value,
})
aiTranslateOpen.value = false
aiTranslateOverride.value = false
await loadAvailableTemplates()
$q.notify({
type: 'positive',
message: `Traduction terminée : ${r.source} → ${r.target}`,
caption: `${r.src_bytes} → ${r.out_bytes} octets (${r.from_lang} → ${r.to_lang})`,
timeout: 6000,
actions: [
{ label: 'Ouvrir', color: 'white', handler: () => { onSelectTemplate(r.target) } },
],
})
} catch (e) {
$q.notify({ type: 'negative', message: 'Échec traduction: ' + e.message, timeout: 6000 })
} finally {
aiTranslating.value = false
}
}
// ── 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
try {
const data = await getTemplate(name)
// Priority: Unlayer design JSON saved by a previous edit > nothing.
// Legacy MJML/HTML templates aren't auto-importable into Unlayer's
// component tree — the user reconstructs visually once, and the
// design saved by the next "Enregistrer" fixes future loads.
if (data.design) {
const design = typeof data.design === 'string' ? JSON.parse(data.design) : data.design
editor.value.loadDesign(design)
} else {
// No saved design — vue-email-editor 2.x doesn't have a loadBlank()
// method on the ref, so we just let the editor show its default
// empty state ("No content here. Drag content from left.") and
// notify the user to start building.
$q.notify({
type: 'info',
message: `Pas encore de design pour "${name}" — drag des blocs depuis la sidebar gauche pour construire la template, puis "Enregistrer".`,
timeout: 8000,
})
}
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur de chargement: ' + e.message })
}
}
function onEditorLoad () {
// Fired once when the editor IFRAME loads (before the editor inside is ready)
}
async function onEditorReady () {
// Fired when the editor INSIDE the iframe is ready to accept loadDesign()
editorReady.value = true
await loadTemplateIntoEditor(currentName.value)
}
function onSelectTemplate (name) {
currentName.value = name
router.replace({ path: `/campaigns/templates/${name}` })
loadTemplateIntoEditor(name)
}
// ── Save: export HTML + design JSON, POST to hub ────────────────────────────
const lastSavedTs = ref(null)
const lastSavedLabel = computed(() => {
if (!lastSavedTs.value) return ''
const diff = Math.floor((Date.now() - lastSavedTs.value) / 1000)
if (diff < 60) return `il y a ${diff}s`
if (diff < 3600) return `il y a ${Math.floor(diff / 60)}min`
return new Date(lastSavedTs.value).toLocaleTimeString('fr-CA')
})
function saveTemplate () {
if (!editor.value) return
saving.value = true
// exportHtml uses a callback (legacy Unlayer API) — wrap in a Promise
editor.value.exportHtml(async (data) => {
try {
const { html, design } = data
await saveTemplateApi(currentName.value, html, { design })
lastSavedTs.value = Date.now()
$q.notify({ type: 'positive', message: 'Template enregistré ✓', timeout: 2500 })
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
})
}
// ── Preview dialog ──────────────────────────────────────────────────────────
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewHtmlContent = ref('')
async function openPreview () {
previewOpen.value = true
previewLoading.value = true
try {
const r = await previewTemplate(currentName.value)
previewHtmlContent.value = r.rendered || ''
} catch (e) {
$q.notify({ type: 'negative', message: 'Aperçu impossible: ' + e.message })
} finally {
previewLoading.value = false
}
}
// ── Test-send dialog ────────────────────────────────────────────────────────
const testSendOpen = ref(false)
const testSending = ref(false)
const testSendForm = ref({
to: 'louis@targo.ca',
subject: '[TEST] Aperçu du courriel TARGO',
vars: {
firstname: 'Louis', lastname: 'Test', amount: '60 $',
commitment_months: '3',
gift_url: 'https://gft.link/TEST123',
description: '123 Rue de Test, Ste-Clotilde',
expiry: '31 décembre 2026',
},
})
async function doTestSend () {
testSending.value = true
try {
const r = await testSendTemplate(currentName.value, {
to: testSendForm.value.to.trim(),
subject: testSendForm.value.subject,
vars: testSendForm.value.vars,
})
$q.notify({ type: 'positive', message: `Test envoyé à ${r.to}`, caption: `${r.bytes} octets`, timeout: 5000 })
testSendOpen.value = false
} catch (e) {
$q.notify({ type: 'negative', message: 'Échec envoi: ' + e.message })
} finally {
testSending.value = false
}
}
onMounted(loadAvailableTemplates)
</script>