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 `
+ `;
+}
+
+export function buildCustomEmail({ content, baseUrl }) {
+ const logoUrl = `${baseUrl}/assets/logo.jpg`;
+ const button = ctaButton(content.buttonUrl, content.buttonLabel);
+ const html = `
+
+
+
+
+${esc(content.subject)}
+
+
+
+
+
+
+
+
+
+ }) |
+
+ Business Club
+ SLUC Nancy
+ |
+
+ |
+
+ ${content.kicker ? `` : ''}
+
+ ${paragraphBlocks(content.body)}
+ |
+ ${button ? `| ${button} | ` : ''}
+ ${content.signature ? `| ${lines(content.signature)} | ` : '| | '}
+
+ Business Club SLUC Nancy · Palais des Sports Jean Weille, Nancy
+ contact@sluc-businessclub.fr
+ |
+
+
+ |
+
+`;
+
+ 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.
+
+
+ {error &&
{error}
}
+
+
+
+
+
+
+ Dans un brouillon Outlook, utilisez Ctrl + clic pour tester un lien. Après envoi, un clic normal suffit.
+
+
+
+
+
Objet : {form.subject}
+
+ {data
+ ?
+ :
Génération de l'aperçu…
}
+
+
+
+ );
+}
diff --git a/web/src/pages/Admin.jsx b/web/src/pages/Admin.jsx
index 62ebffd..5b03de3 100644
--- a/web/src/pages/Admin.jsx
+++ b/web/src/pages/Admin.jsx
@@ -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' && }
{tab === 'categories' && }
{tab === 'contenu' && }
+ {tab === 'emails' && }
{tab === 'utilisateurs' && }