diff --git a/server/src/emailTemplate.js b/server/src/emailTemplate.js index d7271a7..953781a 100644 --- a/server/src/emailTemplate.js +++ b/server/src/emailTemplate.js @@ -49,7 +49,9 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) { year: 'numeric', }); const restantes = Math.max(0, r.places - (r.inscrits ?? 0)); - const inscriptionUrl = `${baseUrl}/?inscription=${r.id}`; + // A dedicated route is more reliable from Outlook than a homepage query + // string: redirects, tracking filters and cached homepages can drop it. + const inscriptionUrl = `${baseUrl}/inscription/${r.id}`; const logoUrl = `${baseUrl}/assets/logo.jpg`; const subject = `Invitation — ${r.titre} · ${dateStr}`; @@ -71,21 +73,17 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) { ? `
${escML(text)}
` : ''; - // "Bulletproof" CTA button. Outlook (Word engine) ignores padding and - // display:inline-block on , collapsing the button to a plain link, so - // it needs a VML roundrect. Other clients get the styled , sized with - // a fixed width + line-height (not padding) so both paths match. The - // conditional comments are inert HTML comments in normal renderers. + // A table-backed CTA survives rich-text paste into Outlook. In particular, + // the visible label remains a normal , whereas VML hyperlinks are + // commonly stripped when Outlook receives them through the clipboard. const ctaButton = (url, label) => ` - - - ${label} - `; + + + + +
+ ${label} +
`; const html = ` diff --git a/server/src/routes/public.js b/server/src/routes/public.js index 9e05497..5bf6c67 100644 --- a/server/src/routes/public.js +++ b/server/src/routes/public.js @@ -56,6 +56,26 @@ publicRouter.get('/bootstrap', async (_req, res, next) => { } }); +// Direct event lookup used by links from invitation emails. It deliberately +// does not depend on the homepage bootstrap payload, so the registration page +// can always resolve a valid upcoming event by id. +publicRouter.get('/rencontres/:id', validate(idParam, 'params'), async (req, res, next) => { + try { + const result = await query( + `SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, + r.participants_par_compte, r.image_path, COUNT(i.id)::int AS inscrits + FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id + WHERE r.id = $1 AND r.date_renc >= CURRENT_DATE + GROUP BY r.id`, + [req.params.id] + ); + if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' }); + res.json({ rencontre: result.rows[0] }); + } catch (err) { + next(err); + } +}); + // Membership request (home page form) publicRouter.post( '/demandes-adhesion', diff --git a/web/src/App.jsx b/web/src/App.jsx index 094b668..2b5bf06 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import Header from './components/Header.jsx'; import Footer from './components/Footer.jsx'; import Home from './pages/Home.jsx'; +import Inscription from './pages/Inscription.jsx'; import Annuaire from './pages/Annuaire.jsx'; import Association from './pages/Association.jsx'; import RencontresPassees from './pages/RencontresPassees.jsx'; @@ -33,6 +34,7 @@ export default function App() {
} /> + } /> } /> } /> } /> diff --git a/web/src/pages/Home.jsx b/web/src/pages/Home.jsx index e202602..f97989c 100644 --- a/web/src/pages/Home.jsx +++ b/web/src/pages/Home.jsx @@ -6,7 +6,7 @@ import { api, dateParts } from '../lib/api.js'; import Modal from '../components/Modal.jsx'; import PastEventCard from '../components/PastEventCard.jsx'; -function InscriptionModal({ rencontre, onClose, onDone }) { +export function InscriptionModal({ rencontre, onClose, onDone }) { const { user, login } = useAuth(); const [context, setContext] = useState(null); const [participants, setParticipants] = useState(['']); diff --git a/web/src/pages/Inscription.jsx b/web/src/pages/Inscription.jsx new file mode 100644 index 0000000..6be9b56 --- /dev/null +++ b/web/src/pages/Inscription.jsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { api } from '../lib/api.js'; +import { InscriptionModal } from './Home.jsx'; + +export default function Inscription() { + const { id } = useParams(); + const navigate = useNavigate(); + const [rencontre, setRencontre] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + api.get(`/api/public/rencontres/${id}`) + .then((data) => setRencontre(data.rencontre)) + .catch((err) => setError(err.message)); + }, [id]); + + const close = () => navigate('/', { replace: true }); + + if (error) { + return ( +
+

Inscription indisponible

+

{error}

+ +
+ ); + } + + if (!rencontre) return
; + + return ( +
+ +
+ ); +}