Email d'invitation complet avec textes personnalisables

L'email généré était réduit au bloc d'invitation. Il devient une vraie
communication du Club : salutation, message d'introduction, carte de la
rencontre (titre, description, date/heure/lieu/places), bouton
d'inscription, message de conclusion, signature et pied de page contact.

La modale d'administration devient un composeur : les quatre zones de
texte (salutation, introduction, conclusion, signature) sont pré-remplies
avec une rédaction par défaut et modifiables, avec régénération de
l'aperçu pendant la saisie ; vider un champ retire le bloc de l'email.
La route passe en POST avec validation zod des textes (longueurs max).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
Claude committed 2026-07-14 07:55:08 +00:00
1 parent d4923e9dfc
commit 03ec1b7a30
4 files changed
+182 -64

No files matched your search

+70 -18
View File
@@ -10,8 +10,38 @@ function esc(v) {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
export function buildInvitationEmail({ rencontre, baseUrl }) { // Multi-line text zones (intro, outro, signature) keep their line breaks.
function escML(v) {
return esc(v).replace(/\r?\n/g, '<br>');
}
// Default wording of each editable zone. The admin can rewrite any of
// them in the composer; clearing a field removes the block entirely.
export function defaultEmailTexts() {
return {
greeting: 'Chère membre, cher membre,',
intro:
"Nous avons le plaisir de vous convier à notre prochaine rencontre du Business Club. "
+ "Networking, échanges et convivialité : nous vous attendons nombreux, dans l'esprit "
+ "d'équipe qui fait la force du Club.",
outro:
"Les places sont limitées — pensez à vous inscrire dès maintenant. N'hésitez pas à "
+ "transférer cette invitation à un dirigeant de votre réseau qui souhaiterait découvrir le Club.",
signature: "À très bientôt,\nL'équipe du Business Club SLUC Nancy",
};
}
export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
const r = rencontre; const r = rencontre;
const defaults = defaultEmailTexts();
// undefined -> default wording; '' (cleared by the admin) -> block hidden
const t = {
greeting: texts.greeting ?? defaults.greeting,
intro: texts.intro ?? defaults.intro,
outro: texts.outro ?? defaults.outro,
signature: texts.signature ?? defaults.signature,
};
const dateStr = new Date(r.date_renc).toLocaleDateString('fr-FR', { const dateStr = new Date(r.date_renc).toLocaleDateString('fr-FR', {
weekday: 'long', weekday: 'long',
day: 'numeric', day: 'numeric',
@@ -25,9 +55,16 @@ export function buildInvitationEmail({ rencontre, baseUrl }) {
const infoLines = [ const infoLines = [
`<strong style="color:#1B1B1B;">${esc(dateStr.charAt(0).toUpperCase() + dateStr.slice(1))}</strong>`, `<strong style="color:#1B1B1B;">${esc(dateStr.charAt(0).toUpperCase() + dateStr.slice(1))}</strong>`,
[r.heure && esc(r.heure), esc(r.lieu)].filter(Boolean).join(' — '), [r.heure && esc(r.heure), esc(r.lieu)].filter(Boolean).join(' &mdash; '),
restantes > 0 ? `${restantes} place${restantes > 1 ? 's' : ''} restante${restantes > 1 ? 's' : ''}` : 'Complet — liste d’attente', restantes > 0
]; ? `${restantes} place${restantes > 1 ? 's' : ''} restante${restantes > 1 ? 's' : ''}`
: 'Complet &mdash; liste d&rsquo;attente',
].filter(Boolean);
const para = (text, extra = '') =>
text
? `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.7;color:#5A544E;${extra}">${escML(text)}</div>`
: '';
const html = `<!doctype html> const html = `<!doctype html>
<html lang="fr"> <html lang="fr">
@@ -40,9 +77,9 @@ export function buildInvitationEmail({ rencontre, baseUrl }) {
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#F2EEE8;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#F2EEE8;">
<tr><td align="center" style="padding:36px 14px;"> <tr><td align="center" style="padding:36px 14px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;background-color:#FFFFFF;border:1px solid #E7E2DB;border-radius:8px;overflow:hidden;"> <table role="presentation" width="620" cellpadding="0" cellspacing="0" border="0" style="max-width:620px;width:100%;background-color:#FFFFFF;border:1px solid #E7E2DB;border-radius:8px;overflow:hidden;">
<tr><td style="background-color:#161514;padding:26px 36px;"> <tr><td style="background-color:#161514;padding:26px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr> <table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td width="52" valign="middle"> <td width="52" valign="middle">
<img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;border-radius:6px;background-color:#FFFFFF;"> <img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;border-radius:6px;background-color:#FFFFFF;">
@@ -54,33 +91,48 @@ export function buildInvitationEmail({ rencontre, baseUrl }) {
</tr></table> </tr></table>
</td></tr> </td></tr>
<tr><td style="padding:40px 36px 32px;"> <tr><td style="padding:40px 40px 12px;">
<div style="font-family:Arial,Helvetica,sans-serif;font-size:11px;letter-spacing:2.5px;text-transform:uppercase;color:#C1272D;font-weight:bold;padding-bottom:16px;">Invitation &middot; Rencontre</div> <div style="font-family:Arial,Helvetica,sans-serif;font-size:11px;letter-spacing:2.5px;text-transform:uppercase;color:#C1272D;font-weight:bold;padding-bottom:18px;">Invitation &middot; Rencontre</div>
<div style="font-family:Georgia,'Times New Roman',serif;font-size:30px;line-height:1.15;color:#1B1B1B;padding-bottom:18px;">${esc(r.titre)}</div> ${t.greeting ? `<div style="font-family:Georgia,'Times New Roman',serif;font-size:19px;color:#1B1B1B;padding-bottom:14px;">${escML(t.greeting)}</div>` : ''}
<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.65;color:#5A544E;padding-bottom:26px;">${esc(r.description)}</div> ${para(t.intro, 'padding-bottom:26px;')}
</td></tr>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#FAF8F5;border-left:3px solid #C1272D;"> <tr><td style="padding:0 40px;">
<tr><td style="padding:18px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#5A544E;line-height:1.9;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#FAF8F5;border:1px solid #E7E2DB;border-left:3px solid #C1272D;border-radius:4px;">
${infoLines.join('<br>')} <tr><td style="padding:26px 28px;">
<div style="font-family:Georgia,'Times New Roman',serif;font-size:24px;line-height:1.2;color:#1B1B1B;padding-bottom:12px;">${esc(r.titre)}</div>
${r.description ? `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14.5px;line-height:1.65;color:#5A544E;padding-bottom:16px;">${esc(r.description)}</div>` : ''}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td style="border-top:1px solid #E7E2DB;padding-top:16px;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#5A544E;line-height:1.9;">
${infoLines.join('<br>')}
</td>
</tr></table>
</td></tr> </td></tr>
</table> </table>
</td></tr>
<tr><td style="padding:6px 40px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td align="center" style="padding:30px 0 0;"> <td align="center" style="padding:28px 0 0;">
<a href="${esc(inscriptionUrl)}" style="display:inline-block;background-color:#C1272D;color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;text-decoration:none;padding:15px 40px;border-radius:3px;">Je m'inscris &agrave; la rencontre</a> <a href="${esc(inscriptionUrl)}" style="display:inline-block;background-color:#C1272D;color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;text-decoration:none;padding:15px 40px;border-radius:3px;">Je m'inscris &agrave; la rencontre</a>
</td> </td>
</tr></table> </tr></table>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#9A938B;text-align:center;padding-top:16px;line-height:1.6;">R&eacute;serv&eacute; aux membres &agrave; jour de leur adh&eacute;sion pour la saison en cours.</div> <div style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#9A938B;text-align:center;padding-top:14px;line-height:1.6;">R&eacute;serv&eacute; aux membres &agrave; jour de leur adh&eacute;sion pour la saison en cours.</div>
</td></tr> </td></tr>
<tr><td style="background-color:#0F0E0D;padding:24px 36px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:1.8;"> <tr><td style="padding:28px 40px 38px;">
${para(t.outro, t.signature ? 'padding-bottom:24px;' : '')}
${t.signature ? `<div style="font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:1.6;color:#1B1B1B;">${escML(t.signature)}</div>` : ''}
</td></tr>
<tr><td style="background-color:#0F0E0D;padding:24px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:1.8;">
Business Club SLUC Nancy &middot; Palais des Sports Jean Weille, Nancy<br> Business Club SLUC Nancy &middot; Palais des Sports Jean Weille, Nancy<br>
<a href="mailto:contact@sluc-businessclub.fr" style="color:#E0777A;text-decoration:none;">contact@sluc-businessclub.fr</a> <a href="mailto:contact@sluc-businessclub.fr" style="color:#E0777A;text-decoration:none;">contact@sluc-businessclub.fr</a>
</td></tr> </td></tr>
</table> </table>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:11px;color:#9A938B;padding-top:18px;max-width:600px;"> <div style="font-family:Arial,Helvetica,sans-serif;font-size:11px;color:#9A938B;padding-top:18px;max-width:620px;">
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur&nbsp;:<br> Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur&nbsp;:<br>
<a href="${esc(inscriptionUrl)}" style="color:#C1272D;text-decoration:underline;word-break:break-all;">${esc(inscriptionUrl)}</a> <a href="${esc(inscriptionUrl)}" style="color:#C1272D;text-decoration:underline;word-break:break-all;">${esc(inscriptionUrl)}</a>
</div> </div>
@@ -90,5 +142,5 @@ export function buildInvitationEmail({ rencontre, baseUrl }) {
</body> </body>
</html>`; </html>`;
return { subject, html, inscriptionUrl }; return { subject, html, inscriptionUrl, texts: t };
} }
+14 -10
View File
@@ -12,6 +12,7 @@ import {
roleChangeSchema, roleChangeSchema,
rencontrePasseeSchema, rencontrePasseeSchema,
pastPhotoParam, pastPhotoParam,
emailComposeSchema,
idParam, idParam,
} from '../schemas.js'; } from '../schemas.js';
import { imageUpload, saveImage, deleteImage } from '../uploads.js'; import { imageUpload, saveImage, deleteImage } from '../uploads.js';
@@ -230,25 +231,28 @@ adminRouter.post('/rencontres/:id/image', validate(idParam, 'params'), (req, res
// Ready-to-send HTML invitation email for a rencontre (admin + moderator). // Ready-to-send HTML invitation email for a rencontre (admin + moderator).
// The registration link opens the public site with the inscription modal // The registration link opens the public site with the inscription modal
// pre-opened (/?inscription=<id>). URLs must be absolute for email // pre-opened (/?inscription=<id>). The body carries the editable text
// clients: the frontend passes its own window.location.origin (?base=), // zones (greeting/intro/outro/signature — omitted = default wording,
// which is exactly the public address the admin is browsing — reliable // empty = block hidden) plus `base`: URLs must be absolute for email
// even behind a TLS-terminating reverse proxy where req.protocol would // clients, and the frontend's window.location.origin is exactly the
// say "http". The request's host is only a fallback. // public address the admin is browsing — reliable even behind a
adminRouter.get('/rencontres/:id/email', validate(idParam, 'params'), async (req, res, next) => { // TLS-terminating reverse proxy where req.protocol would say "http".
// The request's host is only a fallback.
adminRouter.post('/rencontres/:id/email', validate(idParam, 'params'), validate(emailComposeSchema), async (req, res, next) => {
try { try {
const result = await query(`${RENC_SQL} WHERE r.id = $1 GROUP BY r.id`, [req.params.id]); 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' }); if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
const { base, ...texts } = req.data;
let baseUrl = `${req.protocol}://${req.get('host')}`; let baseUrl = `${req.protocol}://${req.get('host')}`;
if (typeof req.query.base === 'string') { if (base) {
try { try {
const u = new URL(req.query.base); const u = new URL(base);
if (u.protocol === 'http:' || u.protocol === 'https:') baseUrl = u.origin; if (u.protocol === 'http:' || u.protocol === 'https:') baseUrl = u.origin;
} catch { } catch {
// invalid ?base= — keep the fallback // invalid base — keep the fallback
} }
} }
res.json(buildInvitationEmail({ rencontre: result.rows[0], baseUrl })); res.json(buildInvitationEmail({ rencontre: result.rows[0], baseUrl, texts }));
} catch (err) { } catch (err) {
next(err); next(err);
} }
+10
View File
@@ -97,3 +97,13 @@ export const contentSchema = z.object({
hero_quote_text: trimmed(300).optional(), hero_quote_text: trimmed(300).optional(),
hero_quote_author: trimmed(120).optional(), hero_quote_author: trimmed(120).optional(),
}); });
// Invitation email composer: every text zone is optional — absent fields
// fall back to the default wording, an empty string hides the block.
export const emailComposeSchema = z.object({
base: z.string().trim().url().max(300).optional(),
greeting: z.string().trim().max(300).optional(),
intro: z.string().trim().max(2000).optional(),
outro: z.string().trim().max(2000).optional(),
signature: z.string().trim().max(500).optional(),
});
+88 -36
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js'; import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js';
import { useAuth } from '../../lib/AuthContext.jsx'; import { useAuth } from '../../lib/AuthContext.jsx';
import Modal from '../Modal.jsx'; import Modal from '../Modal.jsx';
@@ -316,24 +316,53 @@ function escHtml(v) {
return String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
// Preview + export of the ready-to-send HTML invitation email generated // Composer + preview of the ready-to-send HTML invitation email generated
// server-side for one rencontre. "Copier l'email" puts the rendered HTML // server-side for one rencontre. Every text zone (salutation, intro,
// on the clipboard (rich text where supported, so pasting into a mail // conclusion, signature) is editable — the preview regenerates as the
// client keeps the design); the .html download covers the rest. // admin types; clearing a zone removes its block from the email.
// "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 }) { function EmailModal({ renc, onClose }) {
const [data, setData] = useState(null); const [texts, setTexts] = useState(null); // editable zones, prefilled by the server
const [data, setData] = useState(null); // { subject, html, inscriptionUrl }
const [error, setError] = useState(''); const [error, setError] = useState('');
const [copied, setCopied] = useState(''); const [copied, setCopied] = useState('');
const skipRegen = useRef(true);
const generate = (t) =>
api.post(`/api/admin/rencontres/${renc.id}/email`, {
// The browser knows the site's real public origin (scheme included) —
// the server can't always tell behind a reverse proxy.
base: window.location.origin,
...(t || {}),
});
useEffect(() => { useEffect(() => {
// The browser knows the site's real public origin (scheme included) — generate(null)
// the server can't always tell behind a reverse proxy. .then((d) => {
api setData(d);
.get(`/api/admin/rencontres/${renc.id}/email?base=${encodeURIComponent(window.location.origin)}`) skipRegen.current = true;
.then(setData) setTexts(d.texts);
})
.catch((e) => setError(e.message)); .catch((e) => setError(e.message));
}, [renc.id]); }, [renc.id]);
// Regenerate the preview as the admin edits, debounced.
useEffect(() => {
if (!texts) return;
if (skipRegen.current) {
skipRegen.current = false;
return;
}
const id = setTimeout(() => {
generate(texts).then(setData).catch((e) => setError(e.message));
}, 450);
return () => clearTimeout(id);
}, [texts]);
const onText = (e) => setTexts({ ...texts, [e.target.name]: e.target.value });
const flash = (what) => { const flash = (what) => {
setCopied(what); setCopied(what);
setTimeout(() => setCopied(''), 1800); setTimeout(() => setCopied(''), 1800);
@@ -382,35 +411,58 @@ function EmailModal({ renc, onClose }) {
}; };
return ( return (
<Modal onClose={onClose} maxWidth={760} header={{ kicker: "Email d'invitation", title: renc.titre }}> <Modal onClose={onClose} maxWidth={1000} header={{ kicker: "Email d'invitation", title: renc.titre }}>
{!data && !error && <p style={{ padding: '30px 32px', color: 'var(--gray-light)' }}>Génération de l'email…</p>} {!data && !error && <p style={{ padding: '30px 32px', color: 'var(--gray-light)' }}>Génération de l'email…</p>}
{error && <p className="error-text" style={{ padding: '20px 32px 0' }}>{error}</p>} {error && <p className="error-text" style={{ padding: '20px 32px 0' }}>{error}</p>}
{data && ( {data && texts && (
<> <div className="hero-grid" style={{ display: 'grid', gridTemplateColumns: '340px 1fr', gap: 26, padding: '24px 32px 30px', alignItems: 'start' }}>
<div style={{ padding: '18px 32px 0', display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<button className="btn btn-red btn-sm" style={{ fontSize: 13.5, padding: '11px 18px' }} onClick={copyEmail}> <label className="field">Salutation
{copied === 'email' ? '✓ Copié' : "Copier l'email"} <input name="greeting" value={texts.greeting} onChange={onText} maxLength={300} />
</button> </label>
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13.5, padding: '11px 18px' }} onClick={() => copyText(data.subject, 'subject')}> <label className="field">Message d'introduction
{copied === 'subject' ? '✓ Copié' : "Copier l'objet"} <textarea name="intro" value={texts.intro} onChange={onText} rows={4} maxLength={2000} />
</button> </label>
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13.5, padding: '11px 18px' }} onClick={() => copyText(data.inscriptionUrl, 'link')}> <label className="field">Message de conclusion
{copied === 'link' ? '✓ Copié' : "Copier le lien d'inscription"} <textarea name="outro" value={texts.outro} onChange={onText} rows={4} maxLength={2000} />
</button> </label>
<button className="btn btn-outline-soft btn-sm" style={{ fontSize: 13.5, padding: '11px 18px' }} onClick={download}> <label className="field">Signature
↓ Télécharger (.html) <textarea name="signature" value={texts.signature} onChange={onText} rows={2} maxLength={500} />
</button> </label>
<p style={{ fontSize: 12, color: 'var(--gray-light)', lineHeight: 1.55 }}>
Les détails de la rencontre (titre, description, date, lieu, places) sont repris
automatiquement. Videz un champ pour retirer ce bloc de l'email.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 4 }}>
<button className="btn btn-red btn-sm" style={{ fontSize: 13.5, padding: '12px 18px' }} onClick={copyEmail}>
{copied === 'email' ? '✓ Copié' : "Copier l'email"}
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" style={{ flex: 1, background: 'var(--admin-bg)', fontSize: 13, padding: '11px 10px' }} onClick={() => copyText(data.subject, 'subject')}>
{copied === 'subject' ? '✓ Copié' : "Copier l'objet"}
</button>
<button className="btn btn-sm" style={{ flex: 1, background: 'var(--admin-bg)', fontSize: 13, padding: '11px 10px' }} onClick={() => copyText(data.inscriptionUrl, 'link')}>
{copied === 'link' ? '✓ Copié' : 'Copier le lien'}
</button>
</div>
<button className="btn btn-outline-soft btn-sm" style={{ fontSize: 13, padding: '11px 18px' }} onClick={download}>
↓ Télécharger (.html)
</button>
</div>
<p style={{ fontSize: 12, color: 'var(--gray-light)', lineHeight: 1.55 }}>
Collez l'email directement dans votre logiciel de messagerie (« Copier l'email »
conserve la mise en forme), ou envoyez le fichier .html avec votre outil d'emailing.
</p>
</div> </div>
<div style={{ padding: '14px 32px 6px', fontSize: 12.5, color: 'var(--gray-light)', lineHeight: 1.55 }}> <div>
<strong style={{ color: 'var(--gray)' }}>Objet :</strong> {data.subject} <div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginBottom: 10 }}>
<br /> <strong style={{ color: 'var(--gray)' }}>Objet :</strong> {data.subject}
Collez l'email directement dans votre logiciel de messagerie (le bouton « Copier l'email » </div>
conserve la mise en forme), ou envoyez le fichier .html avec votre outil d'emailing. <div style={{ border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
<iframe title="Aperçu de l'email" srcDoc={data.html} sandbox="" style={{ display: 'block', width: '100%', height: 560, border: 'none', background: '#F2EEE8' }} />
</div>
</div> </div>
<div style={{ margin: '14px 32px 30px', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}> </div>
<iframe title="Aperçu de l'email" srcDoc={data.html} sandbox="" style={{ display: 'block', width: '100%', height: 480, border: 'none', background: '#F2EEE8' }} />
</div>
</>
)} )}
</Modal> </Modal>
); );