Add account-based event registration and robust email HTML
This commit is contained in:
1 parent
c647c90078
commit
e170d57577
7 files changed
+340
-95
No files matched your search
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user