Add account-based event registration and robust email HTML

This commit is contained in:
Michael committed 2026-07-14 11:18:11 +02:00
1 parent c647c90078
commit e170d57577
7 files changed
+340 -95

No files matched your search

+39 -6
View File
@@ -225,8 +225,8 @@ function RencFormModal({ renc, onClose, onSaved }) {
const { user } = useAuth();
const [form, setForm] = useState(
renc
? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places }
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30 }
? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places, participants_par_compte: renc.participants_par_compte || 1 }
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30, participants_par_compte: 1 }
);
const [image, setImage] = useState(renc?.image_path || '');
const [error, setError] = useState('');
@@ -281,13 +281,16 @@ function RencFormModal({ renc, onClose, onSaved }) {
<input name="heure" value={form.heure} onChange={onChange} placeholder="18h30" maxLength={20} />
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 12 }}>
<label className="field">Lieu
<input name="lieu" value={form.lieu} onChange={onChange} required maxLength={200} />
</label>
<label className="field">Places
<label className="field">Places totales
<input name="places" type="number" min={0} max={100000} value={form.places} onChange={onChange} required />
</label>
<label className="field">Par compte
<input name="participants_par_compte" type="number" min={1} max={100} value={form.participants_par_compte} onChange={onChange} required />
</label>
</div>
<label className="field">Description
<textarea name="description" value={form.description} onChange={onChange} rows={3} maxLength={2000} />
@@ -316,6 +319,34 @@ function escHtml(v) {
return String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Clipboard HTML must be a fragment, not a second complete document. Outlook
// otherwise rebuilds the pasted <body> and drops part of the outer layout.
function emailBodyFragment(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.innerHTML.trim();
}
// Older browsers do not expose ClipboardItem. A real rich-text selection is
// still preferable to copying the HTML source as plain text.
function legacyCopyRichHtml(html) {
const holder = document.createElement('div');
holder.setAttribute('contenteditable', 'true');
holder.setAttribute('aria-hidden', 'true');
holder.style.cssText = 'position:fixed;left:-10000px;top:0;width:700px;opacity:0;pointer-events:none;';
holder.innerHTML = html;
document.body.appendChild(holder);
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(holder);
selection.removeAllRanges();
selection.addRange(range);
const copied = document.execCommand('copy');
selection.removeAllRanges();
holder.remove();
if (!copied) throw new Error('Rich HTML copy failed');
}
// Composer + preview of the ready-to-send HTML invitation email generated
// server-side for one rencontre. Every text zone (salutation, intro,
// conclusion, signature) is editable — the preview regenerates as the
@@ -371,15 +402,16 @@ function EmailModal({ renc, onClose }) {
const copyEmail = async () => {
setError('');
try {
const fragment = emailBodyFragment(data.html);
if (navigator.clipboard?.write && window.ClipboardItem) {
await navigator.clipboard.write([
new window.ClipboardItem({
'text/html': new Blob([data.html], { type: 'text/html' }),
'text/html': new Blob([fragment], { type: 'text/html' }),
'text/plain': new Blob([`${data.subject}\n${data.inscriptionUrl}`], { type: 'text/plain' }),
}),
]);
} else {
await navigator.clipboard.writeText(data.html);
legacyCopyRichHtml(fragment);
}
flash('email');
} catch {
@@ -602,6 +634,7 @@ export function RencontresTab() {
<div style={{ textAlign: 'center' }}>
<div className="serif" style={{ fontSize: 22, fontWeight: 600, color: 'var(--red)' }}>{e.inscrits}</div>
<div style={{ fontSize: 11, color: 'var(--gray-light)' }}>inscrits / {e.places}</div>
<div style={{ fontSize: 10.5, color: 'var(--gray-light)', marginTop: 3 }}>max. {e.participants_par_compte} / compte</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-dark btn-sm" style={{ fontSize: 13, padding: '9px 16px' }} onClick={() => setParticipantsRenc(e)}>
+111 -24
View File
@@ -1,27 +1,63 @@
import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { usePublicData } from '../lib/usePublic.js';
import { useAuth } from '../lib/AuthContext.jsx';
import { api, dateParts } from '../lib/api.js';
import Modal from '../components/Modal.jsx';
import PastEventCard from '../components/PastEventCard.jsx';
function InscriptionModal({ rencontre, onClose, onDone }) {
const [form, setForm] = useState({ nom: '', entreprise: '', email: '', tel: '' });
const { user, login } = useAuth();
const [context, setContext] = useState(null);
const [participants, setParticipants] = useState(['']);
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [done, setDone] = useState(false);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const { jour, mois } = dateParts(rencontre.date_renc);
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
useEffect(() => {
if (user?.role !== 'member' || user.mustChangePassword) return;
setError('');
api.get(`/api/public/rencontres/${rencontre.id}/inscription`)
.then((d) => {
setContext(d);
setParticipants(d.participants.length ? d.participants.map((p) => p.nom) : [d.member.dirigeant || '']);
})
.catch((err) => setError(err.message));
}, [user, rencontre.id]);
const submitLogin = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
await login(credentials.email, credentials.password);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const changeParticipant = (index, value) => {
setParticipants((current) => current.map((name, i) => (i === index ? value : name)));
};
const maxPerAccount = context?.rencontre.participants_par_compte || rencontre.participants_par_compte || 1;
const submit = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, form);
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, { participants });
setDone(true);
onDone?.();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
@@ -35,7 +71,7 @@ function InscriptionModal({ rencontre, onClose, onDone }) {
Votre demande pour <strong>{rencontre.titre}</strong> est bien enregistrée.
</p>
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', marginBottom: 26 }}>
Elle sera confirmée par l'équipe du Club.
{participants.length} participant{participants.length > 1 ? 's' : ''} — confirmation par l'équipe du Club.
</p>
<button className="btn btn-dark btn-sm" style={{ padding: '13px 28px', fontSize: 14 }} onClick={onClose}>Fermer</button>
</div>
@@ -49,26 +85,74 @@ function InscriptionModal({ rencontre, onClose, onDone }) {
maxWidth={460}
header={{ kicker: 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
>
<form onSubmit={submit} style={{ padding: '28px 30px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label className="field-plain">Nom & prénom
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
</label>
<label className="field-plain">Entreprise
<input name="entreprise" value={form.entreprise} onChange={onChange} required maxLength={120} />
</label>
<label className="field-plain">Email
<input name="email" type="email" value={form.email} onChange={onChange} required maxLength={254} />
</label>
{!user && (
<form onSubmit={submitLogin} style={{ padding: '28px 30px' }}>
<p style={{ fontSize: 14, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 18 }}>
Connectez-vous avec votre compte membre avant de choisir les participants.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label className="field-plain">Email du compte
<input type="email" value={credentials.email} onChange={(e) => setCredentials({ ...credentials, email: e.target.value })} required autoComplete="username" />
</label>
<label className="field-plain">Mot de passe
<input type="password" value={credentials.password} onChange={(e) => setCredentials({ ...credentials, password: e.target.value })} required autoComplete="current-password" />
</label>
</div>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14 }} disabled={busy}>
{busy ? 'Connexion…' : 'Se connecter et continuer'}
</button>
</form>
)}
{user && (user.role !== 'member' || user.mustChangePassword) && (
<div style={{ padding: '30px' }}>
<p className="error-text">
{user.role !== 'member'
? 'Les inscriptions doivent être effectuées avec un compte membre.'
: 'Vous devez d’abord remplacer votre mot de passe temporaire.'}
</p>
<Link to="/espace-membre" className="btn btn-dark" style={{ display: 'block', textAlign: 'center', marginTop: 20, textDecoration: 'none' }}>
Ouvrir mon espace membre
</Link>
</div>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14 }}>
Confirmer mon inscription
</button>
<p style={{ fontSize: 12, color: 'var(--gray-light)', textAlign: 'center', marginTop: 12, lineHeight: 1.5 }}>
Réservé aux membres à jour de leur adhésion pour la saison en cours.
</p>
</form>
)}
{user?.role === 'member' && !user.mustChangePassword && !context && (
<p style={{ padding: '30px', color: 'var(--gray-light)' }}>{error || 'Chargement de votre compte…'}</p>
)}
{user?.role === 'member' && !user.mustChangePassword && context && (
<form onSubmit={submit} style={{ padding: '28px 30px' }}>
<div style={{ background: 'var(--beige)', padding: '12px 14px', borderRadius: 4, fontSize: 13, color: 'var(--gray)', marginBottom: 18 }}>
Compte : <strong>{context.member.nom}</strong> · maximum {maxPerAccount} participant{maxPerAccount > 1 ? 's' : ''}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{participants.map((name, index) => (
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<label className="field-plain" style={{ flex: 1 }}>Participant {index + 1} — nom & prénom
<input value={name} onChange={(e) => changeParticipant(index, e.target.value)} required maxLength={120} />
</label>
{participants.length > 1 && (
<button type="button" className="btn btn-outline-soft btn-sm" style={{ padding: '11px 13px' }} onClick={() => setParticipants((current) => current.filter((_, i) => i !== index))} aria-label={`Retirer le participant ${index + 1}`}>×</button>
)}
</div>
))}
</div>
{participants.length < maxPerAccount && (
<button type="button" className="btn-link" style={{ marginTop: 14 }} onClick={() => setParticipants((current) => [...current, ''])}>
+ Ajouter un participant
</button>
)}
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14 }} disabled={busy}>
{busy ? 'Enregistrement…' : `Enregistrer ${participants.length} participant${participants.length > 1 ? 's' : ''}`}
</button>
<p style={{ fontSize: 12, color: 'var(--gray-light)', textAlign: 'center', marginTop: 12, lineHeight: 1.5 }}>
Vous pourrez rouvrir ce formulaire pour modifier les noms enregistrés.
</p>
</form>
)}
</Modal>
);
}
@@ -251,6 +335,9 @@ export default function Home() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 22, paddingTop: 18, borderTop: '1px solid var(--border-soft)' }}>
<span style={{ fontSize: 13, color: 'var(--red)', fontWeight: 600 }}>
{restantes > 0 ? `${restantes} places restantes` : 'Complet'}
<small style={{ display: 'block', color: 'var(--gray-light)', fontWeight: 400, marginTop: 3 }}>
Max. {e.participants_par_compte} par compte
</small>
</span>
<button className="btn btn-dark btn-sm" style={{ fontSize: 13.5 }} disabled={restantes === 0} onClick={() => setInscrRenc(e)}>
S'inscrire
@@ -344,7 +431,7 @@ export default function Home() {
</section>
{inscrRenc && (
<InscriptionModal rencontre={inscrRenc} onClose={closeInscription} onDone={reload} />
<InscriptionModal key={inscrRenc.id} rencontre={inscrRenc} onClose={closeInscription} onDone={reload} />
)}
</main>
);