From 957a004623950a4364bf60627275d49837df8a6b Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 23 Sep 2026 20:46:52 +0200 Subject: [PATCH] Formulaire public : logo et texte d'introduction personnalisables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 30 ++++-- backend/app/api/forms.py | 65 ++++++++++++- backend/app/api/schemas.py | 10 +- backend/app/i18n/locales/fr.json | 3 + .../app/migrations/versions/0014_form_logo.py | 34 +++++++ backend/app/models/domain.py | 9 +- backend/tests/test_forms_api.py | 65 +++++++++++++ frontend/scripts/smoke.tsx | 12 +++ frontend/src/api/hooks.ts | 38 +++++++- frontend/src/api/http-client.ts | 6 ++ frontend/src/api/mock/dataset.ts | 5 +- frontend/src/api/mock/mock-client.ts | 41 ++++---- frontend/src/api/schema.ts | 8 +- frontend/src/api/types.ts | 5 +- frontend/src/i18n/locales/fr.json | 10 ++ frontend/src/pages/FormBuilderPage.tsx | 96 +++++++++++++++++-- frontend/src/pages/PublicSignupPage.tsx | 22 ++++- 17 files changed, 415 insertions(+), 44 deletions(-) create mode 100644 backend/app/migrations/versions/0014_form_logo.py diff --git a/CLAUDE.md b/CLAUDE.md index df8795c..0e828a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `, 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 + diff --git a/backend/app/api/forms.py b/backend/app/api/forms.py index 8da8dbc..140fa52 100644 --- a/backend/app/api/forms.py +++ b/backend/app/api/forms.py @@ -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, ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index f1750b8..bce6aa0 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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): diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 1a3939c..045fb0b 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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.", diff --git a/backend/app/migrations/versions/0014_form_logo.py b/backend/app/migrations/versions/0014_form_logo.py new file mode 100644 index 0000000..fc6d959 --- /dev/null +++ b/backend/app/migrations/versions/0014_form_logo.py @@ -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") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 9de7d36..790d7c5 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -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): diff --git a/backend/tests/test_forms_api.py b/backend/tests/test_forms_api.py index daaac05..4ee630e 100644 --- a/backend/tests/test_forms_api.py +++ b/backend/tests/test_forms_api.py @@ -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 ): diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index 5b08feb..eed4dfa 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -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}`, diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 26797ba..9796a93 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -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(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() { diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 59de2a5..0b604c5 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -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); diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index eea7bcc..de5d832 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -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; @@ -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, }; diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 69cb177..65b00b9 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -122,6 +122,14 @@ const fileToDataUrl = (file: File): Promise => 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 => - delay({ - edition_id: state.edition.id, - is_open: state.formTemplate.isOpen, - fields: state.formTemplate.fields, - }), + getTemplate: (): Promise => delay(formTemplateRead()), setFields: (_edition, body: FormFieldsSet) => { state.formTemplate.fields = [...new Set(body.fields)]; - return delay({ - edition_id: state.edition.id, - is_open: state.formTemplate.isOpen, - fields: state.formTemplate.fields, - }); + return delay(formTemplateRead()); }, setOpen: (_edition, body: FormTemplateUpdate) => { - state.formTemplate.isOpen = body.is_open; - return delay({ - 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()); + }, + uploadLogo: async (_edition, file: File) => { + state.formTemplate.logo = await fileToDataUrl(file); + state.formTemplate.logoType = file.type; + return delay(formTemplateRead()); + }, + logoBlob: async () => { + if (!state.formTemplate.logo) return null; + return (await fetch(state.formTemplate.logo)).blob(); }, getPublicConfig: (): Promise => { 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) => { diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index bcc72d5..5d9be91 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -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 { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index fd6f62b..34d9026 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -167,9 +167,12 @@ export interface PlanningApi { getTemplate(editionId: string): Promise; setFields(editionId: string, body: FormFieldsSet): Promise; setOpen(editionId: string, body: FormTemplateUpdate): Promise; - /** Publics — aucune authentification, utilisables sans compte connecté. */ + uploadLogo(editionId: string, file: File): Promise; + /** Publics — aucune authentification, utilisables sans compte connecté + * (y compris `logoBlob`, affiché sur `/inscription`). */ getPublicConfig(editionId: string): Promise; submitPublic(editionId: string, body: PublicFormSubmission): Promise; + logoBlob(editionId: string): Promise; }; positionSheets: { /** Feuilles de poste PDF (une page par poste et par jour). */ diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 9b48520..3a0b009 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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.", diff --git a/frontend/src/pages/FormBuilderPage.tsx b/frontend/src/pages/FormBuilderPage.tsx index 7001b76..8d84ab8 100644 --- a/frontend/src/pages/FormBuilderPage.tsx +++ b/frontend/src/pages/FormBuilderPage.tsx @@ -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 = { 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(null); const [enabled, setEnabled] = useState | 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 ; if (template.error) return ; @@ -105,8 +127,8 @@ export function FormBuilderPage() {