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
+20 -10
View File
@@ -200,7 +200,13 @@ les tables sauf les tables d'association.
affectation n'apparaît pas (rien à imprimer).
- **`FormTemplate`** : configuration du formulaire public d'inscription
d'une édition (une ligne, créée à la demande). `is_open` coupe les
nouvelles soumissions sans effacer la configuration.
nouvelles soumissions sans effacer la configuration. `logo`/
`logo_content_type` (image, `PUT .../form/logo`, stockée en base comme les
autres images de l'appli) et `description` (texte libre) habillent la page
publique — aucun rapport avec les champs collectés (`FormField`
ci-dessous) : affichés au-dessus/dans le formulaire, jamais soumis par le
bénévole. `PATCH /editions/{id}/form` prend `is_open`/`description`,
chacun optionnel (absent = inchangé).
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
@@ -212,13 +218,16 @@ les tables sauf les tables d'association.
souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les
**deux seuls endpoints de toute l'API sans authentification** — protégés
par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
désactivée si absente, comme `EMAIL_ENABLED=false`) et un filet de
sécurité global par édition (`app/auth/rate_limit.py:form_submission_limiter`,
hCaptcha reste la défense principale). Le bénévole créé par une soumission
apparaît immédiatement dans la liste de la coordination, comme n'importe
quel autre bénévole.
**seuls endpoints de toute l'API sans authentification qui écrivent ou
révèlent des données de l'édition** — protégés par hCaptcha
(`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification désactivée si
absente, comme `EMAIL_ENABLED=false`) et un filet de sécurité global par
édition (`app/auth/rate_limit.py:form_submission_limiter`, hCaptcha reste
la défense principale). `GET .../form/logo` est également public (affiché
sur la page), mais ne sert qu'une image choisie par la coordination elle-
même — aucune saisie ni donnée nominative, pas de protection hCaptcha
nécessaire. Le bénévole créé par une soumission apparaît immédiatement
dans la liste de la coordination, comme n'importe quel autre bénévole.
Conventions techniques :
@@ -257,8 +266,8 @@ Matrice appliquée par les endpoints :
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
| `badges` (config + génération) | ✔ | — | — |
| `position-sheets` (génération) | ✔ | — | — |
| `form` (config) | ✔ | — | — |
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
| `form` (config, dont logo) | ✔ | — | — |
| `form/public`, `form/public/submit`, `form/logo` | *(aucune authentification — voir § 2, `FormTemplate`/`FormField`)* |||
**Authentification — deux systèmes selon le compte**, une même session JWT
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
@@ -641,6 +650,7 @@ Aucune dépendance locale requise hors Docker.
| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider |
| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider |
| 24 | Feuilles de poste : photo des bénévoles affectés à côté de leur nom (vignette 14 mm, case vide si absente) | à valider |
| 25 | Formulaire public : photo recadrée en carré côté navigateur avant l'envoi, aperçu agrandi ; personnalisation de la page (logo, texte d'introduction) depuis l'écran de configuration (`/inscriptions`) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+61 -4
View File
@@ -28,6 +28,7 @@ from fastapi import (
File,
Form,
HTTPException,
Response,
UploadFile,
status,
)
@@ -96,7 +97,11 @@ def _enabled_fields(session: Session, edition_id: uuid.UUID) -> list[FormFieldTy
def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead:
return FormTemplateRead(
edition_id=template.edition_id, is_open=template.is_open, fields=fields
edition_id=template.edition_id,
is_open=template.is_open,
fields=fields,
has_logo=template.logo is not None,
description=template.description,
)
@@ -126,19 +131,66 @@ def set_fields(
@router.patch("", response_model=FormTemplateRead)
def set_open(
def update_form(
payload: FormTemplateUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormTemplateRead:
template = _get_or_create_template(session, edition.id)
template.is_open = payload.is_open
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(template, field, value)
session.add(template)
session.commit()
return _template_read(template, _enabled_fields(session, edition.id))
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
content_type = file.content_type or ""
if content_type not in _ALLOWED_PHOTO_TYPES:
raise HTTPException(
status_code=422, detail={"code": "form.logo_type_not_supported"}
)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail={"code": "form.logo_empty"})
if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"})
return content, content_type
@router.put("/logo", response_model=FormTemplateRead)
async def set_logo(
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormTemplateRead:
template = _get_or_create_template(session, edition.id)
content, content_type = await _read_logo(file)
template.logo = content
template.logo_content_type = content_type
session.add(template)
session.commit()
return _template_read(template, _enabled_fields(session, edition.id))
@router.get("/logo")
def get_logo(
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Response:
"""Public (aucune authentification) : affiché sur `/inscription`, comme
`GET .../form/public` — voir le commentaire du module."""
template = _get_or_create_template(session, edition.id)
if template.logo is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
content=template.logo,
media_type=template.logo_content_type or "application/octet-stream",
)
# --------------------------------------------------------------------------- #
# Formulaire public (aucune authentification) #
# --------------------------------------------------------------------------- #
@@ -175,7 +227,12 @@ def get_public_form(
]
return PublicFormConfig(
is_open=template.is_open, fields=fields, positions=positions, artists=artists
is_open=template.is_open,
fields=fields,
positions=positions,
artists=artists,
has_logo=template.logo is not None,
description=template.description,
)
+9 -1
View File
@@ -491,10 +491,16 @@ class FormTemplateRead(ApiModel):
edition_id: uuid.UUID
is_open: bool
fields: list[FormFieldType]
has_logo: bool
description: str | None
class FormTemplateUpdate(ApiModel):
is_open: bool
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet
de changer `is_open` sans toucher `description`, et inversement."""
is_open: bool | None = None
description: str | None = None
class PublicPositionOption(ApiModel):
@@ -517,6 +523,8 @@ class PublicFormConfig(ApiModel):
fields: list[FormFieldType]
positions: list[PublicPositionOption]
artists: list[PublicArtistOption]
has_logo: bool
description: str | None
class FormSubmissionResult(ApiModel):
+3
View File
@@ -79,6 +79,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.",
@@ -0,0 +1,34 @@
"""Logo et description du formulaire public
Revision ID: 0014_form_logo
Revises: 0013_signup_form
Create Date: 2026-09-23
Habillage de la page publique (`/inscription`, `FormTemplate`) : `logo`/
`logo_content_type` (image, stockée en base comme les autres images de
l'appli) et `description` (texte libre). Voir `app/api/forms.py`.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0014_form_logo"
down_revision: str | None = "0013_signup_form"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("form_template", sa.Column("logo", sa.LargeBinary(), nullable=True))
op.add_column(
"form_template", sa.Column("logo_content_type", sa.String(), nullable=True)
)
op.add_column("form_template", sa.Column("description", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("form_template", "description")
op.drop_column("form_template", "logo_content_type")
op.drop_column("form_template", "logo")
+8 -1
View File
@@ -466,7 +466,11 @@ class BadgeField(TimestampMixin, table=True):
class FormTemplate(TimestampMixin, table=True):
"""Configuration du formulaire public d'une édition. Une ligne par
édition (créée à la demande). `is_open` coupe les nouvelles soumissions
sans effacer la configuration (les champs activés restent enregistrés)."""
sans effacer la configuration (les champs activés restent enregistrés).
`logo`/`logo_content_type` et `description` habillent la page publique
(`/inscription`) — aucun rapport avec les champs collectés par
`FormField` ; stockage en base comme les autres images de l'appli
(badges, photo de bénévole), pas de volume de fichiers à gérer."""
__tablename__ = "form_template"
@@ -478,6 +482,9 @@ class FormTemplate(TimestampMixin, table=True):
default=True,
sa_column=Column(Boolean, nullable=False, server_default="true"),
)
logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
logo_content_type: str | None = None
description: str | None = None
class FormField(SQLModel, table=True):
+65
View File
@@ -59,6 +59,71 @@ def test_closing_the_form(client, edition):
assert resp.json()["is_open"] is False
def test_setting_description_does_not_touch_is_open(client, edition):
client.patch(_form_url(edition), json={"is_open": False})
resp = client.patch(_form_url(edition), json={"description": "Bienvenue !"})
assert resp.status_code == 200
assert resp.json()["description"] == "Bienvenue !"
assert resp.json()["is_open"] is False # inchangé, absent du corps
def test_clearing_description_with_an_explicit_null(client, edition):
client.patch(_form_url(edition), json={"description": "Bienvenue !"})
resp = client.patch(_form_url(edition), json={"description": None})
assert resp.status_code == 200
assert resp.json()["description"] is None
def test_logo_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
lead_client = as_account(lead)
resp = lead_client.put(
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
)
assert resp.status_code == 403
def test_no_logo_by_default(client, raw_client, edition):
assert client.get(_form_url(edition)).json()["has_logo"] is False
resp = raw_client.get(_form_url(edition, "/logo"))
assert resp.status_code == 404
def test_uploading_a_logo_and_reading_it_back_publicly(client, raw_client, edition):
resp = client.put(
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
)
assert resp.status_code == 200
assert resp.json()["has_logo"] is True
assert client.get(_form_url(edition)).json()["has_logo"] is True
logo = raw_client.get(_form_url(edition, "/logo"))
assert logo.status_code == 200
assert logo.headers["content-type"] == "image/png"
assert logo.content == _PNG
def test_uploading_an_unsupported_logo_type_is_rejected(client, edition):
resp = client.put(
_form_url(edition, "/logo"),
files={"file": ("logo.gif", b"not-a-real-gif", "image/gif")},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.logo_type_not_supported"
def test_public_config_reports_the_logo_and_description(client, raw_client, edition):
client.patch(_form_url(edition), json={"description": "Bienvenue au festival !"})
client.put(
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
)
body = raw_client.get(_form_url(edition, "/public")).json()
assert body["description"] == "Bienvenue au festival !"
assert body["has_logo"] is True
def test_public_form_hides_positions_and_artists_unless_enabled(
client, raw_client, edition, session
):
+12
View File
@@ -99,6 +99,17 @@ async function checkPublicForm() {
if (config.positions.length !== positions.length) {
throw new Error('formulaire public : postes manquants dans la config publique');
}
if (config.has_logo !== false || config.description !== null) {
throw new Error('formulaire public : logo/description devraient être vides par défaut');
}
// Le recadrage carré (canvas) et l'upload du logo (FileReader) sont des
// API navigateur — non testables sous Node (voir scripts/smoke.tsx tel
// qu'exécuté ici) ; seul le texte d'introduction est vérifiable ici.
await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' });
const configWithDescription = await api.forms.getPublicConfig(e);
if (configWithDescription.description !== 'Bienvenue au festival de test !') {
throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
}
const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date);
let tooManyArtistsRejectedWith = 'non';
@@ -145,6 +156,7 @@ async function checkPublicForm() {
JSON.stringify(
{
formOpenByDefault: config.is_open,
descriptionRoundTrips: configWithDescription.description,
tooManyPositionsRejectedWith,
tooManyArtistsRejectedWith,
createdVolunteerName: `${created.first_name} ${created.last_name}`,
+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">