From 6ebd70172156334fa10f19638b9ef9c2ac86d907 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 14 Jul 2026 12:19:04 +0200 Subject: [PATCH] Add branded admin email creator --- server/src/customEmailTemplate.js | 80 ++++++++++++ server/src/routes/admin.js | 17 +++ server/src/schemas.js | 14 ++ web/src/components/admin/AdminEmail.jsx | 167 ++++++++++++++++++++++++ web/src/pages/Admin.jsx | 3 + 5 files changed, 281 insertions(+) create mode 100644 server/src/customEmailTemplate.js create mode 100644 web/src/components/admin/AdminEmail.jsx diff --git a/server/src/customEmailTemplate.js b/server/src/customEmailTemplate.js new file mode 100644 index 0000000..da4e854 --- /dev/null +++ b/server/src/customEmailTemplate.js @@ -0,0 +1,80 @@ +function esc(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function lines(value) { + return esc(value).replace(/\r?\n/g, '
'); +} + +function paragraphBlocks(value) { + return String(value ?? '') + .split(/\r?\n\s*\r?\n/) + .filter(Boolean) + .map((paragraph, index, all) => ` + + ${lines(paragraph)} + `) + .join(''); +} + +function ctaButton(url, label) { + if (!url || !label) return ''; + return ` + + +
+ ${esc(label)} +
`; +} + +export function buildCustomEmail({ content, baseUrl }) { + const logoUrl = `${baseUrl}/assets/logo.jpg`; + const button = ctaButton(content.buttonUrl, content.buttonLabel); + const html = ` + + + + +${esc(content.subject)} + + + + + + +`; + + return { subject: content.subject, html }; +} diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index daa8297..58141e2 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -13,6 +13,7 @@ import { rencontrePasseeSchema, pastPhotoParam, emailComposeSchema, + customEmailSchema, idParam, } from '../schemas.js'; import { imageUpload, saveImage, deleteImage } from '../uploads.js'; @@ -20,6 +21,7 @@ import { ensureMemberAccess } from '../memberAccess.js'; import { createStaffUser, resetStaffAccess } from '../userAccess.js'; import { AccessError } from '../errors.js'; import { buildInvitationEmail } from '../emailTemplate.js'; +import { buildCustomEmail } from '../customEmailTemplate.js'; export const adminRouter = Router(); @@ -270,6 +272,21 @@ adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), asy } }); +// General-purpose branded email composer, reserved to administrators. +adminRouter.post('/emails/preview', adminOnly, validate(customEmailSchema), async (req, res, next) => { + try { + const { base, ...content } = req.data; + let baseUrl = `${req.protocol}://${req.get('host')}`; + if (base) { + const parsed = new URL(base); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') baseUrl = parsed.origin; + } + res.json(buildCustomEmail({ content, baseUrl })); + } catch (err) { + next(err); + } +}); + // ---------- Rencontres passées (admin + moderator manage; admin-only delete) ---------- // Powers the "Rencontres passées" section on the home page and its dedicated // public page. Participant count is derived from the linked rencontre's diff --git a/server/src/schemas.js b/server/src/schemas.js index d8a4284..ab19f97 100644 --- a/server/src/schemas.js +++ b/server/src/schemas.js @@ -105,3 +105,17 @@ export const emailComposeSchema = z.object({ outro: z.string().trim().max(2000).optional(), signature: z.string().trim().max(500).optional(), }); + +export const customEmailSchema = z.object({ + base: z.string().trim().url().max(300).optional(), + subject: trimmed(200, 1), + kicker: trimmed(120).optional().default(''), + title: trimmed(200, 1), + body: trimmed(6000, 1), + buttonLabel: trimmed(100).optional().default(''), + buttonUrl: z.string().trim().url().max(500).or(z.literal('')).optional().default(''), + signature: trimmed(500).optional().default(''), +}).refine((data) => Boolean(data.buttonLabel) === Boolean(data.buttonUrl), { + message: 'le texte et le lien du bouton doivent être renseignés ensemble', + path: ['buttonUrl'], +}); diff --git a/web/src/components/admin/AdminEmail.jsx b/web/src/components/admin/AdminEmail.jsx new file mode 100644 index 0000000..0766e3f --- /dev/null +++ b/web/src/components/admin/AdminEmail.jsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from 'react'; +import { api } from '../../lib/api.js'; + +const INITIAL = { + subject: 'Actualités du Business Club SLUC Nancy', + kicker: 'Business Club · SLUC Nancy', + title: 'Votre titre ici', + body: "Bonjour,\n\nRédigez ici votre message. Vous pouvez créer plusieurs paragraphes en laissant une ligne vide.\n\nLe modèle conserve automatiquement l'identité visuelle du Business Club.", + buttonLabel: 'Découvrir', + buttonUrl: window.location.origin, + signature: "À très bientôt,\nL'équipe du Business Club SLUC Nancy", +}; + +function bodyFragment(html) { + return new DOMParser().parseFromString(html, 'text/html').body.innerHTML.trim(); +} + +function legacyCopy(html) { + const holder = document.createElement('div'); + holder.contentEditable = 'true'; + holder.style.cssText = 'position:fixed;left:-10000px;top:0;width:700px;opacity:0;pointer-events:none;'; + holder.innerHTML = html; + document.body.appendChild(holder); + const range = document.createRange(); + range.selectNodeContents(holder); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + const copied = document.execCommand('copy'); + selection.removeAllRanges(); + holder.remove(); + if (!copied) throw new Error('Rich copy failed'); +} + +export function EmailCreatorTab() { + const [form, setForm] = useState(INITIAL); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const [copied, setCopied] = useState(''); + const requestId = useRef(0); + + useEffect(() => { + const id = ++requestId.current; + const timer = setTimeout(() => { + const completeButton = Boolean(form.buttonLabel && form.buttonUrl); + api.post('/api/admin/emails/preview', { + ...form, + base: window.location.origin, + buttonLabel: completeButton ? form.buttonLabel : '', + buttonUrl: completeButton ? form.buttonUrl : '', + }) + .then((result) => { + if (requestId.current !== id) return; + setData(result); + setError(''); + }) + .catch((err) => { + if (requestId.current === id) setError(err.message); + }); + }, 350); + return () => clearTimeout(timer); + }, [form]); + + const onChange = (event) => setForm({ ...form, [event.target.name]: event.target.value }); + const flash = (key) => { + setCopied(key); + setTimeout(() => setCopied(''), 1800); + }; + + const copyEmail = async () => { + try { + const fragment = bodyFragment(data.html); + if (navigator.clipboard?.write && window.ClipboardItem) { + await navigator.clipboard.write([ + new window.ClipboardItem({ + 'text/html': new Blob([fragment], { type: 'text/html' }), + 'text/plain': new Blob([`${form.subject}\n\n${form.body}`], { type: 'text/plain' }), + }), + ]); + } else { + legacyCopy(fragment); + } + flash('email'); + } catch { + setError('La copie enrichie a échoué dans ce navigateur. Utilisez le téléchargement HTML.'); + } + }; + + const copySubject = async () => { + try { + await navigator.clipboard.writeText(form.subject); + flash('subject'); + } catch { + setError("Impossible de copier l'objet."); + } + }; + + const download = () => { + const blob = new Blob([data.html], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + const slug = form.subject.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase() || 'email'; + anchor.href = url; + anchor.download = `${slug}.html`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 2000); + }; + + return ( +
+
+

Composer un e-mail

+

+ Le logo, les couleurs et le pied de page sont ajoutés automatiquement. +

+
+ + + +