diff --git a/server/src/emailTemplate.js b/server/src/emailTemplate.js index 46814ff..21e4d13 100644 --- a/server/src/emailTemplate.js +++ b/server/src/emailTemplate.js @@ -10,8 +10,38 @@ function esc(v) { .replace(/"/g, '"'); } -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, '
'); +} + +// 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 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', { weekday: 'long', day: 'numeric', @@ -25,9 +55,16 @@ export function buildInvitationEmail({ rencontre, baseUrl }) { 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', - ]; + [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', + ].filter(Boolean); + + const para = (text, extra = '') => + text + ? `
${escML(text)}
` + : ''; const html = ` @@ -40,9 +77,9 @@ export function buildInvitationEmail({ rencontre, baseUrl }) {
- +
- - -
+
SLUC Business Club Nancy @@ -54,33 +91,48 @@ export function buildInvitationEmail({ rencontre, baseUrl }) {
-
Invitation · Rencontre
-
${esc(r.titre)}
-
${esc(r.description)}
+
+
Invitation · Rencontre
+ ${t.greeting ? `
${escML(t.greeting)}
` : ''} + ${para(t.intro, 'padding-bottom:26px;')} +
- + - + +
- ${infoLines.join('
')} +
+ +
+
${esc(r.titre)}
+ ${r.description ? `
${esc(r.description)}
` : ''} + + +
+ ${infoLines.join('
')} +
+
-
+ Je m'inscris à la rencontre
-
Réservé aux membres à jour de leur adhésion pour la saison en cours.
+
Réservé aux membres à jour de leur adhésion pour la saison en cours.
+
+ ${para(t.outro, t.signature ? 'padding-bottom:24px;' : '')} + ${t.signature ? `
${escML(t.signature)}
` : ''} +
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)}
@@ -90,5 +142,5 @@ export function buildInvitationEmail({ rencontre, baseUrl }) { `; - return { subject, html, inscriptionUrl }; + return { subject, html, inscriptionUrl, texts: t }; } diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 2e0d43c..01876ad 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -12,6 +12,7 @@ import { roleChangeSchema, rencontrePasseeSchema, pastPhotoParam, + emailComposeSchema, idParam, } from '../schemas.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). // The registration link opens the public site with the inscription modal -// pre-opened (/?inscription=). URLs must be absolute for email -// clients: the frontend passes its own window.location.origin (?base=), -// which is exactly the public address the admin is browsing — reliable -// even behind a TLS-terminating reverse proxy where req.protocol would -// say "http". The request's host is only a fallback. -adminRouter.get('/rencontres/:id/email', validate(idParam, 'params'), async (req, res, next) => { +// pre-opened (/?inscription=). The body carries the editable text +// zones (greeting/intro/outro/signature — omitted = default wording, +// empty = block hidden) plus `base`: URLs must be absolute for email +// clients, and the frontend's window.location.origin is exactly the +// public address the admin is browsing — reliable even behind a +// 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 { 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 { base, ...texts } = req.data; let baseUrl = `${req.protocol}://${req.get('host')}`; - if (typeof req.query.base === 'string') { + if (base) { try { - const u = new URL(req.query.base); + const u = new URL(base); if (u.protocol === 'http:' || u.protocol === 'https:') baseUrl = u.origin; } 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) { next(err); } diff --git a/server/src/schemas.js b/server/src/schemas.js index 95da047..7d6bbb9 100644 --- a/server/src/schemas.js +++ b/server/src/schemas.js @@ -97,3 +97,13 @@ export const contentSchema = z.object({ hero_quote_text: trimmed(300).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(), +}); diff --git a/web/src/components/admin/AdminTabs.jsx b/web/src/components/admin/AdminTabs.jsx index 4f04ef2..bad6ecb 100644 --- a/web/src/components/admin/AdminTabs.jsx +++ b/web/src/components/admin/AdminTabs.jsx @@ -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 { useAuth } from '../../lib/AuthContext.jsx'; import Modal from '../Modal.jsx'; @@ -316,24 +316,53 @@ 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. +// Composer + preview of the ready-to-send HTML invitation email generated +// server-side for one rencontre. Every text zone (salutation, intro, +// conclusion, signature) is editable — the preview regenerates as the +// 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 }) { - 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 [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(() => { - // The browser knows the site's real public origin (scheme included) — - // the server can't always tell behind a reverse proxy. - api - .get(`/api/admin/rencontres/${renc.id}/email?base=${encodeURIComponent(window.location.origin)}`) - .then(setData) + generate(null) + .then((d) => { + setData(d); + skipRegen.current = true; + setTexts(d.texts); + }) .catch((e) => setError(e.message)); }, [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) => { setCopied(what); setTimeout(() => setCopied(''), 1800); @@ -382,35 +411,58 @@ function EmailModal({ renc, onClose }) { }; return ( - + {!data && !error &&

Génération de l'email…

} {error &&

{error}

} - {data && ( - <> -
- - - - + {data && texts && ( +
+
+ +