É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:
AdamThomas
2026-09-21 20:24:32 +02:00
co-authored by Claude Sonnet 5
parent 55c5d539a8
commit 82088f3b0c
41 changed files with 2197 additions and 81 deletions
@@ -18,6 +18,7 @@ import {
useUpdatePosition,
useUpdateVolunteer,
useVolunteerAvailability,
useVolunteerPhotoUrl,
useVolunteers,
} from '@/api/hooks';
import type {
@@ -30,6 +31,7 @@ import type {
PositionRead,
VolunteerCreate,
VolunteerRead,
VolunteerUpdate,
} from '@/api/schema';
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
@@ -158,6 +160,79 @@ function VolunteerAccessSection({
);
}
/**
* Réponses du formulaire public d'inscription (voir `app/api/forms.py`) —
* note de compagnon en texte libre (à résoudre manuellement en vrai vœu via
* le sélecteur ci-dessus une fois la personne visée elle-même inscrite),
* cases informatives, photo. N'apparaît que si le bénévole vient
* effectivement de ce formulaire (sinon rien à afficher).
*/
function PublicFormAnswers({
volunteer,
patch,
}: {
volunteer: VolunteerRead;
patch: (body: VolunteerUpdate) => void;
}) {
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo);
const hasAnything =
volunteer.has_photo ||
Boolean(volunteer.companion_wish_note) ||
volunteer.self_declared_available ||
volunteer.willing_to_pay_fee;
if (!hasAnything) return null;
return (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>
{t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} />
</FieldLabel>
{photoUrl ? (
<img
src={photoUrl}
alt=""
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
/>
) : null}
{volunteer.companion_wish_note ? (
<label className="block">
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
<textarea
value={volunteer.companion_wish_note}
onChange={(e) => patch({ companion_wish_note: e.target.value || null })}
rows={2}
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
/>
</label>
) : null}
<div className="flex flex-wrap gap-[14px]">
<label className="flex cursor-pointer items-center gap-[7px]">
<input
type="checkbox"
checked={volunteer.self_declared_available}
onChange={(e) => patch({ self_declared_available: e.target.checked })}
className="h-[15px] w-[15px] accent-ink"
/>
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
</label>
<label className="flex cursor-pointer items-center gap-[7px]">
<input
type="checkbox"
checked={volunteer.willing_to_pay_fee}
onChange={(e) => patch({ willing_to_pay_fee: e.target.checked })}
className="h-[15px] w-[15px] accent-ink"
/>
<span className="font-mono text-[11px]">{t('form.field.willingToPayFee')}</span>
</label>
</div>
</div>
);
}
/**
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
@@ -638,6 +713,8 @@ export function ConfigVolunteers() {
searchPlaceholder={t('config.vol.searchCompanion')}
/>
<PublicFormAnswers volunteer={selected} patch={patch} />
<AvailabilityEditor volunteerId={selected.id} />
<button