// BookingModal.jsx — modale de réservation réelle, partagée par index.html // et par les pages de détail des offres (offres/*.html). Créneaux depuis le // backend, formulaire validé, confirmation seulement quand le serveur a // confirmé (spec booking/UX-SPEC-reservation-appel.md). // Exposé via window.BookingModal — chargé après booking-config.js et Icon.jsx. const bookingModalStyles = { modalScrim: { position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(42, 26, 7,.55)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, animation: 'b2a-fade-in 240ms cubic-bezier(.2,.8,.2,1)', }, modal: { width: '100%', maxWidth: 520, maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', background: 'var(--paper)', color: 'var(--ink-900)', border: '1px solid var(--ink-900)', boxShadow: '8px 8px 0 0 var(--amber-500)', padding: 36, position: 'relative', animation: 'b2a-modal-in 240ms cubic-bezier(.2,.8,.2,1)', }, close: { position: 'absolute', top: 16, right: 16, background: 'transparent', border: 'none', cursor: 'pointer', padding: 8, color: 'var(--ink-900)', minWidth: 44, minHeight: 44, display: 'flex', alignItems: 'center', justifyContent: 'center', }, modalTitle: { fontFamily: "'Space Grotesk', sans-serif", fontWeight: 600, fontSize: 30, letterSpacing: '-0.02em', margin: '0 0 12px', outline: 'none', }, modalBody: { fontSize: 16, lineHeight: 1.5, color: 'var(--mist-700)', margin: '0 0 24px', }, dayLabel: { fontFamily: "'JetBrains Mono', monospace", fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--mist-500)', margin: '16px 0 8px', }, slotRow: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }, slot: { border: '1px solid rgba(42, 26, 7,.20)', padding: '12px 10px', background: '#fff', cursor: 'pointer', fontFamily: "'Space Grotesk', sans-serif", fontWeight: 500, fontSize: 16, textAlign: 'center', minHeight: 44, transition: 'border-color 160ms, background 160ms', }, slotSelected: { borderColor: 'var(--ink-900)', background: 'var(--amber-500)', color: 'var(--paper)', }, skeleton: { height: 44, background: 'rgba(42, 26, 7,.07)', animation: 'b2a-fade-in 900ms ease-in-out infinite alternate', }, notice: { display: 'flex', alignItems: 'flex-start', gap: 10, padding: '14px 16px', background: '#fff', border: '1px solid rgba(42, 26, 7,.20)', fontSize: 14, lineHeight: 1.5, margin: '0 0 16px', }, noticeWarn: { borderColor: 'var(--amber-500)' }, field: { margin: '0 0 16px' }, label: { display: 'block', fontFamily: "'JetBrains Mono', monospace", fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--mist-700)', marginBottom: 6, }, input: { width: '100%', boxSizing: 'border-box', border: '1px solid rgba(42, 26, 7,.30)', padding: '12px 14px', minHeight: 44, fontFamily: 'var(--font-body)', fontSize: 16, background: '#fff', color: 'var(--ink-900)', }, inputError: { borderColor: '#C0392B' }, fieldError: { color: '#C0392B', fontSize: 13, margin: '6px 0 0' }, chosen: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 16px', background: '#fff', border: '1px solid var(--ink-900)', marginBottom: 20, }, chosenText: { fontFamily: "'JetBrains Mono', monospace", fontSize: 12, letterSpacing: '0.04em' }, changeLink: { background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--ink-900)', textDecoration: 'underline', fontSize: 13, padding: 8, minHeight: 44, }, consent: { fontSize: 12.5, lineHeight: 1.5, color: 'var(--mist-500)', margin: '12px 0 0' }, confirm: { display: 'flex', alignItems: 'center', gap: 12, padding: '16px', background: '#fff', border: '1px solid var(--ink-900)', marginBottom: 16, }, confirmText: { fontFamily: "'JetBrains Mono', monospace", fontSize: 12, letterSpacing: '0.04em' }, fallback: { fontSize: 13.5, color: 'var(--mist-500)', margin: '16px 0 0', textAlign: 'center' }, honeypot: { position: 'absolute', left: '-9999px', top: 'auto', width: 1, height: 1, overflow: 'hidden', }, }; // --- Validation côté client (mêmes règles que le serveur) --- const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; const validateBookingField = (field, value) => { const v = (value || '').trim(); if (field === 'name' && !v) return 'Entrez votre prénom et votre nom.'; if (field === 'email') { if (!v) return 'Le courriel est requis — c’est là qu’arrive l’invitation.'; if (!EMAIL_RE.test(v)) return 'Ce courriel ne semble pas valide. Vérifiez le format.'; } if (field === 'phone') { if (!v) return 'Le téléphone est requis — on vous rappelle en cas d’imprévu.'; if (v.replace(/\D/g, '').length < 10) return 'Entrez un numéro complet (au moins 10 chiffres).'; } return null; }; // Libellé français d'une date locale YYYY-MM-DD (construit à midi UTC pour // éviter tout glissement de jour). const frBookingDayLabel = (dateLocal) => { const [y, m, d] = dateLocal.split('-').map(Number); return new Intl.DateTimeFormat('fr-CA', { weekday: 'short', day: 'numeric', month: 'long' }) .format(new Date(Date.UTC(y, m - 1, d, 12))); }; const BOOKING_MAILTO = ( hello@theb2atek.com ); const BookingModal = ({ onClose }) => { const [step, setStep] = React.useState('slots'); // slots | form | done const [slotsStatus, setSlotsStatus] = React.useState('loading'); // loading | ready | empty | error | unconfigured const [slots, setSlots] = React.useState([]); const [selected, setSelected] = React.useState(null); // objet créneau const [conflict, setConflict] = React.useState(false); // créneau pris entre-temps const [form, setForm] = React.useState({ name: '', email: '', phone: '', pain: '', website: '' }); const [errors, setErrors] = React.useState({}); const [submitting, setSubmitting] = React.useState(false); const [submitError, setSubmitError] = React.useState(false); const [confirmedWhen, setConfirmedWhen] = React.useState(''); const modalRef = React.useRef(null); const titleRef = React.useRef(null); const loadSlots = React.useCallback(() => { if (!window.isBookingConfigured()) { setSlotsStatus('unconfigured'); return; } setSlotsStatus('loading'); fetch(window.bookingApiUrl('slots'), { headers: window.bookingApiHeaders() }) .then((r) => { if (!r.ok) throw new Error(r.status); return r.json(); }) .then((data) => { const list = data.slots || []; setSlots(list); setSlotsStatus(list.length ? 'ready' : 'empty'); }) .catch(() => setSlotsStatus('error')); }, []); React.useEffect(() => { loadSlots(); }, [loadSlots]); // Blocage du scroll de fond + restauration du focus au déclencheur (M3/M4) React.useEffect(() => { const trigger = document.activeElement; const prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prevOverflow; if (trigger && typeof trigger.focus === 'function') trigger.focus(); }; }, []); // Échap + focus-trap clavier (Q2) React.useEffect(() => { const node = modalRef.current; const focusables = () => node.querySelectorAll( 'button:not([disabled]), [href], input:not([tabindex="-1"]), textarea, [tabindex]:not([tabindex="-1"])' ); const onKey = (e) => { if (e.key === 'Escape') { onClose(); return; } if (e.key !== 'Tab') return; const items = focusables(); if (!items.length) return; const f = items[0], l = items[items.length - 1]; if (e.shiftKey && document.activeElement === f) { e.preventDefault(); l.focus(); } else if (!e.shiftKey && document.activeElement === l) { e.preventDefault(); f.focus(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); // À chaque changement d'étape, le focus va au titre (lecture d'écran + clavier) React.useEffect(() => { if (titleRef.current) titleRef.current.focus(); }, [step, slotsStatus]); const setField = (field) => (e) => { setForm((f) => ({ ...f, [field]: e.target.value })); if (errors[field]) setErrors((er) => ({ ...er, [field]: null })); }; const blurField = (field) => () => { setErrors((er) => ({ ...er, [field]: validateBookingField(field, form[field]) })); }; const submit = (e) => { e.preventDefault(); const errs = {}; for (const f of ['name', 'email', 'phone']) { const msg = validateBookingField(f, form[f]); if (msg) errs[f] = msg; } setErrors(errs); if (Object.keys(errs).length) return; setSubmitting(true); setSubmitError(false); fetch(window.bookingApiUrl('book'), { method: 'POST', headers: window.bookingApiHeaders(), body: JSON.stringify({ slotStartUtc: selected.startUtc, ...form }), }) .then((r) => r.json().then((data) => ({ status: r.status, data }))) .then(({ status, data }) => { if (status === 200 && data.ok) { setConfirmedWhen(data.when || `${frBookingDayLabel(selected.dateLocal)} à ${selected.timeLocal} (HE)`); setStep('done'); } else if (status === 409) { // Créneau pris entre-temps : retour étape 1, créneaux rafraîchis. setSelected(null); setConflict(true); setStep('slots'); loadSlots(); } else if (status === 400 && data.fields) { setErrors(data.fields); } else { setSubmitError(true); } }) .catch(() => setSubmitError(true)) .finally(() => setSubmitting(false)); }; // Groupement des créneaux par jour (max 12 jours affichés) const byDay = []; for (const s of slots) { const last = byDay[byDay.length - 1]; if (last && last.dateLocal === s.dateLocal) last.items.push(s); else byDay.push({ dateLocal: s.dateLocal, items: [s] }); } const fieldProps = (field, label, type, autoComplete, hint) => (
{errors[field]}
)}Choisissez un créneau (heure de l'Est). L'invitation arrive dans votre boîte courriel. Aucune préparation requise.
{conflict && ({frBookingDayLabel(day.dateLocal)}
L'invitation est en route vers {form.email}.
Elle contient le lien de l'appel — rien à préparer.
Au {confirmedWhen}.
Vous préférez écrire ? {BOOKING_MAILTO}
)}