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) {
return String(value ?? '')
.replace(/&/g, '&')
@@ -33,7 +35,8 @@ function ctaButton(url, label) {
</table>`;
}
export function buildCustomEmail({ content, baseUrl }) {
export function buildCustomEmail({ content, baseUrl, association = {} }) {
const settings = associationSettings(association);
const logoUrl = `${baseUrl}/assets/logo.jpg`;
const button = ctaButton(content.buttonUrl, content.buttonLabel);
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;">
<tr><td bgcolor="#161514" style="background-color:#161514;padding:26px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;"><tr>
<td width="52" valign="middle"><img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;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;">
<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:Arial,Helvetica,sans-serif;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:#E0777A;font-weight:bold;line-height:16px;">SLUC Nancy</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>
</td>
</tr></table>
</td></tr>
@@ -70,8 +72,8 @@ export function buildCustomEmail({ content, baseUrl }) {
${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>'}
<tr><td bgcolor="#0F0E0D" style="background-color:#0F0E0D;padding:24px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:22px;">
Business Club SLUC Nancy &middot; Palais des Sports Jean Weille, Nancy<br>
<a href="mailto:contact@sluc-businessclub.fr" style="color:#E0777A;text-decoration:none;">contact@sluc-businessclub.fr</a>
${esc(settings.association_name)}${settings.association_address ? ` &middot; ${lines(settings.association_address)}` : ''}<br>
${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>
</table>
<!--[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
// backend. Email-client constraints apply: table layout, inline styles,
// 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
// them in the composer; clearing a field removes the block entirely.
export function defaultEmailTexts() {
export function defaultEmailTexts(association = {}) {
const settings = associationSettings(association);
return {
greeting: 'Chère membre, cher membre,',
intro:
@@ -27,13 +30,14 @@ export function defaultEmailTexts() {
outro:
"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.",
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 defaults = defaultEmailTexts();
const settings = associationSettings(association);
const defaults = defaultEmailTexts(settings);
// undefined -> default wording; '' (cleared by the admin) -> block hidden
const t = {
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;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;"><tr>
<td width="52" valign="middle">
<img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;border-radius:6px;background-color:#FFFFFF;">
<img src="${esc(logoUrl)}" width="52" height="52" alt="${esc(settings.association_name)}" style="display:block;border-radius:6px;background-color:#FFFFFF;">
</td>
<td valign="middle" style="padding-left:16px;">
<div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:1.1;">Business Club</div>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:#E0777A;font-weight:bold;padding-top:4px;">SLUC Nancy</div>
<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>
</td>
</tr></table>
</td></tr>
@@ -153,8 +156,8 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
</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;">
Business Club SLUC Nancy &middot; Palais des Sports Jean Weille, Nancy<br>
<a href="mailto:contact@sluc-businessclub.fr" style="color:#E0777A;text-decoration:none;">contact@sluc-businessclub.fr</a>
${esc(settings.association_name)}${settings.association_address ? ` &middot; ${escML(settings.association_address)}` : ''}<br>
${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>
</table>
+1
View File
@@ -31,6 +31,7 @@ app.use(
fontSrc: ['https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"],
frameSrc: ["'self'", 'https://www.google.com', 'https://maps.google.com'],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
+16 -9
View File
@@ -33,7 +33,7 @@ const adminOnly = requireAuth('admin');
const MEMBER_SQL = `
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,
(u.id IS NOT NULL) AS has_login,
COALESCE(u.must_change_password, false) AS must_change_password,
@@ -53,6 +53,11 @@ const INSCR_SQL = `
i.rencontre_id, r.titre AS rencontre
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) ----------
adminRouter.get('/dashboard', adminOnly, async (_req, res, next) => {
try {
@@ -89,10 +94,10 @@ adminRouter.post('/members', validate(adminMemberSchema), async (req, res, next)
try {
const d = req.data;
const result = await query(
`INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, presentation, valide)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
`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, $11) RETURNING id`,
[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;
// 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 result = await query(
`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()
WHERE id=$10 RETURNING id`,
tel=$6, site=$7, adresse=$8, presentation=$9, valide=COALESCE($10, valide), updated_at=now()
WHERE id=$11 RETURNING id`,
[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' });
res.json({ ok: true });
@@ -257,7 +262,8 @@ adminRouter.post('/rencontres/:id/email', validate(idParam, 'params'), validate(
// 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) {
next(err);
}
@@ -281,7 +287,8 @@ adminRouter.post('/emails/preview', adminOnly, validate(customEmailSchema), asyn
const parsed = new URL(base);
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) {
next(err);
}
+22 -5
View File
@@ -11,7 +11,7 @@ memberRouter.use(requireAuth('member'));
const PROFILE_SQL = `
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
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id
WHERE m.id = $1`;
@@ -28,12 +28,12 @@ memberRouter.get('/profile', async (req, res, next) => {
memberRouter.put('/profile', validate(memberProfileSchema), async (req, res, next) => {
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(
`UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5,
tel=$6, site=$7, presentation=$8, updated_at=now()
WHERE id=$9`,
[nom, secteur, categorie_id ?? null, dirigeant, email || null, tel, site, presentation, req.user.memberId]
tel=$6, site=$7, adresse=$8, presentation=$9, updated_at=now()
WHERE id=$10`,
[nom, secteur, categorie_id ?? null, dirigeant, email || null, tel, site, adresse, presentation, req.user.memberId]
);
const result = await query(PROFILE_SQL, [req.user.memberId]);
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) {
return (req, res, next) => {
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 id, name FROM categories ORDER BY id'),
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
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id
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(''),
tel: trimmed(30).optional().default(''),
site: trimmed(200).optional().default(''),
adresse: trimmed(300).optional().default(''),
presentation: trimmed(2000).optional().default(''),
});
@@ -95,6 +96,19 @@ export const roleChangeSchema = z.object({ role: staffRoleEnum });
export const contentSchema = z.object({
hero_quote_text: trimmed(300).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
+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),
tel TEXT CHECK (char_length(tel) <= 30),
site TEXT CHECK (char_length(site) <= 200),
adresse TEXT CHECK (char_length(adresse) <= 300),
presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000),
valide BOOLEAN NOT NULL DEFAULT false,
logo_path TEXT,
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS members (
created_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 (
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
('hero_quote_text', 'On ne réussit jamais seul.'),
('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;
-- 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 { usePublicData } from '../lib/usePublic.js';
import { associationSettings } from '../lib/siteSettings.js';
const linkStyle = { color: '#B7AFA6', fontSize: 14, textDecoration: 'none' };
export default function Footer() {
const { data } = usePublicData();
const memberCount = data?.members?.length ?? 0;
const settings = associationSettings(data?.content);
return (
<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 }}>
<img
src="/assets/logo.jpg"
alt="SLUC Business Club"
alt={settings.association_name}
style={{ height: 48, width: 48, objectFit: 'cover', borderRadius: 4, background: '#fff' }}
/>
<span className="serif" style={{ fontSize: 18, fontWeight: 600, color: '#fff' }}>
Business Club SLUC Nancy
{settings.association_name}
</span>
</div>
<p style={{ fontSize: 14, lineHeight: 1.65, maxWidth: 320 }}>
@@ -43,9 +45,11 @@ export default function Footer() {
Contact
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 14, lineHeight: 1.5 }}>
<span>Palais des Sports J. Weille<br />Nancy (54)</span>
<span>contact@sluc-businessclub.fr</span>
<span>+33 3 83 00 00 00</span>
{settings.association_address && <span style={{ whiteSpace: 'pre-line' }}>{settings.association_address}</span>}
{settings.association_contact && <span>{settings.association_contact}</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>
@@ -53,22 +57,22 @@ export default function Footer() {
Suivez-nous
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-start' }}>
<a
href="https://www.facebook.com/search/top?q=sluc%20business%20club"
{settings.association_facebook_url && <a
href={settings.association_facebook_url}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
Facebook
</a>
<a
href="https://www.linkedin.com/company/club-affaires-stanislas/posts/?feedView=all"
</a>}
{settings.association_linkedin_url && <a
href={settings.association_linkedin_url}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
LinkedIn
</a>
</a>}
</div>
</div>
</div>
@@ -80,7 +84,7 @@ export default function Footer() {
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>
</div>
</footer>
+7 -6
View File
@@ -1,8 +1,12 @@
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' : ''}`;
export default function Header() {
const { data } = usePublicData();
const settings = associationSettings(data?.content);
return (
<header
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' }}
>
<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' }} />
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', lineHeight: 1.05 }}>
<span className="serif" style={{ fontSize: 19, fontWeight: 600, color: 'var(--ink)' }}>Business Club</span>
<span style={{ fontSize: 10, letterSpacing: '.22em', textTransform: 'uppercase', color: 'var(--red-dark)', fontWeight: 600 }}>
SLUC Nancy
</span>
<img src="/assets/logo.jpg" alt={settings.association_name} style={{ height: 50, width: 'auto', display: 'block' }} />
<span className="serif" style={{ maxWidth: 220, fontSize: 18, fontWeight: 600, lineHeight: 1.12, color: 'var(--ink)' }}>
{settings.association_name}
</span>
</Link>
<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 }) {
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 (
<Modal onClose={onClose} maxWidth={760}>
<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>
<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 ? (
<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 }}>
@@ -56,6 +59,24 @@ export default function MemberModal({ member, onClose }) {
) : null
)}
</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 && (
<a href={`mailto:${member.email}`} style={{ textDecoration: 'none' }}>
<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 { api } from '../../lib/api.js';
import { ASSOCIATION_DEFAULTS, associationSettings } from '../../lib/siteSettings.js';
import ImageSlot from '../ImageSlot.jsx';
export function CategoriesTab() {
@@ -197,3 +198,118 @@ export function ContenuTab() {
</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 { copyRichEmail } from '../../lib/emailClipboard.js';
import { downloadOutlookDraft } from '../../lib/outlookDraft.js';
import { associationSettings } from '../../lib/siteSettings.js';
const defaultAssociation = associationSettings();
const INITIAL = {
subject: 'Actualités du Business Club SLUC Nancy',
kicker: 'Business Club · SLUC Nancy',
subject: `Actualités de ${defaultAssociation.association_name}`,
kicker: defaultAssociation.association_name,
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.",
buttonLabel: 'Découvrir',
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() {
@@ -20,6 +22,18 @@ export function EmailCreatorTab() {
const [copied, setCopied] = useState('');
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(() => {
const id = ++requestId.current;
const timer = setTimeout(() => {
+14 -5
View File
@@ -6,14 +6,15 @@ import { useAuth } from '../../lib/AuthContext.jsx';
import Modal from '../Modal.jsx';
import ImageSlot from '../ImageSlot.jsx';
import { AccessCell, CredentialsModal } from './AccessControls.jsx';
import { associationSettings } from '../../lib/siteSettings.js';
/* ---------------- Members ---------------- */
function MemberFormModal({ member, categories, onClose, onSaved }) {
const [form, setForm] = useState(
member
? { ...member, email: member.email || '', tel: member.tel || '', site: member.site || '', presentation: member.presentation || '' }
: { nom: '', secteur: '', categorie_id: categories[0]?.id ?? null, dirigeant: '', email: '', tel: '', site: '', 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: '', adresse: '', presentation: '' }
);
const [error, setError] = useState('');
@@ -28,7 +29,7 @@ function MemberFormModal({ member, categories, onClose, onSaved }) {
const body = {
nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id,
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 {
if (member) {
@@ -75,6 +76,9 @@ function MemberFormModal({ member, categories, onClose, onSaved }) {
<label className="field">Site web
<input name="site" value={form.site} onChange={onChange} maxLength={200} />
</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
<textarea name="presentation" value={form.presentation} onChange={onChange} rows={3} maxLength={2000} />
</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 [rows, setRows] = useState([]);
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}'
+ '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>'
+ '<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>`
+ '<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>';
@@ -581,6 +585,7 @@ export function RencontresTab() {
const [editInscr, setEditInscr] = useState(null);
const [cancelInscr, setCancelInscr] = useState(null);
const [refreshKey, setRefreshKey] = useState(0);
const [associationName, setAssociationName] = useState(associationSettings().association_name);
const reload = () => {
api.get('/api/admin/rencontres').then((d) => setRencontres(d.rencontres)).catch(() => {});
@@ -589,6 +594,9 @@ export function RencontresTab() {
useEffect(() => {
reload();
api.get('/api/public/bootstrap')
.then((data) => setAssociationName(associationSettings(data.content).association_name))
.catch(() => {});
}, []);
return (
@@ -643,6 +651,7 @@ export function RencontresTab() {
{participantsRenc && (
<ParticipantsModal
renc={participantsRenc}
associationName={associationName}
refreshKey={refreshKey}
onClose={() => setParticipantsRenc(null)}
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 { api, dateParts, statutLabel } from '../lib/api.js';
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 { UsersTab } from '../components/admin/AdminUsers.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: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', 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: 'utilisateurs', icon: '⚿', label: 'Administrateurs', title: 'Administrateurs & modérateurs', roles: ['admin'] },
];
@@ -155,6 +156,7 @@ export function AdminShell() {
{tab === 'inscriptions' && <InscriptionsTab />}
{tab === 'categories' && <CategoriesTab />}
{tab === 'contenu' && <ContenuTab />}
{tab === 'parametres' && <ParametresTab />}
{tab === 'emails' && <EmailCreatorTab />}
{tab === 'utilisateurs' && <UsersTab />}
</div>
+4 -2
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { usePublicData } from '../lib/usePublic.js';
import MemberModal from '../components/MemberModal.jsx';
import { associationSettings } from '../lib/siteSettings.js';
export default function Annuaire() {
const { data } = usePublicData();
@@ -10,12 +11,13 @@ export default function Annuaire() {
const members = data?.members ?? [];
const categories = data?.categories ?? [];
const settings = associationSettings(data?.content);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return members.filter((m) => {
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;
});
}, [members, search, category]);
@@ -31,7 +33,7 @@ export default function Annuaire() {
{members.length} entreprises, un réseau.
</h1>
<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>
</div>
</section>
+65 -6
View File
@@ -1,3 +1,6 @@
import { usePublicData } from '../lib/usePublic.js';
import { associationSettings } from '../lib/siteSettings.js';
const VALUES = [
{ 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." },
@@ -20,6 +23,20 @@ const STATS = [
];
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 (
<main>
<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.
</h1>
<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.
</p>
</div>
@@ -71,14 +88,14 @@ export default function Association() {
</section>
<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 style={{ textAlign: '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' }}>
{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 }}>
portrait
</div>
<div className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Jean-Marc Lefèvre</div>
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>Président du Business Club</div>
</div>
<div className="serif" style={{ fontSize: 20, fontWeight: 600 }}>{settings.association_president}</div>
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>Présidence de l'association</div>
</div>}
<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 }}>
@@ -93,6 +110,48 @@ export default function Association() {
</div>
</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' }}>
<div style={{ textAlign: 'center', marginBottom: 52 }}>
<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', {
nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id,
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);
setForm(d.member);
@@ -287,6 +287,9 @@ function Portal() {
<label className="field">Site web
<input name="site" value={form.site || ''} onChange={onChange} maxLength={200} />
</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>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 24 }}>
@@ -316,6 +319,9 @@ function Portal() {
<span className="badge-cat">{catName}</span>
</div>
<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)' }}>
<span style={{ fontSize: 13, color: 'var(--gray-light)' }}>{form.dirigeant}</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 PastEventCard from '../components/PastEventCard.jsx';
import { AdhesionForm, AdhesionModal } from '../components/AdhesionForm.jsx';
import { associationSettings } from '../lib/siteSettings.js';
export function InscriptionModal({ rencontre, onClose, onDone }) {
const { user, login } = useAuth();
@@ -13,6 +14,7 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
const [participants, setParticipants] = useState(['']);
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [done, setDone] = useState(false);
const [wasRegistered, setWasRegistered] = useState(false);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
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`)
.then((d) => {
setContext(d);
setWasRegistered(d.participants.length > 0);
setParticipants(d.participants.length ? d.participants.map((p) => p.nom) : [d.member.dirigeant || '']);
})
.catch((err) => setError(err.message));
@@ -67,7 +70,9 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
<Modal onClose={onClose} maxWidth={460}>
<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>
<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 }}>
Votre demande pour <strong>{rencontre.titre}</strong> est bien enregistrée.
</p>
@@ -84,7 +89,7 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
<Modal
onClose={onClose}
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 && (
<form onSubmit={submitLogin} style={{ padding: '28px 30px' }}>
@@ -160,9 +165,25 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
export default function Home() {
const { data, reload } = usePublicData();
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [inscrRenc, setInscrRenc] = useState(null);
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> :
// once the public data is loaded, open the matching inscription modal.
@@ -195,6 +216,7 @@ export default function Home() {
const rencontres = data?.rencontres ?? [];
const passees = data?.rencontresPassees ?? [];
const content = data?.content ?? {};
const settings = associationSettings(content);
const marquee = members.concat(members);
return (
@@ -207,7 +229,7 @@ export default function Home() {
Le business se joue en <em style={{ fontStyle: 'italic', color: 'var(--red)' }}>équipe</em>.
</h1>
<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.
</p>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
@@ -313,6 +335,8 @@ export default function Home() {
{rencontres.map((e) => {
const { jour, mois } = dateParts(e.date_renc);
const restantes = Math.max(0, e.places - e.inscrits);
const registration = registrations[e.id];
const isRegistered = Boolean(registration);
return (
<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' }}>
@@ -331,14 +355,16 @@ export default function Home() {
<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>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 22, paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}>
<span style={{ fontSize: 13, color: 'var(--red)', fontWeight: 600 }}>
{restantes > 0 ? `${restantes} places restantes` : 'Complet'}
<span style={{ fontSize: 13, color: isRegistered ? 'var(--green)' : 'var(--red)', fontWeight: 600 }}>
{isRegistered ? '✓ Inscrit' : (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
{isRegistered
? `${registration.participants} participant${registration.participants > 1 ? 's' : ''} · ${registration.confirmee ? 'Confirmée' : 'En attente'}`
: `Max. ${e.participants_par_compte} par compte`}
</small>
</span>
<button className="btn btn-dark btn-sm" style={{ fontSize: 13.5 }} disabled={restantes === 0} onClick={() => setInscrRenc(e)}>
S'inscrire
<button className="btn btn-dark btn-sm" style={{ fontSize: 13.5 }} disabled={!isRegistered && restantes === 0} onClick={() => setInscrRenc(e)}>
{isRegistered ? 'Modifier' : "S'inscrire"}
</button>
</div>
</div>
@@ -394,7 +420,15 @@ export default function Home() {
</section>
{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} />}
</main>