Events: personal rsvp_url per-language, address field, logo spacing

- rsvpLink() now appends &lang=<en|fr> when generating a recipient's personal
  link, so the English invitation's link opens the English RSVP form instead
  of defaulting to French. Threaded through sendTest and the mass-send worker.
- New optional per-language "address" field (editor input, SEED default for
  fete-20-ans, Unlayer template var), rendered under the date with a pin icon
  in both the festive email and the RSVP page.
- Logo: +20px bottom margin in both the email template and the RSVP page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-09 13:26:45 -04:00
parent cab1a9c94a
commit 2a9f201826
2 changed files with 24 additions and 10 deletions

View File

@ -156,6 +156,9 @@
<q-input v-if="lg === 'en'" v-model="form.en.name" dense outlined label="Name" />
<q-input v-model="form[lg].tagline" dense outlined autogrow label="Accroche" />
<q-input v-model="form[lg].when" dense outlined :label="lg === 'fr' ? 'Quand (texte)' : 'When (text)'" hint="ex. 1ᵉʳ août, de 10 h à 15 h" />
<q-input v-model="form[lg].address" dense outlined :label="lg === 'fr' ? 'Adresse (texte, optionnel)' : 'Address (text, optional)'" hint="ex. À nos bureaux : 1867 chemin de la rivière, Ste-Clotilde">
<template #prepend><q-icon name="place" /></template>
</q-input>
<q-input v-model="form[lg].body" dense outlined type="textarea" autogrow :label="lg === 'fr' ? 'Texte d\'invitation' : 'Invitation text'" />
<div class="text-weight-medium text-grey-8 q-mt-sm">Programme</div>
@ -542,7 +545,7 @@ const uploadingAtt = ref(false)
function attLangLabel (l) { return l === 'fr' ? 'FR' : l === 'en' ? 'EN' : 'Les deux' }
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: '', address: '', body: '', program: [], program_line: '', limited: '', closing: '', notes: [] })
const emptyForm = () => ({ id: '', active: true, date_iso: '', badge_top: '', badge_bottom: '', capacity: 0, email_template: '', fr: emptyLang(), en: emptyLang() })
const form = ref(emptyForm())
@ -581,7 +584,7 @@ async function openEdit () {
for (const lg of ['fr', 'en']) {
const src = c[lg] || {}
f[lg] = {
name: src.name || '', tagline: src.tagline || '', when: src.when || '', body: src.body || '',
name: src.name || '', tagline: src.tagline || '', when: src.when || '', address: src.address || '', body: src.body || '',
program: Array.isArray(src.program) ? src.program.map(p => ({ icon: p.icon || '', label: p.label || '', sub: p.sub || '' })) : [],
program_line: src.program_line || '', limited: src.limited || '', closing: src.closing || '',
notes: Array.isArray(src.notes) ? [...src.notes] : [],
@ -605,7 +608,7 @@ async function openInvitePreview () { invitePreviewOpen.value = true; await load
function cleanLang (c) {
return {
name: (c.name || '').trim(), tagline: (c.tagline || '').trim(), when: (c.when || '').trim(), body: (c.body || '').trim(),
name: (c.name || '').trim(), tagline: (c.tagline || '').trim(), when: (c.when || '').trim(), address: (c.address || '').trim(), body: (c.body || '').trim(),
program: (c.program || []).filter(p => (p.label || '').trim() || (p.icon || '').trim()),
program_line: (c.program_line || '').trim(), limited: (c.limited || '').trim(), closing: (c.closing || '').trim(),
notes: (c.notes || []).map(n => (n || '').trim()).filter(Boolean),

View File

@ -119,6 +119,7 @@ const SEED_CONTENT = {
name: 'Targo fête ses 20 ans',
tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre grande fête d'anniversaire !",
when: '1ᵉʳ août, de 10 h à 15 h',
address: 'À nos bureaux : 1867 chemin de la rivière, Ste-Clotilde',
body: "Joignez-vous à nous pour une journée de plaisir, de rencontres et de célébration en compagnie des employés de Targo et de leurs familles.",
program: [
{ icon: '🚚', label: 'Food truck', sub: 'repas inclus' },
@ -139,6 +140,7 @@ const SEED_CONTENT = {
name: 'Targo turns 20!',
tagline: 'The whole Targo team is happy to invite you to our big anniversary celebration!',
when: 'August 1st, 10 a.m. to 3 p.m.',
address: 'At our offices: 1867 chemin de la rivière, Ste-Clotilde',
body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.',
program: [
{ icon: '🚚', label: 'Food truck', sub: 'meal included' },
@ -224,6 +226,7 @@ function sanitizeLangContent (c) {
name: String(c.name || '').slice(0, 160),
tagline: String(c.tagline || '').slice(0, 300),
when: String(c.when || '').slice(0, 120),
address: String(c.address || '').slice(0, 200),
body: String(c.body || '').slice(0, 1200),
program: sanitizeProgram(c.program),
program_line: String(c.program_line || '').slice(0, 200),
@ -299,9 +302,14 @@ function keyFor (resolved, form) {
}
// ── Lien perso + courriel d'invitation ─────────────────────────────────────
function rsvpLink (eventId, customerId, name, email, ttlHours = 60 * 24) {
// `lang` (optionnel) → ajouté en ?lang= EXPLICITE sur l'URL : la page RSVP priorise TOUJOURS ce
// paramètre de requête sur la langue du jeton (cf handle() : `if (!url.searchParams.get('lang') && p.lang)`).
// Sans ça, la version EN du courriel pointait vers la même URL que la FR (défaut FR). Nécessaire
// même si le modèle éditable (Unlayer) ne permet pas de fixer un href différent par langue.
function rsvpLink (eventId, customerId, name, email, ttlHours = 60 * 24, lang = '') {
const tok = generateCustomerToken(customerId, name, email, ttlHours)
return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}`
const l = lang ? `&lang=${encodeURIComponent(normLang(lang))}` : ''
return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}${l}`
}
function inviteSubject (eventId, lang) {
const e = getEvent(eventId); if (!e) return ''
@ -312,7 +320,7 @@ 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,
event_name: t.name, tagline: t.tagline, when: t.when, address: t.address, body: t.body, closing: t.closing,
year: String(new Date().getFullYear()), lang,
}
}
@ -345,12 +353,13 @@ function inviteEmailFestive (e, lang, name, rsvpUrl) {
+ `<tr><td align="center" style="padding:22px 30px 0">`
+ `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>`
+ (badge ? `<div style="display:inline-block;background:#21a34a;color:#fff;font-weight:800;font-size:12px;letter-spacing:1.5px;padding:6px 16px;border-radius:999px;margin:10px 0 8px">🎉 ${esc(badge)} 🎉</div><br>` : '<div style="height:10px"></div>')
+ `<img src="${LOGO}" alt="TARGO" width="150" style="width:150px;max-width:150px;height:auto;display:inline-block;border:0;margin:2px 0"></td></tr>`
+ `<img src="${LOGO}" alt="TARGO" width="150" style="width:150px;max-width:150px;height:auto;display:inline-block;border:0;margin:2px 0 22px"></td></tr>`
+ `<tr><td align="center" style="padding:8px 26px 0"><h1 style="margin:6px 0 4px;font-size:27px;font-weight:800;color:#1f2937;line-height:1.15">${esc(t.name)} 🎉</h1>`
+ `<p style="margin:0;font-size:15px;font-weight:700;color:#21a34a;line-height:1.4">${esc(t.tagline)}</p></td></tr>`
+ `<tr><td style="padding:16px 34px 4px"><p style="margin:0 0 10px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)) || '')}</p>`
+ `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.body)}</p></td></tr>`
+ (t.when ? `<tr><td align="center" style="padding:16px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#1f2937;border-radius:12px;padding:13px 24px;color:#fff;font-size:18px;font-weight:800">📅 ${esc(t.when)}</td></tr></table></td></tr>` : '')
+ (t.address ? `<tr><td align="center" style="padding:2px 30px 6px"><p style="margin:0;font-size:14px;font-weight:600;color:#374151">📍 ${esc(t.address)}</p></td></tr>` : '')
+ ((t.program || []).length ? `<tr><td style="padding:14px 18px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${prog}</tr></table></td></tr>` : '')
+ (t.program_line ? `<tr><td align="center" style="padding:2px 30px 8px"><p style="margin:0;font-size:13px;font-style:italic;color:#94a3b8">${esc(t.program_line)}</p></td></tr>` : '')
+ (t.limited ? `<tr><td style="padding:6px 30px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;border:1px solid #e5e7eb;border-radius:12px"><tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;color:#374151">⏳ ${esc(t.limited)}</td></tr></table></td></tr>` : '')
@ -440,7 +449,7 @@ async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang
const atts = attachmentPayload(channel, loadSendAttachments(event, lang)) // pièces jointes de la langue testée (+ 'both')
const results = []
for (const em of emails) {
const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24)
const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24, lang)
const html = inviteEmail(eventId, lang, name, link)
const subject = '[TEST] ' + inviteSubject(eventId, lang)
try { const r = await sendInviteMessage({ channel, to: em, subject, html, from, attachments: atts }); results.push({ email: em, ok: r.ok, error: r.error }) }
@ -490,7 +499,7 @@ async function sendEventCampaignAsync (id, eventId, channel, from) {
if (r.status !== 'pending') continue
const lang = normLang(r.language)
const name = ((r.firstname || '') + ' ' + (r.lastname || '')).trim()
const rsvpUrl = rsvpLink(eventId, r.customer_id || ('x-' + r.email), name, r.email)
const rsvpUrl = rsvpLink(eventId, r.customer_id || ('x-' + r.email), name, r.email, 60 * 24, lang)
r.rsvp_url = rsvpUrl
const html = inviteEmail(eventId, lang, name, rsvpUrl)
const subject = inviteSubject(eventId, lang)
@ -749,7 +758,7 @@ body{margin:0;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-s
background:var(--g);color:#fff;font-weight:800;line-height:1;box-shadow:0 8px 20px rgba(33,163,74,.3);margin-bottom:12px}
.badge b{font-size:1.35rem}.badge span{font-size:.6rem;letter-spacing:1px}
.badge.emoji{font-size:2rem}
.logo{height:34px;width:auto;display:block;margin:0 auto 14px}
.logo{height:34px;width:auto;display:block;margin:0 auto 34px}
h1{font-size:1.7rem;font-weight:800;letter-spacing:-.5px;margin:0 0 6px;color:var(--ink)}
.tag{color:var(--g);font-weight:700;font-size:1.02rem;margin:0 0 4px}
.card{background:#fff;border:1px solid #eef2f0;border-radius:20px;box-shadow:0 16px 44px rgba(23,58,94,.09);padding:26px 22px;margin-bottom:16px}
@ -757,6 +766,7 @@ p{line-height:1.6;font-size:1rem;margin:0 0 12px;color:#334155}
.when{display:flex;align-items:center;justify-content:center;gap:10px;background:var(--ink);color:#fff;font-weight:700;
font-size:1.12rem;border-radius:14px;padding:13px 18px;margin:4px 0 16px}
.when .cal{font-size:1.3rem}
.addr{display:flex;align-items:center;justify-content:center;gap:6px;color:#374151;font-weight:600;font-size:.92rem;margin:-8px 0 16px;text-align:center}
.prog-wrap{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin:6px 0 4px}
.prog{flex:1 1 90px;max-width:110px;text-align:center;background:#f8fafc;border:1px solid #eef2f6;border-radius:14px;padding:12px 6px}
.prog-ic{font-size:1.7rem;line-height:1}
@ -799,6 +809,7 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
<div id="invite" class="card">
${t.when ? `<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div>` : ''}
${t.address ? `<div class="addr">📍 ${esc(t.address)}</div>` : ''}
<p>${esc(t.body)}</p>
${program ? `<div class="prog-wrap">${program}</div>` : ''}
${t.program_line ? `<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p>` : ''}