import { useEffect, useMemo, useRef, useState } from 'react'; import { ApiError } from '@/api'; import { usePublicFormConfig, useSubmitPublicForm } from '@/api/hooks'; import type { PublicArtistOption } from '@/api/schema'; import { ErrorNote, Loading } from '@/components/state'; import { Btn, FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui'; import { t } from '@/i18n'; import { ENV } from '@/lib/env'; import { longDayLabel } from '@/lib/time'; const MAX_POSITION_WISHES = 2; // Charge le script hCaptcha une seule fois, quel que soit le nombre de // montages de (StrictMode, navigation aller-retour…). let hcaptchaScriptPromise: Promise | null = null; function loadHCaptchaScript(): Promise { if (!hcaptchaScriptPromise) { hcaptchaScriptPromise = new Promise((resolve) => { const script = document.createElement('script'); script.src = 'https://js.hcaptcha.com/1/api.js'; script.async = true; script.defer = true; script.onload = () => resolve(); document.head.appendChild(script); }); } return hcaptchaScriptPromise; } /** Widget hCaptcha (rendu implicite) : le script scanne le DOM et rend les * `.h-captcha` qu'il trouve. `onToken` reçoit le jeton une fois résolu. */ function HCaptchaWidget({ onToken }: { onToken: (token: string) => void }) { const containerRef = useRef(null); const callbackName = useRef(`hcaptchaCallback${Math.random().toString(36).slice(2)}`); useEffect(() => { const win = window as unknown as Record; const name = callbackName.current; win[name] = onToken; loadHCaptchaScript(); return () => { delete win[name]; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
); } function errorMessage(error: unknown): string { if (error instanceof ApiError) return t(error.code); return t('error.generic'); } function groupArtistsByDay(artists: PublicArtistOption[]): Array<[string, PublicArtistOption[]]> { const byDay = new Map(); for (const artist of artists) { const list = byDay.get(artist.performance_date) ?? []; list.push(artist); byDay.set(artist.performance_date, list); } return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)); } export function PublicSignupPage() { const config = usePublicFormConfig(); const submit = useSubmitPublicForm(); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [email, setEmail] = useState(''); const [phone, setPhone] = useState(''); const [comment, setComment] = useState(''); const [companionNote, setCompanionNote] = useState(''); const [available, setAvailable] = useState(false); const [willingToPayFee, setWillingToPayFee] = useState(false); const [positionIds, setPositionIds] = useState([]); const [artistByDay, setArtistByDay] = useState>({}); const [photo, setPhoto] = useState(null); const [captchaToken, setCaptchaToken] = useState(''); const [submitError, setSubmitError] = useState(null); const [volunteerId, setVolunteerId] = useState(null); const artistsByDay = useMemo( () => groupArtistsByDay(config.data?.artists ?? []), [config.data?.artists], ); if (config.isLoading) return ; if (config.error) return ; if (!config.data) return null; if (volunteerId) { return (
{t('publicForm.success.title')}

{t('publicForm.success.body')}

); } if (!config.data.is_open) { return (
{t('publicForm.closed.title')}

{t('publicForm.closed.body')}

); } const fields = new Set(config.data.fields); const toggleEnabled = ENV.hcaptchaSiteKey.length > 0; const togglePosition = (id: string) => { setPositionIds((prev) => { if (prev.includes(id)) return prev.filter((x) => x !== id); if (prev.length >= MAX_POSITION_WISHES) return prev; return [...prev, id]; }); }; const pickArtistForDay = (day: string, artistId: string) => { setArtistByDay((prev) => { const next = { ...prev }; if (!artistId) delete next[day]; else next[day] = artistId; return next; }); }; const canSubmit = firstName.trim().length > 0 && lastName.trim().length > 0 && (toggleEnabled ? captchaToken.length > 0 : true) && !submit.isPending; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitError(null); try { const result = await submit.mutateAsync({ first_name: firstName, last_name: lastName, hcaptcha_token: captchaToken || 'dev', email: fields.has('EMAIL') ? email : undefined, phone: fields.has('PHONE') ? phone : undefined, comment: fields.has('COMMENT') ? comment : undefined, companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined, available: fields.has('AVAILABLE') ? available : undefined, willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') ? willingToPayFee : undefined, position_wish_ids: fields.has('POSITION_WISHES') ? positionIds : undefined, artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined, photo: fields.has('PHOTO') ? photo : undefined, }); setVolunteerId(result.volunteer_id); } catch (err) { setSubmitError(errorMessage(err)); } }; return (
{t('publicForm.title')}
{fields.has('EMAIL') ? ( ) : null} {fields.has('PHONE') ? ( ) : null} {fields.has('POSITION_WISHES') && config.data.positions.length > 0 ? (
{t('form.field.positionWishes')} — {t('publicForm.positionWishes.count', { count: positionIds.length, max: MAX_POSITION_WISHES, })}
{config.data.positions.map((p) => { const checked = positionIds.includes(p.id); const disabled = !checked && positionIds.length >= MAX_POSITION_WISHES; return ( ); })}
) : null} {fields.has('ARTIST_WISHES') && artistsByDay.length > 0 ? (
{t('form.field.artistWishes')}
{artistsByDay.map(([day, artists]) => ( ))}
) : null} {fields.has('COMPANION_WISH') ? ( ) : null} {fields.has('COMMENT') ? (