gigafibre-fsm/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

599 lines
28 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="data_object" label="Variables" color="grey-8"
class="q-ml-sm" @click="variablesOpen = true">
<q-tooltip>Voir toutes les variables Mustache disponibles et leur exemple</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="warning" 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-info"><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-info">
<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-warning 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-warning">
<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-caption text-grey-7 q-mb-xs">Envoyer via</div>
<q-btn-toggle v-model="testSendForm.via" spread no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" class="q-mb-md"
:options="[{ label: 'Compte Gmail', value: 'gmail', icon: 'mail' }, { label: 'Mailjet', value: 'mailjet', icon: 'send' }]" />
<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" />
<q-input v-model="testSendForm.vars.expires_at_date" label="expires_at_date (auto, date du wrapper)" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.expires_in_days" label="expires_in_days" outlined dense class="col-6" />
</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.
<template v-if="testSendForm.via === 'gmail'">Envoyé via le <b>compte Gmail</b> surveillé — même canal que les vraies invitations (étoiles d'évaluation cliquables incluses).</template>
<template v-else>Envoyé via <b>Mailjet</b> depuis <code>TARGO &lt;support@targointernet.com&gt;</code>.</template>
</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>
<!-- Variables dialog — reference card showing every Mustache placeholder
the worker recognises, with its sample value and a click-to-copy
button so the user can paste it into any field that doesn't surface
Unlayer's Merge Tags icon (e.g. the email subject in the campaign
wizard, or a custom href). Source of truth is the same mergeTags
array passed to Unlayer below — kept in sync via computed. -->
<q-dialog v-model="variablesOpen">
<q-card style="min-width: 540px; max-width: 720px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6"><q-icon name="data_object" class="q-mr-sm" />Variables disponibles</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div class="text-caption text-grey-7 q-mb-md">
Le worker du hub substitue ces variables dans le HTML au moment de l'envoi
(moteur Mustache). Tu peux les insérer dans n'importe quel texte ou champ URL.
Dans l'éditeur Unlayer, le moyen le plus rapide est : clique dans un texte →
barre flottante → icône <code>{ }</code>.
</div>
<div v-for="cat in editorOptions.mergeTags" :key="cat.name" class="q-mb-md">
<div class="text-subtitle2 text-grey-9 q-mb-xs">{{ cat.name }}</div>
<q-list dense bordered class="rounded-borders">
<q-item v-for="tag in cat.mergeTags" :key="tag.value" clickable
@click="copyVariable(tag.value)">
<q-item-section avatar>
<code style="background:#F1F5F9;padding:2px 8px;border-radius:4px;font-size:13px">{{ tag.value }}</code>
</q-item-section>
<q-item-section>
<q-item-label>{{ tag.name }}</q-item-label>
<q-item-label caption>exemple : {{ tag.sample }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn flat dense round icon="content_copy" size="sm" color="grey-7"
@click.stop="copyVariable(tag.value)">
<q-tooltip>Copier {{ tag.value }}</q-tooltip>
</q-btn>
</q-item-section>
</q-item>
</q-list>
</div>
<q-banner class="bg-blue-1 text-info q-mt-md" rounded dense>
<template v-slot:avatar><q-icon name="lightbulb" /></template>
<span v-pre><strong>Bientôt :</strong> à l'importation CSV, on pourra mapper chaque colonne
comme variable additionnelle (ex. <code>{{plan_name}}</code>,
<code>{{renewal_date}}</code>) sans toucher au code.</span>
</q-banner>
</q-card-section>
</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'
import { HUB_URL } from 'src/config/hub'
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 (texte)", value: '{{expiry}}', sample: '31 décembre 2026' },
{ name: 'Expiration auto (date)', value: '{{expires_at_date}}', sample: '21 août 2026' },
{ name: 'Expiration auto (jours)', value: '{{expires_in_days}}', sample: '90' },
{ 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.
// Reference card showing every Mustache variable. Read-only — the variables
// list lives in editorOptions.mergeTags above (same source Unlayer uses).
const variablesOpen = ref(false)
async function copyVariable (val) {
try {
await navigator.clipboard.writeText(val)
$q.notify({ type: 'positive', message: `${val} copié`, timeout: 1200 })
} catch {
$q.notify({ type: 'negative', message: 'Impossible de copier — sélectionne et copie manuellement' })
}
}
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
// Images insérées → NOTRE store d'actifs auto-hébergé (pas le CDN d'Unlayer) ; les URLs des courriels pointent vers msg.gigafibre.ca.
try {
editor.value.registerCallback('image', (file, done) => {
const f = file && file.attachments && file.attachments[0]
if (!f) { done({ progress: 100 }); return }
done({ progress: 20 })
const fr = new FileReader()
fr.onload = async () => {
try {
const r = await fetch(`${HUB_URL}/campaigns/assets/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: f.name, data: fr.result }) })
const d = await r.json()
done({ progress: 100, url: d.url || '' })
} catch (e) { done({ progress: 100 }) }
}
fr.onerror = () => done({ progress: 100 })
fr.readAsDataURL(f)
})
} catch (e) { /* */ }
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)
// Sample expiry — 30 days from now, locale-formatted FR. The Mustache
// section {{#expires_at_date}}…{{/expires_at_date}} guards visible
// blocks, so leaving this blank would hide them in tests. We compute
// a realistic value so test sends and previews show the real layout.
const sampleExpiryDate = new Date(Date.now() + 30 * 86400 * 1000)
.toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' })
const testSendForm = ref({
to: 'louis@targo.ca',
subject: '[TEST] Aperçu du courriel TARGO',
via: 'gmail',
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',
expires_at_date: sampleExpiryDate,
expires_in_days: '30',
},
})
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,
via: testSendForm.value.via,
})
$q.notify({ type: 'positive', message: `Test envoyé à ${r.to}`, caption: `via ${r.channel === 'gmail' ? 'Gmail' : 'Mailjet'} · ${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>