feat(ops/campaigns): Phase 2 — switch editor page to easy-email iframe
Replace the broken GrapesJS-mjml integration with an iframe pointing to
the standalone email-editor microservice at editor.gigafibre.ca (created
in Phase 1).
What changed:
- Dropped all grapesjs* imports and ~250 lines of editor init/save/preview
glue code. That logic now lives in the React app on the other side of
the iframe.
- Page becomes a thin wrapper:
• Top bar: back button, template selector, "saved" chip,
"Aperçu inbox" button, "Envoyer un test" button, reload button.
• Below: full-height iframe to editor.gigafibre.ca/?name=<template-name>.
- Template switching: bumping iframeKey forces a fresh iframe load so the
new ?name= param takes effect. Route is updated via router.replace.
- postMessage listener: receives { type: 'email-editor:saved', ts }
from the editor iframe and shows a positive toast + updates the
"Sauvegardé · il y a Xs" chip. Origin-checked against EDITOR_BASE.
- Preview dialog: unchanged — fetches compiled HTML from hub's preview
endpoint and renders in srcdoc iframe.
- Test-send dialog: unchanged from previous version.
Removed (now handled inside the iframe):
- Visual / HTML / Aperçu view-mode toggle (editor.gigafibre.ca handles
all editing modes natively)
- "Vide" / "Réinitialiser" buttons (editor has its own)
- "Annuler" / "Enregistrer" buttons (editor saves itself on Cmd-S /
toolbar button)
- spell-check on textarea (editor handles it)
- GrapesJS asset manager wiring (editor will use its own image picker
in Phase 3)
DNS prerequisite handled separately: editor.gigafibre.ca → 96.125.196.67
created via Cloudflare API (proxied=false to match the existing pattern
that lets Traefik handle Let's Encrypt directly).
Container running on prod via /opt/email-editor/docker-compose.yml,
Traefik routing to Host(`editor.gigafibre.ca`). HTTPS verified live.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0b6377fa58
commit
f9971e9113
|
|
@ -1,35 +1,55 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page>
|
<q-page>
|
||||||
<!-- Top bar -->
|
<!-- Top bar: thin wrapper. All editing happens inside the iframe; the
|
||||||
|
parent app's job is template selection + test-send + saved-toast. -->
|
||||||
<div class="row items-center q-px-md q-py-sm bg-white" style="border-bottom:1px solid #e5e7eb">
|
<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" />
|
<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>
|
<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-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined
|
||||||
<q-btn flat dense icon="add" label="Vide" class="q-ml-sm" @click="startBlank">
|
style="min-width:240px" @update:model-value="onSelectTemplate" />
|
||||||
<q-tooltip>Repartir d'un canevas vide (pour composer une nouvelle template depuis zéro)</q-tooltip>
|
<q-chip dense color="grey-2" text-color="grey-9" class="q-ml-sm" size="sm" icon="cloud_done"
|
||||||
</q-btn>
|
v-if="lastSaved">
|
||||||
<q-btn flat dense icon="restore" label="Réinitialiser" class="q-ml-sm" @click="reloadFromDisk">
|
Sauvegardé · {{ lastSavedAt }}
|
||||||
<q-tooltip>Recharger la dernière version sauvegardée sur disque</q-tooltip>
|
</q-chip>
|
||||||
</q-btn>
|
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn-toggle v-model="viewMode" :options="[
|
<q-btn flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm"
|
||||||
{ label: 'Visuel', value: 'visual' },
|
@click="openPreview">
|
||||||
{ label: 'HTML', value: 'html' },
|
<q-tooltip>Voir le rendu compilé avec données de test (HTML de l'inbox)</q-tooltip>
|
||||||
{ label: 'Aperçu', value: 'preview' },
|
|
||||||
]" dense unelevated toggle-color="primary" />
|
|
||||||
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-ml-sm" @click="testSendOpen = true">
|
|
||||||
<q-tooltip>Envoie un courriel réel à une adresse de test avec des variables personnalisées</q-tooltip>
|
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn flat icon="undo" label="Annuler" class="q-ml-sm" :disable="!dirty" @click="discardChanges">
|
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm"
|
||||||
<q-tooltip>Annuler les changements non sauvegardés</q-tooltip>
|
@click="testSendOpen = true">
|
||||||
|
<q-tooltip>Envoie un courriel réel à une adresse de test avec variables custom</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense icon="refresh" @click="reloadEditor">
|
||||||
|
<q-tooltip>Recharger l'éditeur (en cas de bug)</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn unelevated color="primary" icon="save" label="Enregistrer" class="q-ml-sm"
|
|
||||||
:loading="saving" :disable="!dirty" @click="save" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Test-send dialog: lets you fire ONE rendered email to any address
|
<!-- The editor itself — full-page iframe to the standalone email-editor
|
||||||
with custom variable values. Always saves first if dirty (otherwise
|
microservice at editor.gigafibre.ca. Communicates back via postMessage
|
||||||
the test would render the OLD template — confusing). -->
|
when the user saves (we listen + show a toast + update lastSaved). -->
|
||||||
|
<iframe v-if="iframeUrl" :src="iframeUrl" :key="iframeKey"
|
||||||
|
style="width:100%; height:calc(100vh - 60px); border:0; display:block;"
|
||||||
|
ref="editorIframe" />
|
||||||
|
|
||||||
|
<!-- Aperçu dialog — renders the LATEST saved compiled HTML in a clean
|
||||||
|
viewer. Useful before sending a test, to see what the inbox version
|
||||||
|
actually looks like with merge tags substituted. -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Test-send dialog (unchanged from previous version) -->
|
||||||
<q-dialog v-model="testSendOpen" persistent>
|
<q-dialog v-model="testSendOpen" persistent>
|
||||||
<q-card style="min-width: 500px; max-width: 90vw">
|
<q-card style="min-width: 500px; max-width: 90vw">
|
||||||
<q-card-section class="row items-center bg-orange-1 text-orange-9">
|
<q-card-section class="row items-center bg-orange-1 text-orange-9">
|
||||||
|
|
@ -39,113 +59,123 @@
|
||||||
<q-btn flat dense round icon="close" v-close-popup />
|
<q-btn flat dense round icon="close" v-close-popup />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-banner v-if="dirty" class="bg-amber-1 text-amber-9 q-mb-md" rounded dense>
|
|
||||||
<q-icon name="warning" class="q-mr-xs" />
|
|
||||||
Tu as des changements non sauvegardés. Sauvegarde avant d'envoyer pour tester la dernière version.
|
|
||||||
</q-banner>
|
|
||||||
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense
|
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense
|
||||||
type="email" autofocus class="q-mb-md"
|
type="email" autofocus class="q-mb-md" />
|
||||||
hint="L'email de test sera envoyé via Mailjet à cette adresse" />
|
|
||||||
<q-input v-model="testSendForm.subject" label="Sujet" outlined dense 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 (pour le rendu)</div>
|
<div class="text-subtitle2 q-mb-sm">Variables</div>
|
||||||
<div class="row q-col-gutter-sm">
|
<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.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.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.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.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.gift_url" label="gift_url" outlined dense class="col-12" />
|
||||||
<q-input v-model="testSendForm.vars.description" label="description (adresse)" 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 (optionnel)" outlined dense class="col-12" />
|
<q-input v-model="testSendForm.vars.expiry" label="expiry" outlined dense class="col-12" />
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="bg-grey-2">
|
<q-card-section class="bg-grey-2">
|
||||||
<div class="text-caption text-grey-8">
|
<div class="text-caption text-grey-8">
|
||||||
Envoyé via Mailjet depuis <code>TARGO <support@targointernet.com></code> (sender validé).
|
Envoyé via Mailjet depuis <code>TARGO <support@targointernet.com></code>.
|
||||||
Le rendu utilisera le HTML compilé actuellement sauvegardé sur disque (pas les modifs non-sauvegardées).
|
Utilise la dernière version sauvegardée (sauvegarde dans l'éditeur d'abord pour tester les modifs récentes).
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn flat label="Annuler" v-close-popup />
|
<q-btn flat label="Annuler" v-close-popup />
|
||||||
<q-btn unelevated color="primary" icon-right="send" label="Envoyer le test maintenant"
|
<q-btn unelevated color="primary" icon-right="send" label="Envoyer le test"
|
||||||
:loading="testSending" :disable="!testSendForm.to" @click="doTestSend" />
|
:loading="testSending" :disable="!testSendForm.to" @click="doTestSend" />
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<div v-show="viewMode === 'html'" class="q-pa-md" style="height:calc(100vh - 110px)">
|
|
||||||
<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.
|
|
||||||
<!-- 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>
|
|
||||||
<!-- lang+spellcheck attributes turn on the browser's native spell-checker.
|
|
||||||
Red squiggles under typos in real time. Language follows the current
|
|
||||||
template suffix (-fr → fr, -en → en) so Chrome/Safari use the right
|
|
||||||
dictionary. For AI-grade rewriting (grammar, tone) plug the future
|
|
||||||
/campaigns/ai/rewrite endpoint into a separate button. -->
|
|
||||||
<q-input v-model="html" type="textarea" outlined dense filled
|
|
||||||
:input-attrs="{ spellcheck: 'true', lang: editorLang, autocorrect: 'on', autocapitalize: 'sentences' }"
|
|
||||||
input-style="font-family:monospace; font-size:0.85rem; line-height:1.4; min-height:calc(100vh - 220px)"
|
|
||||||
@update:model-value="dirty = true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-show="viewMode === 'preview'" style="height:calc(100vh - 110px); overflow:auto; background:#f7f8f7;">
|
|
||||||
<iframe :srcdoc="previewHtml" frameborder="0" style="width:100%; height:100%; display:block;"></iframe>
|
|
||||||
</div>
|
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import { listTemplates, getTemplate, saveTemplate, previewTemplate,
|
import { listTemplates, previewTemplate, testSendTemplate } from 'src/api/campaigns'
|
||||||
testSendTemplate, listAssets, uploadAsset } from 'src/api/campaigns'
|
|
||||||
import grapesjs from 'grapesjs'
|
|
||||||
import 'grapesjs/dist/css/grapes.min.css'
|
|
||||||
import presetNewsletter from 'grapesjs-preset-newsletter'
|
|
||||||
// MJML plugin — used when the template's format is 'mjml'. Provides proper
|
|
||||||
// drag-drop email blocks (mj-section, mj-column, mj-text, mj-button,
|
|
||||||
// mj-image, etc.) that compile to email-safe HTML on save server-side.
|
|
||||||
import mjmlPlugin from 'grapesjs-mjml'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
|
|
||||||
const grapesContainer = ref(null)
|
// ── Editor iframe — standalone microservice at editor.gigafibre.ca.
|
||||||
const templates = ref([])
|
// URL params drive which template loads. iframeKey is bumped to force a
|
||||||
|
// fresh load when the user switches templates (otherwise the iframe
|
||||||
|
// caches the previous template's state). ────────────────────────────────
|
||||||
|
const EDITOR_BASE = 'https://editor.gigafibre.ca'
|
||||||
const currentName = ref(route.params.name || 'gift-email-fr')
|
const currentName = ref(route.params.name || 'gift-email-fr')
|
||||||
const html = ref('')
|
const iframeKey = ref(0)
|
||||||
const previewHtml = ref('')
|
const editorIframe = ref(null)
|
||||||
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
|
|
||||||
const templateFormat = ref('html') // 'mjml' | 'html' — drives editor plugin choice + save body shape
|
|
||||||
const mjmlSource = ref('') // when format=mjml, this holds the MJML source separately from the rendered HTML
|
|
||||||
|
|
||||||
// ── Test-send dialog state ──────────────────────────────────────────────────
|
const iframeUrl = computed(() =>
|
||||||
|
`${EDITOR_BASE}/?name=${encodeURIComponent(currentName.value)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const templates = ref([])
|
||||||
|
const templateOptions = computed(() => templates.value.map(t => ({
|
||||||
|
label: `${t.name}.${t.format || 'html'} (${Math.round(t.size / 1024)} KB)`,
|
||||||
|
value: t.name,
|
||||||
|
})))
|
||||||
|
|
||||||
|
async function loadAvailableTemplates () {
|
||||||
|
templates.value = await listTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectTemplate (name) {
|
||||||
|
currentName.value = name
|
||||||
|
iframeKey.value++ // force iframe reload with new ?name=
|
||||||
|
router.replace({ path: `/campaigns/templates/${name}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadEditor () { iframeKey.value++ }
|
||||||
|
|
||||||
|
// ── Listen for postMessage from the editor iframe ────────────────────────
|
||||||
|
const lastSaved = ref(null)
|
||||||
|
const lastSavedAt = computed(() => {
|
||||||
|
if (!lastSaved.value) return ''
|
||||||
|
const diff = Math.floor((Date.now() - lastSaved.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(lastSaved.value).toLocaleTimeString('fr-CA')
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleMessage (event) {
|
||||||
|
// Only accept messages from our editor domain
|
||||||
|
if (event.origin !== EDITOR_BASE) return
|
||||||
|
if (event.data?.type === 'email-editor:saved') {
|
||||||
|
lastSaved.value = event.data.ts || Date.now()
|
||||||
|
$q.notify({ type: 'positive', message: 'Template enregistré ✓', timeout: 2500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadAvailableTemplates()
|
||||||
|
window.addEventListener('message', handleMessage)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('message', handleMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Preview dialog (compiled HTML from hub) ──────────────────────────────
|
||||||
|
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 testSendOpen = ref(false)
|
||||||
const testSending = ref(false)
|
const testSending = ref(false)
|
||||||
const testSendForm = ref({
|
const testSendForm = ref({
|
||||||
|
|
@ -170,320 +200,12 @@ async function doTestSend () {
|
||||||
subject: testSendForm.value.subject,
|
subject: testSendForm.value.subject,
|
||||||
vars: testSendForm.value.vars,
|
vars: testSendForm.value.vars,
|
||||||
})
|
})
|
||||||
$q.notify({
|
$q.notify({ type: 'positive', message: `Test envoyé à ${r.to}`, caption: `${r.bytes} octets`, timeout: 5000 })
|
||||||
type: 'positive',
|
|
||||||
message: `Test envoyé à ${r.to}`,
|
|
||||||
caption: `${r.bytes} octets via ${r.from}`,
|
|
||||||
timeout: 5000,
|
|
||||||
})
|
|
||||||
testSendOpen.value = false
|
testSendOpen.value = false
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$q.notify({ type: 'negative', message: 'Échec envoi test: ' + e.message, timeout: 6000 })
|
$q.notify({ type: 'negative', message: 'Échec envoi: ' + e.message })
|
||||||
} finally {
|
} finally {
|
||||||
testSending.value = false
|
testSending.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive the editor's spell-check language from the template name suffix.
|
|
||||||
// gift-email-fr → fr, gift-email-en → en, etc. Browser's built-in dictionary
|
|
||||||
// (Chrome/Safari/Firefox) uses this to flag typos in the correct language.
|
|
||||||
const editorLang = computed(() => {
|
|
||||||
const m = (currentName.value || '').match(/-([a-z]{2})$/)
|
|
||||||
return m ? m[1] : 'fr'
|
|
||||||
})
|
|
||||||
|
|
||||||
let editor = null
|
|
||||||
let originalHtml = ''
|
|
||||||
|
|
||||||
const templateOptions = ref([])
|
|
||||||
|
|
||||||
async function loadAvailableTemplates () {
|
|
||||||
templates.value = await listTemplates()
|
|
||||||
templateOptions.value = templates.value.map(t => ({
|
|
||||||
label: `${t.name}.html (${Math.round(t.size/1024)} KB)`,
|
|
||||||
value: t.name,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTemplate (name) {
|
|
||||||
const data = await getTemplate(name || currentName.value)
|
|
||||||
templateFormat.value = data.format || 'html'
|
|
||||||
// For MJML templates, the editor's source is the .mjml file; the .html
|
|
||||||
// shown alongside is just the latest compiled output (used for preview).
|
|
||||||
if (data.format === 'mjml') {
|
|
||||||
mjmlSource.value = data.mjml || ''
|
|
||||||
html.value = data.html || '' // compiled output (for HTML tab fallback)
|
|
||||||
originalHtml = data.mjml || '' // dirty comparison is on the mjml source
|
|
||||||
} else {
|
|
||||||
mjmlSource.value = ''
|
|
||||||
html.value = data.html
|
|
||||||
originalHtml = data.html
|
|
||||||
}
|
|
||||||
dirty.value = false
|
|
||||||
// GrapesJS editor needs to be re-initialized when switching between mjml/html
|
|
||||||
// formats because the plugins are different. Tear down + rebuild.
|
|
||||||
if (editor) {
|
|
||||||
try { editor.destroy() } catch {}
|
|
||||||
editor = null
|
|
||||||
}
|
|
||||||
await nextTick()
|
|
||||||
initGrapes()
|
|
||||||
// Push the loaded source into the GrapesJS canvas
|
|
||||||
if (editor) {
|
|
||||||
const source = data.format === 'mjml' ? data.mjml : data.html
|
|
||||||
editor.setComponents(source || '')
|
|
||||||
setTimeout(() => { dirty.value = false }, 100)
|
|
||||||
}
|
|
||||||
router.replace({ path: `/campaigns/templates/${name || currentName.value}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
function initGrapes () {
|
|
||||||
// Plugin selection by template format:
|
|
||||||
// mjml → grapesjs-mjml (proper email-focused visual builder)
|
|
||||||
// html → grapesjs-preset-newsletter (legacy fallback for raw HTML templates)
|
|
||||||
const isMjml = templateFormat.value === 'mjml'
|
|
||||||
const plugin = isMjml ? mjmlPlugin : presetNewsletter
|
|
||||||
const pluginOpts = isMjml
|
|
||||||
? {
|
|
||||||
// grapesjs-mjml options — see https://github.com/GrapesJS/mjml
|
|
||||||
fonts: {
|
|
||||||
'Plus Jakarta Sans': 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap',
|
|
||||||
'Space Grotesk': 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&display=swap',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
modalLabelImport: 'Coller votre HTML ici',
|
|
||||||
modalLabelExport: 'Copier le HTML',
|
|
||||||
codeViewerTheme: 'hopscotch',
|
|
||||||
importPlaceholder: '<table>...</table>',
|
|
||||||
cellStyle: { 'font-size': '12px', 'font-weight': 300, 'vertical-align': 'top' },
|
|
||||||
}
|
|
||||||
editor = grapesjs.init({
|
|
||||||
container: grapesContainer.value,
|
|
||||||
height: '100%',
|
|
||||||
width: 'auto',
|
|
||||||
storageManager: false, // we manage save ourselves via REST
|
|
||||||
fromElement: false,
|
|
||||||
plugins: [plugin],
|
|
||||||
pluginsOpts: {
|
|
||||||
[plugin]: pluginOpts,
|
|
||||||
},
|
|
||||||
// Asset Manager — self-hosted upload via our /campaigns/assets/upload
|
|
||||||
// endpoint. Custom uploadFile handler bypasses GrapesJS' built-in
|
|
||||||
// multipart uploader to use our base64-JSON convention.
|
|
||||||
assetManager: {
|
|
||||||
// We override the default uploader, so leave `upload` empty here
|
|
||||||
upload: false,
|
|
||||||
uploadName: 'file',
|
|
||||||
// Triggered when user picks file(s) via the AssetManager dialog or
|
|
||||||
// drag-drops onto an image component. Reads each file, POSTs to hub,
|
|
||||||
// adds the returned URL to the AssetManager's library so the user
|
|
||||||
// can drag it from the sidebar.
|
|
||||||
uploadFile: async (e) => {
|
|
||||||
const files = e.dataTransfer ? e.dataTransfer.files : e.target.files
|
|
||||||
if (!files || !files.length) return
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const res = await uploadAsset(file)
|
|
||||||
if (res?.url) {
|
|
||||||
editor.AssetManager.add({ type: 'image', src: res.url })
|
|
||||||
$q.notify({ type: 'positive', message: `Image téléversée : ${file.name}` })
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
$q.notify({ type: 'negative', message: `Échec téléversement: ${err.message}` })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Custom blocks for merge-variable insertion
|
|
||||||
blockManager: {
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: 'var-firstname',
|
|
||||||
label: '{{firstname}}',
|
|
||||||
category: 'Variables',
|
|
||||||
content: '<span>{{firstname}}</span>',
|
|
||||||
attributes: { class: 'fa fa-user' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'var-amount',
|
|
||||||
label: '{{amount}}',
|
|
||||||
category: 'Variables',
|
|
||||||
content: '<strong>{{amount}}</strong>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'var-gift-url',
|
|
||||||
label: '{{gift_url}}',
|
|
||||||
category: 'Variables',
|
|
||||||
content: '<a href="{{gift_url}}" style="color:#019547; font-weight:700;">Lien cadeau</a>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'var-description',
|
|
||||||
label: '{{description}}',
|
|
||||||
category: 'Variables',
|
|
||||||
content: '<span>{{description}}</span>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'var-expiry',
|
|
||||||
label: '{{expiry}}',
|
|
||||||
category: 'Variables',
|
|
||||||
content: '<span>{{expiry}}</span>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'var-commitment',
|
|
||||||
label: '{{commitment_months}}',
|
|
||||||
category: 'Variables',
|
|
||||||
content: '<span>{{commitment_months}}</span>',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Track changes
|
|
||||||
editor.on('component:add component:remove component:update', () => {
|
|
||||||
if (!editor.getHtml) return
|
|
||||||
const next = editor.getHtml({ component: editor.getWrapper() }) + '<style>' + editor.getCss() + '</style>'
|
|
||||||
if (next !== originalHtml) dirty.value = true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync HTML on every change so the HTML tab stays accurate
|
|
||||||
editor.on('update', () => {
|
|
||||||
try {
|
|
||||||
html.value = editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : '')
|
|
||||||
} catch (e) { /* during init the editor may not have a canvas ready */ }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// When viewMode changes to 'preview', call the hub's preview endpoint with
|
|
||||||
// sample data so {{vars}} get substituted live.
|
|
||||||
watch(viewMode, async (mode) => {
|
|
||||||
if (mode === 'preview') {
|
|
||||||
// If user has been editing in HTML mode, use their current html.value;
|
|
||||||
// otherwise pull from the grapes canvas.
|
|
||||||
const sourceHtml = viewMode.value === 'html'
|
|
||||||
? html.value
|
|
||||||
: (editor ? editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : '') : html.value)
|
|
||||||
try {
|
|
||||||
const r = await previewTemplate(currentName.value, { html: sourceHtml })
|
|
||||||
previewHtml.value = r.rendered
|
|
||||||
} catch (e) {
|
|
||||||
$q.notify({ type: 'negative', message: 'Erreur prévisualisation: ' + e.message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// When switching from HTML mode back to Visual, sync grapes canvas from textarea
|
|
||||||
if (mode === 'visual' && editor && html.value && html.value !== editor.getHtml()) {
|
|
||||||
editor.setComponents(html.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function save () {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
if (templateFormat.value === 'mjml') {
|
|
||||||
// MJML path: the editor's getHtml() returns MJML source (the mjml
|
|
||||||
// plugin overrides the default behaviour). We POST that — the hub
|
|
||||||
// compiles to email-safe HTML server-side and saves both files.
|
|
||||||
const mjmlSrc = editor.getHtml()
|
|
||||||
await saveTemplate(currentName.value, mjmlSrc, { format: 'mjml' })
|
|
||||||
mjmlSource.value = mjmlSrc
|
|
||||||
originalHtml = mjmlSrc
|
|
||||||
} else {
|
|
||||||
// HTML legacy path
|
|
||||||
const finalHtml = viewMode.value === 'html'
|
|
||||||
? html.value
|
|
||||||
: (editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : ''))
|
|
||||||
await saveTemplate(currentName.value, finalHtml)
|
|
||||||
originalHtml = finalHtml
|
|
||||||
html.value = finalHtml
|
|
||||||
}
|
|
||||||
dirty.value = false
|
|
||||||
$q.notify({ type: 'positive', message: 'Template enregistré ✓' })
|
|
||||||
} catch (e) {
|
|
||||||
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 () => {
|
|
||||||
await loadAvailableTemplates()
|
|
||||||
await nextTick()
|
|
||||||
initGrapes()
|
|
||||||
await loadTemplate(currentName.value)
|
|
||||||
// Preload previously-uploaded assets into the AssetManager so the user
|
|
||||||
// sees their existing image library in the sidebar (no need to re-upload
|
|
||||||
// an image they used in a previous campaign).
|
|
||||||
try {
|
|
||||||
const assets = await listAssets()
|
|
||||||
if (assets.length && editor) {
|
|
||||||
editor.AssetManager.add(assets.map(a => ({ type: 'image', src: a.url })))
|
|
||||||
}
|
|
||||||
} catch (e) { /* non-fatal — editor still works without preloaded assets */ }
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (editor) try { editor.destroy() } catch {}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* GrapesJS UI is darkish by default — tone down to match Quasar light theme */
|
|
||||||
.gjs-one-bg { background: #f3f4f3 !important; }
|
|
||||||
.gjs-three-bg { background: #019547 !important; }
|
|
||||||
.gjs-four-color, .gjs-four-color-h:hover { color: #019547 !important; }
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user