Étape 21 : formulaire public d'inscription des bénévoles
Retire « constructeur de formulaire » des exclusions du CLAUDE.md (§ 5) — décision explicite prise en conversation. Un formulaire par édition (COORDINATION choisit les champs actifs parmi neuf : email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir de festival, vœu de compagnon en texte libre, disponibilité/ cotisation déclaratives, photo, commentaire ; nom/prénom toujours collectés) ouvert/fermé à volonté, avec un lien à partager (/inscription, aucun compte requis). Backend : FormTemplate/FormField (migration 0013), quatre nouveaux champs sur Volunteer (photo, companion_wish_note, self_declared_available, willing_to_pay_fee), app/api/forms.py — les deux seuls endpoints publics de toute l'API (GET .../form/public, POST .../form/public/submit), protégés par hCaptcha (app/forms/captcha.py, désactivé sans HCAPTCHA_SECRET_KEY) et un filet de sécurité global par édition (form_submission_limiter). resolve_owned_ids/set_artists/ set_wished_positions déplacés vers app/api/_common.py, partagés avec volunteers.py. Frontend : écran de configuration (/inscriptions) avec lien copiable et bascule ouvert/fermé, formulaire public (/inscription, hors RequireAuth) avec widget hCaptcha chargé dynamiquement, capture photo native (<input capture>), vœux de poste/artiste avec les mêmes règles que le back-end. Réponses du formulaire visibles et éditables depuis la fiche bénévole existante (ConfigVolunteers.tsx). Vérifié : pytest (273 passés), ruff, alembic check, tsc --noEmit, eslint, vite build, smoke (rendu SSR de chaque écran + flux de bout en bout du formulaire en mode démonstration) — tous verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
55c5d539a8
commit
82088f3b0c
@@ -0,0 +1,335 @@
|
||||
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 <HCaptchaWidget> (StrictMode, navigation aller-retour…).
|
||||
let hcaptchaScriptPromise: Promise<void> | null = null;
|
||||
function loadHCaptchaScript(): Promise<void> {
|
||||
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<HTMLDivElement>(null);
|
||||
const callbackName = useRef(`hcaptchaCallback${Math.random().toString(36).slice(2)}`);
|
||||
|
||||
useEffect(() => {
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
const name = callbackName.current;
|
||||
win[name] = onToken;
|
||||
loadHCaptchaScript();
|
||||
return () => {
|
||||
delete win[name];
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-captcha"
|
||||
data-sitekey={ENV.hcaptchaSiteKey}
|
||||
data-callback={callbackName.current}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, PublicArtistOption[]>();
|
||||
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<string[]>([]);
|
||||
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
|
||||
const [photo, setPhoto] = useState<File | null>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState('');
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [volunteerId, setVolunteerId] = useState<string | null>(null);
|
||||
|
||||
const artistsByDay = useMemo(
|
||||
() => groupArtistsByDay(config.data?.artists ?? []),
|
||||
[config.data?.artists],
|
||||
);
|
||||
|
||||
if (config.isLoading) return <Loading />;
|
||||
if (config.error) return <ErrorNote error={config.error} />;
|
||||
if (!config.data) return null;
|
||||
|
||||
if (volunteerId) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
||||
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config.data.is_open) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
|
||||
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.closed.body')}</p>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4">
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.title')}</PanelHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
|
||||
<TextInput value={firstName} onChange={setFirstName} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
|
||||
<TextInput value={lastName} onChange={setLastName} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{fields.has('EMAIL') ? (
|
||||
<label className="block">
|
||||
<FieldLabel>{t('form.field.email')}</FieldLabel>
|
||||
<TextInput mono value={email} onChange={setEmail} />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{fields.has('PHONE') ? (
|
||||
<label className="block">
|
||||
<FieldLabel>{t('form.field.phone')}</FieldLabel>
|
||||
<TextInput mono value={phone} onChange={setPhone} />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{fields.has('POSITION_WISHES') && config.data.positions.length > 0 ? (
|
||||
<div>
|
||||
<FieldLabel>
|
||||
{t('form.field.positionWishes')} — {t('publicForm.positionWishes.count', {
|
||||
count: positionIds.length,
|
||||
max: MAX_POSITION_WISHES,
|
||||
})}
|
||||
</FieldLabel>
|
||||
<div className="flex flex-col gap-[6px] border-2 border-ink bg-cream p-[9px]">
|
||||
{config.data.positions.map((p) => {
|
||||
const checked = positionIds.includes(p.id);
|
||||
const disabled = !checked && positionIds.length >= MAX_POSITION_WISHES;
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
className={`flex items-center gap-[8px] font-mono text-[12px] ${disabled ? 'opacity-40' : 'cursor-pointer'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => togglePosition(p.id)}
|
||||
className="h-[15px] w-[15px] accent-ink"
|
||||
/>
|
||||
{p.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{fields.has('ARTIST_WISHES') && artistsByDay.length > 0 ? (
|
||||
<div>
|
||||
<FieldLabel>{t('form.field.artistWishes')}</FieldLabel>
|
||||
<div className="flex flex-col gap-[8px]">
|
||||
{artistsByDay.map(([day, artists]) => (
|
||||
<label key={day} className="block">
|
||||
<span className="mb-[3px] block font-mono text-[10px] opacity-60">
|
||||
{longDayLabel(`${day}T12:00`)}
|
||||
</span>
|
||||
<select
|
||||
value={artistByDay[day] ?? ''}
|
||||
onChange={(e) => pickArtistForDay(day, e.target.value)}
|
||||
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[12px]"
|
||||
>
|
||||
<option value="">{t('publicForm.artistWishes.none')}</option>
|
||||
{artists.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{fields.has('COMPANION_WISH') ? (
|
||||
<label className="block">
|
||||
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
|
||||
<TextInput value={companionNote} onChange={setCompanionNote} />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{fields.has('COMMENT') ? (
|
||||
<label className="block">
|
||||
<FieldLabel>{t('form.field.comment')}</FieldLabel>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{fields.has('AVAILABLE') ? (
|
||||
<label className="flex cursor-pointer items-center gap-[8px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={available}
|
||||
onChange={(e) => setAvailable(e.target.checked)}
|
||||
className="h-[16px] w-[16px] accent-ink"
|
||||
/>
|
||||
<span className="font-mono text-[12px]">{t('form.field.available')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{fields.has('WILLING_TO_PAY_FEE') ? (
|
||||
<label className="flex cursor-pointer items-center gap-[8px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={willingToPayFee}
|
||||
onChange={(e) => setWillingToPayFee(e.target.checked)}
|
||||
className="h-[16px] w-[16px] accent-ink"
|
||||
/>
|
||||
<span className="font-mono text-[12px]">{t('form.field.willingToPayFee')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{fields.has('PHOTO') ? (
|
||||
<label className="block">
|
||||
<FieldLabel>{t('form.field.photo')}</FieldLabel>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={(e) => setPhoto(e.target.files?.[0] ?? null)}
|
||||
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[11px]"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{toggleEnabled ? (
|
||||
<HCaptchaWidget onToken={setCaptchaToken} />
|
||||
) : (
|
||||
<p className="font-mono text-[9.5px] opacity-50">{t('publicForm.captcha.disabled')}</p>
|
||||
)}
|
||||
|
||||
{submitError ? (
|
||||
<p className="font-mono text-[11px] font-bold text-orange-dark">{submitError}</p>
|
||||
) : null}
|
||||
|
||||
<Btn type="submit" disabled={!canSubmit}>
|
||||
{submit.isPending ? t('publicForm.submitting') : t('publicForm.submit')}
|
||||
</Btn>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user