Add account-based event registration and robust email HTML

This commit is contained in:
Michael committed 2026-07-14 11:18:11 +02:00
1 parent c647c90078
commit e170d57577
7 files changed
+329 -84

No files matched your search

+33 -22
View File
@@ -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 &middot; 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 &middot; 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 &agrave; 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&eacute;serv&eacute; aux membres &agrave; jour de leur adh&eacute;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&eacute;serv&eacute; aux membres &agrave; jour de leur adh&eacute;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 &middot; 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&nbsp;:<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>
+10 -7
View File
@@ -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 });
+130 -29
View File
@@ -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
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`,
[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.' });
[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();
}
}
);
+2 -4
View File
@@ -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({
+12
View File
@@ -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,
+39 -6
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 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)}>
+103 -16
View File
@@ -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' }}>
{!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">Nom & prénom
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
<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">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 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 }}>
Confirmer mon inscription
<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>
)}
{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 }}>
Réservé aux membres à jour de leur adhésion pour la saison en cours.
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>
);