Fix Outlook registration links
This commit is contained in:
1 parent
e170d57577
commit
15bc6f063d
5 files changed
+73
-16
No files matched your search
+13
-15
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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(['']);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user