Formulaire public : logo et texte d'introduction personnalisables

FormTemplate porte désormais un logo (image, PUT/GET .../form/logo — GET
public comme .../form/public, affiché sur /inscription) et un texte
d'introduction libre (PATCH .../form, description désormais optionnelle
comme is_open). Écran de configuration (/inscriptions) : nouveau panneau
« Personnalisation » pour importer le logo et rédiger le texte. Le
formulaire public affiche le logo en grand au-dessus du panneau et le
texte juste sous le titre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-23 20:46:52 +02:00
co-authored by Claude Sonnet 5
parent 48d3deec65
commit 957a004623
17 changed files with 415 additions and 44 deletions
+37 -1
View File
@@ -615,7 +615,9 @@ export function useSetFormFields() {
});
}
export function useSetFormOpen() {
/** `PATCH /form` : `is_open` et/ou `description`, chacun optionnel
* (absent = inchangé — voir `FormTemplateUpdate`). */
export function useUpdateFormTemplate() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: (body: FormTemplateUpdate) => api.forms.setOpen(edition, body),
@@ -623,6 +625,40 @@ export function useSetFormOpen() {
});
}
export function useUploadFormLogo() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: (file: File) => api.forms.uploadLogo(edition, file),
onSuccess: invalidate,
});
}
/** URL objet (aperçu) du logo du formulaire public, ou `null` si absent.
* Utilisable sans compte connecté (`GET .../form/logo` n'est pas protégé —
* affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de
* `useFormTemplate`) force un nouveau fetch après un changement de logo. */
export function useFormLogoUrl(hasLogo: boolean, refreshKey: number | string) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
let cancelled = false;
if (!hasLogo) {
setUrl(null);
return;
}
api.forms.logoBlob(edition).then((blob) => {
if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [hasLogo, refreshKey]);
return url;
}
/** Configuration publique du formulaire — utilisable sans compte connecté
* (écran `/inscription`, hors de `RequireAuth`). */
export function usePublicFormConfig() {
+6
View File
@@ -276,7 +276,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
}),
setOpen: (editionId, body: FormTemplateUpdate) =>
request(`${base(editionId)}/form`, { method: 'PATCH', body: JSON.stringify(body) }),
uploadLogo: (editionId, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/form/logo`, { method: 'PUT', body: form });
},
getPublicConfig: (editionId) => request(`${base(editionId)}/form/public`),
logoBlob: (editionId) => requestBlob(`${base(editionId)}/form/logo`),
submitPublic: (editionId, body: PublicFormSubmission) => {
const form = new FormData();
form.append('first_name', body.first_name);
+4 -1
View File
@@ -53,6 +53,9 @@ export interface MockState {
formTemplate: {
isOpen: boolean;
fields: FormFieldType[];
logo: string | null; // data URL
logoType: string | null;
description: string | null;
};
/** volunteerId -> photo (formulaire public), data URL. */
photos: Map<string, string>;
@@ -442,7 +445,7 @@ export function buildMockState(): MockState {
backImageType: null,
fields: [],
},
formTemplate: { isOpen: true, fields: [] },
formTemplate: { isOpen: true, fields: [], logo: null, logoType: null, description: null },
photos: new Map(),
seq,
};
+24 -17
View File
@@ -122,6 +122,14 @@ const fileToDataUrl = (file: File): Promise<string> =>
reader.readAsDataURL(file);
});
const formTemplateRead = (): FormTemplateRead => ({
edition_id: state.edition.id,
is_open: state.formTemplate.isOpen,
fields: state.formTemplate.fields,
has_logo: state.formTemplate.logo !== null,
description: state.formTemplate.description,
});
// PDF valide minimal (une page blanche) — remplace la vraie génération
// (réservée au back-end réel) pour que le téléchargement fonctionne en mode
// démonstration.
@@ -1467,27 +1475,24 @@ export function createMockApi(): PlanningApi {
},
forms: {
getTemplate: (): Promise<FormTemplateRead> =>
delay({
edition_id: state.edition.id,
is_open: state.formTemplate.isOpen,
fields: state.formTemplate.fields,
}),
getTemplate: (): Promise<FormTemplateRead> => delay(formTemplateRead()),
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,
});
return delay<FormTemplateRead>(formTemplateRead());
},
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,
});
if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open;
if (body.description !== undefined) state.formTemplate.description = body.description;
return delay<FormTemplateRead>(formTemplateRead());
},
uploadLogo: async (_edition, file: File) => {
state.formTemplate.logo = await fileToDataUrl(file);
state.formTemplate.logoType = file.type;
return delay<FormTemplateRead>(formTemplateRead());
},
logoBlob: async () => {
if (!state.formTemplate.logo) return null;
return (await fetch(state.formTemplate.logo)).blob();
},
getPublicConfig: (): Promise<PublicFormConfig> => {
const fields = state.formTemplate.fields;
@@ -1504,6 +1509,8 @@ export function createMockApi(): PlanningApi {
performance_date: a.performance_date,
}))
: [],
has_logo: state.formTemplate.logo !== null,
description: state.formTemplate.description,
});
},
submitPublic: async (_edition, body: PublicFormSubmission) => {
+7 -1
View File
@@ -571,10 +571,14 @@ export interface FormTemplateRead {
edition_id: string;
is_open: boolean;
fields: FormFieldType[];
has_logo: boolean;
description: string | null;
}
/** `undefined`/absent = inchangé pour chaque champ. */
export interface FormTemplateUpdate {
is_open: boolean;
is_open?: boolean;
description?: string | null;
}
export interface PublicPositionOption {
@@ -594,6 +598,8 @@ export interface PublicFormConfig {
fields: FormFieldType[];
positions: PublicPositionOption[];
artists: PublicArtistOption[];
has_logo: boolean;
description: string | null;
}
export interface FormSubmissionResult {
+4 -1
View File
@@ -167,9 +167,12 @@ export interface PlanningApi {
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é. */
uploadLogo(editionId: string, file: File): Promise<FormTemplateRead>;
/** Publics — aucune authentification, utilisables sans compte connecté
* (y compris `logoBlob`, affiché sur `/inscription`). */
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
logoBlob(editionId: string): Promise<Blob | null>;
};
positionSheets: {
/** Feuilles de poste PDF (une page par poste et par jour). */
+10
View File
@@ -394,6 +394,13 @@
"form.save": "Enregistrer",
"form.saving": "Enregistrement…",
"form.saved": "Enregistré ✓",
"form.customize.title": "Personnalisation",
"form.customize.logo": "Logo",
"form.customize.logo.choose": "Choisir une image",
"form.customize.logo.replace": "Remplacer",
"form.customize.logo.uploading": "Envoi…",
"form.customize.description": "Texte d'introduction",
"form.customize.description.placeholder": "Un mot de bienvenue affiché en haut du formulaire (facultatif)…",
"publicForm.title": "Inscription bénévole",
"publicForm.positionWishes.count": "{count} / {max}",
@@ -413,6 +420,9 @@
"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.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
"form.logo_empty": "Le logo est vide.",
"form.logo_too_large": "Le logo est trop volumineux.",
"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.",
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
+90 -6
View File
@@ -1,12 +1,24 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useFormTemplate, useSetFormFields, useSetFormOpen } from '@/api/hooks';
import { ApiError } from '@/api';
import {
useFormLogoUrl,
useFormTemplate,
useSetFormFields,
useUpdateFormTemplate,
useUploadFormLogo,
} from '@/api/hooks';
import type { FormFieldType } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state';
import { Btn, HelpTip, Panel, PanelHeader } from '@/components/ui';
import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
function errorMessage(error: unknown): string {
if (error instanceof ApiError) return t(error.code);
return t('error.generic');
}
// 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[] = [
@@ -48,14 +60,24 @@ const FIELD_HELP_KEY: Record<FormFieldType, string | null> = {
export function FormBuilderPage() {
const template = useFormTemplate();
const setFields = useSetFormFields();
const setOpen = useSetFormOpen();
// Deux instances indépendantes du même mutateur (même endpoint PATCH) :
// le statut (pending/success) de l'une ne doit pas déteindre sur l'autre.
const toggleOpen = useUpdateFormTemplate();
const saveDescription = useUpdateFormTemplate();
const uploadLogo = useUploadFormLogo();
const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt);
const logoInputRef = useRef<HTMLInputElement>(null);
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
const [copied, setCopied] = useState(false);
const [description, setDescription] = useState('');
useEffect(() => {
if (template.data) setEnabled(new Set(template.data.fields));
}, [template.data]);
useEffect(() => {
if (template.data) setDescription(template.data.description ?? '');
}, [template.data]);
if (template.isLoading || !enabled) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
@@ -105,8 +127,8 @@ export function FormBuilderPage() {
</span>
<button
type="button"
onClick={() => setOpen.mutate({ is_open: !template.data?.is_open })}
disabled={setOpen.isPending}
onClick={() => toggleOpen.mutate({ is_open: !template.data?.is_open })}
disabled={toggleOpen.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',
@@ -152,6 +174,68 @@ export function FormBuilderPage() {
</div>
</Panel>
</div>
<Panel className="mt-4 max-w-[720px]">
<PanelHeader>{t('form.customize.title')}</PanelHeader>
<div className="flex flex-col gap-4 p-3 sm:flex-row">
<div className="flex flex-none flex-col items-start gap-2">
<FieldLabel>{t('form.customize.logo')}</FieldLabel>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[96px] w-[96px] border-2 border-ink object-contain bg-cream"
/>
) : null}
<Btn variant="ghost" onClick={() => logoInputRef.current?.click()} disabled={uploadLogo.isPending}>
{uploadLogo.isPending
? t('form.customize.logo.uploading')
: logoUrl
? t('form.customize.logo.replace')
: t('form.customize.logo.choose')}
</Btn>
<input
ref={logoInputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) uploadLogo.mutate(file);
e.target.value = '';
}}
/>
{uploadLogo.isError ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
{errorMessage(uploadLogo.error)}
</span>
) : null}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<FieldLabel>{t('form.customize.description')}</FieldLabel>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
placeholder={t('form.customize.description.placeholder')}
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
/>
<div className="flex items-center gap-3">
<Btn
variant="ghost"
onClick={() => saveDescription.mutate({ description: description.trim() || null })}
disabled={saveDescription.isPending}
>
{saveDescription.isPending ? t('form.saving') : t('form.save')}
</Btn>
{saveDescription.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
) : null}
</div>
</div>
</div>
</Panel>
</div>
);
}
+21 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ApiError } from '@/api';
import { usePublicFormConfig, useSubmitPublicForm } from '@/api/hooks';
import { useFormLogoUrl, 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';
@@ -114,9 +114,21 @@ function groupArtistsByDay(artists: PublicArtistOption[]): Array<[string, Public
return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b));
}
function FormLogo({ url }: { url: string | null }) {
if (!url) return null;
return (
<img
src={url}
alt=""
className="mx-auto h-[120px] w-[120px] border-2 border-ink bg-cream object-contain shadow-brutal-sm"
/>
);
}
export function PublicSignupPage() {
const config = usePublicFormConfig();
const submit = useSubmitPublicForm();
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
@@ -165,6 +177,7 @@ export function PublicSignupPage() {
if (volunteerId) {
return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
<FormLogo url={logoUrl} />
<Panel>
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
@@ -176,6 +189,7 @@ export function PublicSignupPage() {
if (!config.data.is_open) {
return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
<FormLogo url={logoUrl} />
<Panel>
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.closed.body')}</p>
@@ -236,8 +250,14 @@ export function PublicSignupPage() {
return (
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4">
<FormLogo url={logoUrl} />
<Panel>
<PanelHeader>{t('publicForm.title')}</PanelHeader>
{config.data.description ? (
<p className="whitespace-pre-line border-b-2 border-ink bg-cream p-4 font-mono text-[12px] leading-relaxed">
{config.data.description}
</p>
) : null}
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
<div className="grid grid-cols-2 gap-3">
<label className="block">