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 > 0
|
||||||
? `${restantes} place${restantes > 1 ? 's' : ''} restante${restantes > 1 ? 's' : ''}`
|
? `${restantes} place${restantes > 1 ? 's' : ''} restante${restantes > 1 ? 's' : ''}`
|
||||||
: 'Complet — liste d’attente',
|
: '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);
|
].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 = '') =>
|
const para = (text, extra = '') =>
|
||||||
text
|
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
|
// "Bulletproof" CTA button. Outlook (Word engine) ignores padding and
|
||||||
@@ -79,24 +84,29 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
|
|||||||
</v:roundrect>
|
</v:roundrect>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<!--[if !mso]><!-->
|
<!--[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]-->`;
|
<!--<![endif]-->`;
|
||||||
|
|
||||||
const html = `<!doctype html>
|
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>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>${esc(subject)}</title>
|
<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>
|
</head>
|
||||||
<body style="margin:0;padding:0;background-color:#F2EEE8;">
|
<body bgcolor="#F2EEE8" style="margin:0;padding:0;background-color:#F2EEE8;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#F2EEE8;">
|
<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" style="padding:36px 14px;">
|
<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;">
|
<tr><td bgcolor="#161514" style="background-color:#161514;padding:26px 40px;">
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
|
<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">
|
<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;">
|
<img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;border-radius:6px;background-color:#FFFFFF;">
|
||||||
</td>
|
</td>
|
||||||
@@ -108,17 +118,17 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
|
|||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
<tr><td style="padding:40px 40px 12px;">
|
<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>
|
<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 ? `<div style="font-family:Georgia,'Times New Roman',serif;font-size:19px;color:#1B1B1B;padding-bottom:14px;">${escML(t.greeting)}</div>` : ''}
|
${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;')}
|
${para(t.intro, 'padding-bottom:26px;')}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
<tr><td style="padding:0 40px;">
|
<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;">
|
<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 style="padding:26px 28px;">
|
<tr><td bgcolor="#FAF8F5" 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>
|
<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 ? `<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>` : ''}
|
${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"><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 style="border-top:1px solid #E7E2DB;padding-top:16px;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#5A544E;line-height:1.9;">
|
<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>')}
|
${infoLines.join('<br>')}
|
||||||
</td>
|
</td>
|
||||||
@@ -128,30 +138,31 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
|
|||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
<tr><td style="padding:6px 40px 0;">
|
<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;">
|
<td align="center" style="padding:28px 0 0;">
|
||||||
${ctaButton(inscriptionUrl, "Je m'inscris à la rencontre")}
|
${ctaButton(inscriptionUrl, "Je m'inscris à la rencontre")}
|
||||||
</td>
|
</td>
|
||||||
</tr></table>
|
</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>
|
</td></tr>
|
||||||
|
|
||||||
<tr><td style="padding:28px 40px 38px;">
|
<tr><td style="padding:28px 40px 38px;">
|
||||||
${para(t.outro, t.signature ? 'padding-bottom:24px;' : '')}
|
${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>
|
</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>
|
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>
|
<a href="mailto:contact@sluc-businessclub.fr" style="color:#E0777A;text-decoration:none;">contact@sluc-businessclub.fr</a>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
</table>
|
</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>
|
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>
|
<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>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ const MEMBER_SQL = `
|
|||||||
LEFT JOIN users u ON u.member_id = m.id`;
|
LEFT JOIN users u ON u.member_id = m.id`;
|
||||||
|
|
||||||
const RENC_SQL = `
|
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
|
COUNT(i.id)::int AS inscrits
|
||||||
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id`;
|
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 {
|
try {
|
||||||
const d = req.data;
|
const d = req.data;
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places)
|
`INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places, participants_par_compte)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||||
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places]
|
[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 });
|
res.status(201).json({ id: result.rows[0].id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -188,9 +189,11 @@ adminRouter.put('/rencontres/:id', validate(idParam, 'params'), validate(rencont
|
|||||||
try {
|
try {
|
||||||
const d = req.data;
|
const d = req.data;
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`UPDATE rencontres SET titre=$1, date_renc=$2, heure=$3, lieu=$4, description=$5, places=$6
|
`UPDATE rencontres SET titre=$1, date_renc=$2, heure=$3, lieu=$4, description=$5,
|
||||||
WHERE id=$7 RETURNING id`,
|
places=$6, participants_par_compte=$7
|
||||||
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places, req.params.id]
|
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' });
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
|||||||
+133
-32
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
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 { validate } from '../middleware/validate.js';
|
||||||
import { publicFormLimiter } from '../middleware/security.js';
|
import { publicFormLimiter } from '../middleware/security.js';
|
||||||
import { demandeSchema, inscriptionPublicSchema, idParam } from '../schemas.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`
|
WHERE m.valide = true ORDER BY m.nom`
|
||||||
),
|
),
|
||||||
query(
|
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
|
COUNT(i.id)::int AS inscrits
|
||||||
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
|
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
|
||||||
WHERE r.date_renc >= CURRENT_DATE
|
WHERE r.date_renc >= CURRENT_DATE
|
||||||
@@ -73,42 +75,141 @@ publicRouter.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Event registration (public modal)
|
// Registration context for the connected member account. Existing names are
|
||||||
publicRouter.post(
|
// returned so the member can review or amend the complete participant list.
|
||||||
'/rencontres/:id/inscriptions',
|
publicRouter.get(
|
||||||
publicFormLimiter,
|
'/rencontres/:id/inscription',
|
||||||
|
requireAuth('member'),
|
||||||
validate(idParam, 'params'),
|
validate(idParam, 'params'),
|
||||||
validate(inscriptionPublicSchema),
|
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const [member, rencontre, participants] = await Promise.all([
|
||||||
const { nom, entreprise, email, tel } = req.data;
|
query('SELECT id, nom, dirigeant, email, tel, valide FROM members WHERE id = $1', [req.user.memberId]),
|
||||||
const renc = await query(
|
query(
|
||||||
`SELECT r.places, COUNT(i.id)::int AS inscrits
|
`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
|
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
|
||||||
WHERE r.id = $1 AND r.date_renc >= CURRENT_DATE
|
WHERE r.id = $1 AND r.date_renc >= CURRENT_DATE
|
||||||
GROUP BY r.id`,
|
GROUP BY r.id`,
|
||||||
[id]
|
[req.params.id]
|
||||||
);
|
),
|
||||||
if (renc.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
query(
|
||||||
if (renc.rows[0].inscrits >= renc.rows[0].places) {
|
`SELECT nom, statut FROM inscriptions
|
||||||
return res.status(409).json({ error: 'Cette rencontre est complète.' });
|
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(
|
if (rencontre.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||||
'SELECT 1 FROM inscriptions WHERE rencontre_id = $1 AND email = $2',
|
res.json({ member: member.rows[0], rencontre: rencontre.rows[0], participants: participants.rows });
|
||||||
[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 });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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({
|
export const inscriptionPublicSchema = z.object({
|
||||||
nom: trimmed(120, 1),
|
participants: z.array(trimmed(120, 1)).min(1).max(100),
|
||||||
entreprise: trimmed(120, 1),
|
|
||||||
email: trimmed(254, 3).email(),
|
|
||||||
tel: trimmed(30).optional().default(''),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const memberProfileSchema = z.object({
|
export const memberProfileSchema = z.object({
|
||||||
@@ -55,6 +52,7 @@ export const rencontreSchema = z.object({
|
|||||||
lieu: trimmed(200).optional().default(''),
|
lieu: trimmed(200).optional().default(''),
|
||||||
description: trimmed(2000).optional().default(''),
|
description: trimmed(2000).optional().default(''),
|
||||||
places: z.coerce.number().int().min(0).max(100000),
|
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({
|
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),
|
lieu TEXT NOT NULL DEFAULT '' CHECK (char_length(lieu) <= 200),
|
||||||
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
|
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
|
||||||
places INTEGER NOT NULL CHECK (places >= 0 AND places <= 100000),
|
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,
|
image_path TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
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 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 (
|
CREATE TABLE IF NOT EXISTS inscriptions (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
rencontre_id INTEGER NOT NULL REFERENCES rencontres(id) ON DELETE CASCADE,
|
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),
|
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
|
||||||
entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120),
|
entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120),
|
||||||
email CITEXT CHECK (char_length(email) <= 254),
|
email CITEXT CHECK (char_length(email) <= 254),
|
||||||
@@ -72,6 +78,12 @@ CREATE TABLE IF NOT EXISTS inscriptions (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_inscriptions_rencontre ON inscriptions(rencontre_id);
|
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 (
|
CREATE TABLE IF NOT EXISTS rencontres_passees (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
@@ -225,8 +225,8 @@ function RencFormModal({ renc, onClose, onSaved }) {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [form, setForm] = useState(
|
const [form, setForm] = useState(
|
||||||
renc
|
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: 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 }
|
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30, participants_par_compte: 1 }
|
||||||
);
|
);
|
||||||
const [image, setImage] = useState(renc?.image_path || '');
|
const [image, setImage] = useState(renc?.image_path || '');
|
||||||
const [error, setError] = useState('');
|
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} />
|
<input name="heure" value={form.heure} onChange={onChange} placeholder="18h30" maxLength={20} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 12 }}>
|
||||||
<label className="field">Lieu
|
<label className="field">Lieu
|
||||||
<input name="lieu" value={form.lieu} onChange={onChange} required maxLength={200} />
|
<input name="lieu" value={form.lieu} onChange={onChange} required maxLength={200} />
|
||||||
</label>
|
</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 />
|
<input name="places" type="number" min={0} max={100000} value={form.places} onChange={onChange} required />
|
||||||
</label>
|
</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>
|
</div>
|
||||||
<label className="field">Description
|
<label className="field">Description
|
||||||
<textarea name="description" value={form.description} onChange={onChange} rows={3} maxLength={2000} />
|
<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, '>');
|
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
|
// Composer + preview of the ready-to-send HTML invitation email generated
|
||||||
// server-side for one rencontre. Every text zone (salutation, intro,
|
// server-side for one rencontre. Every text zone (salutation, intro,
|
||||||
// conclusion, signature) is editable — the preview regenerates as the
|
// conclusion, signature) is editable — the preview regenerates as the
|
||||||
@@ -371,15 +402,16 @@ function EmailModal({ renc, onClose }) {
|
|||||||
const copyEmail = async () => {
|
const copyEmail = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
const fragment = emailBodyFragment(data.html);
|
||||||
if (navigator.clipboard?.write && window.ClipboardItem) {
|
if (navigator.clipboard?.write && window.ClipboardItem) {
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([
|
||||||
new window.ClipboardItem({
|
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' }),
|
'text/plain': new Blob([`${data.subject}\n${data.inscriptionUrl}`], { type: 'text/plain' }),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
await navigator.clipboard.writeText(data.html);
|
legacyCopyRichHtml(fragment);
|
||||||
}
|
}
|
||||||
flash('email');
|
flash('email');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -602,6 +634,7 @@ export function RencontresTab() {
|
|||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<div className="serif" style={{ fontSize: 22, fontWeight: 600, color: 'var(--red)' }}>{e.inscrits}</div>
|
<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: 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>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<button className="btn btn-dark btn-sm" style={{ fontSize: 13, padding: '9px 16px' }} onClick={() => setParticipantsRenc(e)}>
|
<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 { useEffect, useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { usePublicData } from '../lib/usePublic.js';
|
import { usePublicData } from '../lib/usePublic.js';
|
||||||
|
import { useAuth } from '../lib/AuthContext.jsx';
|
||||||
import { api, dateParts } from '../lib/api.js';
|
import { api, dateParts } from '../lib/api.js';
|
||||||
import Modal from '../components/Modal.jsx';
|
import Modal from '../components/Modal.jsx';
|
||||||
import PastEventCard from '../components/PastEventCard.jsx';
|
import PastEventCard from '../components/PastEventCard.jsx';
|
||||||
|
|
||||||
function InscriptionModal({ rencontre, onClose, onDone }) {
|
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 [done, setDone] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
const { jour, mois } = dateParts(rencontre.date_renc);
|
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) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, form);
|
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, { participants });
|
||||||
setDone(true);
|
setDone(true);
|
||||||
onDone?.();
|
onDone?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
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.
|
Votre demande pour <strong>{rencontre.titre}</strong> est bien enregistrée.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', marginBottom: 26 }}>
|
<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>
|
</p>
|
||||||
<button className="btn btn-dark btn-sm" style={{ padding: '13px 28px', fontSize: 14 }} onClick={onClose}>Fermer</button>
|
<button className="btn btn-dark btn-sm" style={{ padding: '13px 28px', fontSize: 14 }} onClick={onClose}>Fermer</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,26 +85,74 @@ function InscriptionModal({ rencontre, onClose, onDone }) {
|
|||||||
maxWidth={460}
|
maxWidth={460}
|
||||||
header={{ kicker: 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
|
header={{ kicker: 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
|
||||||
>
|
>
|
||||||
<form onSubmit={submit} style={{ padding: '28px 30px' }}>
|
{!user && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<form onSubmit={submitLogin} style={{ padding: '28px 30px' }}>
|
||||||
<label className="field-plain">Nom & prénom
|
<p style={{ fontSize: 14, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 18 }}>
|
||||||
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
|
Connectez-vous avec votre compte membre avant de choisir les participants.
|
||||||
</label>
|
</p>
|
||||||
<label className="field-plain">Entreprise
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
<input name="entreprise" value={form.entreprise} onChange={onChange} required maxLength={120} />
|
<label className="field-plain">Email du compte
|
||||||
</label>
|
<input type="email" value={credentials.email} onChange={(e) => setCredentials({ ...credentials, email: e.target.value })} required autoComplete="username" />
|
||||||
<label className="field-plain">Email
|
</label>
|
||||||
<input name="email" type="email" value={form.email} onChange={onChange} required maxLength={254} />
|
<label className="field-plain">Mot de passe
|
||||||
</label>
|
<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>
|
</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
|
{user?.role === 'member' && !user.mustChangePassword && !context && (
|
||||||
</button>
|
<p style={{ padding: '30px', color: 'var(--gray-light)' }}>{error || 'Chargement de votre compte…'}</p>
|
||||||
<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>
|
{user?.role === 'member' && !user.mustChangePassword && context && (
|
||||||
</form>
|
<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>
|
</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)' }}>
|
<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 }}>
|
<span style={{ fontSize: 13, color: 'var(--red)', fontWeight: 600 }}>
|
||||||
{restantes > 0 ? `${restantes} places restantes` : 'Complet'}
|
{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>
|
</span>
|
||||||
<button className="btn btn-dark btn-sm" style={{ fontSize: 13.5 }} disabled={restantes === 0} onClick={() => setInscrRenc(e)}>
|
<button className="btn btn-dark btn-sm" style={{ fontSize: 13.5 }} disabled={restantes === 0} onClick={() => setInscrRenc(e)}>
|
||||||
S'inscrire
|
S'inscrire
|
||||||
@@ -344,7 +431,7 @@ export default function Home() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{inscrRenc && (
|
{inscrRenc && (
|
||||||
<InscriptionModal rencontre={inscrRenc} onClose={closeInscription} onDone={reload} />
|
<InscriptionModal key={inscrRenc.id} rencontre={inscrRenc} onClose={closeInscription} onDone={reload} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user