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,80 @@
|
|||||||
|
function esc(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;"> </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 · 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 };
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
rencontrePasseeSchema,
|
rencontrePasseeSchema,
|
||||||
pastPhotoParam,
|
pastPhotoParam,
|
||||||
emailComposeSchema,
|
emailComposeSchema,
|
||||||
|
customEmailSchema,
|
||||||
idParam,
|
idParam,
|
||||||
} from '../schemas.js';
|
} from '../schemas.js';
|
||||||
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
|
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
|
||||||
@@ -20,6 +21,7 @@ import { ensureMemberAccess } from '../memberAccess.js';
|
|||||||
import { createStaffUser, resetStaffAccess } from '../userAccess.js';
|
import { createStaffUser, resetStaffAccess } from '../userAccess.js';
|
||||||
import { AccessError } from '../errors.js';
|
import { AccessError } from '../errors.js';
|
||||||
import { buildInvitationEmail } from '../emailTemplate.js';
|
import { buildInvitationEmail } from '../emailTemplate.js';
|
||||||
|
import { buildCustomEmail } from '../customEmailTemplate.js';
|
||||||
|
|
||||||
export const adminRouter = Router();
|
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) ----------
|
// ---------- Rencontres passées (admin + moderator manage; admin-only delete) ----------
|
||||||
// Powers the "Rencontres passées" section on the home page and its dedicated
|
// Powers the "Rencontres passées" section on the home page and its dedicated
|
||||||
// public page. Participant count is derived from the linked rencontre's
|
// public page. Participant count is derived from the linked rencontre's
|
||||||
|
|||||||
@@ -105,3 +105,17 @@ export const emailComposeSchema = z.object({
|
|||||||
outro: z.string().trim().max(2000).optional(),
|
outro: z.string().trim().max(2000).optional(),
|
||||||
signature: z.string().trim().max(500).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'],
|
||||||
|
});
|
||||||
@@ -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 { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx';
|
||||||
import { PastEventsTab } from '../components/admin/AdminPast.jsx';
|
import { PastEventsTab } from '../components/admin/AdminPast.jsx';
|
||||||
import { UsersTab } from '../components/admin/AdminUsers.jsx';
|
import { UsersTab } from '../components/admin/AdminUsers.jsx';
|
||||||
|
import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx';
|
||||||
|
|
||||||
// Moderators only get members/rencontres/inscriptions/rencontres passées
|
// Moderators only get members/rencontres/inscriptions/rencontres passées
|
||||||
// (create + edit, never delete) — everything else (dashboard, taxonomy,
|
// (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: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] },
|
||||||
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] },
|
{ 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: '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'] },
|
{ key: 'utilisateurs', icon: '⚿', label: 'Administrateurs', title: 'Administrateurs & modérateurs', roles: ['admin'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -150,6 +152,7 @@ export function AdminShell() {
|
|||||||
{tab === 'inscriptions' && <InscriptionsTab />}
|
{tab === 'inscriptions' && <InscriptionsTab />}
|
||||||
{tab === 'categories' && <CategoriesTab />}
|
{tab === 'categories' && <CategoriesTab />}
|
||||||
{tab === 'contenu' && <ContenuTab />}
|
{tab === 'contenu' && <ContenuTab />}
|
||||||
|
{tab === 'emails' && <EmailCreatorTab />}
|
||||||
{tab === 'utilisateurs' && <UsersTab />}
|
{tab === 'utilisateurs' && <UsersTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user