- Couleurs restreintes à la charte TARGO (noir/vert/blanc/gris + bleu en haut) sur le courriel ET la page RSVP : bandeau supérieur bleu #1a86c7, accents/CTA verts, texte noir/gris, fond blanc, programme en pastilles vert/bleu/gris. Retrait orange/jaune/rose/rouge. - Admin : le téléphone saisi s'affiche sous le courriel (n'apparaissait pas avant). - Hub : capture de l'adresse IP du visiteur à l'inscription (x-forwarded-for → socket). - CSV enrichi = TOUTES les infos : client, n° client, ID compte, nom au dossier, téléphone, personnes, courriel, allergies, confiance, signaux, langue, IP, créé le, répondu le. Déployé hub + SPA 2026-07-06. Vérifié prod : palette conforme, téléphone + IP stockés (819 555-9999 / 96.125.192.22), test envoyé OK. 21/21 tests unitaires. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
495 lines
31 KiB
JavaScript
495 lines
31 KiB
JavaScript
'use strict'
|
|
/**
|
|
* Événements clients + inscription (RSVP).
|
|
*
|
|
* - Page PUBLIQUE tokenisable : GET /rsvp/<eventId>[?t=<jwt>&lang=fr|en]
|
|
* Champs : nom, courriel, téléphone, n° client (optionnel), combien, allergies.
|
|
* ?t=<jwt magic-link client> → pré-remplit nom/courriel + rattache d'office.
|
|
* - POST /rsvp/<eventId>/submit → rattachement + validation croisée AVEUGLE (voir matchAndValidate),
|
|
* clé par client → re-soumission = mise à jour (pas de doublon). Jamais bloquant.
|
|
* - Endpoints STAFF (gatés) : GET /events, GET /events/<id>/rsvps (+ décompte + confiance),
|
|
* DELETE /events/<id>/rsvps/<key>, POST /events/<id>/send (mode test).
|
|
*
|
|
* IMPORTANT (demande user) : AUCUN autosuggest/auto-remplissage à partir des données client
|
|
* (fuite de PII). Le client saisit ce qu'il connaît ; on valide côté serveur, à la soumission,
|
|
* sans jamais RENVOYER de donnée stockée. « confirmed » = ≥2 infos concordent sur le même compte.
|
|
*
|
|
* Réutilise : magic-link.generateCustomerToken/verifyJwt · portal-auth.lookupCustomer ·
|
|
* helpers.lookupCustomersByEmail/lookupCustomersByPhone · email.sendEmail / gmail.sendMessage.
|
|
*/
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const cfg = require('./config')
|
|
const { log, json, parseBody, readJsonFile, writeJsonFile, lookupCustomersByEmail, lookupCustomersByPhone } = require('./helpers')
|
|
const { generateCustomerToken, verifyJwt } = require('./magic-link')
|
|
|
|
const DATA_DIR = process.env.EVENTS_DATA_DIR || '/app/data/events'
|
|
try { fs.mkdirSync(DATA_DIR, { recursive: true }) } catch { /* best-effort */ }
|
|
const pub = () => cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'
|
|
const LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png'
|
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
|
const normLang = (l) => /^en/i.test(String(l || '')) ? 'en' : 'fr'
|
|
const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || ''
|
|
|
|
// ── Config événements (semé : Fête Targo 20 ans) ───────────────────────────
|
|
const SEED = {
|
|
'fete-20-ans': {
|
|
id: 'fete-20-ans',
|
|
active: true,
|
|
date_iso: '2026-08-01',
|
|
fr: {
|
|
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',
|
|
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' },
|
|
{ icon: '🎧', label: 'DJ' },
|
|
{ icon: '🎪', label: 'Jeux gonflables' },
|
|
{ icon: '🎨', label: 'Maquillage' },
|
|
{ icon: '🎁', label: 'Et des surprises' },
|
|
],
|
|
program_line: "Le tout dans une ambiance familiale et conviviale.",
|
|
limited: "Les places étant limitées, nous vous invitons à confirmer votre présence dès que possible en remplissant le formulaire ci-dessous.",
|
|
closing: "Nous avons hâte de vous retrouver pour célébrer ensemble !",
|
|
notes: [
|
|
'Si vous avez des allergies, le menu pourrait ne pas vous convenir.',
|
|
"Vous recevrez un coupon repas sur place (à l'accueil).",
|
|
],
|
|
f_name: 'Votre nom',
|
|
f_email: 'Votre adresse courriel',
|
|
f_phone: 'Votre téléphone',
|
|
f_number: 'Votre numéro client',
|
|
f_party: 'Combien serez-vous ?',
|
|
f_allerg: 'Allergies ou restrictions alimentaires',
|
|
opt: '(optionnel)',
|
|
f_party_ph: 'Nombre de personnes',
|
|
submit: 'Confirmer ma présence',
|
|
greet: (n) => n ? `Bonjour ${n} !` : '',
|
|
thanks_title: 'Merci, votre présence est confirmée ! 🎉',
|
|
thanks_body: "On a hâte de vous voir le 1ᵉʳ août. Vous recevrez votre coupon repas à l'accueil.",
|
|
thanks_recognized: (n) => `✓ Nous vous avons reconnu${n} — inscription reliée à votre compte.`,
|
|
thanks_tocheck: 'Nous validerons votre inscription sous peu.',
|
|
thanks_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.',
|
|
err_name: 'Veuillez entrer votre nom.',
|
|
err_party: 'Veuillez indiquer combien de personnes seront présentes.',
|
|
err_email: 'Veuillez entrer une adresse courriel valide.',
|
|
err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.",
|
|
foot: 'TARGO — votre fournisseur Internet local',
|
|
},
|
|
en: {
|
|
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.',
|
|
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' },
|
|
{ icon: '🎧', label: 'DJ' },
|
|
{ icon: '🎪', label: 'Bouncy castles' },
|
|
{ icon: '🎨', label: 'Face painting' },
|
|
{ icon: '🎁', label: 'And surprises' },
|
|
],
|
|
program_line: 'All in a warm, family-friendly atmosphere.',
|
|
limited: 'Space is limited, so please confirm your attendance as soon as possible using the form below.',
|
|
closing: "We can't wait to celebrate together!",
|
|
notes: [
|
|
'If you have allergies, the menu may not suit you.',
|
|
'You will receive a meal coupon on site (at the welcome desk).',
|
|
],
|
|
f_name: 'Your name',
|
|
f_email: 'Your email address',
|
|
f_phone: 'Your phone',
|
|
f_number: 'Your customer number',
|
|
f_party: 'How many will attend?',
|
|
f_allerg: 'Allergies or dietary restrictions',
|
|
opt: '(optional)',
|
|
f_party_ph: 'Number of people',
|
|
submit: 'Confirm my attendance',
|
|
greet: (n) => n ? `Hi ${n}!` : '',
|
|
thanks_title: "Thank you, you're confirmed! 🎉",
|
|
thanks_body: 'We look forward to seeing you on August 1st. Pick up your meal coupon at the welcome desk.',
|
|
thanks_recognized: (n) => `✓ We found your account${n} — your RSVP is linked.`,
|
|
thanks_tocheck: "We'll confirm your registration shortly.",
|
|
thanks_update: 'You can change your answer anytime from this link.',
|
|
err_name: 'Please enter your name.',
|
|
err_party: 'Please tell us how many people will attend.',
|
|
err_email: 'Please enter a valid email address.',
|
|
err_generic: 'Something went wrong. Please try again or contact us.',
|
|
foot: 'TARGO — your local Internet provider',
|
|
},
|
|
},
|
|
}
|
|
|
|
function getEvent (eventId) { return SEED[eventId] || null }
|
|
function listEvents () { return Object.values(SEED).map(e => ({ id: e.id, active: e.active, date_iso: e.date_iso, name: e.fr.name })) }
|
|
|
|
// ── Stockage des réponses (une map par événement, clé = client) ────────────
|
|
function respFile (eventId) { return path.join(DATA_DIR, `${eventId}.json`) }
|
|
function loadResp (eventId) { const d = readJsonFile(respFile(eventId), {}); return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {} }
|
|
function saveResp (eventId, m) { writeJsonFile(respFile(eventId), m) }
|
|
|
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
|
|
function clampParty (v) { const n = parseInt(v, 10); if (!Number.isFinite(n) || n < 1) return null; return Math.min(30, n) }
|
|
|
|
// ── Comparaison de noms (signal CORROBORANT, aveugle) ──────────────────────
|
|
function normName (s) { return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(t => t.length >= 2) }
|
|
function nameMatches (entered, candidate) {
|
|
const A = new Set(normName(entered)); const B = normName(candidate)
|
|
if (!A.size || !B.length) return false
|
|
const shared = B.filter(t => A.has(t)).length
|
|
return shared >= 2 || (shared >= 1 && (A.size <= 1 || B.length <= 1))
|
|
}
|
|
|
|
// ── Rattachement + validation croisée (AVEUGLE : ne révèle/renvoie JAMAIS de PII) ──
|
|
// Jeton = preuve forte. Sinon on cherche des candidats par courriel/téléphone/numéro (en parallèle) ;
|
|
// le NOM saisi corrobore. « confirmed » = ≥2 signaux concordent sur le MÊME compte ; « probable » = 1 ;
|
|
// « none » = aucun (accepté quand même). Aucun autosuggest, aucun retour de donnée client.
|
|
async function matchAndValidate ({ token = '', number = '', email = '', phone = '', name = '' } = {}) {
|
|
if (token) {
|
|
const p = verifyJwt(token)
|
|
if (p && p.scope === 'customer' && p.sub) return { customer_id: p.sub, customer_name: p.name || name || '', confidence: 'confirmed', signals: ['lien'] }
|
|
}
|
|
const votes = {}; const nameById = {}
|
|
const add = (cid, cname, sig) => { if (!cid) return; (votes[cid] = votes[cid] || new Set()).add(sig); if (cname && !nameById[cid]) nameById[cid] = cname }
|
|
const tasks = []
|
|
if (email) tasks.push(lookupCustomersByEmail(email, 5).then(r => (r || []).forEach(c => add(c.name, c.customer_name, 'courriel'))).catch(() => {}))
|
|
if (phone) tasks.push(lookupCustomersByPhone(phone, 5).then(r => (r || []).forEach(c => add(c.name, c.customer_name, 'téléphone'))).catch(() => {}))
|
|
if (number) tasks.push(require('./portal-auth').lookupCustomer(number).then(c => { if (c && c.name) add(c.name, c.customer_name, 'numéro') }).catch(() => {}))
|
|
await Promise.all(tasks)
|
|
if (name) for (const cid of Object.keys(votes)) if (nameMatches(name, nameById[cid])) votes[cid].add('nom')
|
|
let best = null; let bestN = 0
|
|
for (const [cid, set] of Object.entries(votes)) if (set.size > bestN) { best = cid; bestN = set.size }
|
|
if (!best) return { customer_id: null, customer_name: '', confidence: 'none', signals: [] }
|
|
const signals = [...votes[best]]
|
|
return { customer_id: best, customer_name: nameById[best] || '', confidence: signals.length >= 2 ? 'confirmed' : 'probable', signals }
|
|
}
|
|
|
|
function keyFor (resolved, form) {
|
|
if (resolved.customer_id) return 'c:' + resolved.customer_id
|
|
const id = (form.number || form.email || form.phone || '').trim().toLowerCase()
|
|
return 'x:' + id
|
|
}
|
|
|
|
// ── Lien perso + courriel d'invitation ─────────────────────────────────────
|
|
function rsvpLink (eventId, customerId, name, email, ttlHours = 60 * 24) {
|
|
const tok = generateCustomerToken(customerId, name, email, ttlHours)
|
|
return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}`
|
|
}
|
|
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} 🎉`
|
|
}
|
|
function inviteEmail (eventId, lang, name, rsvpUrl) {
|
|
const e = getEvent(eventId); if (!e) return ''
|
|
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 CIRC = ['#e8f5ec', '#e6f2f9', '#f1f5f9', '#e8f5ec', '#e6f2f9'] // vert / bleu / gris (palette TARGO)
|
|
const LBL = ['#1e8e3e', '#1a6f9e', '#4b5563', '#1e8e3e', '#1a6f9e']
|
|
const prog = t.program.map((p, i) =>
|
|
`<td align="center" valign="top" style="padding:6px 3px">`
|
|
+ `<div style="width:50px;height:50px;line-height:50px;border-radius:25px;background:${CIRC[i % 5]};font-size:25px;margin:0 auto">${p.icon}</div>`
|
|
+ `<div style="margin-top:6px;font-size:10px;font-weight:700;color:${LBL[i % 5]};line-height:1.2">${esc(p.label)}</div></td>`).join('')
|
|
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>`
|
|
+ `<body style="margin:0;background:#eef4f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
|
|
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#eef4f8"><tr><td align="center" style="padding:30px 14px">`
|
|
+ `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#fff;border-radius:18px;overflow:hidden;border:1px solid #e2e8f0">`
|
|
+ `<tr><td style="height:10px;background:#1a86c7;font-size:0;line-height:0"> </td></tr>`
|
|
+ `<tr><td align="center" style="padding:22px 30px 0">`
|
|
+ `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>`
|
|
+ `<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">🎉 20 ANS 🎉</div><br>`
|
|
+ `<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>`
|
|
+ `<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>`
|
|
+ `<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>`
|
|
+ `<tr><td style="padding:14px 18px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${prog}</tr></table></td></tr>`
|
|
+ `<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>`
|
|
+ `<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>`
|
|
+ `<tr><td align="center" style="padding:20px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#21a34a;border-radius:14px;box-shadow:0 6px 16px rgba(33,163,74,.3)"><a href="${esc(rsvpUrl)}" target="_blank" style="display:inline-block;padding:16px 40px;color:#fff;font-size:17px;font-weight:800;text-decoration:none">${esc(t.submit)} →</a></td></tr></table></td></tr>`
|
|
+ `<tr><td align="center" style="padding:8px 30px 20px"><p style="margin:0;font-size:15px;font-weight:700;color:#21a34a">${esc(merci)}</p>`
|
|
+ `<p style="margin:6px 0 0;font-size:13px;color:#94a3b8">${esc(t.closing)}</p></td></tr>`
|
|
+ `<tr><td style="background:#f0f7f2;padding:16px 30px;text-align:center;font-size:12px;line-height:1.6;color:#94a3b8;border-top:1px solid #e2e8f0">${esc(t.notes[0])}<br>${esc(t.notes[1])}`
|
|
+ `<div style="margin-top:12px;padding-top:12px;border-top:1px solid #dce8e0"><a href="https://targo.ca" style="color:#21a34a;font-weight:700;text-decoration:none">🌐 targo.ca</a> · ${esc(t.foot)}</div></td></tr>`
|
|
+ `</table></td></tr></table></body></html>`
|
|
}
|
|
|
|
// ── Envoi TEST (déclenché par le staff depuis l'admin ; jamais automatique) ──
|
|
async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '' }) {
|
|
const results = []
|
|
for (const em of emails) {
|
|
const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24)
|
|
const html = inviteEmail(eventId, lang, name, link)
|
|
const subject = '[TEST] ' + inviteSubject(eventId, lang)
|
|
try {
|
|
let ok
|
|
if (channel === 'gmail') { const r = await require('./gmail').sendMessage({ to: em, subject, html, from: from || undefined }); ok = !!r }
|
|
else ok = await require('./email').sendEmail({ to: em, subject, html, from: from || undefined })
|
|
results.push({ email: em, ok: !!ok })
|
|
} catch (e) { results.push({ email: em, ok: false, error: e.message }) }
|
|
}
|
|
return results
|
|
}
|
|
|
|
// ── Page publique (festive, mobile-first, bilingue) ────────────────────────
|
|
function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) {
|
|
return `<label for="${id}">${labelHtml}</label>${inputHtml}${errId ? `<div class="err" id="${errId}">${esc(errMsg)}</div>` : ''}`
|
|
}
|
|
function page (event, lang, prefill = {}) {
|
|
lang = normLang(lang)
|
|
const t = event[lang]
|
|
const other = lang === 'fr' ? 'en' : 'fr'
|
|
const known = !!prefill.customer_id
|
|
const greet = t.greet(firstName(prefill.name))
|
|
const program = t.program.map(p =>
|
|
`<div class="prog"><div class="prog-ic">${p.icon}</div><div class="prog-lb">${esc(p.label)}${p.sub ? `<span>${esc(p.sub)}</span>` : ''}</div></div>`
|
|
).join('')
|
|
const notes = t.notes.map((n, i) => `<div class="note">${'*'.repeat(i + 1)} ${esc(n)}</div>`).join('')
|
|
const optlbl = (s) => `${esc(s)} <span class="opt">${esc(t.opt)}</span>`
|
|
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>${esc(t.name)}</title>
|
|
<style>
|
|
:root{--g:#21a34a;--blue:#1a86c7;--ink:#1f2937;--muted:#6b7280;--line:#e5e7eb}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:var(--ink);
|
|
background:radial-gradient(1200px 400px at 50% -120px,#eafbf0 0%,transparent 70%),linear-gradient(180deg,#f7fbff 0%,#ffffff 60%);min-height:100vh}
|
|
.confetti{height:10px;background:var(--blue)}
|
|
.wrap{max-width:600px;margin:0 auto;padding:26px 18px 46px}
|
|
.lang{text-align:right;margin-bottom:6px}
|
|
.lang a{color:var(--muted);text-decoration:none;font-size:.82rem;border:1px solid var(--line);padding:4px 10px;border-radius:999px}
|
|
.head{text-align:center;margin:6px 0 18px}
|
|
.badge{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;
|
|
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}
|
|
.logo{height:34px;width:auto;display:block;margin:0 auto 14px}
|
|
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}
|
|
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}
|
|
.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}
|
|
.prog-lb{margin-top:6px;font-size:.78rem;font-weight:700;color:var(--ink)}
|
|
.prog-lb span{display:block;font-weight:500;color:var(--muted);font-size:.7rem}
|
|
.limited{display:flex;gap:10px;align-items:flex-start;background:#f3f4f6;border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin:14px 0 2px}
|
|
.limited .i{font-size:1.1rem}
|
|
.limited p{margin:0;font-size:.92rem;color:#374151}
|
|
.greet{font-weight:700;color:var(--ink);font-size:1.05rem;margin:0 0 10px}
|
|
label{display:block;font-weight:600;font-size:.92rem;color:var(--ink);margin:14px 0 6px}
|
|
label .opt{font-weight:400;color:var(--muted);font-size:.82rem}
|
|
input,textarea{width:100%;border:1.5px solid var(--line);border-radius:12px;padding:13px 15px;font:inherit;font-size:1rem;background:#fcfdfc;transition:border-color .15s,box-shadow .15s}
|
|
input:focus,textarea:focus{outline:none;border-color:var(--g);box-shadow:0 0 0 4px rgba(33,163,74,.14)}
|
|
input[readonly]{background:#f1f5f9;color:var(--muted)}
|
|
textarea{min-height:74px;resize:vertical}
|
|
.err{color:#dc2626;font-size:.85rem;margin-top:5px;display:none}
|
|
button{margin-top:20px;width:100%;background:var(--g);color:#fff;border:0;border-radius:14px;padding:15px;font:inherit;font-weight:800;
|
|
font-size:1.05rem;cursor:pointer;box-shadow:0 10px 24px rgba(33,163,74,.3);transition:filter .15s,transform .08s}
|
|
button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}button:disabled{opacity:.6;cursor:default}
|
|
.notes{margin-top:16px}.note{color:var(--muted);font-size:.8rem;line-height:1.5;margin-top:3px}
|
|
.foot{text-align:center;color:#9aa7a0;font-size:.8rem;margin-top:20px}
|
|
.thanks{text-align:center;padding:14px 4px}
|
|
.thanks .big{font-size:3rem;line-height:1;margin-bottom:10px}
|
|
.thanks h2{color:var(--g);font-size:1.4rem;margin:0 0 10px}
|
|
.recog{font-weight:600;color:var(--g);margin:0 0 10px}
|
|
.hidden{display:none}
|
|
</style></head>
|
|
<body><div class="confetti"></div><div class="wrap">
|
|
<div class="lang"><a href="?lang=${other}">${other === 'en' ? 'English' : 'Français'}</a></div>
|
|
<div class="head">
|
|
<div class="badge"><b>20</b><span>ANS</span></div>
|
|
<img class="logo" src="${LOGO}" alt="TARGO">
|
|
<h1>${esc(t.name)}</h1>
|
|
<div class="tag">${esc(t.tagline)}</div>
|
|
</div>
|
|
|
|
<div id="invite" class="card">
|
|
<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div>
|
|
<p>${esc(t.body)}</p>
|
|
<div class="prog-wrap">${program}</div>
|
|
<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p>
|
|
<div class="limited"><span class="i">⏳</span><p>${esc(t.limited)}</p></div>
|
|
</div>
|
|
|
|
<div id="formCard" class="card">
|
|
${greet ? `<div class="greet">${esc(greet)}</div>` : ''}
|
|
<form id="rsvp" onsubmit="return submitRsvp(event)" autocomplete="on">
|
|
${field('nm', 'name', esc(t.f_name), `<input id="nm" name="name" value="${esc(prefill.name || '')}" autocomplete="name" placeholder="Jean Tremblay">`, 'e_nm', t.err_name)}
|
|
${field('em', 'email', esc(t.f_email), `<input id="em" name="email" type="email" value="${esc(prefill.email || '')}" autocomplete="email" placeholder="vous@exemple.com">`, 'e_em', t.err_email)}
|
|
${field('ph', 'phone', optlbl(t.f_phone), `<input id="ph" name="phone" type="tel" autocomplete="tel" placeholder="819 555-1234">`)}
|
|
${field('cn', 'customer_number', optlbl(t.f_number), `<input id="cn" name="customer_number" value="${esc(prefill.customer_number || '')}" ${known ? 'readonly' : ''} autocomplete="off" placeholder="C-12345">`)}
|
|
${field('ps', 'party_size', esc(t.f_party), `<input id="ps" name="party_size" type="number" min="1" max="30" inputmode="numeric" placeholder="${esc(t.f_party_ph)}">`, 'e_ps', t.err_party)}
|
|
${field('al', 'allergies', optlbl(t.f_allerg), `<textarea id="al" name="allergies" maxlength="500"></textarea>`)}
|
|
<button type="submit" id="sub">${esc(t.submit)}</button>
|
|
<div class="err" id="e_gen" style="text-align:center">${esc(t.err_generic)}</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div id="thanks" class="card hidden">
|
|
<div class="thanks">
|
|
<div class="big">🎉</div>
|
|
<h2>${esc(t.thanks_title)}</h2>
|
|
<p class="recog" id="recog"></p>
|
|
<p>${esc(t.thanks_body)}</p>
|
|
<p style="color:var(--muted);font-size:.9rem">${esc(t.thanks_update)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="notes">${notes}</div>
|
|
<div class="foot">${esc(t.foot)} · targo.ca</div>
|
|
</div>
|
|
<script>
|
|
var TOKEN=${JSON.stringify(prefill.token || '')};
|
|
var EVENT=${JSON.stringify(event.id)};
|
|
var LANG=${JSON.stringify(lang)};
|
|
var RECOG_TPL=${JSON.stringify(t.thanks_recognized('###'))};
|
|
var MSG_CHECK=${JSON.stringify(t.thanks_tocheck)};
|
|
function show(id){document.getElementById(id).classList.remove('hidden')}
|
|
function hide(id){document.getElementById(id).classList.add('hidden')}
|
|
function submitRsvp(e){
|
|
e.preventDefault();
|
|
var f=e.target;
|
|
['e_nm','e_em','e_ps','e_gen'].forEach(function(id){document.getElementById(id).style.display='none'});
|
|
var nm=(f.name.value||'').trim();
|
|
var em=(f.email.value||'').trim();
|
|
var ps=parseInt(f.party_size.value,10);
|
|
var ok=true;
|
|
if(!nm){document.getElementById('e_nm').style.display='block';ok=false}
|
|
if(!em||!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(em)){document.getElementById('e_em').style.display='block';ok=false}
|
|
if(!ps||ps<1){document.getElementById('e_ps').style.display='block';ok=false}
|
|
if(!ok)return false;
|
|
var b=document.getElementById('sub');b.disabled=true;
|
|
fetch('/rsvp/'+encodeURIComponent(EVENT)+'/submit',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({token:TOKEN,name:nm,email:em,phone:(f.phone.value||'').trim(),customer_number:(f.customer_number.value||'').trim(),party_size:ps,allergies:(f.allergies.value||'').trim(),lang:LANG})})
|
|
.then(function(r){return r.json()})
|
|
.then(function(d){
|
|
if(d&&d.ok){
|
|
var rec=document.getElementById('recog');
|
|
if(d.confidence==='confirmed'){rec.textContent=RECOG_TPL.replace('###',d.name?(', '+d.name):'')}
|
|
else{rec.textContent=MSG_CHECK;rec.style.color='#64748b'}
|
|
hide('formCard');show('thanks');window.scrollTo({top:0,behavior:'smooth'})
|
|
}else{b.disabled=false;document.getElementById('e_gen').style.display='block'}
|
|
})
|
|
.catch(function(){b.disabled=false;document.getElementById('e_gen').style.display='block'});
|
|
return false;
|
|
}
|
|
</script>
|
|
</body></html>`
|
|
}
|
|
|
|
// ── Vue staff : liste + décompte + confiance ───────────────────────────────
|
|
function listRsvps (eventId) {
|
|
const m = loadResp(eventId)
|
|
const responses = Object.entries(m).map(([key, r]) => ({ key, ...r }))
|
|
.sort((a, b) => String(b.updated || b.created || '').localeCompare(String(a.updated || a.created || '')))
|
|
const headcount = responses.reduce((s, r) => s + (parseInt(r.party_size, 10) || 0), 0)
|
|
const confirmed = responses.filter(r => r.confidence === 'confirmed').length
|
|
const probable = responses.filter(r => r.confidence === 'probable').length
|
|
const unverified = responses.filter(r => !r.confidence || r.confidence === 'none').length
|
|
return { responses, count: responses.length, headcount, confirmed, probable, unverified, matched: confirmed + probable, unmatched: unverified }
|
|
}
|
|
function deleteRsvp (eventId, key) { const m = loadResp(eventId); if (m[key]) { delete m[key]; saveResp(eventId, m); return true } return false }
|
|
|
|
// ── Routage HTTP ───────────────────────────────────────────────────────────
|
|
async function handle (req, res, method, path, url) {
|
|
try {
|
|
// Page publique : GET /rsvp/<eventId>
|
|
let mm = path.match(/^\/rsvp\/([A-Za-z0-9_-]+)$/)
|
|
if (mm && method === 'GET') {
|
|
const event = getEvent(mm[1])
|
|
if (!event || !event.active) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('<h1>Événement introuvable</h1>') }
|
|
const token = url.searchParams.get('t') || ''
|
|
let lang = url.searchParams.get('lang') || 'fr'
|
|
const prefill = { token }
|
|
if (token) {
|
|
const p = verifyJwt(token)
|
|
if (p && p.scope === 'customer') { prefill.customer_id = p.sub; prefill.customer_number = p.sub; prefill.name = p.name || ''; prefill.email = p.email || ''; if (!url.searchParams.get('lang') && p.lang) lang = p.lang }
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
return res.end(page(event, lang, prefill))
|
|
}
|
|
|
|
// Soumission publique : POST /rsvp/<eventId>/submit
|
|
mm = path.match(/^\/rsvp\/([A-Za-z0-9_-]+)\/submit$/)
|
|
if (mm && method === 'POST') {
|
|
const eventId = mm[1]
|
|
const event = getEvent(eventId)
|
|
if (!event || !event.active) return json(res, 404, { ok: false, error: 'event_not_found' })
|
|
const b = await parseBody(req)
|
|
const party = clampParty(b.party_size)
|
|
const email = String(b.email || '').trim()
|
|
const name = String(b.name || '').trim().slice(0, 120)
|
|
const phone = String(b.phone || '').trim().slice(0, 30)
|
|
const number = String(b.customer_number || '').trim().slice(0, 40)
|
|
if (!party) return json(res, 400, { ok: false, error: 'party_size' })
|
|
if (!EMAIL_RE.test(email)) return json(res, 400, { ok: false, error: 'email' })
|
|
const r = await matchAndValidate({ token: b.token, number, email, phone, name })
|
|
const key = keyFor(r, { number, email, phone })
|
|
const now = new Date().toISOString()
|
|
const ip = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || (req.socket && req.socket.remoteAddress) || ''
|
|
const m = loadResp(eventId)
|
|
const prev = m[key] || {}
|
|
m[key] = {
|
|
event_id: eventId,
|
|
ip,
|
|
customer_id: r.customer_id,
|
|
customer_number: number || (r.customer_id || ''),
|
|
customer_name: name || r.customer_name || prev.customer_name || '',
|
|
account_name: r.customer_name || '',
|
|
party_size: party,
|
|
email,
|
|
phone,
|
|
allergies: String(b.allergies || '').trim().slice(0, 500),
|
|
lang: normLang(b.lang),
|
|
confidence: r.confidence,
|
|
matched: r.confidence !== 'none',
|
|
signals: r.signals,
|
|
created: prev.created || now,
|
|
updated: now,
|
|
}
|
|
saveResp(eventId, m)
|
|
log(`RSVP ${eventId} — ${key} · ${party}p · ${r.confidence} [${r.signals.join(',')}]`)
|
|
return json(res, 200, { ok: true, confidence: r.confidence, name: firstName(name || r.customer_name) })
|
|
}
|
|
|
|
// Staff : GET /events
|
|
if (path === '/events' && method === 'GET') return json(res, 200, { events: listEvents() })
|
|
|
|
// 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, public_url: `${pub()}/rsvp/${event.id}` }, ...data })
|
|
}
|
|
// Staff : DELETE /events/<id>/rsvps/<key>
|
|
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/)
|
|
if (mm && method === 'DELETE') {
|
|
const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2]))
|
|
return json(res, ok ? 200 : 404, { ok })
|
|
}
|
|
// Staff : POST /events/<id>/send (mode TEST uniquement ; masse = pas encore activé)
|
|
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/send$/)
|
|
if (mm && method === 'POST') {
|
|
const eventId = mm[1]; const event = getEvent(eventId)
|
|
if (!event) return json(res, 404, { error: 'event_not_found' })
|
|
const b = await parseBody(req)
|
|
const channel = b.channel === 'gmail' ? 'gmail' : 'mailjet'
|
|
if (b.test) {
|
|
const emails = [...new Set((b.test_emails || []).map(e => String(e).trim()).filter(e => EMAIL_RE.test(e)))].slice(0, 10)
|
|
if (!emails.length) return json(res, 400, { error: 'no_test_emails' })
|
|
const results = await sendTest({ eventId, emails, channel, from: b.from, lang: b.lang || 'fr', name: b.name })
|
|
log(`Event invite TEST — ${eventId} · ${channel} · ${results.filter(r => r.ok).length}/${results.length} ok`)
|
|
return json(res, 200, { ok: results.every(r => r.ok), test: true, results })
|
|
}
|
|
return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." })
|
|
}
|
|
|
|
return json(res, 404, { error: 'not found' })
|
|
} catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) }
|
|
}
|
|
|
|
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, page, normLang }
|