É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
+74
View File
@@ -23,10 +23,13 @@ import type {
DayPartCreate,
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormTemplateUpdate,
ImportMapping,
PlanningNotificationRequest,
PositionCreate,
PositionUpdate,
PublicFormSubmission,
ShiftCreate,
ShiftUpdate,
VolunteerCreate,
@@ -97,6 +100,30 @@ export function useVolunteerPlanning(volunteerId: string | null | undefined) {
});
}
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public), ou
* `null` si absente. */
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
let cancelled = false;
if (!hasPhoto || !volunteerId) {
setUrl(null);
return;
}
api.volunteers.photoBlob(edition, volunteerId).then((blob) => {
if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [volunteerId, hasPhoto]);
return url;
}
export function useShifts(positionId?: string) {
return useQuery({
queryKey: queryKeys.shifts(positionId),
@@ -553,3 +580,50 @@ export function useGenerateBadges() {
mutationFn: (body: BadgeGenerateRequest) => api.badges.generate(edition, body),
});
}
// --------------------------------------------------------------------------- //
// Formulaire public d'inscription //
// --------------------------------------------------------------------------- //
/** Configuration du formulaire (COORDINATION). */
export function useFormTemplate() {
return useQuery({
queryKey: ['formTemplate', edition],
queryFn: () => api.forms.getTemplate(edition),
});
}
function useInvalidateFormTemplate() {
const client = useQueryClient();
return () => client.invalidateQueries({ queryKey: ['formTemplate', edition] });
}
export function useSetFormFields() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: (body: FormFieldsSet) => api.forms.setFields(edition, body),
onSuccess: invalidate,
});
}
export function useSetFormOpen() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: (body: FormTemplateUpdate) => api.forms.setOpen(edition, body),
onSuccess: invalidate,
});
}
/** Configuration publique du formulaire — utilisable sans compte connecté
* (écran `/inscription`, hors de `RequireAuth`). */
export function usePublicFormConfig() {
return useQuery({
queryKey: ['publicFormConfig', edition],
queryFn: () => api.forms.getPublicConfig(edition),
});
}
export function useSubmitPublicForm() {
return useMutation({
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body),
});
}
+37
View File
@@ -13,12 +13,15 @@ import type {
DayPartCreate,
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormTemplateUpdate,
ImportMapping,
LoginRequest,
MagicLinkRequest,
PlanningNotificationRequest,
PositionCreate,
PositionUpdate,
PublicFormSubmission,
ShiftCreate,
ShiftUpdate,
VerifyRequest,
@@ -144,6 +147,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
planning: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/planning`),
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
},
accounts: {
list: (editionId) => request(`${base(editionId)}/accounts`),
@@ -262,6 +266,39 @@ export function createHttpApi(baseUrl: string): PlanningApi {
return blob;
},
},
forms: {
getTemplate: (editionId) => request(`${base(editionId)}/form`),
setFields: (editionId, body: FormFieldsSet) =>
request(`${base(editionId)}/form/fields`, {
method: 'PUT',
body: JSON.stringify(body),
}),
setOpen: (editionId, body: FormTemplateUpdate) =>
request(`${base(editionId)}/form`, { method: 'PATCH', body: JSON.stringify(body) }),
getPublicConfig: (editionId) => request(`${base(editionId)}/form/public`),
submitPublic: (editionId, body: PublicFormSubmission) => {
const form = new FormData();
form.append('first_name', body.first_name);
form.append('last_name', body.last_name);
form.append('hcaptcha_token', body.hcaptcha_token);
if (body.email) form.append('email', body.email);
if (body.phone) form.append('phone', body.phone);
if (body.comment) form.append('comment', body.comment);
if (body.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note);
if (body.available !== undefined) form.append('available', String(body.available));
if (body.willing_to_pay_fee !== undefined) {
form.append('willing_to_pay_fee', String(body.willing_to_pay_fee));
}
if (body.position_wish_ids) {
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
}
if (body.artist_wish_ids) {
form.append('artist_wish_ids', JSON.stringify(body.artist_wish_ids));
}
if (body.photo) form.append('photo', body.photo);
return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form });
},
},
solver: {
run: (editionId) =>
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
+13
View File
@@ -8,6 +8,7 @@ import type {
BadgeFieldRead,
DayPartRead,
EditionRead,
FormFieldType,
PositionCategory,
PositionRead,
ShiftRead,
@@ -49,6 +50,12 @@ export interface MockState {
backImageType: string | null;
fields: BadgeFieldRead[];
};
formTemplate: {
isOpen: boolean;
fields: FormFieldType[];
};
/** volunteerId -> photo (formulaire public), data URL. */
photos: Map<string, string>;
seq: number;
}
@@ -258,6 +265,10 @@ export function buildMockState(): MockState {
artist_ids,
wished_position_ids,
wished_companion_ids,
has_photo: false,
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
created_at: NOW,
updated_at: NOW,
});
@@ -431,6 +442,8 @@ export function buildMockState(): MockState {
backImageType: null,
fields: [],
},
formTemplate: { isOpen: true, fields: [] },
photos: new Map(),
seq,
};
}
+127
View File
@@ -19,6 +19,10 @@ import type {
DayPartRead,
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormSubmissionResult,
FormTemplateRead,
FormTemplateUpdate,
ImportAnalyzeResponse,
ImportMapping,
ImportPreviewRow,
@@ -26,6 +30,8 @@ import type {
ImportRowReport,
MatrixResponse,
PlanningNotificationRequest,
PublicFormConfig,
PublicFormSubmission,
ShiftRead,
SolverRunResult,
SolverSuggestion,
@@ -102,6 +108,11 @@ const imageSizeMm = async (file: File): Promise<{ width_mm: number; height_mm: n
return { width_mm, height_mm };
};
// Approche de `str.title()` (Python) utilisé côté back-end à la soumission
// du formulaire public — capitalise chaque mot (« - » compris).
const titleCase = (s: string): string =>
s.toLowerCase().replace(/\b\p{L}/gu, (c) => c.toUpperCase());
const fileToDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -831,6 +842,10 @@ export function createMockApi(): PlanningApi {
'position.unknown',
),
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
has_photo: false,
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
created_at: now,
updated_at: now,
};
@@ -861,6 +876,15 @@ export function createMockApi(): PlanningApi {
...(body.wished_companion_ids != null
? { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids, v.id) }
: {}),
...('companion_wish_note' in body
? { companion_wish_note: body.companion_wish_note ?? null }
: {}),
...('self_declared_available' in body && body.self_declared_available != null
? { self_declared_available: body.self_declared_available }
: {}),
...('willing_to_pay_fee' in body && body.willing_to_pay_fee != null
? { willing_to_pay_fee: body.willing_to_pay_fee }
: {}),
updated_at: touch(),
});
return delay(v);
@@ -869,6 +893,7 @@ export function createMockApi(): PlanningApi {
state.volunteers = state.volunteers.filter((x) => x.id !== id);
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
state.availability.delete(id);
state.photos.delete(id);
// RGPD : effacement complet, y compris le compte de connexion éventuel
// et les vœux de compagnon dont il était la cible.
state.accounts = state.accounts.filter((a) => a.volunteer_id !== id);
@@ -877,6 +902,11 @@ export function createMockApi(): PlanningApi {
});
return delay(undefined);
},
photoBlob: async (_edition, id) => {
const dataUrl = state.photos.get(id);
if (!dataUrl) return null;
return (await fetch(dataUrl)).blob();
},
planning: (_edition, id) => {
if (!state.volunteers.some((v) => v.id === id)) return notFound();
const entries = state.assignments
@@ -1147,6 +1177,10 @@ export function createMockApi(): PlanningApi {
artist_ids: [],
wished_position_ids: [],
wished_companion_ids: [],
has_photo: false,
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
created_at: now,
updated_at: now,
};
@@ -1430,6 +1464,99 @@ export function createMockApi(): PlanningApi {
},
},
forms: {
getTemplate: (): Promise<FormTemplateRead> =>
delay({
edition_id: state.edition.id,
is_open: state.formTemplate.isOpen,
fields: state.formTemplate.fields,
}),
setFields: (_edition, body: FormFieldsSet) => {
state.formTemplate.fields = [...new Set(body.fields)];
return delay<FormTemplateRead>({
edition_id: state.edition.id,
is_open: state.formTemplate.isOpen,
fields: state.formTemplate.fields,
});
},
setOpen: (_edition, body: FormTemplateUpdate) => {
state.formTemplate.isOpen = body.is_open;
return delay<FormTemplateRead>({
edition_id: state.edition.id,
is_open: state.formTemplate.isOpen,
fields: state.formTemplate.fields,
});
},
getPublicConfig: (): Promise<PublicFormConfig> => {
const fields = state.formTemplate.fields;
return delay({
is_open: state.formTemplate.isOpen,
fields,
positions: fields.includes('POSITION_WISHES')
? state.positions.map((p) => ({ id: p.id, name: p.name }))
: [],
artists: fields.includes('ARTIST_WISHES')
? state.artists.map((a) => ({
id: a.id,
name: a.name,
performance_date: a.performance_date,
}))
: [],
});
},
submitPublic: async (_edition, body: PublicFormSubmission) => {
if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed');
if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed');
if (!body.first_name.trim() || !body.last_name.trim()) {
throw new ApiError(422, 'import.row.missing_required_field');
}
const fields = new Set(state.formTemplate.fields);
const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : [];
if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes');
const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : [];
const artistDates = artistIds.map(
(id) => state.artists.find((a) => a.id === id)?.performance_date,
);
if (new Set(artistDates).size !== artistDates.length) {
throw new ApiError(422, 'form.too_many_artists_same_day');
}
const now = touch();
const volunteer: VolunteerRead = {
id: `v${state.seq++}`,
edition_id: state.edition.id,
first_name: titleCase(body.first_name.trim()),
last_name: titleCase(body.last_name.trim()),
email: fields.has('EMAIL') ? body.email?.trim() || null : null,
phone: fields.has('PHONE') ? body.phone?.trim() || null : null,
comment: fields.has('COMMENT') ? body.comment?.trim() || null : null,
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
wished_companion_ids: [],
has_photo: false,
companion_wish_note: fields.has('COMPANION_WISH')
? body.companion_wish_note?.trim() || null
: null,
self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE')
? Boolean(body.willing_to_pay_fee)
: false,
created_at: now,
updated_at: now,
};
if (fields.has('PHOTO') && body.photo) {
volunteer.has_photo = true;
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
}
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
return delay<FormSubmissionResult>({ volunteer_id: volunteer.id });
},
},
solver: {
run: () => delay(runSolver()),
},
+78
View File
@@ -108,6 +108,11 @@ export interface VolunteerRead {
wished_position_ids: string[];
/** Vœux : bénévoles avec qui il voudrait être affecté (0 à N, à sens unique). */
wished_companion_ids: string[];
/** Renseignés par le formulaire public d'inscription (voir schema forms). */
has_photo: boolean;
companion_wish_note: string | null;
self_declared_available: boolean;
willing_to_pay_fee: boolean;
created_at: string;
updated_at: string;
}
@@ -133,6 +138,9 @@ export interface VolunteerUpdate {
artist_ids?: string[] | null;
wished_position_ids?: string[] | null;
wished_companion_ids?: string[] | null;
companion_wish_note?: string | null;
self_declared_available?: boolean | null;
willing_to_pay_fee?: boolean | null;
}
/**
@@ -532,3 +540,73 @@ export interface PlanningNotificationResult {
skipped_no_email: number;
recipients: string[];
}
// --------------------------------------------------------------------------- //
// Formulaire public d'inscription //
// --------------------------------------------------------------------------- //
// Nom/prénom toujours collectés, pas dans cette liste (voir app/api/forms.py).
export type FormFieldType =
| 'EMAIL'
| 'PHONE'
| 'POSITION_WISHES'
| 'ARTIST_WISHES'
| 'COMPANION_WISH'
| 'AVAILABLE'
| 'WILLING_TO_PAY_FEE'
| 'PHOTO'
| 'COMMENT';
export interface FormFieldsSet {
fields: FormFieldType[];
}
export interface FormTemplateRead {
edition_id: string;
is_open: boolean;
fields: FormFieldType[];
}
export interface FormTemplateUpdate {
is_open: boolean;
}
export interface PublicPositionOption {
id: string;
name: string;
}
export interface PublicArtistOption {
id: string;
name: string;
performance_date: string;
}
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
export interface PublicFormConfig {
is_open: boolean;
fields: FormFieldType[];
positions: PublicPositionOption[];
artists: PublicArtistOption[];
}
export interface FormSubmissionResult {
volunteer_id: string;
}
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
* possible). `position_wish_ids`/`artist_wish_ids` : JSON stringifié
* (`'["id1","id2"]'`), pas de tableau natif possible en multipart. */
export interface PublicFormSubmission {
first_name: string;
last_name: string;
hcaptcha_token: string;
email?: string;
phone?: string;
comment?: string;
companion_wish_note?: string;
available?: boolean;
willing_to_pay_fee?: boolean;
position_wish_ids?: string[];
artist_wish_ids?: string[];
photo?: File | null;
}
+17
View File
@@ -23,6 +23,10 @@ import type {
DayPartUpdate,
EditionRead,
EditionUpdate,
FormFieldsSet,
FormSubmissionResult,
FormTemplateRead,
FormTemplateUpdate,
GenerateShiftsResult,
ImportAnalyzeResponse,
ImportMapping,
@@ -35,6 +39,8 @@ import type {
PositionCreate,
PositionRead,
PositionUpdate,
PublicFormConfig,
PublicFormSubmission,
ShiftCreate,
ShiftRead,
ShiftUpdate,
@@ -77,6 +83,8 @@ export interface PlanningApi {
remove(editionId: string, id: string): Promise<void>;
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
/** Photo transmise via le formulaire public, `null` si absente. */
photoBlob(editionId: string, id: string): Promise<Blob | null>;
};
accounts: {
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
@@ -153,6 +161,15 @@ export interface PlanningApi {
/** Génère la planche PDF (recto personnalisé + verso fixe). */
generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>;
};
forms: {
/** Configuration (COORDINATION uniquement). */
getTemplate(editionId: string): Promise<FormTemplateRead>;
setFields(editionId: string, body: FormFieldsSet): Promise<FormTemplateRead>;
setOpen(editionId: string, body: FormTemplateUpdate): Promise<FormTemplateRead>;
/** Publics — aucune authentification, utilisables sans compte connecté. */
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
};
solver: {
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
run(editionId: string): Promise<SolverRunResult>;
+7
View File
@@ -6,14 +6,20 @@ import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
import { BadgesPage } from '@/pages/BadgesPage';
import { ConfigPage } from '@/pages/ConfigPage';
import { FormBuilderPage } from '@/pages/FormBuilderPage';
import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage';
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
import { PublicSignupPage } from '@/pages/PublicSignupPage';
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
export const routes: RouteObject[] = [
{ path: '/connexion', element: <ConnexionPage /> },
{ path: '/connexion/coordination', element: <AdminLoginPage /> },
// Formulaire public d'inscription : aucun compte requis, donc hors de
// <RequireAuth/> — voir app/api/forms.py (les seuls endpoints publics de
// toute l'API).
{ path: '/inscription', element: <PublicSignupPage /> },
{
element: <RequireAuth />,
children: [
@@ -28,6 +34,7 @@ export const routes: RouteObject[] = [
{ path: 'mobile-referent', element: <MobileLeadPage /> },
{ path: 'configuration', element: <ConfigPage /> },
{ path: 'badges', element: <BadgesPage /> },
{ path: 'inscriptions', element: <FormBuilderPage /> },
],
},
],
+2
View File
@@ -10,6 +10,7 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
'/benevoles': ['COORDINATOR'],
'/configuration': ['COORDINATOR'],
'/badges': ['COORDINATOR'],
'/inscriptions': ['COORDINATOR'],
'/mobile-benevole': ['VOLUNTEER'],
'/mobile-referent': ['LEAD'],
};
@@ -21,6 +22,7 @@ export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
{ to: '/badges', num: '07', labelKey: 'tab.badges' },
{ to: '/inscriptions', num: '08', labelKey: 'tab.forms' },
];
export function canAccess(pathname: string, role: Role): boolean {
+47 -1
View File
@@ -300,6 +300,8 @@
"config.vol.noCompanions": "Aucun bénévole choisi",
"config.vol.pickCompanion": "Ajouter un bénévole…",
"config.vol.searchCompanion": "Rechercher un bénévole…",
"config.vol.publicForm.title": "Formulaire public",
"config.vol.publicForm.hint": "Réponses saisies par le bénévole lui-même via le formulaire public d'inscription.",
"config.vol.colWishes": "VŒUX",
"config.vol.wishesCount": "{count} vœu(x)",
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
@@ -360,5 +362,49 @@
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
"badge.invalid_size": "La taille du badge doit être strictement positive.",
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection."
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"tab.forms": "Inscriptions",
"form.field.email": "Email",
"form.field.phone": "Téléphone",
"form.field.positionWishes": "Vœux de poste",
"form.field.positionWishes.help": "Deux vœux au maximum, pour forcer une vraie priorisation.",
"form.field.artistWishes": "Concert(s) à voir",
"form.field.artistWishes.help": "Un seul artiste souhaité par soir de festival.",
"form.field.companionWish": "Collègue apprécié",
"form.field.companionWish.help": "Texte libre : la personne visée n'est pas forcément déjà inscrite. À résoudre ensuite depuis sa fiche.",
"form.field.available": "Case « je suis disponible »",
"form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.",
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
"form.field.photo": "Photo",
"form.field.comment": "Commentaire",
"form.link.title": "Lien du formulaire",
"form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire.",
"form.link.copy": "Copier",
"form.link.copied": "Copié ✓",
"form.status.title": "Inscriptions",
"form.status.open": "OUVERTES",
"form.status.closed": "FERMÉES",
"form.fields.title": "Champs du formulaire",
"form.save": "Enregistrer",
"form.saving": "Enregistrement…",
"form.saved": "Enregistré ✓",
"publicForm.title": "Inscription bénévole",
"publicForm.positionWishes.count": "{count} / {max}",
"publicForm.artistWishes.none": "— Aucun —",
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
"publicForm.submitting": "Envoi…",
"publicForm.submit": "Envoyer mon inscription",
"publicForm.success.title": "Inscription reçue",
"publicForm.success.body": "Merci ! Ton inscription a bien été enregistrée. La coordination du festival prendra contact avec toi.",
"publicForm.closed.title": "Inscriptions fermées",
"publicForm.closed.body": "Les inscriptions bénévoles ne sont pas ouvertes pour le moment. Reviens un peu plus tard !",
"form.closed": "Les inscriptions sont actuellement fermées.",
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
"form.invalid_payload": "Données du formulaire invalides.",
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
"form.photo_too_large": "La photo est trop volumineuse.",
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival."
}
+3
View File
@@ -7,4 +7,7 @@ export const ENV = {
apiBaseUrl: raw.VITE_API_BASE_URL ?? '/api',
editionId: raw.VITE_EDITION_ID,
demoVolunteerId: raw.VITE_DEMO_VOLUNTEER_ID ?? '',
// Clé de site hCaptcha (publique, sans risque à exposer) — le formulaire
// public d'inscription (`/inscription`) ne s'affiche pas sans elle.
hcaptchaSiteKey: raw.VITE_HCAPTCHA_SITE_KEY ?? '',
};
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from 'react';
import { useFormTemplate, useSetFormFields, useSetFormOpen } from '@/api/hooks';
import type { FormFieldType } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state';
import { Btn, HelpTip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
// Nom/prénom toujours collectés (indispensables à la création du bénévole) —
// pas dans cette liste, voir CLAUDE.md § 2 (Volunteer) et app/api/forms.py.
const FIELD_TYPES: FormFieldType[] = [
'EMAIL',
'PHONE',
'POSITION_WISHES',
'ARTIST_WISHES',
'COMPANION_WISH',
'AVAILABLE',
'WILLING_TO_PAY_FEE',
'PHOTO',
'COMMENT',
];
const FIELD_LABEL_KEY: Record<FormFieldType, string> = {
EMAIL: 'form.field.email',
PHONE: 'form.field.phone',
POSITION_WISHES: 'form.field.positionWishes',
ARTIST_WISHES: 'form.field.artistWishes',
COMPANION_WISH: 'form.field.companionWish',
AVAILABLE: 'form.field.available',
WILLING_TO_PAY_FEE: 'form.field.willingToPayFee',
PHOTO: 'form.field.photo',
COMMENT: 'form.field.comment',
};
const FIELD_HELP_KEY: Record<FormFieldType, string | null> = {
EMAIL: null,
PHONE: null,
POSITION_WISHES: 'form.field.positionWishes.help',
ARTIST_WISHES: 'form.field.artistWishes.help',
COMPANION_WISH: 'form.field.companionWish.help',
AVAILABLE: 'form.field.available.help',
WILLING_TO_PAY_FEE: null,
PHOTO: null,
COMMENT: null,
};
export function FormBuilderPage() {
const template = useFormTemplate();
const setFields = useSetFormFields();
const setOpen = useSetFormOpen();
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (template.data) setEnabled(new Set(template.data.fields));
}, [template.data]);
if (template.isLoading || !enabled) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
const toggle = (type: FormFieldType) => {
setEnabled((prev) => {
if (!prev) return prev;
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
};
const link = `${window.location.origin}/inscription`;
const copyLink = () => {
navigator.clipboard.writeText(link).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
<Panel className="max-w-[420px] flex-1">
<PanelHeader>{t('form.link.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3">
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('form.link.hint')}
</p>
<div className="flex items-center gap-2">
<input
readOnly
value={link}
onFocus={(e) => e.target.select()}
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
/>
<Btn variant="ghost" onClick={copyLink}>
{copied ? t('form.link.copied') : t('form.link.copy')}
</Btn>
</div>
<div className="mt-2 flex items-center gap-3 border-t-2 border-ink pt-3">
<span className="font-display text-[11px] font-extrabold uppercase tracking-wide">
{t('form.status.title')}
</span>
<button
type="button"
onClick={() => setOpen.mutate({ is_open: !template.data?.is_open })}
disabled={setOpen.isPending}
className={cx(
'cursor-pointer border-2 border-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wide',
template.data?.is_open ? 'bg-green text-white' : 'bg-orange text-ink',
)}
>
{template.data?.is_open ? t('form.status.open') : t('form.status.closed')}
</button>
</div>
</div>
</Panel>
<Panel className="min-w-[320px] flex-1">
<PanelHeader>{t('form.fields.title')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => (
<label
key={type}
className="flex cursor-pointer items-center gap-[10px] p-3 hover:bg-yellow-soft"
>
<input
type="checkbox"
checked={enabled.has(type)}
onChange={() => toggle(type)}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
{t(FIELD_LABEL_KEY[type])}
</span>
{FIELD_HELP_KEY[type] ? <HelpTip text={t(FIELD_HELP_KEY[type]!)} /> : null}
</label>
))}
</div>
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
<Btn
onClick={() => setFields.mutate({ fields: [...enabled] })}
disabled={setFields.isPending}
>
{setFields.isPending ? t('form.saving') : t('form.save')}
</Btn>
{setFields.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
) : null}
</div>
</Panel>
</div>
</div>
);
}
+335
View File
@@ -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>
);
}
@@ -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