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

+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>