Fix Outlook registration links

This commit is contained in:
Michael committed 2026-07-14 12:09:23 +02:00
1 parent e170d57577
commit 15bc6f063d
5 files changed
+73 -16

No files matched your search

+13 -15
View File
@@ -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 = {} }) {
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;"><tr><td style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;mso-line-height-rule:exactly;color:#5A544E;${extra}">${escML(text)}</td></tr></table>`
: '';
// "Bulletproof" CTA button. Outlook (Word engine) ignores padding and
// display:inline-block on <a>, collapsing the button to a plain link, so
// it needs a VML roundrect. Other clients get the styled <a>, 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 <a href>, whereas VML hyperlinks are
// commonly stripped when Outlook receives them through the clipboard.
const ctaButton = (url, label) => `
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${esc(url)}" style="height:48px;v-text-anchor:middle;width:300px;" arcsize="7%" fillcolor="#C1272D" strokecolor="#C1272D">
<w:anchorlock/>
<center style="color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">${label}</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="${esc(url)}" target="_blank" style="display:inline-block;width:300px;line-height:48px;background-color:#C1272D;color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;text-align:center;text-decoration:none;border-radius:3px;-webkit-text-size-adjust:none;">${label}</a>
<!--<![endif]-->`;
<table role="presentation" width="300" cellpadding="0" cellspacing="0" border="0" style="width:300px;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">
<tr>
<td width="300" height="48" align="center" valign="middle" bgcolor="#C1272D" style="width:300px;height:48px;background-color:#C1272D;">
<a href="${esc(url)}" target="_blank" title="Ouvrir l'inscription" style="display:block;width:300px;line-height:48px;color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;text-align:center;text-decoration:none;-webkit-text-size-adjust:none;">${label}</a>
</td>
</tr>
</table>`;
const html = `<!doctype html>
<html lang="fr" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
+20
View File
@@ -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',
+2
View File
@@ -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() {
<div style={{ flex: 1 }}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/inscription/:id" element={<Inscription />} />
<Route path="/annuaire" element={<Annuaire />} />
<Route path="/rencontres-passees" element={<RencontresPassees />} />
<Route path="/association" element={<Association />} />
+1 -1
View File
@@ -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(['']);
+37
View File
@@ -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 (
<main className="container" style={{ minHeight: '60vh', padding: '80px 32px', textAlign: 'center' }}>
<h1 className="serif" style={{ fontSize: 30, marginBottom: 12 }}>Inscription indisponible</h1>
<p className="error-text">{error}</p>
<button className="btn btn-dark" style={{ marginTop: 24 }} onClick={close}>Retour à l'accueil</button>
</main>
);
}
if (!rencontre) return <main style={{ minHeight: '60vh' }} />;
return (
<main style={{ minHeight: '60vh' }}>
<InscriptionModal rencontre={rencontre} onClose={close} />
</main>
);
}