diff --git a/server/src/emailTemplate.js b/server/src/emailTemplate.js new file mode 100644 index 0000000..46814ff --- /dev/null +++ b/server/src/emailTemplate.js @@ -0,0 +1,94 @@ +// Invitation email for a rencontre, generated on demand from the admin +// backend. Email-client constraints apply: table layout, inline styles, +// web-safe font stacks, absolute URLs. The palette mirrors the site theme +// (see web/src/styles.css) with Georgia standing in for Newsreader. +function esc(v) { + return String(v ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export function buildInvitationEmail({ rencontre, baseUrl }) { + const r = rencontre; + const dateStr = new Date(r.date_renc).toLocaleDateString('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + }); + const restantes = Math.max(0, r.places - (r.inscrits ?? 0)); + const inscriptionUrl = `${baseUrl}/?inscription=${r.id}`; + const logoUrl = `${baseUrl}/assets/logo.jpg`; + const subject = `Invitation — ${r.titre} · ${dateStr}`; + + const infoLines = [ + `${esc(dateStr.charAt(0).toUpperCase() + dateStr.slice(1))}`, + [r.heure && esc(r.heure), esc(r.lieu)].filter(Boolean).join(' — '), + restantes > 0 ? `${restantes} place${restantes > 1 ? 's' : ''} restante${restantes > 1 ? 's' : ''}` : 'Complet — liste d’attente', + ]; + + const html = ` + + + + +${esc(subject)} + + + + +
+ + + + + + + + + +
+ + + +
+ SLUC Business Club Nancy + +
Business Club
+
SLUC Nancy
+
+
+
Invitation · Rencontre
+
${esc(r.titre)}
+
${esc(r.description)}
+ + + +
+ ${infoLines.join('
')} +
+ + + +
+ Je m'inscris à la rencontre +
+
Réservé aux membres à jour de leur adhésion pour la saison en cours.
+
+ Business Club SLUC Nancy · Palais des Sports Jean Weille, Nancy
+ contact@sluc-businessclub.fr +
+ +
+ Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
+ ${esc(inscriptionUrl)} +
+ +
+ +`; + + return { subject, html, inscriptionUrl }; +} diff --git a/server/src/index.js b/server/src/index.js index 047a1df..143e861 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -39,6 +39,11 @@ app.use( }, }, xFrameOptions: { action: 'deny' }, + // Site images (logo, uploads) are public and referenced from outside + // the app's origin — e.g. the logo embedded in invitation emails is + // loaded by webmail clients. The default same-origin policy would + // block those loads. + crossOriginResourcePolicy: { policy: 'cross-origin' }, }) ); app.use(compression()); diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index ef98982..944d82c 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -18,6 +18,7 @@ import { imageUpload, saveImage, deleteImage } from '../uploads.js'; import { ensureMemberAccess } from '../memberAccess.js'; import { createStaffUser, resetStaffAccess } from '../userAccess.js'; import { AccessError } from '../errors.js'; +import { buildInvitationEmail } from '../emailTemplate.js'; export const adminRouter = Router(); @@ -227,6 +228,22 @@ adminRouter.post('/rencontres/:id/image', validate(idParam, 'params'), (req, res }); }); +// Ready-to-send HTML invitation email for a rencontre (admin + moderator). +// The registration link opens the public site with the inscription modal +// pre-opened (/?inscription=). URLs are absolute, built from the +// request's host so they match however the site is reached (direct port +// or reverse proxy). +adminRouter.get('/rencontres/:id/email', validate(idParam, 'params'), async (req, res, next) => { + try { + const result = await query(`${RENC_SQL} WHERE r.id = $1 GROUP BY r.id`, [req.params.id]); + if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' }); + const baseUrl = `${req.protocol}://${req.get('host')}`; + res.json(buildInvitationEmail({ rencontre: result.rows[0], baseUrl })); + } catch (err) { + next(err); + } +}); + adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), async (req, res, next) => { try { const result = await query(`${INSCR_SQL} WHERE i.rencontre_id = $1 ORDER BY i.nom`, [req.params.id]); diff --git a/web/src/components/admin/AdminTabs.jsx b/web/src/components/admin/AdminTabs.jsx index a6acc9f..baafb1c 100644 --- a/web/src/components/admin/AdminTabs.jsx +++ b/web/src/components/admin/AdminTabs.jsx @@ -316,6 +316,101 @@ function escHtml(v) { return String(v ?? '').replace(/&/g, '&').replace(//g, '>'); } +// Preview + export of the ready-to-send HTML invitation email generated +// server-side for one rencontre. "Copier l'email" puts the rendered HTML +// on the clipboard (rich text where supported, so pasting into a mail +// client keeps the design); the .html download covers the rest. +function EmailModal({ renc, onClose }) { + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const [copied, setCopied] = useState(''); + + useEffect(() => { + api.get(`/api/admin/rencontres/${renc.id}/email`).then(setData).catch((e) => setError(e.message)); + }, [renc.id]); + + const flash = (what) => { + setCopied(what); + setTimeout(() => setCopied(''), 1800); + }; + + const copyEmail = async () => { + setError(''); + try { + if (navigator.clipboard?.write && window.ClipboardItem) { + await navigator.clipboard.write([ + new window.ClipboardItem({ + 'text/html': new Blob([data.html], { type: 'text/html' }), + 'text/plain': new Blob([`${data.subject}\n${data.inscriptionUrl}`], { type: 'text/plain' }), + }), + ]); + } else { + await navigator.clipboard.writeText(data.html); + } + flash('email'); + } catch { + setError('Copie impossible dans ce navigateur — utilisez le téléchargement.'); + } + }; + + const copyText = async (text, what) => { + setError(''); + try { + await navigator.clipboard.writeText(text); + flash(what); + } catch { + setError('Copie impossible dans ce navigateur.'); + } + }; + + const download = () => { + const blob = new Blob([data.html], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const slug = renc.titre.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase(); + a.href = url; + a.download = `invitation-${slug}.html`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 2000); + }; + + return ( + + {!data && !error &&

Génération de l'email…

} + {error &&

{error}

} + {data && ( + <> +
+ + + + +
+
+ Objet : {data.subject} +
+ Collez l'email directement dans votre logiciel de messagerie (le bouton « Copier l'email » + conserve la mise en forme), ou envoyez le fichier .html avec votre outil d'emailing. +
+
+