Add account-based event registration and robust email HTML
This commit is contained in:
1 parent
c647c90078
commit
e170d57577
7 files changed
+340
-95
No files matched your search
+33
-22
@@ -59,11 +59,16 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
|
||||
restantes > 0
|
||||
? `${restantes} place${restantes > 1 ? 's' : ''} restante${restantes > 1 ? 's' : ''}`
|
||||
: 'Complet — liste d’attente',
|
||||
r.participants_par_compte
|
||||
? `${r.participants_par_compte} participant${r.participants_par_compte > 1 ? 's' : ''} maximum par compte membre`
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
|
||||
// Spacing belongs on table cells: Outlook's Word renderer commonly drops
|
||||
// padding and margins applied to div/p elements when HTML is pasted.
|
||||
const para = (text, extra = '') =>
|
||||
text
|
||||
? `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.7;color:#5A544E;${extra}">${escML(text)}</div>`
|
||||
? `<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;"><tr><td style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;mso-line-height-rule:exactly;color:#5A544E;${extra}">${escML(text)}</td></tr></table>`
|
||||
: '';
|
||||
|
||||
// "Bulletproof" CTA button. Outlook (Word engine) ignores padding and
|
||||
@@ -79,24 +84,29 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<a href="${esc(url)}" target="_blank" style="display:inline-block;width:300px;line-height:48px;background-color:#C1272D;color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;text-align:center;text-decoration:none;border-radius:3px;-webkit-text-size-adjust:none;mso-hide:all;">${label}</a>
|
||||
<a href="${esc(url)}" target="_blank" style="display:inline-block;width:300px;line-height:48px;background-color:#C1272D;color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;text-align:center;text-decoration:none;border-radius:3px;-webkit-text-size-adjust:none;">${label}</a>
|
||||
<!--<![endif]-->`;
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="fr">
|
||||
<html lang="fr" xmlns:v="urn:schemas-microsoft-com:vml" 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(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 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;">
|
||||
<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;">
|
||||
|
||||
<table role="presentation" width="620" cellpadding="0" cellspacing="0" border="0" style="max-width:620px;width:100%;background-color:#FFFFFF;border:1px solid #E7E2DB;border-radius:8px;overflow:hidden;">
|
||||
<!--[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 style="background-color:#161514;padding:26px 40px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<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;border-radius:6px;background-color:#FFFFFF;">
|
||||
</td>
|
||||
@@ -108,17 +118,17 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:40px 40px 12px;">
|
||||
<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:18px;">Invitation · Rencontre</div>
|
||||
${t.greeting ? `<div style="font-family:Georgia,'Times New Roman',serif;font-size:19px;color:#1B1B1B;padding-bottom:14px;">${escML(t.greeting)}</div>` : ''}
|
||||
<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;"><tr><td style="padding-bottom:18px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.5px;text-transform:uppercase;color:#C1272D;font-weight:bold;">Invitation · Rencontre</td></tr></table>
|
||||
${t.greeting ? `<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;"><tr><td style="padding-bottom:14px;font-family:Georgia,'Times New Roman',serif;font-size:19px;line-height:23px;color:#1B1B1B;">${escML(t.greeting)}</td></tr></table>` : ''}
|
||||
${para(t.intro, 'padding-bottom:26px;')}
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:0 40px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#FAF8F5;border:1px solid #E7E2DB;border-left:3px solid #C1272D;border-radius:4px;">
|
||||
<tr><td style="padding:26px 28px;">
|
||||
<div style="font-family:Georgia,'Times New Roman',serif;font-size:24px;line-height:1.2;color:#1B1B1B;padding-bottom:12px;">${esc(r.titre)}</div>
|
||||
${r.description ? `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14.5px;line-height:1.65;color:#5A544E;padding-bottom:16px;">${esc(r.description)}</div>` : ''}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FAF8F5" style="width:100%;background-color:#FAF8F5;border:1px solid #E7E2DB;border-left:3px solid #C1272D;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">
|
||||
<tr><td bgcolor="#FAF8F5" style="padding:26px 28px;">
|
||||
<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;"><tr><td style="padding-bottom:12px;font-family:Georgia,'Times New Roman',serif;font-size:24px;line-height:29px;color:#1B1B1B;">${esc(r.titre)}</td></tr></table>
|
||||
${r.description ? `<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;"><tr><td style="padding-bottom:16px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:23px;mso-line-height-rule:exactly;color:#5A544E;">${esc(r.description)}</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;"><tr>
|
||||
<td style="border-top:1px solid #E7E2DB;padding-top:16px;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#5A544E;line-height:1.9;">
|
||||
${infoLines.join('<br>')}
|
||||
</td>
|
||||
@@ -128,30 +138,31 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:6px 40px 0;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<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;"><tr>
|
||||
<td align="center" style="padding:28px 0 0;">
|
||||
${ctaButton(inscriptionUrl, "Je m'inscris à la rencontre")}
|
||||
</td>
|
||||
</tr></table>
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#9A938B;text-align:center;padding-top:14px;line-height:1.6;">Réservé aux membres à jour de leur adhésion pour la saison en cours.</div>
|
||||
<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;"><tr><td align="center" style="padding-top:14px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#9A938B;">Réservé aux membres à jour de leur adhésion pour la saison en cours.</td></tr></table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:28px 40px 38px;">
|
||||
${para(t.outro, t.signature ? 'padding-bottom:24px;' : '')}
|
||||
${t.signature ? `<div style="font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:1.6;color:#1B1B1B;">${escML(t.signature)}</div>` : ''}
|
||||
${t.signature ? `<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;"><tr><td style="font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:26px;color:#1B1B1B;">${escML(t.signature)}</td></tr></table>` : ''}
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="background-color:#0F0E0D;padding:24px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:1.8;">
|
||||
<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>
|
||||
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;font-size:11px;color:#9A938B;padding-top:18px;max-width:620px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:620px;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;"><tr><td style="padding-top:18px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#9A938B;">
|
||||
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>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -41,7 +41,8 @@ const MEMBER_SQL = `
|
||||
LEFT JOIN users u ON u.member_id = m.id`;
|
||||
|
||||
const RENC_SQL = `
|
||||
SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, r.image_path,
|
||||
SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places,
|
||||
r.participants_par_compte, r.image_path,
|
||||
COUNT(i.id)::int AS inscrits
|
||||
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id`;
|
||||
|
||||
@@ -174,9 +175,9 @@ adminRouter.post('/rencontres', validate(rencontreSchema), async (req, res, next
|
||||
try {
|
||||
const d = req.data;
|
||||
const result = await query(
|
||||
`INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places]
|
||||
`INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places, participants_par_compte)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places, d.participants_par_compte]
|
||||
);
|
||||
res.status(201).json({ id: result.rows[0].id });
|
||||
} catch (err) {
|
||||
@@ -188,9 +189,11 @@ adminRouter.put('/rencontres/:id', validate(idParam, 'params'), validate(rencont
|
||||
try {
|
||||
const d = req.data;
|
||||
const result = await query(
|
||||
`UPDATE rencontres SET titre=$1, date_renc=$2, heure=$3, lieu=$4, description=$5, places=$6
|
||||
WHERE id=$7 RETURNING id`,
|
||||
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places, req.params.id]
|
||||
`UPDATE rencontres SET titre=$1, date_renc=$2, heure=$3, lieu=$4, description=$5,
|
||||
places=$6, participants_par_compte=$7
|
||||
WHERE id=$8 RETURNING id`,
|
||||
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places,
|
||||
d.participants_par_compte, req.params.id]
|
||||
);
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||
res.json({ ok: true });
|
||||
|
||||
+133
-32
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../db.js';
|
||||
import { pool, query } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import { publicFormLimiter } from '../middleware/security.js';
|
||||
import { demandeSchema, inscriptionPublicSchema, idParam } from '../schemas.js';
|
||||
@@ -19,7 +20,8 @@ publicRouter.get('/bootstrap', async (_req, res, next) => {
|
||||
WHERE m.valide = true ORDER BY m.nom`
|
||||
),
|
||||
query(
|
||||
`SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, r.image_path,
|
||||
`SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places,
|
||||
r.participants_par_compte, r.image_path,
|
||||
COUNT(i.id)::int AS inscrits
|
||||
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
|
||||
WHERE r.date_renc >= CURRENT_DATE
|
||||
@@ -73,42 +75,141 @@ publicRouter.post(
|
||||
}
|
||||
);
|
||||
|
||||
// Event registration (public modal)
|
||||
publicRouter.post(
|
||||
'/rencontres/:id/inscriptions',
|
||||
publicFormLimiter,
|
||||
// Registration context for the connected member account. Existing names are
|
||||
// returned so the member can review or amend the complete participant list.
|
||||
publicRouter.get(
|
||||
'/rencontres/:id/inscription',
|
||||
requireAuth('member'),
|
||||
validate(idParam, 'params'),
|
||||
validate(inscriptionPublicSchema),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { nom, entreprise, email, tel } = req.data;
|
||||
const renc = await query(
|
||||
`SELECT r.places, COUNT(i.id)::int AS inscrits
|
||||
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
|
||||
WHERE r.id = $1 AND r.date_renc >= CURRENT_DATE
|
||||
GROUP BY r.id`,
|
||||
[id]
|
||||
);
|
||||
if (renc.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||
if (renc.rows[0].inscrits >= renc.rows[0].places) {
|
||||
return res.status(409).json({ error: 'Cette rencontre est complète.' });
|
||||
const [member, rencontre, participants] = await Promise.all([
|
||||
query('SELECT id, nom, dirigeant, email, tel, valide FROM members WHERE id = $1', [req.user.memberId]),
|
||||
query(
|
||||
`SELECT r.id, r.places, r.participants_par_compte, COUNT(i.id)::int AS inscrits
|
||||
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
|
||||
WHERE r.id = $1 AND r.date_renc >= CURRENT_DATE
|
||||
GROUP BY r.id`,
|
||||
[req.params.id]
|
||||
),
|
||||
query(
|
||||
`SELECT nom, statut FROM inscriptions
|
||||
WHERE rencontre_id = $1 AND member_id = $2 ORDER BY created_at, id`,
|
||||
[req.params.id, req.user.memberId]
|
||||
),
|
||||
]);
|
||||
if (member.rowCount === 0) return res.status(403).json({ error: 'Compte membre introuvable.' });
|
||||
if (!member.rows[0].valide) {
|
||||
return res.status(403).json({ error: "Votre adhésion doit être validée pour vous inscrire." });
|
||||
}
|
||||
const dup = await query(
|
||||
'SELECT 1 FROM inscriptions WHERE rencontre_id = $1 AND email = $2',
|
||||
[id, email]
|
||||
);
|
||||
if (dup.rowCount > 0) {
|
||||
return res.status(409).json({ error: 'Une inscription existe déjà avec cet email.' });
|
||||
}
|
||||
await query(
|
||||
`INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut)
|
||||
VALUES ($1, $2, $3, $4, $5, 'en_attente')`,
|
||||
[id, nom, entreprise, email, tel]
|
||||
);
|
||||
res.status(201).json({ ok: true });
|
||||
if (rencontre.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||
res.json({ member: member.rows[0], rencontre: rencontre.rows[0], participants: participants.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// One authenticated member account can register several named people, within
|
||||
// both the per-account limit and the event's total capacity.
|
||||
publicRouter.post(
|
||||
'/rencontres/:id/inscriptions',
|
||||
publicFormLimiter,
|
||||
requireAuth('member'),
|
||||
validate(idParam, 'params'),
|
||||
validate(inscriptionPublicSchema),
|
||||
async (req, res, next) => {
|
||||
let client;
|
||||
try {
|
||||
client = await pool.connect();
|
||||
const { id } = req.params;
|
||||
const participants = req.data.participants;
|
||||
const names = participants.map((name) => name.toLocaleLowerCase('fr-FR'));
|
||||
if (new Set(names).size !== names.length) {
|
||||
return res.status(400).json({ error: 'Chaque participant doit avoir un nom différent.' });
|
||||
}
|
||||
|
||||
await client.query('BEGIN');
|
||||
// Lock the event before counting places so concurrent registrations
|
||||
// cannot overbook it.
|
||||
const renc = await client.query(
|
||||
`SELECT id, places, participants_par_compte FROM rencontres
|
||||
WHERE id = $1 AND date_renc >= CURRENT_DATE FOR UPDATE`,
|
||||
[id]
|
||||
);
|
||||
if (renc.rowCount === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||
}
|
||||
const member = await client.query(
|
||||
'SELECT id, nom, email, tel, valide FROM members WHERE id = $1',
|
||||
[req.user.memberId]
|
||||
);
|
||||
if (member.rowCount === 0 || !member.rows[0].valide) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(403).json({ error: "Votre adhésion doit être validée pour vous inscrire." });
|
||||
}
|
||||
if (participants.length > renc.rows[0].participants_par_compte) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({
|
||||
error: `Cette rencontre autorise ${renc.rows[0].participants_par_compte} participant(s) maximum par compte.`,
|
||||
});
|
||||
}
|
||||
|
||||
const otherCount = await client.query(
|
||||
`SELECT COUNT(*)::int AS n FROM inscriptions
|
||||
WHERE rencontre_id = $1 AND (member_id IS NULL OR member_id <> $2)`,
|
||||
[id, req.user.memberId]
|
||||
);
|
||||
if (otherCount.rows[0].n + participants.length > renc.rows[0].places) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({ error: 'Il ne reste pas assez de places pour ces participants.' });
|
||||
}
|
||||
|
||||
const existing = await client.query(
|
||||
`SELECT id, nom FROM inscriptions
|
||||
WHERE rencontre_id = $1 AND member_id = $2 ORDER BY created_at, id`,
|
||||
[id, req.user.memberId]
|
||||
);
|
||||
const available = new Map();
|
||||
for (const row of existing.rows) {
|
||||
const key = row.nom.toLocaleLowerCase('fr-FR');
|
||||
if (!available.has(key)) available.set(key, []);
|
||||
available.get(key).push(row);
|
||||
}
|
||||
|
||||
const keptIds = [];
|
||||
const account = member.rows[0];
|
||||
for (const nom of participants) {
|
||||
const key = nom.toLocaleLowerCase('fr-FR');
|
||||
const row = available.get(key)?.shift();
|
||||
if (row) {
|
||||
keptIds.push(row.id);
|
||||
await client.query(
|
||||
'UPDATE inscriptions SET nom=$1, entreprise=$2, email=$3, tel=$4 WHERE id=$5',
|
||||
[nom, account.nom, account.email, account.tel, row.id]
|
||||
);
|
||||
} else {
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO inscriptions (rencontre_id, member_id, nom, entreprise, email, tel, statut)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'en_attente') RETURNING id`,
|
||||
[id, account.id, nom, account.nom, account.email, account.tel]
|
||||
);
|
||||
keptIds.push(inserted.rows[0].id);
|
||||
}
|
||||
}
|
||||
await client.query(
|
||||
`DELETE FROM inscriptions
|
||||
WHERE rencontre_id = $1 AND member_id = $2 AND NOT (id = ANY($3::int[]))`,
|
||||
[id, account.id, keptIds]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json({ ok: true, participants: participants.length });
|
||||
} catch (err) {
|
||||
if (client) await client.query('ROLLBACK').catch(() => {});
|
||||
next(err);
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -27,10 +27,7 @@ export const demandeSchema = z.object({
|
||||
});
|
||||
|
||||
export const inscriptionPublicSchema = z.object({
|
||||
nom: trimmed(120, 1),
|
||||
entreprise: trimmed(120, 1),
|
||||
email: trimmed(254, 3).email(),
|
||||
tel: trimmed(30).optional().default(''),
|
||||
participants: z.array(trimmed(120, 1)).min(1).max(100),
|
||||
});
|
||||
|
||||
export const memberProfileSchema = z.object({
|
||||
@@ -55,6 +52,7 @@ export const rencontreSchema = z.object({
|
||||
lieu: trimmed(200).optional().default(''),
|
||||
description: trimmed(2000).optional().default(''),
|
||||
places: z.coerce.number().int().min(0).max(100000),
|
||||
participants_par_compte: z.coerce.number().int().min(1).max(100).optional().default(1),
|
||||
});
|
||||
|
||||
export const inscriptionAdminSchema = z.object({
|
||||
|
||||
@@ -56,14 +56,20 @@ CREATE TABLE IF NOT EXISTS rencontres (
|
||||
lieu TEXT NOT NULL DEFAULT '' CHECK (char_length(lieu) <= 200),
|
||||
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
|
||||
places INTEGER NOT NULL CHECK (places >= 0 AND places <= 100000),
|
||||
participants_par_compte INTEGER NOT NULL DEFAULT 1 CHECK (participants_par_compte BETWEEN 1 AND 100),
|
||||
image_path TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE rencontres ADD COLUMN IF NOT EXISTS image_path TEXT;
|
||||
ALTER TABLE rencontres ADD COLUMN IF NOT EXISTS participants_par_compte INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE rencontres DROP CONSTRAINT IF EXISTS rencontres_participants_par_compte_check;
|
||||
ALTER TABLE rencontres ADD CONSTRAINT rencontres_participants_par_compte_check
|
||||
CHECK (participants_par_compte BETWEEN 1 AND 100);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
rencontre_id INTEGER NOT NULL REFERENCES rencontres(id) ON DELETE CASCADE,
|
||||
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
||||
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||
entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120),
|
||||
email CITEXT CHECK (char_length(email) <= 254),
|
||||
@@ -72,6 +78,12 @@ CREATE TABLE IF NOT EXISTS inscriptions (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_inscriptions_rencontre ON inscriptions(rencontre_id);
|
||||
ALTER TABLE inscriptions ADD COLUMN IF NOT EXISTS member_id INTEGER REFERENCES members(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_inscriptions_member_rencontre ON inscriptions(member_id, rencontre_id);
|
||||
-- Attach legacy registrations when their email identifies a member account.
|
||||
UPDATE inscriptions i SET member_id = m.id
|
||||
FROM members m
|
||||
WHERE i.member_id IS NULL AND i.email IS NOT NULL AND i.email = m.email;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rencontres_passees (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
@@ -225,8 +225,8 @@ function RencFormModal({ renc, onClose, onSaved }) {
|
||||
const { user } = useAuth();
|
||||
const [form, setForm] = useState(
|
||||
renc
|
||||
? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places }
|
||||
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30 }
|
||||
? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places, participants_par_compte: renc.participants_par_compte || 1 }
|
||||
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30, participants_par_compte: 1 }
|
||||
);
|
||||
const [image, setImage] = useState(renc?.image_path || '');
|
||||
const [error, setError] = useState('');
|
||||
@@ -281,13 +281,16 @@ function RencFormModal({ renc, onClose, onSaved }) {
|
||||
<input name="heure" value={form.heure} onChange={onChange} placeholder="18h30" maxLength={20} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 12 }}>
|
||||
<label className="field">Lieu
|
||||
<input name="lieu" value={form.lieu} onChange={onChange} required maxLength={200} />
|
||||
</label>
|
||||
<label className="field">Places
|
||||
<label className="field">Places totales
|
||||
<input name="places" type="number" min={0} max={100000} value={form.places} onChange={onChange} required />
|
||||
</label>
|
||||
<label className="field">Par compte
|
||||
<input name="participants_par_compte" type="number" min={1} max={100} value={form.participants_par_compte} onChange={onChange} required />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">Description
|
||||
<textarea name="description" value={form.description} onChange={onChange} rows={3} maxLength={2000} />
|
||||
@@ -316,6 +319,34 @@ function escHtml(v) {
|
||||
return String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Clipboard HTML must be a fragment, not a second complete document. Outlook
|
||||
// otherwise rebuilds the pasted <body> and drops part of the outer layout.
|
||||
function emailBodyFragment(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return doc.body.innerHTML.trim();
|
||||
}
|
||||
|
||||
// Older browsers do not expose ClipboardItem. A real rich-text selection is
|
||||
// still preferable to copying the HTML source as plain text.
|
||||
function legacyCopyRichHtml(html) {
|
||||
const holder = document.createElement('div');
|
||||
holder.setAttribute('contenteditable', 'true');
|
||||
holder.setAttribute('aria-hidden', '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 selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(holder);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
const copied = document.execCommand('copy');
|
||||
selection.removeAllRanges();
|
||||
holder.remove();
|
||||
if (!copied) throw new Error('Rich HTML copy failed');
|
||||
}
|
||||
|
||||
// Composer + preview of the ready-to-send HTML invitation email generated
|
||||
// server-side for one rencontre. Every text zone (salutation, intro,
|
||||
// conclusion, signature) is editable — the preview regenerates as the
|
||||
@@ -371,15 +402,16 @@ function EmailModal({ renc, onClose }) {
|
||||
const copyEmail = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const fragment = emailBodyFragment(data.html);
|
||||
if (navigator.clipboard?.write && window.ClipboardItem) {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
'text/html': new Blob([data.html], { type: 'text/html' }),
|
||||
'text/html': new Blob([fragment], { type: 'text/html' }),
|
||||
'text/plain': new Blob([`${data.subject}\n${data.inscriptionUrl}`], { type: 'text/plain' }),
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(data.html);
|
||||
legacyCopyRichHtml(fragment);
|
||||
}
|
||||
flash('email');
|
||||
} catch {
|
||||
@@ -602,6 +634,7 @@ export function RencontresTab() {
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<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 style={{ fontSize: 10.5, color: 'var(--gray-light)', marginTop: 3 }}>max. {e.participants_par_compte} / compte</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-dark btn-sm" style={{ fontSize: 13, padding: '9px 16px' }} onClick={() => setParticipantsRenc(e)}>
|
||||
|
||||
+111
-24
@@ -1,27 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { usePublicData } from '../lib/usePublic.js';
|
||||
import { useAuth } from '../lib/AuthContext.jsx';
|
||||
import { api, dateParts } from '../lib/api.js';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import PastEventCard from '../components/PastEventCard.jsx';
|
||||
|
||||
function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
const [form, setForm] = useState({ nom: '', entreprise: '', email: '', tel: '' });
|
||||
const { user, login } = useAuth();
|
||||
const [context, setContext] = useState(null);
|
||||
const [participants, setParticipants] = useState(['']);
|
||||
const [credentials, setCredentials] = useState({ email: '', password: '' });
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { jour, mois } = dateParts(rencontre.date_renc);
|
||||
|
||||
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'member' || user.mustChangePassword) return;
|
||||
setError('');
|
||||
api.get(`/api/public/rencontres/${rencontre.id}/inscription`)
|
||||
.then((d) => {
|
||||
setContext(d);
|
||||
setParticipants(d.participants.length ? d.participants.map((p) => p.nom) : [d.member.dirigeant || '']);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
}, [user, rencontre.id]);
|
||||
|
||||
const submitLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(credentials.email, credentials.password);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeParticipant = (index, value) => {
|
||||
setParticipants((current) => current.map((name, i) => (i === index ? value : name)));
|
||||
};
|
||||
|
||||
const maxPerAccount = context?.rencontre.participants_par_compte || rencontre.participants_par_compte || 1;
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, form);
|
||||
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, { participants });
|
||||
setDone(true);
|
||||
onDone?.();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,7 +71,7 @@ function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
Votre demande pour <strong>{rencontre.titre}</strong> est bien enregistrée.
|
||||
</p>
|
||||
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', marginBottom: 26 }}>
|
||||
Elle sera confirmée par l'équipe du Club.
|
||||
{participants.length} participant{participants.length > 1 ? 's' : ''} — confirmation par l'équipe du Club.
|
||||
</p>
|
||||
<button className="btn btn-dark btn-sm" style={{ padding: '13px 28px', fontSize: 14 }} onClick={onClose}>Fermer</button>
|
||||
</div>
|
||||
@@ -49,26 +85,74 @@ function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||
maxWidth={460}
|
||||
header={{ kicker: 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
|
||||
>
|
||||
<form onSubmit={submit} style={{ padding: '28px 30px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<label className="field-plain">Nom & prénom
|
||||
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field-plain">Entreprise
|
||||
<input name="entreprise" value={form.entreprise} onChange={onChange} required maxLength={120} />
|
||||
</label>
|
||||
<label className="field-plain">Email
|
||||
<input name="email" type="email" value={form.email} onChange={onChange} required maxLength={254} />
|
||||
</label>
|
||||
{!user && (
|
||||
<form onSubmit={submitLogin} style={{ padding: '28px 30px' }}>
|
||||
<p style={{ fontSize: 14, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 18 }}>
|
||||
Connectez-vous avec votre compte membre avant de choisir les participants.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<label className="field-plain">Email du compte
|
||||
<input type="email" value={credentials.email} onChange={(e) => setCredentials({ ...credentials, email: e.target.value })} required autoComplete="username" />
|
||||
</label>
|
||||
<label className="field-plain">Mot de passe
|
||||
<input type="password" value={credentials.password} onChange={(e) => setCredentials({ ...credentials, password: e.target.value })} required autoComplete="current-password" />
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14 }} disabled={busy}>
|
||||
{busy ? 'Connexion…' : 'Se connecter et continuer'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{user && (user.role !== 'member' || user.mustChangePassword) && (
|
||||
<div style={{ padding: '30px' }}>
|
||||
<p className="error-text">
|
||||
{user.role !== 'member'
|
||||
? 'Les inscriptions doivent être effectuées avec un compte membre.'
|
||||
: 'Vous devez d’abord remplacer votre mot de passe temporaire.'}
|
||||
</p>
|
||||
<Link to="/espace-membre" className="btn btn-dark" style={{ display: 'block', textAlign: 'center', marginTop: 20, textDecoration: 'none' }}>
|
||||
Ouvrir mon espace membre
|
||||
</Link>
|
||||
</div>
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14 }}>
|
||||
Confirmer mon inscription
|
||||
</button>
|
||||
<p style={{ fontSize: 12, color: 'var(--gray-light)', textAlign: 'center', marginTop: 12, lineHeight: 1.5 }}>
|
||||
Réservé aux membres à jour de leur adhésion pour la saison en cours.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{user?.role === 'member' && !user.mustChangePassword && !context && (
|
||||
<p style={{ padding: '30px', color: 'var(--gray-light)' }}>{error || 'Chargement de votre compte…'}</p>
|
||||
)}
|
||||
|
||||
{user?.role === 'member' && !user.mustChangePassword && context && (
|
||||
<form onSubmit={submit} style={{ padding: '28px 30px' }}>
|
||||
<div style={{ background: 'var(--beige)', padding: '12px 14px', borderRadius: 4, fontSize: 13, color: 'var(--gray)', marginBottom: 18 }}>
|
||||
Compte : <strong>{context.member.nom}</strong> · maximum {maxPerAccount} participant{maxPerAccount > 1 ? 's' : ''}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{participants.map((name, index) => (
|
||||
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
<label className="field-plain" style={{ flex: 1 }}>Participant {index + 1} — nom & prénom
|
||||
<input value={name} onChange={(e) => changeParticipant(index, e.target.value)} required maxLength={120} />
|
||||
</label>
|
||||
{participants.length > 1 && (
|
||||
<button type="button" className="btn btn-outline-soft btn-sm" style={{ padding: '11px 13px' }} onClick={() => setParticipants((current) => current.filter((_, i) => i !== index))} aria-label={`Retirer le participant ${index + 1}`}>×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{participants.length < maxPerAccount && (
|
||||
<button type="button" className="btn-link" style={{ marginTop: 14 }} onClick={() => setParticipants((current) => [...current, ''])}>
|
||||
+ Ajouter un participant
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14 }} disabled={busy}>
|
||||
{busy ? 'Enregistrement…' : `Enregistrer ${participants.length} participant${participants.length > 1 ? 's' : ''}`}
|
||||
</button>
|
||||
<p style={{ fontSize: 12, color: 'var(--gray-light)', textAlign: 'center', marginTop: 12, lineHeight: 1.5 }}>
|
||||
Vous pourrez rouvrir ce formulaire pour modifier les noms enregistrés.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -251,6 +335,9 @@ export default function Home() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 22, paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--red)', fontWeight: 600 }}>
|
||||
{restantes > 0 ? `${restantes} places restantes` : 'Complet'}
|
||||
<small style={{ display: 'block', color: 'var(--gray-light)', fontWeight: 400, marginTop: 3 }}>
|
||||
Max. {e.participants_par_compte} par compte
|
||||
</small>
|
||||
</span>
|
||||
<button className="btn btn-dark btn-sm" style={{ fontSize: 13.5 }} disabled={restantes === 0} onClick={() => setInscrRenc(e)}>
|
||||
S'inscrire
|
||||
@@ -344,7 +431,7 @@ export default function Home() {
|
||||
</section>
|
||||
|
||||
{inscrRenc && (
|
||||
<InscriptionModal rencontre={inscrRenc} onClose={closeInscription} onDone={reload} />
|
||||
<InscriptionModal key={inscrRenc.id} rencontre={inscrRenc} onClose={closeInscription} onDone={reload} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user