Files
benevoles/frontend/src/pages/PublicSignupPage.tsx
T
AdamThomasandClaude Sonnet 5 2a2a0c23ca Formulaire public : phrases des cases à cocher plus chaleureuses
« Case « je suis disponible »" / « Case « je veux bien payer une
cotisation »" étaient en fait le libellé de configuration (écran
coordination) réutilisé tel quel comme texte de la case à cocher côté
formulaire public — d'où le ton administratif. Clés dédiées
(publicForm.field.*) avec une formulation à la première personne, plus
adaptée à quelqu'un qui remplit le formulaire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:16:49 +02:00

336 lines
12 KiB
TypeScript

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('publicForm.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('publicForm.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>
);
}