Email d'invitation HTML généré depuis l'administration des rencontres
Chaque rencontre dispose d'un bouton « ✉ Email » dans l'administration (admins et modérateurs) qui génère un email HTML prêt à envoyer, aux couleurs du site avec le logo du club : titre, description, encadré date/heure/lieu/places restantes et bouton d'inscription. - Nouvelle route GET /api/admin/rencontres/:id/email : construit l'email (mise en page en tables + styles inline, compatible clients mail) avec des URL absolues dérivées de l'hôte de la requête. - Le bouton de l'email pointe vers /?inscription=<id> : la page d'accueil ouvre alors automatiquement la modale d'inscription de la rencontre. - Modale d'aperçu dans l'admin : copie de l'email (HTML enrichi presse- papiers pour coller directement dans un logiciel de messagerie), copie de l'objet et du lien d'inscription, téléchargement du fichier .html. - Les images publiques (logo, uploads) sont maintenant servies avec Cross-Origin-Resource-Policy: cross-origin pour que le logo se charge dans les webmails et l'aperçu. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
5 files changed
+235
-4
No files matched your search
@@ -0,0 +1,94 @@
|
||||
// Invitation email for a rencontre, generated on demand from the admin
|
||||
// backend. Email-client constraints apply: table layout, inline styles,
|
||||
// web-safe font stacks, absolute URLs. The palette mirrors the site theme
|
||||
// (see web/src/styles.css) with Georgia standing in for Newsreader.
|
||||
function esc(v) {
|
||||
return String(v ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function buildInvitationEmail({ rencontre, baseUrl }) {
|
||||
const r = rencontre;
|
||||
const dateStr = new Date(r.date_renc).toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
const restantes = Math.max(0, r.places - (r.inscrits ?? 0));
|
||||
const inscriptionUrl = `${baseUrl}/?inscription=${r.id}`;
|
||||
const logoUrl = `${baseUrl}/assets/logo.jpg`;
|
||||
const subject = `Invitation — ${r.titre} · ${dateStr}`;
|
||||
|
||||
const infoLines = [
|
||||
`<strong style="color:#1B1B1B;">${esc(dateStr.charAt(0).toUpperCase() + dateStr.slice(1))}</strong>`,
|
||||
[r.heure && esc(r.heure), esc(r.lieu)].filter(Boolean).join(' — '),
|
||||
restantes > 0 ? `${restantes} place${restantes > 1 ? 's' : ''} restante${restantes > 1 ? 's' : ''}` : 'Complet — liste d’attente',
|
||||
];
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${esc(subject)}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#F2EEE8;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#F2EEE8;">
|
||||
<tr><td align="center" style="padding:36px 14px;">
|
||||
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;background-color:#FFFFFF;border:1px solid #E7E2DB;border-radius:8px;overflow:hidden;">
|
||||
|
||||
<tr><td style="background-color:#161514;padding:26px 36px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td width="52" valign="middle">
|
||||
<img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;border-radius:6px;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:1.1;">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;padding-top:4px;">SLUC Nancy</div>
|
||||
</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:40px 36px 32px;">
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;font-size:11px;letter-spacing:2.5px;text-transform:uppercase;color:#C1272D;font-weight:bold;padding-bottom:16px;">Invitation · Rencontre</div>
|
||||
<div style="font-family:Georgia,'Times New Roman',serif;font-size:30px;line-height:1.15;color:#1B1B1B;padding-bottom:18px;">${esc(r.titre)}</div>
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.65;color:#5A544E;padding-bottom:26px;">${esc(r.description)}</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#FAF8F5;border-left:3px solid #C1272D;">
|
||||
<tr><td style="padding:18px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#5A544E;line-height:1.9;">
|
||||
${infoLines.join('<br>')}
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td align="center" style="padding:30px 0 0;">
|
||||
<a href="${esc(inscriptionUrl)}" style="display:inline-block;background-color:#C1272D;color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;text-decoration:none;padding:15px 40px;border-radius:3px;">Je m'inscris à la rencontre</a>
|
||||
</td>
|
||||
</tr></table>
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#9A938B;text-align:center;padding-top:16px;line-height:1.6;">Réservé aux membres à jour de leur adhésion pour la saison en cours.</div>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="background-color:#0F0E0D;padding:24px 36px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:1.8;">
|
||||
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>
|
||||
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;font-size:11px;color:#9A938B;padding-top:18px;max-width:600px;">
|
||||
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
|
||||
<a href="${esc(inscriptionUrl)}" style="color:#C1272D;text-decoration:underline;word-break:break-all;">${esc(inscriptionUrl)}</a>
|
||||
</div>
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject, html, inscriptionUrl };
|
||||
}
|
||||
@@ -39,6 +39,11 @@ app.use(
|
||||
},
|
||||
},
|
||||
xFrameOptions: { action: 'deny' },
|
||||
// Site images (logo, uploads) are public and referenced from outside
|
||||
// the app's origin — e.g. the logo embedded in invitation emails is
|
||||
// loaded by webmail clients. The default same-origin policy would
|
||||
// block those loads.
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
})
|
||||
);
|
||||
app.use(compression());
|
||||
|
||||
@@ -18,6 +18,7 @@ import { imageUpload, saveImage, deleteImage } from '../uploads.js';
|
||||
import { ensureMemberAccess } from '../memberAccess.js';
|
||||
import { createStaffUser, resetStaffAccess } from '../userAccess.js';
|
||||
import { AccessError } from '../errors.js';
|
||||
import { buildInvitationEmail } from '../emailTemplate.js';
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
@@ -227,6 +228,22 @@ adminRouter.post('/rencontres/:id/image', validate(idParam, 'params'), (req, res
|
||||
});
|
||||
});
|
||||
|
||||
// Ready-to-send HTML invitation email for a rencontre (admin + moderator).
|
||||
// The registration link opens the public site with the inscription modal
|
||||
// pre-opened (/?inscription=<id>). URLs are absolute, built from the
|
||||
// request's host so they match however the site is reached (direct port
|
||||
// or reverse proxy).
|
||||
adminRouter.get('/rencontres/:id/email', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(`${RENC_SQL} WHERE r.id = $1 GROUP BY r.id`, [req.params.id]);
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
||||
res.json(buildInvitationEmail({ rencontre: result.rows[0], baseUrl }));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(`${INSCR_SQL} WHERE i.rencontre_id = $1 ORDER BY i.nom`, [req.params.id]);
|
||||
|
||||
@@ -316,6 +316,101 @@ function escHtml(v) {
|
||||
return String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Preview + export of the ready-to-send HTML invitation email generated
|
||||
// server-side for one rencontre. "Copier l'email" puts the rendered HTML
|
||||
// on the clipboard (rich text where supported, so pasting into a mail
|
||||
// client keeps the design); the .html download covers the rest.
|
||||
function EmailModal({ renc, onClose }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/api/admin/rencontres/${renc.id}/email`).then(setData).catch((e) => setError(e.message));
|
||||
}, [renc.id]);
|
||||
|
||||
const flash = (what) => {
|
||||
setCopied(what);
|
||||
setTimeout(() => setCopied(''), 1800);
|
||||
};
|
||||
|
||||
const copyEmail = async () => {
|
||||
setError('');
|
||||
try {
|
||||
if (navigator.clipboard?.write && window.ClipboardItem) {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
'text/html': new Blob([data.html], { type: 'text/html' }),
|
||||
'text/plain': new Blob([`${data.subject}\n${data.inscriptionUrl}`], { type: 'text/plain' }),
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(data.html);
|
||||
}
|
||||
flash('email');
|
||||
} catch {
|
||||
setError('Copie impossible dans ce navigateur — utilisez le téléchargement.');
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (text, what) => {
|
||||
setError('');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
flash(what);
|
||||
} catch {
|
||||
setError('Copie impossible dans ce navigateur.');
|
||||
}
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
const blob = new Blob([data.html], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const slug = renc.titre.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
|
||||
a.href = url;
|
||||
a.download = `invitation-${slug}.html`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth={760} header={{ kicker: "Email d'invitation", title: renc.titre }}>
|
||||
{!data && !error && <p style={{ padding: '30px 32px', color: 'var(--gray-light)' }}>Génération de l'email…</p>}
|
||||
{error && <p className="error-text" style={{ padding: '20px 32px 0' }}>{error}</p>}
|
||||
{data && (
|
||||
<>
|
||||
<div style={{ padding: '18px 32px 0', display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button className="btn btn-red btn-sm" style={{ fontSize: 13.5, padding: '11px 18px' }} onClick={copyEmail}>
|
||||
{copied === 'email' ? '✓ Copié' : "Copier l'email"}
|
||||
</button>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13.5, padding: '11px 18px' }} onClick={() => copyText(data.subject, 'subject')}>
|
||||
{copied === 'subject' ? '✓ Copié' : "Copier l'objet"}
|
||||
</button>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13.5, padding: '11px 18px' }} onClick={() => copyText(data.inscriptionUrl, 'link')}>
|
||||
{copied === 'link' ? '✓ Copié' : "Copier le lien d'inscription"}
|
||||
</button>
|
||||
<button className="btn btn-outline-soft btn-sm" style={{ fontSize: 13.5, padding: '11px 18px' }} onClick={download}>
|
||||
↓ Télécharger (.html)
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '14px 32px 6px', fontSize: 12.5, color: 'var(--gray-light)', lineHeight: 1.55 }}>
|
||||
<strong style={{ color: 'var(--gray)' }}>Objet :</strong> {data.subject}
|
||||
<br />
|
||||
Collez l'email directement dans votre logiciel de messagerie (le bouton « Copier l'email »
|
||||
conserve la mise en forme), ou envoyez le fichier .html avec votre outil d'emailing.
|
||||
</div>
|
||||
<div style={{ margin: '14px 32px 30px', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<iframe title="Aperçu de l'email" srcDoc={data.html} sandbox="" style={{ display: 'block', width: '100%', height: 480, border: 'none', background: '#F2EEE8' }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
|
||||
const { user } = useAuth();
|
||||
const [rows, setRows] = useState([]);
|
||||
@@ -412,6 +507,7 @@ export function RencontresTab() {
|
||||
const [rencontres, setRencontres] = useState([]);
|
||||
const [formModal, setFormModal] = useState(null); // null | 'new' | renc
|
||||
const [participantsRenc, setParticipantsRenc] = useState(null);
|
||||
const [emailRenc, setEmailRenc] = useState(null);
|
||||
const [editInscr, setEditInscr] = useState(null);
|
||||
const [cancelInscr, setCancelInscr] = useState(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
@@ -450,10 +546,13 @@ export function RencontresTab() {
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 600, color: 'var(--red)' }}>{e.inscrits}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--gray-light)' }}>inscrits / {e.places}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-dark btn-sm" style={{ fontSize: 13, padding: '9px 16px' }} onClick={() => setParticipantsRenc(e)}>
|
||||
Inscrits ({e.inscrits})
|
||||
</button>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13, padding: '9px 16px' }} onClick={() => setEmailRenc(e)} title="Générer l'email d'invitation">
|
||||
✉ Email
|
||||
</button>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13, padding: '9px 16px' }} onClick={() => setFormModal(e)}>
|
||||
Gérer
|
||||
</button>
|
||||
@@ -479,6 +578,7 @@ export function RencontresTab() {
|
||||
onCancel={setCancelInscr}
|
||||
/>
|
||||
)}
|
||||
{emailRenc && <EmailModal renc={emailRenc} onClose={() => setEmailRenc(null)} />}
|
||||
{editInscr && (
|
||||
<InscriptionEditModal
|
||||
inscription={editInscr}
|
||||
|
||||
+18
-3
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { usePublicData } from '../lib/usePublic.js';
|
||||
import { api, dateParts } from '../lib/api.js';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
@@ -75,11 +75,26 @@ function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
|
||||
export default function Home() {
|
||||
const { data, reload } = usePublicData();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [inscrRenc, setInscrRenc] = useState(null);
|
||||
const [homeDone, setHomeDone] = useState(false);
|
||||
const [homeError, setHomeError] = useState('');
|
||||
const [form, setForm] = useState({ nom: '', fonction: '', entreprise: '', email: '' });
|
||||
|
||||
// Registration links in invitation emails point to /?inscription=<id> :
|
||||
// once the public data is loaded, open the matching inscription modal.
|
||||
useEffect(() => {
|
||||
const id = Number(searchParams.get('inscription'));
|
||||
if (!id || !data) return;
|
||||
const renc = (data.rencontres || []).find((r) => r.id === id);
|
||||
if (renc) setInscrRenc(renc);
|
||||
}, [data, searchParams]);
|
||||
|
||||
const closeInscription = () => {
|
||||
setInscrRenc(null);
|
||||
if (searchParams.has('inscription')) setSearchParams({}, { replace: true });
|
||||
};
|
||||
|
||||
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
|
||||
|
||||
const submitHome = async (e) => {
|
||||
@@ -329,7 +344,7 @@ export default function Home() {
|
||||
</section>
|
||||
|
||||
{inscrRenc && (
|
||||
<InscriptionModal rencontre={inscrRenc} onClose={() => setInscrRenc(null)} onDone={reload} />
|
||||
<InscriptionModal rencontre={inscrRenc} onClose={closeInscription} onDone={reload} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user