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',
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
const restantes = Math.max(0, r.places - (r.inscrits ?? 0));
|
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 logoUrl = `${baseUrl}/assets/logo.jpg`;
|
||||||
const subject = `Invitation — ${r.titre} · ${dateStr}`;
|
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>`
|
? `<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
|
// A table-backed CTA survives rich-text paste into Outlook. In particular,
|
||||||
// display:inline-block on <a>, collapsing the button to a plain link, so
|
// the visible label remains a normal <a href>, whereas VML hyperlinks are
|
||||||
// it needs a VML roundrect. Other clients get the styled <a>, sized with
|
// commonly stripped when Outlook receives them through the clipboard.
|
||||||
// a fixed width + line-height (not padding) so both paths match. The
|
|
||||||
// conditional comments are inert HTML comments in normal renderers.
|
|
||||||
const ctaButton = (url, label) => `
|
const ctaButton = (url, label) => `
|
||||||
<!--[if mso]>
|
<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;">
|
||||||
<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">
|
<tr>
|
||||||
<w:anchorlock/>
|
<td width="300" height="48" align="center" valign="middle" bgcolor="#C1272D" style="width:300px;height:48px;background-color:#C1272D;">
|
||||||
<center style="color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">${label}</center>
|
<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>
|
||||||
</v:roundrect>
|
</td>
|
||||||
<![endif]-->
|
</tr>
|
||||||
<!--[if !mso]><!-->
|
</table>`;
|
||||||
<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]-->`;
|
|
||||||
|
|
||||||
const html = `<!doctype html>
|
const html = `<!doctype html>
|
||||||
<html lang="fr" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
<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)
|
// Membership request (home page form)
|
||||||
publicRouter.post(
|
publicRouter.post(
|
||||||
'/demandes-adhesion',
|
'/demandes-adhesion',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect } from 'react';
|
|||||||
import Header from './components/Header.jsx';
|
import Header from './components/Header.jsx';
|
||||||
import Footer from './components/Footer.jsx';
|
import Footer from './components/Footer.jsx';
|
||||||
import Home from './pages/Home.jsx';
|
import Home from './pages/Home.jsx';
|
||||||
|
import Inscription from './pages/Inscription.jsx';
|
||||||
import Annuaire from './pages/Annuaire.jsx';
|
import Annuaire from './pages/Annuaire.jsx';
|
||||||
import Association from './pages/Association.jsx';
|
import Association from './pages/Association.jsx';
|
||||||
import RencontresPassees from './pages/RencontresPassees.jsx';
|
import RencontresPassees from './pages/RencontresPassees.jsx';
|
||||||
@@ -33,6 +34,7 @@ export default function App() {
|
|||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/inscription/:id" element={<Inscription />} />
|
||||||
<Route path="/annuaire" element={<Annuaire />} />
|
<Route path="/annuaire" element={<Annuaire />} />
|
||||||
<Route path="/rencontres-passees" element={<RencontresPassees />} />
|
<Route path="/rencontres-passees" element={<RencontresPassees />} />
|
||||||
<Route path="/association" element={<Association />} />
|
<Route path="/association" element={<Association />} />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { api, dateParts } from '../lib/api.js';
|
|||||||
import Modal from '../components/Modal.jsx';
|
import Modal from '../components/Modal.jsx';
|
||||||
import PastEventCard from '../components/PastEventCard.jsx';
|
import PastEventCard from '../components/PastEventCard.jsx';
|
||||||
|
|
||||||
function InscriptionModal({ rencontre, onClose, onDone }) {
|
export function InscriptionModal({ rencontre, onClose, onDone }) {
|
||||||
const { user, login } = useAuth();
|
const { user, login } = useAuth();
|
||||||
const [context, setContext] = useState(null);
|
const [context, setContext] = useState(null);
|
||||||
const [participants, setParticipants] = useState(['']);
|
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