Ajoute les inscriptions modifiables et les paramètres association
This commit is contained in:
1 parent
c49c45e65f
commit
cf67fdc9de
22 files changed
+442
-77
No files matched your search
@@ -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;"> </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 · 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 ? ` · ${lines(settings.association_address)}` : ''}<br>
|
||||
${settings.association_contact ? `${esc(settings.association_contact)} · ` : ''}${settings.association_email ? `<a href="mailto:${esc(settings.association_email)}" style="color:#E0777A;text-decoration:none;">${esc(settings.association_email)}</a>` : ''}${settings.association_phone ? ` · ${esc(settings.association_phone)}` : ''}
|
||||
</td></tr>
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
|
||||
@@ -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 · 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 ? ` · ${escML(settings.association_address)}` : ''}<br>
|
||||
${settings.association_contact ? `${esc(settings.association_contact)} · ` : ''}${settings.association_email ? `<a href="mailto:${esc(settings.association_email)}" style="color:#E0777A;text-decoration:none;">${esc(settings.association_email)}</a>` : ''}${settings.association_phone ? ` · ${esc(settings.association_phone)}` : ''}
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
|
||||
@@ -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'"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user