Add branded admin email creator

This commit is contained in:
Michael committed 2026-07-14 12:19:04 +02:00
1 parent 15bc6f063d
commit 6ebd701721
5 files changed
+281

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function lines(value) {
return esc(value).replace(/\r?\n/g, '<br>');
}
function paragraphBlocks(value) {
return String(value ?? '')
.split(/\r?\n\s*\r?\n/)
.filter(Boolean)
.map((paragraph, index, all) => `
<tr><td style="padding-bottom:${index === all.length - 1 ? 0 : 18}px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;mso-line-height-rule:exactly;color:#5A544E;">
${lines(paragraph)}
</td></tr>`)
.join('');
}
function ctaButton(url, label) {
if (!url || !label) return '';
return `
<table role="presentation" width="300" cellpadding="0" cellspacing="0" border="0" style="width:300px;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">
<tr><td width="300" height="48" align="center" valign="middle" bgcolor="#C1272D" style="width:300px;height:48px;background-color:#C1272D;">
<a href="${esc(url)}" target="_blank" style="display:block;width:300px;line-height:48px;color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;text-align:center;text-decoration:none;">${esc(label)}</a>
</td></tr>
</table>`;
}
export function buildCustomEmail({ content, baseUrl }) {
const logoUrl = `${baseUrl}/assets/logo.jpg`;
const button = ctaButton(content.buttonUrl, content.buttonLabel);
const html = `<!doctype html>
<html lang="fr" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(content.subject)}</title>
<!--[if mso]>
<xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>
<style>table { border-collapse:collapse; } td { font-family:Arial,Helvetica,sans-serif; }</style>
<![endif]-->
</head>
<body bgcolor="#F2EEE8" style="margin:0;padding:0;background-color:#F2EEE8;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<table id="sbc-email-root" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F2EEE8" style="width:100%;background-color:#F2EEE8;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">
<tr><td align="center" valign="top" style="padding:36px 14px;">
<!--[if mso]><table role="presentation" width="620" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:620px;background-color:#FFFFFF;border:1px solid #E7E2DB;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">
<tr><td bgcolor="#161514" style="background-color:#161514;padding:26px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;"><tr>
<td width="52" valign="middle"><img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;background-color:#FFFFFF;"></td>
<td valign="middle" style="padding-left:16px;">
<div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:22px;">Business Club</div>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:#E0777A;font-weight:bold;line-height:16px;">SLUC Nancy</div>
</td>
</tr></table>
</td></tr>
<tr><td style="padding:40px 40px 16px;">
${content.kicker ? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-bottom:16px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.5px;text-transform:uppercase;color:#C1272D;font-weight:bold;">${esc(content.kicker)}</td></tr></table>` : ''}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-bottom:22px;font-family:Georgia,'Times New Roman',serif;font-size:28px;line-height:34px;color:#1B1B1B;">${esc(content.title)}</td></tr></table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">${paragraphBlocks(content.body)}</table>
</td></tr>
${button ? `<tr><td align="center" style="padding:18px 40px 12px;">${button}</td></tr>` : ''}
${content.signature ? `<tr><td style="padding:24px 40px 38px;font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:26px;color:#1B1B1B;">${lines(content.signature)}</td></tr>` : '<tr><td height="26" style="height:26px;line-height:26px;">&nbsp;</td></tr>'}
<tr><td bgcolor="#0F0E0D" style="background-color:#0F0E0D;padding:24px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:22px;">
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>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr></table>
</body>
</html>`;
return { subject: content.subject, html };
}
+17
View File
@@ -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
+14
View File
@@ -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'],
});
+167
View File
@@ -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 (
<div className="hero-grid" style={{ display: 'grid', gridTemplateColumns: '360px minmax(0, 1fr)', gap: 26, alignItems: 'start' }}>
<div className="card" style={{ padding: 24, borderRadius: 6 }}>
<h2 className="serif" style={{ fontSize: 20, fontWeight: 600, marginBottom: 6 }}>Composer un e-mail</h2>
<p style={{ fontSize: 13, color: 'var(--gray-light)', lineHeight: 1.55, marginBottom: 20 }}>
Le logo, les couleurs et le pied de page sont ajoutés automatiquement.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 13 }}>
<label className="field">Objet
<input name="subject" value={form.subject} onChange={onChange} required maxLength={200} />
</label>
<label className="field">Surtitre
<input name="kicker" value={form.kicker} onChange={onChange} maxLength={120} placeholder="Facultatif" />
</label>
<label className="field">Titre
<input name="title" value={form.title} onChange={onChange} required maxLength={200} />
</label>
<label className="field">Message
<textarea name="body" value={form.body} onChange={onChange} rows={9} required maxLength={6000} />
</label>
<div style={{ borderTop: '1px solid var(--border-soft)', paddingTop: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 10 }}>Bouton facultatif</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label className="field">Texte du bouton
<input name="buttonLabel" value={form.buttonLabel} onChange={onChange} maxLength={100} />
</label>
<label className="field">Lien du bouton
<input name="buttonUrl" type="url" value={form.buttonUrl} onChange={onChange} maxLength={500} placeholder="https://…" />
</label>
</div>
</div>
<label className="field">Signature
<textarea name="signature" value={form.signature} onChange={onChange} rows={3} maxLength={500} />
</label>
</div>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 20 }}>
<button className="btn btn-red" onClick={copyEmail} disabled={!data}>{copied === 'email' ? '✓ E-mail copié' : "Copier l'e-mail"}</button>
<button className="btn btn-outline-soft btn-sm" style={{ padding: 12 }} onClick={copySubject}>{copied === 'subject' ? '✓ Objet copié' : "Copier l'objet"}</button>
<button className="btn btn-outline-soft btn-sm" style={{ padding: 12 }} onClick={download} disabled={!data}>Télécharger le HTML</button>
</div>
<p style={{ fontSize: 11.5, color: 'var(--gray-light)', lineHeight: 1.5, marginTop: 14 }}>
Dans un brouillon Outlook, utilisez Ctrl + clic pour tester un lien. Après envoi, un clic normal suffit.
</p>
</div>
<div>
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginBottom: 10 }}><strong style={{ color: 'var(--gray)' }}>Objet :</strong> {form.subject}</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', background: '#F2EEE8' }}>
{data
? <iframe title="Aperçu de l'e-mail" srcDoc={data.html} sandbox="" style={{ display: 'block', width: '100%', height: 720, border: 'none', background: '#F2EEE8' }} />
: <div style={{ height: 300, padding: 30, color: 'var(--gray-light)' }}>Génération de l'aperçu…</div>}
</div>
</div>
</div>
);
}
+3
View File
@@ -6,6 +6,7 @@ import { MembersTab, RencontresTab, InscriptionsTab } from '../components/admin/
import { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx';
import { PastEventsTab } from '../components/admin/AdminPast.jsx';
import { UsersTab } from '../components/admin/AdminUsers.jsx';
import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx';
// Moderators only get members/rencontres/inscriptions/rencontres passées
// (create + edit, never delete) — everything else (dashboard, taxonomy,
@@ -19,6 +20,7 @@ const TABS = [
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] },
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] },
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site', roles: ['admin'] },
{ key: 'emails', icon: '✉', label: "Créateur d'e-mail", title: "Créateur d'e-mail", roles: ['admin'] },
{ key: 'utilisateurs', icon: '⚿', label: 'Administrateurs', title: 'Administrateurs & modérateurs', roles: ['admin'] },
];
@@ -150,6 +152,7 @@ export function AdminShell() {
{tab === 'inscriptions' && <InscriptionsTab />}
{tab === 'categories' && <CategoriesTab />}
{tab === 'contenu' && <ContenuTab />}
{tab === 'emails' && <EmailCreatorTab />}
{tab === 'utilisateurs' && <UsersTab />}
</div>
</div>