Ajoute les inscriptions modifiables et les paramètres association

This commit is contained in:
Michael committed 2026-07-16 19:48:14 +02:00
1 parent c49c45e65f
commit cf67fdc9de
22 files changed
+442 -77

No files matched your search

+8 -6
View File
@@ -1,3 +1,5 @@
import { associationSettings } from './siteSettings.js';
function esc(value) { function esc(value) {
return String(value ?? '') return String(value ?? '')
.replace(/&/g, '&') .replace(/&/g, '&')
@@ -33,7 +35,8 @@ function ctaButton(url, label) {
</table>`; </table>`;
} }
export function buildCustomEmail({ content, baseUrl }) { export function buildCustomEmail({ content, baseUrl, association = {} }) {
const settings = associationSettings(association);
const logoUrl = `${baseUrl}/assets/logo.jpg`; const logoUrl = `${baseUrl}/assets/logo.jpg`;
const button = ctaButton(content.buttonUrl, content.buttonLabel); const button = ctaButton(content.buttonUrl, content.buttonLabel);
const html = `<!doctype html> const html = `<!doctype html>
@@ -55,10 +58,9 @@ export function buildCustomEmail({ content, baseUrl }) {
<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;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:620px;background-color:#FFFFFF;border:1px solid #E7E2DB;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">
<tr><td bgcolor="#161514" style="background-color:#161514;padding:26px 40px;"> <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> <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;"><tr>
<td width="52" valign="middle"><img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;background-color:#FFFFFF;"></td> <td width="52" valign="middle"><img src="${esc(logoUrl)}" width="52" height="52" alt="${esc(settings.association_name)}" style="display:block;background-color:#FFFFFF;"></td>
<td valign="middle" style="padding-left:16px;"> <td valign="middle" style="padding-left:16px;">
<div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:22px;">Business Club</div> <div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:22px;">${esc(settings.association_name)}</div>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:#E0777A;font-weight:bold;line-height:16px;">SLUC Nancy</div>
</td> </td>
</tr></table> </tr></table>
</td></tr> </td></tr>
@@ -70,8 +72,8 @@ export function buildCustomEmail({ content, baseUrl }) {
${button ? `<tr><td align="center" style="padding:18px 40px 12px;">${button}</td></tr>` : ''} ${button ? `<tr><td align="center" style="padding:18px 40px 12px;">${button}</td></tr>` : ''}
${content.signature ? `<tr><td style="padding:24px 40px 38px;font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:26px;color:#1B1B1B;">${lines(content.signature)}</td></tr>` : '<tr><td height="26" style="height:26px;line-height:26px;">&nbsp;</td></tr>'} ${content.signature ? `<tr><td style="padding:24px 40px 38px;font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:26px;color:#1B1B1B;">${lines(content.signature)}</td></tr>` : '<tr><td height="26" style="height:26px;line-height:26px;">&nbsp;</td></tr>'}
<tr><td bgcolor="#0F0E0D" style="background-color:#0F0E0D;padding:24px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:22px;"> <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> ${esc(settings.association_name)}${settings.association_address ? ` &middot; ${lines(settings.association_address)}` : ''}<br>
<a href="mailto:contact@sluc-businessclub.fr" style="color:#E0777A;text-decoration:none;">contact@sluc-businessclub.fr</a> ${settings.association_contact ? `${esc(settings.association_contact)} &middot; ` : ''}${settings.association_email ? `<a href="mailto:${esc(settings.association_email)}" style="color:#E0777A;text-decoration:none;">${esc(settings.association_email)}</a>` : ''}${settings.association_phone ? ` &middot; ${esc(settings.association_phone)}` : ''}
</td></tr> </td></tr>
</table> </table>
<!--[if mso]></td></tr></table><![endif]--> <!--[if mso]></td></tr></table><![endif]-->
+12 -9
View File
@@ -1,3 +1,5 @@
import { associationSettings } from './siteSettings.js';
// Invitation email for a rencontre, generated on demand from the admin // Invitation email for a rencontre, generated on demand from the admin
// backend. Email-client constraints apply: table layout, inline styles, // backend. Email-client constraints apply: table layout, inline styles,
// web-safe font stacks, absolute URLs. The palette mirrors the site theme // web-safe font stacks, absolute URLs. The palette mirrors the site theme
@@ -17,7 +19,8 @@ function escML(v) {
// Default wording of each editable zone. The admin can rewrite any of // Default wording of each editable zone. The admin can rewrite any of
// them in the composer; clearing a field removes the block entirely. // them in the composer; clearing a field removes the block entirely.
export function defaultEmailTexts() { export function defaultEmailTexts(association = {}) {
const settings = associationSettings(association);
return { return {
greeting: 'Chère membre, cher membre,', greeting: 'Chère membre, cher membre,',
intro: intro:
@@ -27,13 +30,14 @@ export function defaultEmailTexts() {
outro: outro:
"Les places sont limitées — pensez à vous inscrire dès maintenant. N'hésitez pas à " "Les places sont limitées — pensez à vous inscrire dès maintenant. N'hésitez pas à "
+ "transférer cette invitation à un dirigeant de votre réseau qui souhaiterait découvrir le Club.", + "transférer cette invitation à un dirigeant de votre réseau qui souhaiterait découvrir le Club.",
signature: "À très bientôt,\nL'équipe du Business Club SLUC Nancy", signature: `À très bientôt,\nL'équipe de ${settings.association_name}`,
}; };
} }
export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) { export function buildInvitationEmail({ rencontre, baseUrl, texts = {}, association = {} }) {
const r = rencontre; const r = rencontre;
const defaults = defaultEmailTexts(); const settings = associationSettings(association);
const defaults = defaultEmailTexts(settings);
// undefined -> default wording; '' (cleared by the admin) -> block hidden // undefined -> default wording; '' (cleared by the admin) -> block hidden
const t = { const t = {
greeting: texts.greeting ?? defaults.greeting, greeting: texts.greeting ?? defaults.greeting,
@@ -109,11 +113,10 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
<tr><td bgcolor="#161514" 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" style="border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;"><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="${esc(settings.association_name)}" style="display:block;border-radius:6px;background-color:#FFFFFF;">
</td> </td>
<td valign="middle" style="padding-left:16px;"> <td valign="middle" style="padding-left:16px;">
<div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:1.1;">Business Club</div> <div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:1.1;">${esc(settings.association_name)}</div>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:#E0777A;font-weight:bold;padding-top:4px;">SLUC Nancy</div>
</td> </td>
</tr></table> </tr></table>
</td></tr> </td></tr>
@@ -153,8 +156,8 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
</td></tr> </td></tr>
<tr><td bgcolor="#0F0E0D" style="background-color:#0F0E0D;padding:24px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:22px;"> <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> ${esc(settings.association_name)}${settings.association_address ? ` &middot; ${escML(settings.association_address)}` : ''}<br>
<a href="mailto:contact@sluc-businessclub.fr" style="color:#E0777A;text-decoration:none;">contact@sluc-businessclub.fr</a> ${settings.association_contact ? `${esc(settings.association_contact)} &middot; ` : ''}${settings.association_email ? `<a href="mailto:${esc(settings.association_email)}" style="color:#E0777A;text-decoration:none;">${esc(settings.association_email)}</a>` : ''}${settings.association_phone ? ` &middot; ${esc(settings.association_phone)}` : ''}
</td></tr> </td></tr>
</table> </table>
+1
View File
@@ -31,6 +31,7 @@ app.use(
fontSrc: ['https://fonts.gstatic.com'], fontSrc: ['https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:', 'blob:'], imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"], connectSrc: ["'self'"],
frameSrc: ["'self'", 'https://www.google.com', 'https://maps.google.com'],
frameAncestors: ["'none'"], frameAncestors: ["'none'"],
baseUri: ["'self'"], baseUri: ["'self'"],
formAction: ["'self'"], formAction: ["'self'"],
+16 -9
View File
@@ -33,7 +33,7 @@ const adminOnly = requireAuth('admin');
const MEMBER_SQL = ` const MEMBER_SQL = `
SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant, SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant,
m.adhesion, m.email, m.tel, m.site, m.presentation, m.valide, m.adhesion, m.email, m.tel, m.site, m.adresse, m.presentation, m.valide,
m.logo_path, m.photo_path, m.logo_path, m.photo_path,
(u.id IS NOT NULL) AS has_login, (u.id IS NOT NULL) AS has_login,
COALESCE(u.must_change_password, false) AS must_change_password, COALESCE(u.must_change_password, false) AS must_change_password,
@@ -53,6 +53,11 @@ const INSCR_SQL = `
i.rencontre_id, r.titre AS rencontre i.rencontre_id, r.titre AS rencontre
FROM inscriptions i JOIN rencontres r ON r.id = i.rencontre_id`; FROM inscriptions i JOIN rencontres r ON r.id = i.rencontre_id`;
async function loadAssociationSettings() {
const result = await query(`SELECT key, value FROM site_content WHERE key LIKE 'association_%'`);
return Object.fromEntries(result.rows.map((row) => [row.key, row.value]));
}
// ---------- Dashboard (admin only) ---------- // ---------- Dashboard (admin only) ----------
adminRouter.get('/dashboard', adminOnly, async (_req, res, next) => { adminRouter.get('/dashboard', adminOnly, async (_req, res, next) => {
try { try {
@@ -89,10 +94,10 @@ adminRouter.post('/members', validate(adminMemberSchema), async (req, res, next)
try { try {
const d = req.data; const d = req.data;
const result = await query( const result = await query(
`INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, presentation, valide) `INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, adresse, presentation, valide)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
[d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, new Date().getFullYear(), [d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, new Date().getFullYear(),
d.email || null, d.tel, d.site, d.presentation, d.valide ?? true] d.email || null, d.tel, d.site, d.adresse, d.presentation, d.valide ?? true]
); );
const id = result.rows[0].id; const id = result.rows[0].id;
// A login account (with a temporary password) is only created when an // A login account (with a temporary password) is only created when an
@@ -120,10 +125,10 @@ adminRouter.put('/members/:id', validate(idParam, 'params'), validate(adminMembe
const d = req.data; const d = req.data;
const result = await query( const result = await query(
`UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5, `UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5,
tel=$6, site=$7, presentation=$8, valide=COALESCE($9, valide), updated_at=now() tel=$6, site=$7, adresse=$8, presentation=$9, valide=COALESCE($10, valide), updated_at=now()
WHERE id=$10 RETURNING id`, WHERE id=$11 RETURNING id`,
[d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, d.email || null, [d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, d.email || null,
d.tel, d.site, d.presentation, d.valide ?? null, req.params.id] d.tel, d.site, d.adresse, d.presentation, d.valide ?? null, req.params.id]
); );
if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' }); if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' });
res.json({ ok: true }); res.json({ ok: true });
@@ -257,7 +262,8 @@ adminRouter.post('/rencontres/:id/email', validate(idParam, 'params'), validate(
// invalid base — keep the fallback // invalid base — keep the fallback
} }
} }
res.json(buildInvitationEmail({ rencontre: result.rows[0], baseUrl, texts })); const association = await loadAssociationSettings();
res.json(buildInvitationEmail({ rencontre: result.rows[0], baseUrl, texts, association }));
} catch (err) { } catch (err) {
next(err); next(err);
} }
@@ -281,7 +287,8 @@ adminRouter.post('/emails/preview', adminOnly, validate(customEmailSchema), asyn
const parsed = new URL(base); const parsed = new URL(base);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') baseUrl = parsed.origin; if (parsed.protocol === 'http:' || parsed.protocol === 'https:') baseUrl = parsed.origin;
} }
res.json(buildCustomEmail({ content, baseUrl })); const association = await loadAssociationSettings();
res.json(buildCustomEmail({ content, baseUrl, association }));
} catch (err) { } catch (err) {
next(err); next(err);
} }
+22 -5
View File
@@ -11,7 +11,7 @@ memberRouter.use(requireAuth('member'));
const PROFILE_SQL = ` const PROFILE_SQL = `
SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant, SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant,
m.adhesion, m.email, m.tel, m.site, m.presentation, m.valide, m.adhesion, m.email, m.tel, m.site, m.adresse, m.presentation, m.valide,
m.logo_path, m.photo_path m.logo_path, m.photo_path
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id FROM members m LEFT JOIN categories c ON c.id = m.categorie_id
WHERE m.id = $1`; WHERE m.id = $1`;
@@ -28,12 +28,12 @@ memberRouter.get('/profile', async (req, res, next) => {
memberRouter.put('/profile', validate(memberProfileSchema), async (req, res, next) => { memberRouter.put('/profile', validate(memberProfileSchema), async (req, res, next) => {
try { try {
const { nom, secteur, categorie_id, dirigeant, email, tel, site, presentation } = req.data; const { nom, secteur, categorie_id, dirigeant, email, tel, site, adresse, presentation } = req.data;
await query( await query(
`UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5, `UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5,
tel=$6, site=$7, presentation=$8, updated_at=now() tel=$6, site=$7, adresse=$8, presentation=$9, updated_at=now()
WHERE id=$9`, WHERE id=$10`,
[nom, secteur, categorie_id ?? null, dirigeant, email || null, tel, site, presentation, req.user.memberId] [nom, secteur, categorie_id ?? null, dirigeant, email || null, tel, site, adresse, presentation, req.user.memberId]
); );
const result = await query(PROFILE_SQL, [req.user.memberId]); const result = await query(PROFILE_SQL, [req.user.memberId]);
res.json({ member: result.rows[0] }); res.json({ member: result.rows[0] });
@@ -42,6 +42,23 @@ memberRouter.put('/profile', validate(memberProfileSchema), async (req, res, nex
} }
}); });
memberRouter.get('/inscriptions', async (req, res, next) => {
try {
const result = await query(
`SELECT i.rencontre_id, COUNT(*)::int AS participants,
bool_and(i.statut = 'confirmee') AS confirmee
FROM inscriptions i
JOIN rencontres r ON r.id = i.rencontre_id
WHERE i.member_id = $1 AND r.date_renc >= CURRENT_DATE
GROUP BY i.rencontre_id`,
[req.user.memberId]
);
res.json({ inscriptions: result.rows });
} catch (err) {
next(err);
}
});
function imageRoute(column) { function imageRoute(column) {
return (req, res, next) => { return (req, res, next) => {
imageUpload(req, res, async (err) => { imageUpload(req, res, async (err) => {
+1 -1
View File
@@ -14,7 +14,7 @@ publicRouter.get('/bootstrap', async (_req, res, next) => {
query('SELECT key, value FROM site_content'), query('SELECT key, value FROM site_content'),
query('SELECT id, name FROM categories ORDER BY id'), query('SELECT id, name FROM categories ORDER BY id'),
query( query(
`SELECT m.id, m.nom, m.secteur, m.dirigeant, m.adhesion, m.email, m.tel, m.site, `SELECT m.id, m.nom, m.secteur, m.dirigeant, m.adhesion, m.email, m.tel, m.site, m.adresse,
m.presentation, m.logo_path, m.photo_path, c.name AS categorie m.presentation, m.logo_path, m.photo_path, c.name AS categorie
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id FROM members m LEFT JOIN categories c ON c.id = m.categorie_id
WHERE m.valide = true ORDER BY m.nom` WHERE m.valide = true ORDER BY m.nom`
+14
View File
@@ -39,6 +39,7 @@ export const memberProfileSchema = z.object({
email: trimmed(254).email().or(z.literal('')).optional().default(''), email: trimmed(254).email().or(z.literal('')).optional().default(''),
tel: trimmed(30).optional().default(''), tel: trimmed(30).optional().default(''),
site: trimmed(200).optional().default(''), site: trimmed(200).optional().default(''),
adresse: trimmed(300).optional().default(''),
presentation: trimmed(2000).optional().default(''), presentation: trimmed(2000).optional().default(''),
}); });
@@ -95,6 +96,19 @@ export const roleChangeSchema = z.object({ role: staffRoleEnum });
export const contentSchema = z.object({ export const contentSchema = z.object({
hero_quote_text: trimmed(300).optional(), hero_quote_text: trimmed(300).optional(),
hero_quote_author: trimmed(120).optional(), hero_quote_author: trimmed(120).optional(),
association_name: trimmed(160, 1).optional(),
association_address: trimmed(500).optional(),
association_email: trimmed(254).email().or(z.literal('')).optional(),
association_phone: trimmed(40).optional(),
association_contact: trimmed(160).optional(),
association_website: z.string().trim().url().max(300).or(z.literal('')).optional(),
association_facebook_url: z.string().trim().url().max(500).or(z.literal('')).optional(),
association_linkedin_url: z.string().trim().url().max(500).or(z.literal('')).optional(),
association_president: trimmed(160).optional(),
association_vice_president: trimmed(160).optional(),
association_treasurer: trimmed(160).optional(),
association_secretary: trimmed(160).optional(),
association_board_members: trimmed(3000).optional(),
}); });
// Invitation email composer: every text zone is optional — absent fields // Invitation email composer: every text zone is optional — absent fields
+19
View File
@@ -0,0 +1,19 @@
export const ASSOCIATION_DEFAULTS = {
association_name: 'Business Club SLUC Nancy',
association_address: 'Palais des Sports Jean Weille\nNancy (54)',
association_email: 'contact@sluc-businessclub.fr',
association_phone: '+33 3 83 00 00 00',
association_contact: '',
association_website: '',
association_facebook_url: 'https://www.facebook.com/search/top?q=sluc%20business%20club',
association_linkedin_url: 'https://www.linkedin.com/company/club-affaires-stanislas/posts/?feedView=all',
association_president: 'Jean-Marc Lefèvre',
association_vice_president: '',
association_treasurer: '',
association_secretary: '',
association_board_members: '',
};
export function associationSettings(content = {}) {
return { ...ASSOCIATION_DEFAULTS, ...content };
}
+2
View File
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS members (
email CITEXT CHECK (char_length(email) <= 254), email CITEXT CHECK (char_length(email) <= 254),
tel TEXT CHECK (char_length(tel) <= 30), tel TEXT CHECK (char_length(tel) <= 30),
site TEXT CHECK (char_length(site) <= 200), site TEXT CHECK (char_length(site) <= 200),
adresse TEXT CHECK (char_length(adresse) <= 300),
presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000), presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000),
valide BOOLEAN NOT NULL DEFAULT false, valide BOOLEAN NOT NULL DEFAULT false,
logo_path TEXT, logo_path TEXT,
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS members (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
ALTER TABLE members ADD COLUMN IF NOT EXISTS adresse TEXT CHECK (char_length(adresse) <= 300);
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
+14 -1
View File
@@ -58,7 +58,20 @@ INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES
INSERT INTO site_content (key, value) VALUES INSERT INTO site_content (key, value) VALUES
('hero_quote_text', 'On ne réussit jamais seul.'), ('hero_quote_text', 'On ne réussit jamais seul.'),
('hero_quote_author', 'L''esprit du Club'), ('hero_quote_author', 'L''esprit du Club'),
('hero_photo', '') ('hero_photo', ''),
('association_name', 'Business Club SLUC Nancy'),
('association_address', E'Palais des Sports Jean Weille\nNancy (54)'),
('association_email', 'contact@sluc-businessclub.fr'),
('association_phone', '+33 3 83 00 00 00'),
('association_contact', ''),
('association_website', ''),
('association_facebook_url', 'https://www.facebook.com/search/top?q=sluc%20business%20club'),
('association_linkedin_url', 'https://www.linkedin.com/company/club-affaires-stanislas/posts/?feedView=all'),
('association_president', 'Jean-Marc Lefèvre'),
('association_vice_president', ''),
('association_treasurer', ''),
('association_secretary', ''),
('association_board_members', '')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
-- Accounts: created locked ('*seed*'); real passwords are applied at startup -- Accounts: created locked ('*seed*'); real passwords are applied at startup
+16 -12
View File
@@ -1,11 +1,13 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { usePublicData } from '../lib/usePublic.js'; import { usePublicData } from '../lib/usePublic.js';
import { associationSettings } from '../lib/siteSettings.js';
const linkStyle = { color: '#B7AFA6', fontSize: 14, textDecoration: 'none' }; const linkStyle = { color: '#B7AFA6', fontSize: 14, textDecoration: 'none' };
export default function Footer() { export default function Footer() {
const { data } = usePublicData(); const { data } = usePublicData();
const memberCount = data?.members?.length ?? 0; const memberCount = data?.members?.length ?? 0;
const settings = associationSettings(data?.content);
return ( return (
<footer style={{ background: 'var(--footer)', color: '#B7AFA6', padding: '64px 0 34px' }}> <footer style={{ background: 'var(--footer)', color: '#B7AFA6', padding: '64px 0 34px' }}>
@@ -14,11 +16,11 @@ export default function Footer() {
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<img <img
src="/assets/logo.jpg" src="/assets/logo.jpg"
alt="SLUC Business Club" alt={settings.association_name}
style={{ height: 48, width: 48, objectFit: 'cover', borderRadius: 4, background: '#fff' }} style={{ height: 48, width: 48, objectFit: 'cover', borderRadius: 4, background: '#fff' }}
/> />
<span className="serif" style={{ fontSize: 18, fontWeight: 600, color: '#fff' }}> <span className="serif" style={{ fontSize: 18, fontWeight: 600, color: '#fff' }}>
Business Club SLUC Nancy {settings.association_name}
</span> </span>
</div> </div>
<p style={{ fontSize: 14, lineHeight: 1.65, maxWidth: 320 }}> <p style={{ fontSize: 14, lineHeight: 1.65, maxWidth: 320 }}>
@@ -43,9 +45,11 @@ export default function Footer() {
Contact Contact
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 14, lineHeight: 1.5 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 14, lineHeight: 1.5 }}>
<span>Palais des Sports J. Weille<br />Nancy (54)</span> {settings.association_address && <span style={{ whiteSpace: 'pre-line' }}>{settings.association_address}</span>}
<span>contact@sluc-businessclub.fr</span> {settings.association_contact && <span>{settings.association_contact}</span>}
<span>+33 3 83 00 00 00</span> {settings.association_email && <a href={`mailto:${settings.association_email}`} style={linkStyle}>{settings.association_email}</a>}
{settings.association_phone && <a href={`tel:${settings.association_phone.replace(/[^+\d]/g, '')}`} style={linkStyle}>{settings.association_phone}</a>}
{settings.association_website && <a href={settings.association_website} target="_blank" rel="noopener noreferrer" style={linkStyle}>Site web</a>}
</div> </div>
</div> </div>
<div> <div>
@@ -53,22 +57,22 @@ export default function Footer() {
Suivez-nous Suivez-nous
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-start' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-start' }}>
<a {settings.association_facebook_url && <a
href="https://www.facebook.com/search/top?q=sluc%20business%20club" href={settings.association_facebook_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={linkStyle} style={linkStyle}
> >
Facebook Facebook
</a> </a>}
<a {settings.association_linkedin_url && <a
href="https://www.linkedin.com/company/club-affaires-stanislas/posts/?feedView=all" href={settings.association_linkedin_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={linkStyle} style={linkStyle}
> >
LinkedIn LinkedIn
</a> </a>}
</div> </div>
</div> </div>
</div> </div>
@@ -80,7 +84,7 @@ export default function Footer() {
fontSize: 12.5, color: '#6E675F', fontSize: 12.5, color: '#6E675F',
}} }}
> >
<span>© {new Date().getFullYear()} SLUC Nancy Business Club</span> <span>© {new Date().getFullYear()} {settings.association_name}</span>
<span>Mentions légales · Confidentialité</span> <span>Mentions légales · Confidentialité</span>
</div> </div>
</footer> </footer>
+7 -6
View File
@@ -1,8 +1,12 @@
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { usePublicData } from '../lib/usePublic.js';
import { associationSettings } from '../lib/siteSettings.js';
const navLinkClass = ({ isActive }) => `nav-btn${isActive ? ' active' : ''}`; const navLinkClass = ({ isActive }) => `nav-btn${isActive ? ' active' : ''}`;
export default function Header() { export default function Header() {
const { data } = usePublicData();
const settings = associationSettings(data?.content);
return ( return (
<header <header
style={{ style={{
@@ -16,12 +20,9 @@ export default function Header() {
style={{ minHeight: 76, paddingTop: 12, paddingBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, flexWrap: 'wrap' }} style={{ minHeight: 76, paddingTop: 12, paddingBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, flexWrap: 'wrap' }}
> >
<Link to="/" style={{ display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}> <Link to="/" style={{ display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}>
<img src="/assets/logo.jpg" alt="SLUC Business Club Nancy" style={{ height: 50, width: 'auto', display: 'block' }} /> <img src="/assets/logo.jpg" alt={settings.association_name} style={{ height: 50, width: 'auto', display: 'block' }} />
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', lineHeight: 1.05 }}> <span className="serif" style={{ maxWidth: 220, fontSize: 18, fontWeight: 600, lineHeight: 1.12, color: 'var(--ink)' }}>
<span className="serif" style={{ fontSize: 19, fontWeight: 600, color: 'var(--ink)' }}>Business Club</span> {settings.association_name}
<span style={{ fontSize: 10, letterSpacing: '.22em', textTransform: 'uppercase', color: 'var(--red-dark)', fontWeight: 600 }}>
SLUC Nancy
</span>
</span> </span>
</Link> </Link>
<nav className="site-nav" style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <nav className="site-nav" style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
+22 -1
View File
@@ -2,6 +2,9 @@ import Modal from './Modal.jsx';
export default function MemberModal({ member, onClose }) { export default function MemberModal({ member, onClose }) {
if (!member) return null; if (!member) return null;
const encodedAddress = member.adresse ? encodeURIComponent(member.adresse) : '';
const mapUrl = encodedAddress ? `https://www.google.com/maps?q=${encodedAddress}&output=embed` : '';
const mapLink = encodedAddress ? `https://www.google.com/maps/search/?api=1&query=${encodedAddress}` : '';
return ( return (
<Modal onClose={onClose} maxWidth={760}> <Modal onClose={onClose} maxWidth={760}>
<div style={{ position: 'relative', background: 'var(--dark)', color: '#fff', padding: '32px 36px', display: 'flex', alignItems: 'center', gap: 22 }}> <div style={{ position: 'relative', background: 'var(--dark)', color: '#fff', padding: '32px 36px', display: 'flex', alignItems: 'center', gap: 22 }}>
@@ -45,7 +48,7 @@ export default function MemberModal({ member, onClose }) {
<p style={{ fontSize: 15, lineHeight: 1.7, color: 'var(--gray)', marginBottom: 26 }}>{member.presentation}</p> <p style={{ fontSize: 15, lineHeight: 1.7, color: 'var(--gray)', marginBottom: 26 }}>{member.presentation}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, borderTop: '1px solid var(--border-soft)', paddingTop: 22 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12, borderTop: '1px solid var(--border-soft)', paddingTop: 22 }}>
{[['Email', member.email], ['Tél', member.tel], ['Site', member.site]].map(([label, value]) => {[['Adresse', member.adresse], ['Email', member.email], ['Tél', member.tel], ['Site', member.site]].map(([label, value]) =>
value ? ( value ? (
<div key={label} style={{ display: 'flex', gap: 12, alignItems: 'center', fontSize: 14 }}> <div key={label} style={{ display: 'flex', gap: 12, alignItems: 'center', fontSize: 14 }}>
<span style={{ width: 64, fontSize: 11, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--gray-light)', fontWeight: 600 }}> <span style={{ width: 64, fontSize: 11, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--gray-light)', fontWeight: 600 }}>
@@ -56,6 +59,24 @@ export default function MemberModal({ member, onClose }) {
) : null ) : null
)} )}
</div> </div>
{member.adresse && (
<div style={{ marginTop: 26 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 12 }}>
<h3 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Situation géographique</h3>
<a href={mapLink} target="_blank" rel="noreferrer" className="btn-link" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>
Ouvrir la carte →
</a>
</div>
<iframe
title={`Carte de ${member.nom}`}
src={mapUrl}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
style={{ width: '100%', height: 280, border: 0, borderRadius: 6, display: 'block' }}
allowFullScreen
/>
</div>
)}
{member.email && ( {member.email && (
<a href={`mailto:${member.email}`} style={{ textDecoration: 'none' }}> <a href={`mailto:${member.email}`} style={{ textDecoration: 'none' }}>
<button className="btn btn-dark btn-sm" style={{ marginTop: 26, padding: '13px 24px', fontSize: 14 }}> <button className="btn btn-dark btn-sm" style={{ marginTop: 26, padding: '13px 24px', fontSize: 14 }}>
+116
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../../lib/api.js'; import { api } from '../../lib/api.js';
import { ASSOCIATION_DEFAULTS, associationSettings } from '../../lib/siteSettings.js';
import ImageSlot from '../ImageSlot.jsx'; import ImageSlot from '../ImageSlot.jsx';
export function CategoriesTab() { export function CategoriesTab() {
@@ -197,3 +198,118 @@ export function ContenuTab() {
</div> </div>
); );
} }
export function ParametresTab() {
const [settings, setSettings] = useState(ASSOCIATION_DEFAULTS);
const [saved, setSaved] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
api.get('/api/public/bootstrap')
.then((data) => setSettings(associationSettings(data.content)))
.catch((err) => setError(err.message));
}, []);
const onChange = (event) => {
setSettings({ ...settings, [event.target.name]: event.target.value });
setSaved(false);
};
const save = async (event) => {
event.preventDefault();
setError('');
try {
const body = Object.fromEntries(
Object.keys(ASSOCIATION_DEFAULTS).map((key) => [key, settings[key] || ''])
);
await api.put('/api/admin/content', body);
setSaved(true);
} catch (err) {
setError(err.message);
}
};
return (
<form onSubmit={save} style={{ maxWidth: 980, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 8 }}>Identité et coordonnées</h3>
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', lineHeight: 1.55, marginBottom: 22 }}>
Ces informations sont reprises dans le site public et les e-mails générés.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label className="field">Nom officiel de l'association
<input name="association_name" value={settings.association_name} onChange={onChange} required maxLength={160} />
</label>
<label className="field">Adresse
<textarea name="association_address" value={settings.association_address} onChange={onChange} rows={3} maxLength={500} placeholder="Adresse complète" />
</label>
<div className="grid-2" style={{ gap: 12 }}>
<label className="field">E-mail
<input name="association_email" type="email" value={settings.association_email} onChange={onChange} maxLength={254} />
</label>
<label className="field">Téléphone
<input name="association_phone" type="tel" value={settings.association_phone} onChange={onChange} maxLength={40} />
</label>
</div>
<div className="grid-2" style={{ gap: 12 }}>
<label className="field">Contact principal
<input name="association_contact" value={settings.association_contact} onChange={onChange} maxLength={160} placeholder="Nom et fonction" />
</label>
<label className="field">Site web
<input name="association_website" type="url" value={settings.association_website} onChange={onChange} maxLength={300} placeholder="https://…" />
</label>
</div>
<div className="grid-2" style={{ gap: 12 }}>
<label className="field">Page Facebook
<input name="association_facebook_url" type="url" value={settings.association_facebook_url} onChange={onChange} maxLength={500} />
</label>
<label className="field">Page LinkedIn
<input name="association_linkedin_url" type="url" value={settings.association_linkedin_url} onChange={onChange} maxLength={500} />
</label>
</div>
</div>
</div>
<div className="card" style={{ borderRadius: 6, padding: 26 }}>
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600, marginBottom: 8 }}>Gouvernance</h3>
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', lineHeight: 1.55, marginBottom: 22 }}>
Laissez un poste vide pour ne pas l'afficher sur la page Association.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="grid-2" style={{ gap: 12 }}>
<label className="field">Président ou présidente
<input name="association_president" value={settings.association_president} onChange={onChange} maxLength={160} />
</label>
<label className="field">Vice-président ou vice-présidente
<input name="association_vice_president" value={settings.association_vice_president} onChange={onChange} maxLength={160} />
</label>
</div>
<div className="grid-2" style={{ gap: 12 }}>
<label className="field">Trésorier ou trésorière
<input name="association_treasurer" value={settings.association_treasurer} onChange={onChange} maxLength={160} />
</label>
<label className="field">Secrétaire
<input name="association_secretary" value={settings.association_secretary} onChange={onChange} maxLength={160} />
</label>
</div>
<label className="field">Membres du conseil d'administration
<textarea
name="association_board_members"
value={settings.association_board_members}
onChange={onChange}
rows={6}
maxLength={3000}
placeholder="Une personne par ligne"
/>
</label>
</div>
</div>
{error && <p className="error-text">{error}</p>}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<button type="submit" className="btn btn-red btn-sm" style={{ fontSize: 14, padding: '12px 22px' }}>Enregistrer les paramètres</button>
{saved && <span className="success-text">✓ Paramètres enregistrés</span>}
</div>
</form>
);
}
+17 -3
View File
@@ -2,15 +2,17 @@ import { useEffect, useRef, useState } from 'react';
import { api } from '../../lib/api.js'; import { api } from '../../lib/api.js';
import { copyRichEmail } from '../../lib/emailClipboard.js'; import { copyRichEmail } from '../../lib/emailClipboard.js';
import { downloadOutlookDraft } from '../../lib/outlookDraft.js'; import { downloadOutlookDraft } from '../../lib/outlookDraft.js';
import { associationSettings } from '../../lib/siteSettings.js';
const defaultAssociation = associationSettings();
const INITIAL = { const INITIAL = {
subject: 'Actualités du Business Club SLUC Nancy', subject: `Actualités de ${defaultAssociation.association_name}`,
kicker: 'Business Club · SLUC Nancy', kicker: defaultAssociation.association_name,
title: 'Votre titre ici', title: 'Votre titre ici',
body: "Bonjour,\n\nRédigez ici votre message. Vous pouvez créer plusieurs paragraphes en laissant une ligne vide.\n\nLe modèle conserve automatiquement l'identité visuelle du Business Club.", body: "Bonjour,\n\nRédigez ici votre message. Vous pouvez créer plusieurs paragraphes en laissant une ligne vide.\n\nLe modèle conserve automatiquement l'identité visuelle du Business Club.",
buttonLabel: 'Découvrir', buttonLabel: 'Découvrir',
buttonUrl: window.location.origin, buttonUrl: window.location.origin,
signature: "À très bientôt,\nL'équipe du Business Club SLUC Nancy", signature: `À très bientôt,\nL'équipe de ${defaultAssociation.association_name}`,
}; };
export function EmailCreatorTab() { export function EmailCreatorTab() {
@@ -20,6 +22,18 @@ export function EmailCreatorTab() {
const [copied, setCopied] = useState(''); const [copied, setCopied] = useState('');
const requestId = useRef(0); const requestId = useRef(0);
useEffect(() => {
api.get('/api/public/bootstrap').then((result) => {
const settings = associationSettings(result.content);
setForm((current) => ({
...current,
subject: `Actualités de ${settings.association_name}`,
kicker: settings.association_name,
signature: `À très bientôt,\nL'équipe de ${settings.association_name}`,
}));
}).catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
const id = ++requestId.current; const id = ++requestId.current;
const timer = setTimeout(() => { const timer = setTimeout(() => {
+14 -5
View File
@@ -6,14 +6,15 @@ import { useAuth } from '../../lib/AuthContext.jsx';
import Modal from '../Modal.jsx'; import Modal from '../Modal.jsx';
import ImageSlot from '../ImageSlot.jsx'; import ImageSlot from '../ImageSlot.jsx';
import { AccessCell, CredentialsModal } from './AccessControls.jsx'; import { AccessCell, CredentialsModal } from './AccessControls.jsx';
import { associationSettings } from '../../lib/siteSettings.js';
/* ---------------- Members ---------------- */ /* ---------------- Members ---------------- */
function MemberFormModal({ member, categories, onClose, onSaved }) { function MemberFormModal({ member, categories, onClose, onSaved }) {
const [form, setForm] = useState( const [form, setForm] = useState(
member member
? { ...member, email: member.email || '', tel: member.tel || '', site: member.site || '', presentation: member.presentation || '' } ? { ...member, email: member.email || '', tel: member.tel || '', site: member.site || '', adresse: member.adresse || '', presentation: member.presentation || '' }
: { nom: '', secteur: '', categorie_id: categories[0]?.id ?? null, dirigeant: '', email: '', tel: '', site: '', presentation: '' } : { nom: '', secteur: '', categorie_id: categories[0]?.id ?? null, dirigeant: '', email: '', tel: '', site: '', adresse: '', presentation: '' }
); );
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -28,7 +29,7 @@ function MemberFormModal({ member, categories, onClose, onSaved }) {
const body = { const body = {
nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id, nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id,
dirigeant: form.dirigeant, email: form.email, tel: form.tel, dirigeant: form.dirigeant, email: form.email, tel: form.tel,
site: form.site, presentation: form.presentation, site: form.site, adresse: form.adresse, presentation: form.presentation,
}; };
try { try {
if (member) { if (member) {
@@ -75,6 +76,9 @@ function MemberFormModal({ member, categories, onClose, onSaved }) {
<label className="field">Site web <label className="field">Site web
<input name="site" value={form.site} onChange={onChange} maxLength={200} /> <input name="site" value={form.site} onChange={onChange} maxLength={200} />
</label> </label>
<label className="field">Adresse de l'entreprise
<input name="adresse" value={form.adresse} onChange={onChange} maxLength={300} placeholder="Numéro, rue, code postal et ville" />
</label>
<label className="field">Présentation <label className="field">Présentation
<textarea name="presentation" value={form.presentation} onChange={onChange} rows={3} maxLength={2000} /> <textarea name="presentation" value={form.presentation} onChange={onChange} rows={3} maxLength={2000} />
</label> </label>
@@ -481,7 +485,7 @@ function EmailModal({ renc, onClose }) {
); );
} }
function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) { function ParticipantsModal({ renc, associationName, onClose, onEdit, onCancel, refreshKey }) {
const { user } = useAuth(); const { user } = useAuth();
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const { jour, mois } = dateParts(renc.date_renc); const { jour, mois } = dateParts(renc.date_renc);
@@ -519,7 +523,7 @@ function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
+ '<style>body{font-family:Arial,Helvetica,sans-serif;color:#1B1B1B;padding:32px}h1{font-size:22px;margin:0 0 4px}.meta{color:#666;font-size:13px;margin-bottom:20px}' + '<style>body{font-family:Arial,Helvetica,sans-serif;color:#1B1B1B;padding:32px}h1{font-size:22px;margin:0 0 4px}.meta{color:#666;font-size:13px;margin-bottom:20px}'
+ 'table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:9px 10px;border-bottom:1px solid #ddd}th{background:#f4f1ec;text-transform:uppercase;font-size:11px;letter-spacing:.05em}' + 'table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:9px 10px;border-bottom:1px solid #ddd}th{background:#f4f1ec;text-transform:uppercase;font-size:11px;letter-spacing:.05em}'
+ '.brand{color:#C1272D;font-weight:700;font-size:12px;letter-spacing:.1em;text-transform:uppercase;margin-bottom:16px}</style></head><body>' + '.brand{color:#C1272D;font-weight:700;font-size:12px;letter-spacing:.1em;text-transform:uppercase;margin-bottom:16px}</style></head><body>'
+ '<div class="brand">Business Club SLUC Nancy</div>' + `<div class="brand">${escHtml(associationName)}</div>`
+ `<h1>${escHtml(renc.titre)}</h1><div class="meta">${escHtml(meta)} — ${rows.length} inscrits</div>` + `<h1>${escHtml(renc.titre)}</h1><div class="meta">${escHtml(meta)} — ${rows.length} inscrits</div>`
+ '<table><thead><tr><th>#</th><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Statut</th></tr></thead><tbody>' + '<table><thead><tr><th>#</th><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Statut</th></tr></thead><tbody>'
+ body + '</tbody></table></body></html>'; + body + '</tbody></table></body></html>';
@@ -581,6 +585,7 @@ export function RencontresTab() {
const [editInscr, setEditInscr] = useState(null); const [editInscr, setEditInscr] = useState(null);
const [cancelInscr, setCancelInscr] = useState(null); const [cancelInscr, setCancelInscr] = useState(null);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [associationName, setAssociationName] = useState(associationSettings().association_name);
const reload = () => { const reload = () => {
api.get('/api/admin/rencontres').then((d) => setRencontres(d.rencontres)).catch(() => {}); api.get('/api/admin/rencontres').then((d) => setRencontres(d.rencontres)).catch(() => {});
@@ -589,6 +594,9 @@ export function RencontresTab() {
useEffect(() => { useEffect(() => {
reload(); reload();
api.get('/api/public/bootstrap')
.then((data) => setAssociationName(associationSettings(data.content).association_name))
.catch(() => {});
}, []); }, []);
return ( return (
@@ -643,6 +651,7 @@ export function RencontresTab() {
{participantsRenc && ( {participantsRenc && (
<ParticipantsModal <ParticipantsModal
renc={participantsRenc} renc={participantsRenc}
associationName={associationName}
refreshKey={refreshKey} refreshKey={refreshKey}
onClose={() => setParticipantsRenc(null)} onClose={() => setParticipantsRenc(null)}
onEdit={setEditInscr} onEdit={setEditInscr}
+19
View File
@@ -0,0 +1,19 @@
export const ASSOCIATION_DEFAULTS = {
association_name: 'Business Club SLUC Nancy',
association_address: 'Palais des Sports Jean Weille\nNancy (54)',
association_email: 'contact@sluc-businessclub.fr',
association_phone: '+33 3 83 00 00 00',
association_contact: '',
association_website: '',
association_facebook_url: 'https://www.facebook.com/search/top?q=sluc%20business%20club',
association_linkedin_url: 'https://www.linkedin.com/company/club-affaires-stanislas/posts/?feedView=all',
association_president: 'Jean-Marc Lefèvre',
association_vice_president: '',
association_treasurer: '',
association_secretary: '',
association_board_members: '',
};
export function associationSettings(content = {}) {
return { ...ASSOCIATION_DEFAULTS, ...content };
}
+3 -1
View File
@@ -3,7 +3,7 @@ import { Link, Navigate } from 'react-router-dom';
import { useAuth } from '../lib/AuthContext.jsx'; import { useAuth } from '../lib/AuthContext.jsx';
import { api, dateParts, statutLabel } from '../lib/api.js'; import { api, dateParts, statutLabel } from '../lib/api.js';
import { MembersTab, RencontresTab, InscriptionsTab } from '../components/admin/AdminTabs.jsx'; import { MembersTab, RencontresTab, InscriptionsTab } from '../components/admin/AdminTabs.jsx';
import { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx'; import { CategoriesTab, ContenuTab, ParametresTab } from '../components/admin/AdminContent.jsx';
import { PastEventsTab } from '../components/admin/AdminPast.jsx'; import { PastEventsTab } from '../components/admin/AdminPast.jsx';
import { UsersTab } from '../components/admin/AdminUsers.jsx'; import { UsersTab } from '../components/admin/AdminUsers.jsx';
import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx'; import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx';
@@ -22,6 +22,7 @@ const TABS = [
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] }, { key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] },
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] }, { key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] },
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site', roles: ['admin'] }, { key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site', roles: ['admin'] },
{ key: 'parametres', icon: '⚙', label: 'Paramètres', title: "Paramètres de l'association", roles: ['admin'] },
{ key: 'emails', icon: '✉', label: "Créateur d'e-mail", title: "Créateur d'e-mail", roles: ['admin'] }, { key: 'emails', icon: '✉', label: "Créateur d'e-mail", title: "Créateur d'e-mail", roles: ['admin'] },
{ key: 'utilisateurs', icon: '⚿', label: 'Administrateurs', title: 'Administrateurs & modérateurs', roles: ['admin'] }, { key: 'utilisateurs', icon: '⚿', label: 'Administrateurs', title: 'Administrateurs & modérateurs', roles: ['admin'] },
]; ];
@@ -155,6 +156,7 @@ export function AdminShell() {
{tab === 'inscriptions' && <InscriptionsTab />} {tab === 'inscriptions' && <InscriptionsTab />}
{tab === 'categories' && <CategoriesTab />} {tab === 'categories' && <CategoriesTab />}
{tab === 'contenu' && <ContenuTab />} {tab === 'contenu' && <ContenuTab />}
{tab === 'parametres' && <ParametresTab />}
{tab === 'emails' && <EmailCreatorTab />} {tab === 'emails' && <EmailCreatorTab />}
{tab === 'utilisateurs' && <UsersTab />} {tab === 'utilisateurs' && <UsersTab />}
</div> </div>
+4 -2
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { usePublicData } from '../lib/usePublic.js'; import { usePublicData } from '../lib/usePublic.js';
import MemberModal from '../components/MemberModal.jsx'; import MemberModal from '../components/MemberModal.jsx';
import { associationSettings } from '../lib/siteSettings.js';
export default function Annuaire() { export default function Annuaire() {
const { data } = usePublicData(); const { data } = usePublicData();
@@ -10,12 +11,13 @@ export default function Annuaire() {
const members = data?.members ?? []; const members = data?.members ?? [];
const categories = data?.categories ?? []; const categories = data?.categories ?? [];
const settings = associationSettings(data?.content);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
return members.filter((m) => { return members.filter((m) => {
const matchCat = category === 'Toutes' || (m.categorie || 'Non classée') === category; const matchCat = category === 'Toutes' || (m.categorie || 'Non classée') === category;
const matchText = !q || `${m.nom} ${m.dirigeant} ${m.secteur} ${m.categorie || ''}`.toLowerCase().includes(q); const matchText = !q || `${m.nom} ${m.dirigeant} ${m.secteur} ${m.categorie || ''} ${m.adresse || ''}`.toLowerCase().includes(q);
return matchCat && matchText; return matchCat && matchText;
}); });
}, [members, search, category]); }, [members, search, category]);
@@ -31,7 +33,7 @@ export default function Annuaire() {
{members.length} entreprises, un réseau. {members.length} entreprises, un réseau.
</h1> </h1>
<p style={{ fontSize: 17, lineHeight: 1.6, color: 'var(--gray)', maxWidth: 600 }}> <p style={{ fontSize: 17, lineHeight: 1.6, color: 'var(--gray)', maxWidth: 600 }}>
Découvrez les entreprises membres du Business Club SLUC Nancy et prenez contact directement avec leurs dirigeants. Découvrez les entreprises membres de {settings.association_name} et prenez contact directement avec leurs dirigeants.
</p> </p>
</div> </div>
</section> </section>
+65 -6
View File
@@ -1,3 +1,6 @@
import { usePublicData } from '../lib/usePublic.js';
import { associationSettings } from '../lib/siteSettings.js';
const VALUES = [ const VALUES = [
{ t: 'Réseau', d: "Un carnet d'adresses vivant, nourri par des rencontres régulières et de vraies affinités." }, { t: 'Réseau', d: "Un carnet d'adresses vivant, nourri par des rencontres régulières et de vraies affinités." },
{ t: 'Convivialité', d: "Des moments d'échange chaleureux, où les relations se nouent avant les contrats." }, { t: 'Convivialité', d: "Des moments d'échange chaleureux, où les relations se nouent avant les contrats." },
@@ -20,6 +23,20 @@ const STATS = [
]; ];
export default function Association() { export default function Association() {
const { data } = usePublicData();
const settings = associationSettings(data?.content);
const officers = [
['Vice-présidence', settings.association_vice_president],
['Trésorerie', settings.association_treasurer],
['Secrétariat', settings.association_secretary],
].filter(([, name]) => name);
const boardMembers = settings.association_board_members
.split(/\r?\n/)
.map((name) => name.trim())
.filter(Boolean);
const hasContact = settings.association_address || settings.association_email
|| settings.association_phone || settings.association_contact;
return ( return (
<main> <main>
<section style={{ background: 'var(--dark)', color: '#fff' }}> <section style={{ background: 'var(--dark)', color: '#fff' }}>
@@ -29,7 +46,7 @@ export default function Association() {
Trente ans à réunir le sport<br />et l'entreprise. Trente ans à réunir le sport<br />et l'entreprise.
</h1> </h1>
<p style={{ fontSize: 18, lineHeight: 1.65, color: '#B7AFA6', maxWidth: 620, margin: '0 auto' }}> <p style={{ fontSize: 18, lineHeight: 1.65, color: '#B7AFA6', maxWidth: 620, margin: '0 auto' }}>
Le Business Club est l'association des partenaires économiques du SLUC Nancy Basket. {settings.association_name} est l'association des partenaires économiques du SLUC Nancy Basket.
Une communauté de dirigeants qui partagent les valeurs du haut niveau. Une communauté de dirigeants qui partagent les valeurs du haut niveau.
</p> </p>
</div> </div>
@@ -71,14 +88,14 @@ export default function Association() {
</section> </section>
<section style={{ background: 'var(--beige)', marginTop: 56, padding: '90px 0' }}> <section style={{ background: 'var(--beige)', marginTop: 56, padding: '90px 0' }}>
<div className="hero-grid" style={{ maxWidth: 980, margin: '0 auto', padding: '0 32px', display: 'grid', gridTemplateColumns: '.8fr 1.2fr', gap: 56, alignItems: 'center' }}> <div className="hero-grid" style={{ maxWidth: 980, margin: '0 auto', padding: '0 32px', display: 'grid', gridTemplateColumns: settings.association_president ? '.8fr 1.2fr' : '1fr', gap: 56, alignItems: 'center' }}>
<div style={{ textAlign: 'center' }}> {settings.association_president && <div style={{ textAlign: 'center' }}>
<div style={{ aspectRatio: '1/1', borderRadius: 6, background: 'repeating-linear-gradient(135deg,#e4dfd8 0 12px,#eeeae3 12px 24px)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 11, color: '#a8a099', marginBottom: 16 }}> <div style={{ aspectRatio: '1/1', borderRadius: 6, background: 'repeating-linear-gradient(135deg,#e4dfd8 0 12px,#eeeae3 12px 24px)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', fontSize: 11, color: '#a8a099', marginBottom: 16 }}>
portrait portrait
</div> </div>
<div className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Jean-Marc Lefèvre</div> <div className="serif" style={{ fontSize: 20, fontWeight: 600 }}>{settings.association_president}</div>
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>Président du Business Club</div> <div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>Présidence de l'association</div>
</div> </div>}
<div> <div>
<div className="serif" style={{ fontSize: 60, color: 'var(--red)', lineHeight: 0.5, height: 32 }}>“</div> <div className="serif" style={{ fontSize: 60, color: 'var(--red)', lineHeight: 0.5, height: 32 }}>“</div>
<p className="serif" style={{ fontSize: 26, lineHeight: 1.45, fontWeight: 400, marginBottom: 22 }}> <p className="serif" style={{ fontSize: 26, lineHeight: 1.45, fontWeight: 400, marginBottom: 22 }}>
@@ -93,6 +110,48 @@ export default function Association() {
</div> </div>
</section> </section>
{(officers.length > 0 || boardMembers.length > 0) && (
<section style={{ maxWidth: 1080, margin: '0 auto', padding: '90px 32px 30px' }}>
<div className="kicker" style={{ marginBottom: 14 }}>Gouvernance</div>
<h2 className="serif" style={{ fontWeight: 500, fontSize: 38, marginBottom: 34 }}>Le conseil d'administration</h2>
{officers.length > 0 && (
<div className="grid-3" style={{ marginBottom: boardMembers.length ? 38 : 0 }}>
{officers.map(([role, name]) => (
<div key={role} style={{ borderTop: '3px solid var(--red)', paddingTop: 18 }}>
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.1em', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 8 }}>{role}</div>
<div className="serif" style={{ fontSize: 21, fontWeight: 600 }}>{name}</div>
</div>
))}
</div>
)}
{boardMembers.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 24 }}>
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.1em', color: 'var(--gray-light)', fontWeight: 600, marginBottom: 16 }}>Membres du CA</div>
<div className="grid-3" style={{ gap: 12 }}>
{boardMembers.map((name, index) => <div key={`${name}-${index}`} style={{ fontSize: 15, fontWeight: 600 }}>{name}</div>)}
</div>
</div>
)}
</section>
)}
{hasContact && (
<section style={{ maxWidth: 1080, margin: '0 auto', padding: '80px 32px 30px' }}>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 44, display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: 50 }} className="hero-grid">
<div>
<div className="kicker" style={{ marginBottom: 14 }}>Contact</div>
<h2 className="serif" style={{ fontWeight: 500, fontSize: 36 }}>Nous contacter</h2>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 15, lineHeight: 1.6 }}>
{settings.association_contact && <strong>{settings.association_contact}</strong>}
{settings.association_address && <span style={{ whiteSpace: 'pre-line', color: 'var(--gray)' }}>{settings.association_address}</span>}
{settings.association_email && <a className="btn-link" href={`mailto:${settings.association_email}`}>{settings.association_email}</a>}
{settings.association_phone && <a className="btn-link" href={`tel:${settings.association_phone.replace(/[^+\d]/g, '')}`}>{settings.association_phone}</a>}
</div>
</div>
</section>
)}
<section style={{ maxWidth: 1080, margin: '0 auto', padding: '96px 32px' }}> <section style={{ maxWidth: 1080, margin: '0 auto', padding: '96px 32px' }}>
<div style={{ textAlign: 'center', marginBottom: 52 }}> <div style={{ textAlign: 'center', marginBottom: 52 }}>
<div className="kicker" style={{ marginBottom: 14 }}>Notre histoire</div> <div className="kicker" style={{ marginBottom: 14 }}>Notre histoire</div>
+7 -1
View File
@@ -193,7 +193,7 @@ function Portal() {
const d = await api.put('/api/member/profile', { const d = await api.put('/api/member/profile', {
nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id, nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id,
dirigeant: form.dirigeant, email: form.email || '', tel: form.tel || '', dirigeant: form.dirigeant, email: form.email || '', tel: form.tel || '',
site: form.site || '', presentation: form.presentation || '', site: form.site || '', adresse: form.adresse || '', presentation: form.presentation || '',
}); });
setMember(d.member); setMember(d.member);
setForm(d.member); setForm(d.member);
@@ -287,6 +287,9 @@ function Portal() {
<label className="field">Site web <label className="field">Site web
<input name="site" value={form.site || ''} onChange={onChange} maxLength={200} /> <input name="site" value={form.site || ''} onChange={onChange} maxLength={200} />
</label> </label>
<label className="field">Adresse de l'entreprise
<input name="adresse" value={form.adresse || ''} onChange={onChange} maxLength={300} placeholder="Numéro, rue, code postal et ville" />
</label>
</div> </div>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>} {error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 24 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 24 }}>
@@ -316,6 +319,9 @@ function Portal() {
<span className="badge-cat">{catName}</span> <span className="badge-cat">{catName}</span>
</div> </div>
<p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--gray)', marginBottom: 20 }}>{form.presentation}</p> <p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--gray)', marginBottom: 20 }}>{form.presentation}</p>
{form.adresse && (
<p style={{ fontSize: 13, lineHeight: 1.5, color: 'var(--gray-light)', marginBottom: 18 }}>{form.adresse}</p>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}>
<span style={{ fontSize: 13, color: 'var(--gray-light)' }}>{form.dirigeant}</span> <span style={{ fontSize: 13, color: 'var(--gray-light)' }}>{form.dirigeant}</span>
<span className="btn-link">Voir la fiche →</span> <span className="btn-link">Voir la fiche →</span>
+43 -9
View File
@@ -6,6 +6,7 @@ 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';
import { AdhesionForm, AdhesionModal } from '../components/AdhesionForm.jsx'; import { AdhesionForm, AdhesionModal } from '../components/AdhesionForm.jsx';
import { associationSettings } from '../lib/siteSettings.js';
export function InscriptionModal({ rencontre, onClose, onDone }) { export function InscriptionModal({ rencontre, onClose, onDone }) {
const { user, login } = useAuth(); const { user, login } = useAuth();
@@ -13,6 +14,7 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
const [participants, setParticipants] = useState(['']); const [participants, setParticipants] = useState(['']);
const [credentials, setCredentials] = useState({ email: '', password: '' }); const [credentials, setCredentials] = useState({ email: '', password: '' });
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [wasRegistered, setWasRegistered] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const { jour, mois } = dateParts(rencontre.date_renc); const { jour, mois } = dateParts(rencontre.date_renc);
@@ -23,6 +25,7 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
api.get(`/api/public/rencontres/${rencontre.id}/inscription`) api.get(`/api/public/rencontres/${rencontre.id}/inscription`)
.then((d) => { .then((d) => {
setContext(d); setContext(d);
setWasRegistered(d.participants.length > 0);
setParticipants(d.participants.length ? d.participants.map((p) => p.nom) : [d.member.dirigeant || '']); setParticipants(d.participants.length ? d.participants.map((p) => p.nom) : [d.member.dirigeant || '']);
}) })
.catch((err) => setError(err.message)); .catch((err) => setError(err.message));
@@ -67,7 +70,9 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
<Modal onClose={onClose} maxWidth={460}> <Modal onClose={onClose} maxWidth={460}>
<div style={{ textAlign: 'center', padding: '50px 36px' }}> <div style={{ textAlign: 'center', padding: '50px 36px' }}>
<div style={{ width: 60, height: 60, borderRadius: '50%', background: '#FBEDEC', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 30, margin: '0 auto 22px' }}>✓</div> <div style={{ width: 60, height: 60, borderRadius: '50%', background: '#FBEDEC', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 30, margin: '0 auto 22px' }}>✓</div>
<h3 className="serif" style={{ fontSize: 26, fontWeight: 600, marginBottom: 10 }}>Inscription enregistrée</h3> <h3 className="serif" style={{ fontSize: 26, fontWeight: 600, marginBottom: 10 }}>
{wasRegistered ? 'Inscription modifiée' : 'Inscription enregistrée'}
</h3>
<p style={{ fontSize: 15, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 8 }}> <p style={{ fontSize: 15, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 8 }}>
Votre demande pour <strong>{rencontre.titre}</strong> est bien enregistrée. Votre demande pour <strong>{rencontre.titre}</strong> est bien enregistrée.
</p> </p>
@@ -84,7 +89,7 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
<Modal <Modal
onClose={onClose} onClose={onClose}
maxWidth={460} maxWidth={460}
header={{ kicker: 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }} header={{ kicker: wasRegistered ? 'Modifier mon inscription' : 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
> >
{!user && ( {!user && (
<form onSubmit={submitLogin} style={{ padding: '28px 30px' }}> <form onSubmit={submitLogin} style={{ padding: '28px 30px' }}>
@@ -160,9 +165,25 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
export default function Home() { export default function Home() {
const { data, reload } = usePublicData(); const { data, reload } = usePublicData();
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [inscrRenc, setInscrRenc] = useState(null); const [inscrRenc, setInscrRenc] = useState(null);
const [adhesionOpen, setAdhesionOpen] = useState(false); const [adhesionOpen, setAdhesionOpen] = useState(false);
const [registrations, setRegistrations] = useState({});
const refreshRegistrations = () => {
if (user?.role !== 'member' || user.mustChangePassword) {
setRegistrations({});
return Promise.resolve();
}
return api.get('/api/member/inscriptions').then((result) => {
setRegistrations(Object.fromEntries(result.inscriptions.map((item) => [item.rencontre_id, item])));
}).catch(() => setRegistrations({}));
};
useEffect(() => {
refreshRegistrations();
}, [user]);
// Registration links in invitation emails point to /?inscription=<id> : // Registration links in invitation emails point to /?inscription=<id> :
// once the public data is loaded, open the matching inscription modal. // once the public data is loaded, open the matching inscription modal.
@@ -195,6 +216,7 @@ export default function Home() {
const rencontres = data?.rencontres ?? []; const rencontres = data?.rencontres ?? [];
const passees = data?.rencontresPassees ?? []; const passees = data?.rencontresPassees ?? [];
const content = data?.content ?? {}; const content = data?.content ?? {};
const settings = associationSettings(content);
const marquee = members.concat(members); const marquee = members.concat(members);
return ( return (
@@ -207,7 +229,7 @@ export default function Home() {
Le business se joue en <em style={{ fontStyle: 'italic', color: 'var(--red)' }}>équipe</em>. Le business se joue en <em style={{ fontStyle: 'italic', color: 'var(--red)' }}>équipe</em>.
</h1> </h1>
<p style={{ fontSize: 18, lineHeight: 1.65, color: 'var(--gray)', maxWidth: 480, marginBottom: 38 }}> <p style={{ fontSize: 18, lineHeight: 1.65, color: 'var(--gray)', maxWidth: 480, marginBottom: 38 }}>
Depuis 30 ans, le Business Club rassemble les dirigeants du Grand Est autour des valeurs du sport Depuis 30 ans, {settings.association_name} rassemble les dirigeants du Grand Est autour des valeurs du sport
de haut niveau : performance, engagement et esprit collectif. de haut niveau : performance, engagement et esprit collectif.
</p> </p>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
@@ -313,6 +335,8 @@ export default function Home() {
{rencontres.map((e) => { {rencontres.map((e) => {
const { jour, mois } = dateParts(e.date_renc); const { jour, mois } = dateParts(e.date_renc);
const restantes = Math.max(0, e.places - e.inscrits); const restantes = Math.max(0, e.places - e.inscrits);
const registration = registrations[e.id];
const isRegistered = Boolean(registration);
return ( return (
<div key={e.id} className="card" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}> <div key={e.id} className="card" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div className={e.image_path ? '' : 'placeholder-pattern'} style={{ height: 170, position: 'relative' }}> <div className={e.image_path ? '' : 'placeholder-pattern'} style={{ height: 170, position: 'relative' }}>
@@ -331,14 +355,16 @@ export default function Home() {
<h3 className="serif" style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.2, marginBottom: 10 }}>{e.titre}</h3> <h3 className="serif" style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.2, marginBottom: 10 }}>{e.titre}</h3>
<p style={{ fontSize: 14.5, lineHeight: 1.6, color: 'var(--gray)', flex: 1 }}>{e.description}</p> <p style={{ fontSize: 14.5, lineHeight: 1.6, color: 'var(--gray)', flex: 1 }}>{e.description}</p>
<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: isRegistered ? 'var(--green)' : 'var(--red)', fontWeight: 600 }}>
{restantes > 0 ? `${restantes} places restantes` : 'Complet'} {isRegistered ? '✓ Inscrit' : (restantes > 0 ? `${restantes} places restantes` : 'Complet')}
<small style={{ display: 'block', color: 'var(--gray-light)', fontWeight: 400, marginTop: 3 }}> <small style={{ display: 'block', color: 'var(--gray-light)', fontWeight: 400, marginTop: 3 }}>
Max. {e.participants_par_compte} par compte {isRegistered
? `${registration.participants} participant${registration.participants > 1 ? 's' : ''} · ${registration.confirmee ? 'Confirmée' : 'En attente'}`
: `Max. ${e.participants_par_compte} par compte`}
</small> </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={!isRegistered && restantes === 0} onClick={() => setInscrRenc(e)}>
S'inscrire {isRegistered ? 'Modifier' : "S'inscrire"}
</button> </button>
</div> </div>
</div> </div>
@@ -394,7 +420,15 @@ export default function Home() {
</section> </section>
{inscrRenc && ( {inscrRenc && (
<InscriptionModal key={inscrRenc.id} rencontre={inscrRenc} onClose={closeInscription} onDone={reload} /> <InscriptionModal
key={inscrRenc.id}
rencontre={inscrRenc}
onClose={closeInscription}
onDone={() => {
reload();
refreshRegistrations();
}}
/>
)} )}
{adhesionOpen && <AdhesionModal onClose={closeAdhesion} />} {adhesionOpen && <AdhesionModal onClose={closeAdhesion} />}
</main> </main>