« 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>
336 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|