Add branded admin email creator
This commit is contained in:
1 parent
15bc6f063d
commit
6ebd701721
5 files changed
+281
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user