Events: editable (Unlayer) email template, "Tous" includes résiliés, realtime picker
- Email template: an event can use an editable campaign-style template (Unlayer) for
its invitation instead of the built-in festive builder. Config field email_template
('' = festive default/fallback). inviteEmail() renders the named template via
campaigns.renderNamedTemplate with {{firstname}}/{{rsvp_url}} (+ event vars), else
festive. campaigns.js: add 'event-' template prefix + export renderNamedTemplate.
New GET /events/:id/invite-preview (festive | configured | override) for live preview.
Editor gains a template selector, "design in editor" link, and an inbox preview dialog.
- Audience "Tous (incl. résiliés)": filters.include_resiliated skips the résilié guard
for win-back; "Clients actifs" still excludes them.
- Manual picker: realtime fuzzy search into a results list (add per row), separate pool,
then add-batch — replaces the combobox.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3aa59adec3
commit
d26722f048
|
|
@ -27,6 +27,13 @@ export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${enc
|
||||||
export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) }
|
export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) }
|
||||||
export async function previewAudience (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience`, { method: 'POST', body }) }
|
export async function previewAudience (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience`, { method: 'POST', body }) }
|
||||||
|
|
||||||
|
// Aperçu HTML de l'invitation (gabarit festif OU template éditable). template: undefined=modèle configuré, ''=festif, '<nom>'=ce template.
|
||||||
|
export async function getInvitePreview (eventId, { lang = 'fr', template } = {}) {
|
||||||
|
const qs = new URLSearchParams({ lang })
|
||||||
|
if (template !== undefined) qs.set('template', template)
|
||||||
|
return hubFetch(`/events/${encodeURIComponent(eventId)}/invite-preview?${qs.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Recherche FLOUE de clients (pg_trgm, typo/accent-tolérante) — réutilise l'endpoint app-wide.
|
// Recherche FLOUE de clients (pg_trgm, typo/accent-tolérante) — réutilise l'endpoint app-wide.
|
||||||
export async function searchCustomersFuzzy (q) { return hubFetch(`/collab/customer-search?q=${encodeURIComponent(q)}`) }
|
export async function searchCustomersFuzzy (q) { return hubFetch(`/collab/customer-search?q=${encodeURIComponent(q)}`) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,22 @@
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
</q-tab-panels>
|
</q-tab-panels>
|
||||||
|
|
||||||
|
<!-- Modèle de courriel d'invitation — festif intégré OU template éditable (Unlayer, comme les campagnes) -->
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
<div class="text-weight-medium text-grey-8 q-mb-xs"><q-icon name="mail" size="18px" /> Modèle du courriel d'invitation</div>
|
||||||
|
<q-select v-model="form.email_template" :options="emailTemplateOptions" emit-value map-options dense outlined
|
||||||
|
label="Modèle" hint="« Festif intégré » = charte TARGO verrouillée. Un template éditable se conçoit dans l'éditeur (glisser-déposer)." />
|
||||||
|
<div class="row q-gutter-sm q-mt-xs">
|
||||||
|
<template v-if="formMode === 'edit'">
|
||||||
|
<q-btn outline no-caps size="sm" color="primary" icon="visibility" label="Aperçu" :loading="previewLoading" @click="openInvitePreview" />
|
||||||
|
<q-btn v-if="form.email_template" flat no-caps size="sm" color="primary" icon="brush" label="Concevoir dans l'éditeur"
|
||||||
|
type="a" :href="'/ops/#/campaigns/templates/' + form.email_template" target="_blank">
|
||||||
|
<q-tooltip>Ouvre l'éditeur glisser-déposer (nouvel onglet). Variables : firstname, rsvp_url.</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</template>
|
||||||
|
<div v-else class="text-caption text-grey-6">Aperçu et conception disponibles après la création.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pièces jointes (courriel) — seulement en édition (l'événement doit exister) -->
|
<!-- Pièces jointes (courriel) — seulement en édition (l'événement doit exister) -->
|
||||||
<template v-if="formMode === 'edit'">
|
<template v-if="formMode === 'edit'">
|
||||||
<q-separator class="q-my-md" />
|
<q-separator class="q-my-md" />
|
||||||
|
|
@ -213,6 +229,22 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- Aperçu de l'invitation (rendu HTML, échantillon de variables) -->
|
||||||
|
<q-dialog v-model="invitePreviewOpen" maximized>
|
||||||
|
<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 de l'invitation</q-toolbar-title>
|
||||||
|
<q-btn-toggle v-model="invitePreviewLang" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||||
|
:options="[{ label: 'FR', value: 'fr' }, { label: 'EN', value: 'en' }]" @update:model-value="loadInvitePreview" class="q-mr-sm" />
|
||||||
|
<q-btn flat dense round icon="close" v-close-popup />
|
||||||
|
</q-toolbar>
|
||||||
|
<q-card-section style="height:calc(100vh - 60px);overflow:hidden">
|
||||||
|
<iframe :srcdoc="invitePreviewHtml" style="width:100%;height:100%;border:1px solid #e5e7eb;background:#fff" />
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
<!-- Dialogue d'envoi : test OU audience de masse (aperçu ; le blast reste à activer) -->
|
<!-- Dialogue d'envoi : test OU audience de masse (aperçu ; le blast reste à activer) -->
|
||||||
<q-dialog v-model="showSend">
|
<q-dialog v-model="showSend">
|
||||||
<q-card style="min-width:340px;max-width:560px;width:100%">
|
<q-card style="min-width:340px;max-width:560px;width:100%">
|
||||||
|
|
@ -236,7 +268,9 @@
|
||||||
|
|
||||||
<!-- MASSE -->
|
<!-- MASSE -->
|
||||||
<q-card-section v-else class="q-gutter-sm">
|
<q-card-section v-else class="q-gutter-sm">
|
||||||
<q-select v-model="template" :options="templates" dense outlined emit-value map-options label="Modèle de courriel" />
|
<div class="text-caption text-grey-7">
|
||||||
|
<q-icon name="mail" size="14px" /> Modèle : <b>{{ ev.email_template || 'Festif intégré (TARGO)' }}</b> — se change/conçoit dans « Modifier ».
|
||||||
|
</div>
|
||||||
<q-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
|
<q-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
|
||||||
<div class="text-caption text-grey-7"><q-icon name="info" size="14px" /> Mailjet = suivi ouvertures/clics. Gmail direct = aucun suivi.</div>
|
<div class="text-caption text-grey-7"><q-icon name="info" size="14px" /> Mailjet = suivi ouvertures/clics. Gmail direct = aucun suivi.</div>
|
||||||
<div v-if="attachments.length" class="text-caption text-grey-7"><q-icon name="attach_file" size="14px" /> {{ attachments.length }} pièce(s) jointe(s) — gérées dans « Modifier ».</div>
|
<div v-if="attachments.length" class="text-caption text-grey-7"><q-icon name="attach_file" size="14px" /> {{ attachments.length }} pièce(s) jointe(s) — gérées dans « Modifier ».</div>
|
||||||
|
|
@ -273,7 +307,7 @@
|
||||||
|
|
||||||
<div v-if="audienceSource === 'list'" class="q-gutter-sm">
|
<div v-if="audienceSource === 'list'" class="q-gutter-sm">
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<q-select class="col-12 col-sm-6" v-model="audFilters.statut" :options="[{ label: 'Clients actifs', value: 'active' }, { label: 'Tous', value: '' }]" dense outlined emit-value map-options label="Statut" />
|
<q-select class="col-12 col-sm-6" v-model="audFilters.statut" :options="[{ label: 'Clients actifs', value: 'active' }, { label: 'Tous (incl. résiliés)', value: '' }]" dense outlined emit-value map-options label="Statut" />
|
||||||
<q-select class="col-12 col-sm-6" v-model="audFilters.customer_type" :options="[{ label: 'Tous types', value: '' }, { label: 'Résidentiel', value: 'Individual' }, { label: 'Commercial', value: 'Company' }]" dense outlined emit-value map-options label="Type de client" />
|
<q-select class="col-12 col-sm-6" v-model="audFilters.customer_type" :options="[{ label: 'Tous types', value: '' }, { label: 'Résidentiel', value: 'Individual' }, { label: 'Commercial', value: 'Company' }]" dense outlined emit-value map-options label="Type de client" />
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
|
|
@ -293,24 +327,30 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="audienceSource === 'manual'" class="q-gutter-sm">
|
<div v-else-if="audienceSource === 'manual'" class="q-gutter-sm">
|
||||||
<q-select
|
<q-input v-model="poolQuery" dense outlined clearable debounce="300" :loading="poolSearching"
|
||||||
v-model="poolPick" dense outlined use-input hide-selected fill-input input-debounce="300"
|
label="Rechercher un client (nom, n°, courriel)…" @update:model-value="onPoolSearch">
|
||||||
label="Rechercher un client (nom, n°, courriel)…"
|
|
||||||
:options="poolResults" :loading="poolSearching" option-label="label" option-value="customer_id"
|
|
||||||
@filter="onPoolFilter" @update:model-value="addToPool" hide-dropdown-icon>
|
|
||||||
<template #prepend><q-icon name="person_search" /></template>
|
<template #prepend><q-icon name="person_search" /></template>
|
||||||
<template #no-option><q-item><q-item-section class="text-grey-6">{{ poolSearching ? 'Recherche…' : 'Tapez pour rechercher' }}</q-item-section></q-item></template>
|
</q-input>
|
||||||
<template #option="scope">
|
<!-- Résultats en temps réel : cliquer « + » ajoute au lot, la liste reste affichée -->
|
||||||
<q-item v-bind="scope.itemProps">
|
<q-list v-if="poolResults.length" bordered separator class="rounded-borders" style="max-height:200px;overflow:auto">
|
||||||
<q-item-section>
|
<q-item v-for="m in poolResults" :key="m.customer_id" dense>
|
||||||
<q-item-label>{{ scope.opt.customer_name || scope.opt.customer_id }}<q-badge v-if="scope.opt.matched" dense color="grey-4" text-color="grey-8" :label="scope.opt.matched" class="q-ml-xs" /></q-item-label>
|
<q-item-section>
|
||||||
<q-item-label caption>{{ scope.opt.customer_id }}<span v-if="scope.opt.email"> · {{ scope.opt.email }}</span><span v-if="!scope.opt.email" class="text-orange-8"> · sans courriel</span></q-item-label>
|
<q-item-label>{{ m.customer_name }}<q-badge v-if="m.matched" dense color="grey-4" text-color="grey-8" :label="m.matched" class="q-ml-xs" /></q-item-label>
|
||||||
</q-item-section>
|
<q-item-label caption>{{ m.customer_id }}<span v-if="m.email"> · {{ m.email }}</span><span v-else class="text-orange-8"> · sans courriel</span></q-item-label>
|
||||||
</q-item>
|
</q-item-section>
|
||||||
</template>
|
<q-item-section side>
|
||||||
</q-select>
|
<q-btn flat dense round size="sm" :icon="inPool(m) ? 'check' : 'add'" :color="inPool(m) ? 'green-7' : 'primary'" :disable="inPool(m)" @click="addToPool(m)">
|
||||||
<div v-if="pool.length" class="q-gutter-xs">
|
<q-tooltip>{{ inPool(m) ? 'Déjà dans le lot' : 'Ajouter au lot' }}</q-tooltip>
|
||||||
<q-list bordered separator class="rounded-borders" style="max-height:170px;overflow:auto">
|
</q-btn>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<div v-else-if="(poolQuery || '').trim().length >= 2 && !poolSearching" class="text-caption text-grey-6">Aucun résultat pour « {{ poolQuery }} ».</div>
|
||||||
|
<div v-else-if="!pool.length" class="text-caption text-grey-6">Tapez au moins 2 caractères. Recherche floue (tolère les fautes et accents).</div>
|
||||||
|
|
||||||
|
<div v-if="pool.length" class="q-gutter-xs q-pt-xs">
|
||||||
|
<div class="text-caption text-weight-medium text-grey-8">Lot sélectionné : {{ pool.length }}</div>
|
||||||
|
<q-list bordered separator class="rounded-borders" style="max-height:150px;overflow:auto">
|
||||||
<q-item v-for="p in pool" :key="p.customer_id" dense>
|
<q-item v-for="p in pool" :key="p.customer_id" dense>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ p.customer_name || p.customer_id }}</q-item-label>
|
<q-item-label>{{ p.customer_name || p.customer_id }}</q-item-label>
|
||||||
|
|
@ -321,7 +361,6 @@
|
||||||
</q-list>
|
</q-list>
|
||||||
<q-btn unelevated no-caps color="primary" icon="playlist_add" :label="`Ajouter les ${pool.length} sélectionné(s) à la liste`" :loading="listBusy" @click="addManualToList" />
|
<q-btn unelevated no-caps color="primary" icon="playlist_add" :label="`Ajouter les ${pool.length} sélectionné(s) à la liste`" :loading="listBusy" @click="addManualToList" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-caption text-grey-6">Cherchez un client et cliquez pour le sélectionner, puis ajoutez-les à la liste d'envoi.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="q-gutter-sm">
|
<div v-else class="q-gutter-sm">
|
||||||
|
|
@ -361,6 +400,7 @@ import {
|
||||||
listEvents, listRsvps, deleteRsvp, sendInvite, listTemplates,
|
listEvents, listRsvps, deleteRsvp, sendInvite, listTemplates,
|
||||||
getEventConfig, createEvent, updateEvent, deleteEvent, uploadAttachment, deleteAttachment,
|
getEventConfig, createEvent, updateEvent, deleteEvent, uploadAttachment, deleteAttachment,
|
||||||
getAudienceList, audienceListAdd, audienceListRemove, audienceListClear, searchCustomersFuzzy,
|
getAudienceList, audienceListAdd, audienceListRemove, audienceListClear, searchCustomersFuzzy,
|
||||||
|
getInvitePreview,
|
||||||
} from 'src/api/events'
|
} from 'src/api/events'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
|
|
@ -386,15 +426,14 @@ const testLang = ref('fr')
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
// Envoi de masse (audience + modèle) — aperçu seulement ; le blast reste à activer.
|
// Envoi de masse (audience + modèle) — aperçu seulement ; le blast reste à activer.
|
||||||
const sendMode = ref('test')
|
const sendMode = ref('test')
|
||||||
const template = ref('')
|
|
||||||
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
|
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
|
||||||
const audienceSource = ref('list')
|
const audienceSource = ref('list')
|
||||||
const audFilters = ref({ statut: 'active', customer_type: '', city: '', name_like: '', active_sub: false, min_monthly: 0 })
|
const audFilters = ref({ statut: 'active', customer_type: '', city: '', name_like: '', active_sub: false, min_monthly: 0 })
|
||||||
const csvFile = ref(null)
|
const csvFile = ref(null)
|
||||||
const audCsvText = ref('')
|
const audCsvText = ref('')
|
||||||
// Pool manuel : clients sélectionnés avant ajout à la liste
|
// Pool manuel : recherche temps réel → résultats → sélection avant ajout à la liste
|
||||||
const pool = ref([])
|
const pool = ref([])
|
||||||
const poolPick = ref(null)
|
const poolQuery = ref('')
|
||||||
const poolResults = ref([])
|
const poolResults = ref([])
|
||||||
const poolSearching = ref(false)
|
const poolSearching = ref(false)
|
||||||
// Liste principale (additive, persistée côté hub)
|
// Liste principale (additive, persistée côté hub)
|
||||||
|
|
@ -501,13 +540,26 @@ function attLangLabel (l) { return l === 'fr' ? 'FR' : l === 'en' ? 'EN' : 'Les
|
||||||
function attLangColor (l) { return l === 'fr' ? 'blue-7' : l === 'en' ? 'deep-orange-7' : 'grey-6' }
|
function attLangColor (l) { return l === 'fr' ? 'blue-7' : l === 'en' ? 'deep-orange-7' : 'grey-6' }
|
||||||
|
|
||||||
const emptyLang = () => ({ name: '', tagline: '', when: '', body: '', program: [], program_line: '', limited: '', closing: '', notes: [] })
|
const emptyLang = () => ({ name: '', tagline: '', when: '', body: '', program: [], program_line: '', limited: '', closing: '', notes: [] })
|
||||||
const emptyForm = () => ({ id: '', active: true, date_iso: '', badge_top: '', badge_bottom: '', capacity: 0, fr: emptyLang(), en: emptyLang() })
|
const emptyForm = () => ({ id: '', active: true, date_iso: '', badge_top: '', badge_bottom: '', capacity: 0, email_template: '', fr: emptyLang(), en: emptyLang() })
|
||||||
const form = ref(emptyForm())
|
const form = ref(emptyForm())
|
||||||
|
|
||||||
|
// Options du sélecteur de modèle : festif intégré + templates éditables (/campaigns/templates)
|
||||||
|
const emailTemplateOptions = computed(() => {
|
||||||
|
const opts = templates.value.map(t => ({ value: t.value, label: t.value === '' ? 'Modèle festif intégré (TARGO)' : t.label }))
|
||||||
|
if (!opts.some(o => o.value === '')) opts.unshift({ value: '', label: 'Modèle festif intégré (TARGO)' })
|
||||||
|
return opts
|
||||||
|
})
|
||||||
|
// Aperçu de l'invitation
|
||||||
|
const invitePreviewOpen = ref(false)
|
||||||
|
const invitePreviewLang = ref('fr')
|
||||||
|
const invitePreviewHtml = ref('')
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
|
||||||
function openCreate () {
|
function openCreate () {
|
||||||
form.value = emptyForm()
|
form.value = emptyForm()
|
||||||
formMode.value = 'create'
|
formMode.value = 'create'
|
||||||
formLang.value = 'fr'
|
formLang.value = 'fr'
|
||||||
|
if (templates.value.length <= 1) loadTemplates()
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
}
|
}
|
||||||
async function openEdit () {
|
async function openEdit () {
|
||||||
|
|
@ -522,6 +574,7 @@ async function openEdit () {
|
||||||
f.badge_top = c.badge_top || ''
|
f.badge_top = c.badge_top || ''
|
||||||
f.badge_bottom = c.badge_bottom || ''
|
f.badge_bottom = c.badge_bottom || ''
|
||||||
f.capacity = c.capacity || 0
|
f.capacity = c.capacity || 0
|
||||||
|
f.email_template = c.email_template || ''
|
||||||
for (const lg of ['fr', 'en']) {
|
for (const lg of ['fr', 'en']) {
|
||||||
const src = c[lg] || {}
|
const src = c[lg] || {}
|
||||||
f[lg] = {
|
f[lg] = {
|
||||||
|
|
@ -534,9 +587,18 @@ async function openEdit () {
|
||||||
form.value = f
|
form.value = f
|
||||||
formMode.value = 'edit'
|
formMode.value = 'edit'
|
||||||
formLang.value = 'fr'
|
formLang.value = 'fr'
|
||||||
|
if (templates.value.length <= 1) loadTemplates()
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
||||||
}
|
}
|
||||||
|
async function loadInvitePreview () {
|
||||||
|
previewLoading.value = true
|
||||||
|
try {
|
||||||
|
const r = await getInvitePreview(form.value.id, { lang: invitePreviewLang.value, template: form.value.email_template })
|
||||||
|
invitePreviewHtml.value = r.html || ''
|
||||||
|
} catch (e) { $q.notify({ type: 'negative', message: 'Aperçu impossible : ' + e.message }); invitePreviewHtml.value = '' } finally { previewLoading.value = false }
|
||||||
|
}
|
||||||
|
async function openInvitePreview () { invitePreviewOpen.value = true; await loadInvitePreview() }
|
||||||
|
|
||||||
function cleanLang (c) {
|
function cleanLang (c) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -557,6 +619,7 @@ async function saveForm () {
|
||||||
badge_top: form.value.badge_top,
|
badge_top: form.value.badge_top,
|
||||||
badge_bottom: form.value.badge_bottom,
|
badge_bottom: form.value.badge_bottom,
|
||||||
capacity: form.value.capacity,
|
capacity: form.value.capacity,
|
||||||
|
email_template: form.value.email_template || '',
|
||||||
fr: cleanLang(form.value.fr),
|
fr: cleanLang(form.value.fr),
|
||||||
...(enHasContent ? { en: cleanLang(form.value.en) } : {}),
|
...(enHasContent ? { en: cleanLang(form.value.en) } : {}),
|
||||||
}
|
}
|
||||||
|
|
@ -634,24 +697,24 @@ function onCsvFile (file) {
|
||||||
}
|
}
|
||||||
function onActiveSubToggle (v) { if (!v) audFilters.value.min_monthly = 0 } // le montant n'a de sens qu'avec les abonnements actifs
|
function onActiveSubToggle (v) { if (!v) audFilters.value.min_monthly = 0 } // le montant n'a de sens qu'avec les abonnements actifs
|
||||||
|
|
||||||
// ── Pool manuel : recherche + sélection avant ajout ────────────────────────
|
// ── Pool manuel : recherche floue temps réel → résultats en liste → sélection ──
|
||||||
function onPoolFilter (val, update) {
|
function onPoolSearch (val) {
|
||||||
const q = (val || '').trim()
|
const q = (val || '').trim()
|
||||||
if (q.length < 2) { update(() => { poolResults.value = [] }); return }
|
if (q.length < 2) { poolResults.value = []; return }
|
||||||
poolSearching.value = true
|
poolSearching.value = true
|
||||||
// Recherche FLOUE app-wide (pg_trgm, tolère typos + accents : « repa » trouve « Réparation »).
|
// Recherche FLOUE app-wide (pg_trgm, tolère typos + accents : « repa » trouve « Réparation »).
|
||||||
searchCustomersFuzzy(q)
|
searchCustomersFuzzy(q)
|
||||||
.then(d => update(() => {
|
.then(d => {
|
||||||
poolResults.value = (d.matches || [])
|
poolResults.value = (d.matches || [])
|
||||||
.filter(m => m.name && m.kind !== 'équipe') // clients seulement
|
.filter(m => m.name && m.kind !== 'équipe') // clients seulement
|
||||||
.map(m => ({ customer_id: m.name, customer_name: m.customer_name || m.name, email: (m.email || '').split(/[;,]/)[0].trim(), language: m.language || '', matched: m.matched || '', label: (m.customer_name || m.name) }))
|
.map(m => ({ customer_id: m.name, customer_name: m.customer_name || m.name, email: (m.email || '').split(/[;,]/)[0].trim(), language: m.language || '', matched: m.matched || '' }))
|
||||||
}))
|
})
|
||||||
.catch(() => update(() => { poolResults.value = [] }))
|
.catch(() => { poolResults.value = [] })
|
||||||
.finally(() => { poolSearching.value = false })
|
.finally(() => { poolSearching.value = false })
|
||||||
}
|
}
|
||||||
|
function inPool (m) { return pool.value.some(p => p.customer_id === m.customer_id) }
|
||||||
function addToPool (sel) {
|
function addToPool (sel) {
|
||||||
if (sel && sel.customer_id && !pool.value.some(p => p.customer_id === sel.customer_id)) pool.value.push(sel)
|
if (sel && sel.customer_id && !pool.value.some(p => p.customer_id === sel.customer_id)) pool.value.push(sel)
|
||||||
poolPick.value = null; poolResults.value = []
|
|
||||||
}
|
}
|
||||||
function removeFromPool (p) { pool.value = pool.value.filter(x => x.customer_id !== p.customer_id) }
|
function removeFromPool (p) { pool.value = pool.value.filter(x => x.customer_id !== p.customer_id) }
|
||||||
|
|
||||||
|
|
@ -675,11 +738,14 @@ function filterLabel () {
|
||||||
if (f.name_like) p.push('Nom~' + f.name_like)
|
if (f.name_like) p.push('Nom~' + f.name_like)
|
||||||
if (f.active_sub) p.push(f.min_monthly > 0 ? `Abo ≥${f.min_monthly}$` : 'Abo actif')
|
if (f.active_sub) p.push(f.min_monthly > 0 ? `Abo ≥${f.min_monthly}$` : 'Abo actif')
|
||||||
if (f.statut === 'active' && !p.length) p.push('Clients actifs')
|
if (f.statut === 'active' && !p.length) p.push('Clients actifs')
|
||||||
|
else if (f.statut !== 'active') p.push('incl. résiliés')
|
||||||
return p.join(' · ') || 'Filtre'
|
return p.join(' · ') || 'Filtre'
|
||||||
}
|
}
|
||||||
async function addFilterToList () {
|
async function addFilterToList () {
|
||||||
listBusy.value = true
|
listBusy.value = true
|
||||||
try { applyListResult(await audienceListAdd(eventId.value, { mode: 'filter', filters: audFilters.value, source_label: filterLabel() }), 'add') }
|
// « Tous » (statut vide) autorise les ex-clients résiliés ; « Clients actifs » les exclut.
|
||||||
|
const filters = { ...audFilters.value, include_resiliated: audFilters.value.statut !== 'active' }
|
||||||
|
try { applyListResult(await audienceListAdd(eventId.value, { mode: 'filter', filters, source_label: filterLabel() }), 'add') }
|
||||||
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
|
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
|
||||||
}
|
}
|
||||||
async function addManualToList () {
|
async function addManualToList () {
|
||||||
|
|
|
||||||
|
|
@ -825,6 +825,16 @@ function renderTemplate (tpl, vars) {
|
||||||
// Templates are bundled inside the hub at services/targo-hub/templates/.
|
// Templates are bundled inside the hub at services/targo-hub/templates/.
|
||||||
// Keep them in sync with scripts/campaigns/templates/ for the CLI use case.
|
// Keep them in sync with scripts/campaigns/templates/ for the CLI use case.
|
||||||
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates')
|
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates')
|
||||||
|
|
||||||
|
// Rend un template éditable (Unlayer/HTML) par NOM avec des variables Mustache.
|
||||||
|
// Réutilisé par lib/events.js (invitation d'événement). Renvoie null si nom invalide/absent
|
||||||
|
// (l'appelant retombe sur son gabarit intégré). Anti-LFI : nom validé par l'allow-list.
|
||||||
|
function renderNamedTemplate (name, vars = {}) {
|
||||||
|
if (!isValidTemplateName(name)) return null
|
||||||
|
const file = path.join(TEMPLATES_DIR, name + '.html')
|
||||||
|
if (!fs.existsSync(file)) return null
|
||||||
|
try { return renderTemplate(fs.readFileSync(file, 'utf8'), vars) } catch { return null }
|
||||||
|
}
|
||||||
const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')
|
const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')
|
||||||
|
|
||||||
// Allow-list templates that can be edited via the API. Naming convention:
|
// Allow-list templates that can be edited via the API. Naming convention:
|
||||||
|
|
@ -836,7 +846,7 @@ const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')
|
||||||
// + disk-scan approach so the user can create new templates from the editor
|
// + disk-scan approach so the user can create new templates from the editor
|
||||||
// UI without us re-deploying the hub. The prefix list bounds what names are
|
// UI without us re-deploying the hub. The prefix list bounds what names are
|
||||||
// allowed (prevents arbitrary file writes outside the campaign domain).
|
// allowed (prevents arbitrary file writes outside the campaign domain).
|
||||||
const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-']
|
const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-', 'event-']
|
||||||
const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only
|
const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only
|
||||||
|
|
||||||
function isValidTemplateName (name) {
|
function isValidTemplateName (name) {
|
||||||
|
|
@ -2787,5 +2797,5 @@ module.exports = {
|
||||||
// Exposed for testing
|
// Exposed for testing
|
||||||
parseCsv, parseMapCsv, parseGiftbitCsv,
|
parseCsv, parseMapCsv, parseGiftbitCsv,
|
||||||
matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
|
matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
|
||||||
renderTemplate,
|
renderTemplate, renderNamedTemplate,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,7 @@ function buildEvent (base) {
|
||||||
badge_top: String(base.badge_top || ''),
|
badge_top: String(base.badge_top || ''),
|
||||||
badge_bottom: String(base.badge_bottom || ''),
|
badge_bottom: String(base.badge_bottom || ''),
|
||||||
capacity: Math.max(0, parseInt(base.capacity, 10) || 0),
|
capacity: Math.max(0, parseInt(base.capacity, 10) || 0),
|
||||||
|
email_template: String(base.email_template || ''), // '' = gabarit festif intégré ; sinon nom d'un template éditable (Unlayer)
|
||||||
attachments: Array.isArray(base.attachments) ? base.attachments : [],
|
attachments: Array.isArray(base.attachments) ? base.attachments : [],
|
||||||
fr: content('fr'),
|
fr: content('fr'),
|
||||||
en: content('en'),
|
en: content('en'),
|
||||||
|
|
@ -242,6 +243,7 @@ function normalizeConfig (b, existing = null) {
|
||||||
badge_top: String(b.badge_top || '').slice(0, 6),
|
badge_top: String(b.badge_top || '').slice(0, 6),
|
||||||
badge_bottom: String(b.badge_bottom || '').slice(0, 8),
|
badge_bottom: String(b.badge_bottom || '').slice(0, 8),
|
||||||
capacity: Math.max(0, parseInt(b.capacity, 10) || 0),
|
capacity: Math.max(0, parseInt(b.capacity, 10) || 0),
|
||||||
|
email_template: /^[a-z0-9-]+$/.test(String(b.email_template || '')) ? String(b.email_template) : '',
|
||||||
attachments: existing && Array.isArray(existing.attachments) ? existing.attachments : [],
|
attachments: existing && Array.isArray(existing.attachments) ? existing.attachments : [],
|
||||||
fr,
|
fr,
|
||||||
...(enRaw ? { en: enRaw } : {}),
|
...(enRaw ? { en: enRaw } : {}),
|
||||||
|
|
@ -305,8 +307,27 @@ function inviteSubject (eventId, lang) {
|
||||||
const e = getEvent(eventId); if (!e) return ''
|
const e = getEvent(eventId); if (!e) return ''
|
||||||
return normLang(lang) === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉`
|
return normLang(lang) === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉`
|
||||||
}
|
}
|
||||||
|
// Variables Mustache offertes aux templates éditables d'invitation d'événement.
|
||||||
|
function inviteTemplateVars (e, lang, name, rsvpUrl) {
|
||||||
|
const t = e[lang]
|
||||||
|
return {
|
||||||
|
firstname: firstName(name), name: name || '', rsvp_url: rsvpUrl, gift_url: rsvpUrl, // gift_url = alias (templates cadeau réutilisables)
|
||||||
|
event_name: t.name, tagline: t.tagline, when: t.when, body: t.body, closing: t.closing,
|
||||||
|
year: String(new Date().getFullYear()), lang,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Invitation : modèle éditable (Unlayer) si configuré, sinon gabarit festif intégré.
|
||||||
function inviteEmail (eventId, lang, name, rsvpUrl) {
|
function inviteEmail (eventId, lang, name, rsvpUrl) {
|
||||||
const e = getEvent(eventId); if (!e) return ''
|
const e = getEvent(eventId); if (!e) return ''
|
||||||
|
lang = normLang(lang)
|
||||||
|
if (e.email_template) {
|
||||||
|
const html = require('./campaigns').renderNamedTemplate(e.email_template, inviteTemplateVars(e, lang, name, rsvpUrl))
|
||||||
|
if (html) return html // template introuvable → repli festif
|
||||||
|
}
|
||||||
|
return inviteEmailFestive(e, lang, name, rsvpUrl)
|
||||||
|
}
|
||||||
|
// Gabarit festif intégré (charte TARGO garantie) — utilisé par défaut et en repli.
|
||||||
|
function inviteEmailFestive (e, lang, name, rsvpUrl) {
|
||||||
lang = normLang(lang); const t = e[lang]
|
lang = normLang(lang); const t = e[lang]
|
||||||
const merci = lang === 'en' ? 'Thank you for your trust over the years! 💚' : 'Merci de votre confiance au fil des années ! 💚'
|
const merci = lang === 'en' ? 'Thank you for your trust over the years! 💚' : 'Merci de votre confiance au fil des années ! 💚'
|
||||||
const badge = [e.badge_top, e.badge_bottom].filter(Boolean).join(' ')
|
const badge = [e.badge_top, e.badge_bottom].filter(Boolean).join(' ')
|
||||||
|
|
@ -537,10 +558,14 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
|
||||||
for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: AUD_CFIELDS, limit: 500, start: s }); customers.push(...b); if (b.length < 500) break; s += 500 }
|
for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: AUD_CFIELDS, limit: 500, start: s }); customers.push(...b); if (b.length < 500) break; s += 500 }
|
||||||
}
|
}
|
||||||
if (byCust) customers.forEach(c => { c._monthly = byCust[c.name] || 0 })
|
if (byCust) customers.forEach(c => { c._monthly = byCust[c.name] || 0 })
|
||||||
const resil = await resiliatedSet() // GARDE-FOU : jamais inviter un compte F résilié (abos fantômes, cf feedback_ghost_active_subscriptions)
|
// GARDE-FOU par défaut : exclure les comptes F résiliés (abos fantômes, cf feedback_ghost_active_subscriptions).
|
||||||
const before = customers.length
|
// Sauf si `include_resiliated` (choisi via « Tous ») → on autorise les ex-clients (relance / win-back).
|
||||||
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id)))
|
if (!filters.include_resiliated) {
|
||||||
resiliatedExcluded = before - customers.length
|
const resil = await resiliatedSet()
|
||||||
|
const before = customers.length
|
||||||
|
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id)))
|
||||||
|
resiliatedExcluded = before - customers.length
|
||||||
|
}
|
||||||
rows = customers.map(mapCustomerRow)
|
rows = customers.map(mapCustomerRow)
|
||||||
}
|
}
|
||||||
const seen = new Set(); const recipients = []; const noEmail = []; let dropped = 0
|
const seen = new Set(); const recipients = []; const noEmail = []; let dropped = 0
|
||||||
|
|
@ -892,12 +917,31 @@ async function handle (req, res, method, path, url) {
|
||||||
return json(res, 200, { ok: true, reverted_to_seed: !!SEED_CONTENT[mm[1]] })
|
return json(res, 200, { ok: true, reverted_to_seed: !!SEED_CONTENT[mm[1]] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Staff : GET /events/<id>/invite-preview?lang=fr|en[&template=<name>] → HTML rendu (aperçu inbox)
|
||||||
|
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/invite-preview$/)
|
||||||
|
if (mm && method === 'GET') {
|
||||||
|
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
|
||||||
|
const lang = normLang(url.searchParams.get('lang') || 'fr')
|
||||||
|
const sampleName = lang === 'en' ? 'Jean Smith' : 'Jean Tremblay'
|
||||||
|
const sampleUrl = `${pub()}/rsvp/${event.id}`
|
||||||
|
// ?template absent → modèle configuré de l'événement · ='' → forcer le festif · =<nom> → ce template (aperçu avant enregistrement)
|
||||||
|
const override = url.searchParams.get('template')
|
||||||
|
let html
|
||||||
|
if (override === null) html = inviteEmail(event.id, lang, sampleName, sampleUrl)
|
||||||
|
else if (override === '') html = inviteEmailFestive(event, lang, sampleName, sampleUrl)
|
||||||
|
else {
|
||||||
|
html = require('./campaigns').renderNamedTemplate(override, inviteTemplateVars(event, lang, sampleName, sampleUrl))
|
||||||
|
if (!html) return json(res, 400, { error: 'template_not_found' })
|
||||||
|
}
|
||||||
|
return json(res, 200, { html })
|
||||||
|
}
|
||||||
|
|
||||||
// Staff : GET /events/<id>/rsvps
|
// Staff : GET /events/<id>/rsvps
|
||||||
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/)
|
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/)
|
||||||
if (mm && method === 'GET') {
|
if (mm && method === 'GET') {
|
||||||
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
|
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
|
||||||
const data = listRsvps(mm[1])
|
const data = listRsvps(mm[1])
|
||||||
return json(res, 200, { event: { id: event.id, name: event.fr.name, date_iso: event.date_iso, when: event.fr.when, capacity: event.capacity, attachments: event.attachments || [], public_url: `${pub()}/rsvp/${event.id}` }, ...data })
|
return json(res, 200, { event: { id: event.id, name: event.fr.name, date_iso: event.date_iso, when: event.fr.when, capacity: event.capacity, email_template: event.email_template || '', attachments: event.attachments || [], public_url: `${pub()}/rsvp/${event.id}` }, ...data })
|
||||||
}
|
}
|
||||||
// Staff : DELETE /events/<id>/rsvps/<key>
|
// Staff : DELETE /events/<id>/rsvps/<key>
|
||||||
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/)
|
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user