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 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.
|
||||
export async function searchCustomersFuzzy (q) { return hubFetch(`/collab/customer-search?q=${encodeURIComponent(q)}`) }
|
||||
|
||||
|
|
|
|||
|
|
@ -180,6 +180,22 @@
|
|||
</q-tab-panel>
|
||||
</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) -->
|
||||
<template v-if="formMode === 'edit'">
|
||||
<q-separator class="q-my-md" />
|
||||
|
|
@ -213,6 +229,22 @@
|
|||
</q-card>
|
||||
</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) -->
|
||||
<q-dialog v-model="showSend">
|
||||
<q-card style="min-width:340px;max-width:560px;width:100%">
|
||||
|
|
@ -236,7 +268,9 @@
|
|||
|
||||
<!-- MASSE -->
|
||||
<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' }]" />
|
||||
<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>
|
||||
|
|
@ -273,7 +307,7 @@
|
|||
|
||||
<div v-if="audienceSource === 'list'" class="q-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" />
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
|
|
@ -293,24 +327,30 @@
|
|||
</div>
|
||||
|
||||
<div v-else-if="audienceSource === 'manual'" class="q-gutter-sm">
|
||||
<q-select
|
||||
v-model="poolPick" dense outlined use-input hide-selected fill-input input-debounce="300"
|
||||
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>
|
||||
<q-input v-model="poolQuery" dense outlined clearable debounce="300" :loading="poolSearching"
|
||||
label="Rechercher un client (nom, n°, courriel)…" @update:model-value="onPoolSearch">
|
||||
<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>
|
||||
<template #option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section>
|
||||
<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-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-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
<div v-if="pool.length" class="q-gutter-xs">
|
||||
<q-list bordered separator class="rounded-borders" style="max-height:170px;overflow:auto">
|
||||
</q-input>
|
||||
<!-- Résultats en temps réel : cliquer « + » ajoute au lot, la liste reste affichée -->
|
||||
<q-list v-if="poolResults.length" bordered separator class="rounded-borders" style="max-height:200px;overflow:auto">
|
||||
<q-item v-for="m in poolResults" :key="m.customer_id" dense>
|
||||
<q-item-section>
|
||||
<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-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-section>
|
||||
<q-item-section side>
|
||||
<q-btn flat dense round size="sm" :icon="inPool(m) ? 'check' : 'add'" :color="inPool(m) ? 'green-7' : 'primary'" :disable="inPool(m)" @click="addToPool(m)">
|
||||
<q-tooltip>{{ inPool(m) ? 'Déjà dans le lot' : 'Ajouter au lot' }}</q-tooltip>
|
||||
</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-section>
|
||||
<q-item-label>{{ p.customer_name || p.customer_id }}</q-item-label>
|
||||
|
|
@ -321,7 +361,6 @@
|
|||
</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" />
|
||||
</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 v-else class="q-gutter-sm">
|
||||
|
|
@ -361,6 +400,7 @@ import {
|
|||
listEvents, listRsvps, deleteRsvp, sendInvite, listTemplates,
|
||||
getEventConfig, createEvent, updateEvent, deleteEvent, uploadAttachment, deleteAttachment,
|
||||
getAudienceList, audienceListAdd, audienceListRemove, audienceListClear, searchCustomersFuzzy,
|
||||
getInvitePreview,
|
||||
} from 'src/api/events'
|
||||
|
||||
const $q = useQuasar()
|
||||
|
|
@ -386,15 +426,14 @@ const testLang = ref('fr')
|
|||
const sending = ref(false)
|
||||
// Envoi de masse (audience + modèle) — aperçu seulement ; le blast reste à activer.
|
||||
const sendMode = ref('test')
|
||||
const template = ref('')
|
||||
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
|
||||
const audienceSource = ref('list')
|
||||
const audFilters = ref({ statut: 'active', customer_type: '', city: '', name_like: '', active_sub: false, min_monthly: 0 })
|
||||
const csvFile = ref(null)
|
||||
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 poolPick = ref(null)
|
||||
const poolQuery = ref('')
|
||||
const poolResults = ref([])
|
||||
const poolSearching = ref(false)
|
||||
// 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' }
|
||||
|
||||
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())
|
||||
|
||||
// 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 () {
|
||||
form.value = emptyForm()
|
||||
formMode.value = 'create'
|
||||
formLang.value = 'fr'
|
||||
if (templates.value.length <= 1) loadTemplates()
|
||||
showForm.value = true
|
||||
}
|
||||
async function openEdit () {
|
||||
|
|
@ -522,6 +574,7 @@ async function openEdit () {
|
|||
f.badge_top = c.badge_top || ''
|
||||
f.badge_bottom = c.badge_bottom || ''
|
||||
f.capacity = c.capacity || 0
|
||||
f.email_template = c.email_template || ''
|
||||
for (const lg of ['fr', 'en']) {
|
||||
const src = c[lg] || {}
|
||||
f[lg] = {
|
||||
|
|
@ -534,9 +587,18 @@ async function openEdit () {
|
|||
form.value = f
|
||||
formMode.value = 'edit'
|
||||
formLang.value = 'fr'
|
||||
if (templates.value.length <= 1) loadTemplates()
|
||||
showForm.value = true
|
||||
} 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) {
|
||||
return {
|
||||
|
|
@ -557,6 +619,7 @@ async function saveForm () {
|
|||
badge_top: form.value.badge_top,
|
||||
badge_bottom: form.value.badge_bottom,
|
||||
capacity: form.value.capacity,
|
||||
email_template: form.value.email_template || '',
|
||||
fr: cleanLang(form.value.fr),
|
||||
...(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
|
||||
|
||||
// ── Pool manuel : recherche + sélection avant ajout ────────────────────────
|
||||
function onPoolFilter (val, update) {
|
||||
// ── Pool manuel : recherche floue temps réel → résultats en liste → sélection ──
|
||||
function onPoolSearch (val) {
|
||||
const q = (val || '').trim()
|
||||
if (q.length < 2) { update(() => { poolResults.value = [] }); return }
|
||||
if (q.length < 2) { poolResults.value = []; return }
|
||||
poolSearching.value = true
|
||||
// Recherche FLOUE app-wide (pg_trgm, tolère typos + accents : « repa » trouve « Réparation »).
|
||||
searchCustomersFuzzy(q)
|
||||
.then(d => update(() => {
|
||||
.then(d => {
|
||||
poolResults.value = (d.matches || [])
|
||||
.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) }))
|
||||
}))
|
||||
.catch(() => update(() => { poolResults.value = [] }))
|
||||
.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(() => { poolResults.value = [] })
|
||||
.finally(() => { poolSearching.value = false })
|
||||
}
|
||||
function inPool (m) { return pool.value.some(p => p.customer_id === m.customer_id) }
|
||||
function addToPool (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) }
|
||||
|
||||
|
|
@ -675,11 +738,14 @@ function filterLabel () {
|
|||
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.statut === 'active' && !p.length) p.push('Clients actifs')
|
||||
else if (f.statut !== 'active') p.push('incl. résiliés')
|
||||
return p.join(' · ') || 'Filtre'
|
||||
}
|
||||
async function addFilterToList () {
|
||||
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 }
|
||||
}
|
||||
async function addManualToList () {
|
||||
|
|
|
|||
|
|
@ -825,6 +825,16 @@ function renderTemplate (tpl, vars) {
|
|||
// Templates are bundled inside the hub at services/targo-hub/templates/.
|
||||
// Keep them in sync with scripts/campaigns/templates/ for the CLI use case.
|
||||
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')
|
||||
|
||||
// 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
|
||||
// UI without us re-deploying the hub. The prefix list bounds what names are
|
||||
// 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
|
||||
|
||||
function isValidTemplateName (name) {
|
||||
|
|
@ -2787,5 +2797,5 @@ module.exports = {
|
|||
// Exposed for testing
|
||||
parseCsv, parseMapCsv, parseGiftbitCsv,
|
||||
matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
|
||||
renderTemplate,
|
||||
renderTemplate, renderNamedTemplate,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ function buildEvent (base) {
|
|||
badge_top: String(base.badge_top || ''),
|
||||
badge_bottom: String(base.badge_bottom || ''),
|
||||
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 : [],
|
||||
fr: content('fr'),
|
||||
en: content('en'),
|
||||
|
|
@ -242,6 +243,7 @@ function normalizeConfig (b, existing = null) {
|
|||
badge_top: String(b.badge_top || '').slice(0, 6),
|
||||
badge_bottom: String(b.badge_bottom || '').slice(0, 8),
|
||||
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 : [],
|
||||
fr,
|
||||
...(enRaw ? { en: enRaw } : {}),
|
||||
|
|
@ -305,8 +307,27 @@ function inviteSubject (eventId, lang) {
|
|||
const e = getEvent(eventId); if (!e) return ''
|
||||
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) {
|
||||
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]
|
||||
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(' ')
|
||||
|
|
@ -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 }
|
||||
}
|
||||
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)
|
||||
const before = customers.length
|
||||
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id)))
|
||||
resiliatedExcluded = before - customers.length
|
||||
// GARDE-FOU par défaut : exclure les comptes F résiliés (abos fantômes, cf feedback_ghost_active_subscriptions).
|
||||
// Sauf si `include_resiliated` (choisi via « Tous ») → on autorise les ex-clients (relance / win-back).
|
||||
if (!filters.include_resiliated) {
|
||||
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)
|
||||
}
|
||||
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]] })
|
||||
}
|
||||
|
||||
// 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
|
||||
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/)
|
||||
if (mm && method === 'GET') {
|
||||
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
|
||||
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>
|
||||
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user