diff --git a/server/src/customEmailTemplate.js b/server/src/customEmailTemplate.js index 065011d..963b35a 100644 --- a/server/src/customEmailTemplate.js +++ b/server/src/customEmailTemplate.js @@ -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) { `; } -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 = ` @@ -55,10 +58,9 @@ export function buildCustomEmail({ content, baseUrl }) { @@ -70,8 +72,8 @@ export function buildCustomEmail({ content, baseUrl }) { ${button ? `` : ''} ${content.signature ? `` : ''}
- +
SLUC Business Club Nancy${esc(settings.association_name)} -
Business Club
-
SLUC Nancy
+
${esc(settings.association_name)}
${button}
${lines(content.signature)}
 
- Business Club SLUC Nancy · Palais des Sports Jean Weille, Nancy
- contact@sluc-businessclub.fr + ${esc(settings.association_name)}${settings.association_address ? ` · ${lines(settings.association_address)}` : ''}
+ ${settings.association_contact ? `${esc(settings.association_contact)} · ` : ''}${settings.association_email ? `${esc(settings.association_email)}` : ''}${settings.association_phone ? ` · ${esc(settings.association_phone)}` : ''}
diff --git a/server/src/emailTemplate.js b/server/src/emailTemplate.js index bb3940e..10bb2a1 100644 --- a/server/src/emailTemplate.js +++ b/server/src/emailTemplate.js @@ -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 = {} }) {
- SLUC Business Club Nancy + ${esc(settings.association_name)} -
Business Club
-
SLUC Nancy
+
${esc(settings.association_name)}
@@ -153,8 +156,8 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) { - Business Club SLUC Nancy · Palais des Sports Jean Weille, Nancy
- contact@sluc-businessclub.fr + ${esc(settings.association_name)}${settings.association_address ? ` · ${escML(settings.association_address)}` : ''}
+ ${settings.association_contact ? `${esc(settings.association_contact)} · ` : ''}${settings.association_email ? `${esc(settings.association_email)}` : ''}${settings.association_phone ? ` · ${esc(settings.association_phone)}` : ''} diff --git a/server/src/index.js b/server/src/index.js index 143e861..2ec3203 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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'"], diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index fd862cc..8ba2c09 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -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); } diff --git a/server/src/routes/member.js b/server/src/routes/member.js index e53cb54..895dcad 100644 --- a/server/src/routes/member.js +++ b/server/src/routes/member.js @@ -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) => { diff --git a/server/src/routes/public.js b/server/src/routes/public.js index 978bd61..08de27c 100644 --- a/server/src/routes/public.js +++ b/server/src/routes/public.js @@ -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` diff --git a/server/src/schemas.js b/server/src/schemas.js index 3dcdd5c..448c19d 100644 --- a/server/src/schemas.js +++ b/server/src/schemas.js @@ -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 diff --git a/server/src/siteSettings.js b/server/src/siteSettings.js new file mode 100644 index 0000000..0913ef4 --- /dev/null +++ b/server/src/siteSettings.js @@ -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 }; +} diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql index 338663d..e1072d8 100644 --- a/server/src/sql/schema.sql +++ b/server/src/sql/schema.sql @@ -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, diff --git a/server/src/sql/seed.sql b/server/src/sql/seed.sql index c08ce4a..4488be4 100644 --- a/server/src/sql/seed.sql +++ b/server/src/sql/seed.sql @@ -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 diff --git a/web/src/components/Footer.jsx b/web/src/components/Footer.jsx index 0cc1b04..62f2be0 100644 --- a/web/src/components/Footer.jsx +++ b/web/src/components/Footer.jsx @@ -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 ( diff --git a/web/src/components/Header.jsx b/web/src/components/Header.jsx index 70dfef9..e7c59dd 100644 --- a/web/src/components/Header.jsx +++ b/web/src/components/Header.jsx @@ -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 (
- SLUC Business Club Nancy - - Business Club - - SLUC Nancy - + {settings.association_name} + + {settings.association_name}