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:
co-authored by
Claude Sonnet 5
parent
48d3deec65
commit
957a004623
@@ -200,7 +200,13 @@ les tables sauf les tables d'association.
|
|||||||
affectation n'apparaît pas (rien à imprimer).
|
affectation n'apparaît pas (rien à imprimer).
|
||||||
- **`FormTemplate`** : configuration du formulaire public d'inscription
|
- **`FormTemplate`** : configuration du formulaire public d'inscription
|
||||||
d'une édition (une ligne, créée à la demande). `is_open` coupe les
|
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` ∈
|
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
|
||||||
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
|
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
|
||||||
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
|
/ `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`).
|
souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
|
||||||
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
|
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
|
||||||
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les
|
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les
|
||||||
**deux seuls endpoints de toute l'API sans authentification** — protégés
|
**seuls endpoints de toute l'API sans authentification qui écrivent ou
|
||||||
par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
|
révèlent des données de l'édition** — protégés par hCaptcha
|
||||||
désactivée si absente, comme `EMAIL_ENABLED=false`) et un filet de
|
(`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification désactivée si
|
||||||
sécurité global par édition (`app/auth/rate_limit.py:form_submission_limiter`,
|
absente, comme `EMAIL_ENABLED=false`) et un filet de sécurité global par
|
||||||
hCaptcha reste la défense principale). Le bénévole créé par une soumission
|
édition (`app/auth/rate_limit.py:form_submission_limiter`, hCaptcha reste
|
||||||
apparaît immédiatement dans la liste de la coordination, comme n'importe
|
la défense principale). `GET .../form/logo` est également public (affiché
|
||||||
quel autre bénévole.
|
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 :
|
Conventions techniques :
|
||||||
|
|
||||||
@@ -257,8 +266,8 @@ Matrice appliquée par les endpoints :
|
|||||||
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
||||||
| `badges` (config + génération) | ✔ | — | — |
|
| `badges` (config + génération) | ✔ | — | — |
|
||||||
| `position-sheets` (génération) | ✔ | — | — |
|
| `position-sheets` (génération) | ✔ | — | — |
|
||||||
| `form` (config) | ✔ | — | — |
|
| `form` (config, dont logo) | ✔ | — | — |
|
||||||
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
|
| `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
|
**Authentification — deux systèmes selon le compte**, une même session JWT
|
||||||
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
|
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 |
|
| 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 |
|
| 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 |
|
| 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
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from fastapi import (
|
|||||||
File,
|
File,
|
||||||
Form,
|
Form,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
|
Response,
|
||||||
UploadFile,
|
UploadFile,
|
||||||
status,
|
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:
|
def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead:
|
||||||
return 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)
|
@router.patch("", response_model=FormTemplateRead)
|
||||||
def set_open(
|
def update_form(
|
||||||
payload: FormTemplateUpdate,
|
payload: FormTemplateUpdate,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
_guard: Account = Depends(require_coordinator),
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> FormTemplateRead:
|
) -> FormTemplateRead:
|
||||||
template = _get_or_create_template(session, edition.id)
|
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.add(template)
|
||||||
session.commit()
|
session.commit()
|
||||||
return _template_read(template, _enabled_fields(session, edition.id))
|
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) #
|
# Formulaire public (aucune authentification) #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -175,7 +227,12 @@ def get_public_form(
|
|||||||
]
|
]
|
||||||
|
|
||||||
return PublicFormConfig(
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -491,10 +491,16 @@ class FormTemplateRead(ApiModel):
|
|||||||
edition_id: uuid.UUID
|
edition_id: uuid.UUID
|
||||||
is_open: bool
|
is_open: bool
|
||||||
fields: list[FormFieldType]
|
fields: list[FormFieldType]
|
||||||
|
has_logo: bool
|
||||||
|
description: str | None
|
||||||
|
|
||||||
|
|
||||||
class FormTemplateUpdate(ApiModel):
|
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):
|
class PublicPositionOption(ApiModel):
|
||||||
@@ -517,6 +523,8 @@ class PublicFormConfig(ApiModel):
|
|||||||
fields: list[FormFieldType]
|
fields: list[FormFieldType]
|
||||||
positions: list[PublicPositionOption]
|
positions: list[PublicPositionOption]
|
||||||
artists: list[PublicArtistOption]
|
artists: list[PublicArtistOption]
|
||||||
|
has_logo: bool
|
||||||
|
description: str | None
|
||||||
|
|
||||||
|
|
||||||
class FormSubmissionResult(ApiModel):
|
class FormSubmissionResult(ApiModel):
|
||||||
|
|||||||
@@ -79,6 +79,9 @@
|
|||||||
"form.invalid_payload": "Données du formulaire invalides.",
|
"form.invalid_payload": "Données du formulaire invalides.",
|
||||||
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||||
"form.photo_too_large": "La photo est trop volumineuse.",
|
"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_position_wishes": "Deux vœux de poste au maximum.",
|
||||||
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
|
"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.",
|
"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")
|
||||||
@@ -466,7 +466,11 @@ class BadgeField(TimestampMixin, table=True):
|
|||||||
class FormTemplate(TimestampMixin, table=True):
|
class FormTemplate(TimestampMixin, table=True):
|
||||||
"""Configuration du formulaire public d'une édition. Une ligne par
|
"""Configuration du formulaire public d'une édition. Une ligne par
|
||||||
édition (créée à la demande). `is_open` coupe les nouvelles soumissions
|
é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"
|
__tablename__ = "form_template"
|
||||||
|
|
||||||
@@ -478,6 +482,9 @@ class FormTemplate(TimestampMixin, table=True):
|
|||||||
default=True,
|
default=True,
|
||||||
sa_column=Column(Boolean, nullable=False, server_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):
|
class FormField(SQLModel, table=True):
|
||||||
|
|||||||
@@ -59,6 +59,71 @@ def test_closing_the_form(client, edition):
|
|||||||
assert resp.json()["is_open"] is False
|
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(
|
def test_public_form_hides_positions_and_artists_unless_enabled(
|
||||||
client, raw_client, edition, session
|
client, raw_client, edition, session
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ async function checkPublicForm() {
|
|||||||
if (config.positions.length !== positions.length) {
|
if (config.positions.length !== positions.length) {
|
||||||
throw new Error('formulaire public : postes manquants dans la config publique');
|
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);
|
const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date);
|
||||||
let tooManyArtistsRejectedWith = 'non';
|
let tooManyArtistsRejectedWith = 'non';
|
||||||
@@ -145,6 +156,7 @@ async function checkPublicForm() {
|
|||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
formOpenByDefault: config.is_open,
|
formOpenByDefault: config.is_open,
|
||||||
|
descriptionRoundTrips: configWithDescription.description,
|
||||||
tooManyPositionsRejectedWith,
|
tooManyPositionsRejectedWith,
|
||||||
tooManyArtistsRejectedWith,
|
tooManyArtistsRejectedWith,
|
||||||
createdVolunteerName: `${created.first_name} ${created.last_name}`,
|
createdVolunteerName: `${created.first_name} ${created.last_name}`,
|
||||||
|
|||||||
@@ -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();
|
const invalidate = useInvalidateFormTemplate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: FormTemplateUpdate) => api.forms.setOpen(edition, body),
|
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é
|
/** Configuration publique du formulaire — utilisable sans compte connecté
|
||||||
* (écran `/inscription`, hors de `RequireAuth`). */
|
* (écran `/inscription`, hors de `RequireAuth`). */
|
||||||
export function usePublicFormConfig() {
|
export function usePublicFormConfig() {
|
||||||
|
|||||||
@@ -276,7 +276,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
}),
|
}),
|
||||||
setOpen: (editionId, body: FormTemplateUpdate) =>
|
setOpen: (editionId, body: FormTemplateUpdate) =>
|
||||||
request(`${base(editionId)}/form`, { method: 'PATCH', body: JSON.stringify(body) }),
|
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`),
|
getPublicConfig: (editionId) => request(`${base(editionId)}/form/public`),
|
||||||
|
logoBlob: (editionId) => requestBlob(`${base(editionId)}/form/logo`),
|
||||||
submitPublic: (editionId, body: PublicFormSubmission) => {
|
submitPublic: (editionId, body: PublicFormSubmission) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('first_name', body.first_name);
|
form.append('first_name', body.first_name);
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export interface MockState {
|
|||||||
formTemplate: {
|
formTemplate: {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
fields: FormFieldType[];
|
fields: FormFieldType[];
|
||||||
|
logo: string | null; // data URL
|
||||||
|
logoType: string | null;
|
||||||
|
description: string | null;
|
||||||
};
|
};
|
||||||
/** volunteerId -> photo (formulaire public), data URL. */
|
/** volunteerId -> photo (formulaire public), data URL. */
|
||||||
photos: Map<string, string>;
|
photos: Map<string, string>;
|
||||||
@@ -442,7 +445,7 @@ export function buildMockState(): MockState {
|
|||||||
backImageType: null,
|
backImageType: null,
|
||||||
fields: [],
|
fields: [],
|
||||||
},
|
},
|
||||||
formTemplate: { isOpen: true, fields: [] },
|
formTemplate: { isOpen: true, fields: [], logo: null, logoType: null, description: null },
|
||||||
photos: new Map(),
|
photos: new Map(),
|
||||||
seq,
|
seq,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ const fileToDataUrl = (file: File): Promise<string> =>
|
|||||||
reader.readAsDataURL(file);
|
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
|
// 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
|
// (réservée au back-end réel) pour que le téléchargement fonctionne en mode
|
||||||
// démonstration.
|
// démonstration.
|
||||||
@@ -1467,27 +1475,24 @@ export function createMockApi(): PlanningApi {
|
|||||||
},
|
},
|
||||||
|
|
||||||
forms: {
|
forms: {
|
||||||
getTemplate: (): Promise<FormTemplateRead> =>
|
getTemplate: (): Promise<FormTemplateRead> => delay(formTemplateRead()),
|
||||||
delay({
|
|
||||||
edition_id: state.edition.id,
|
|
||||||
is_open: state.formTemplate.isOpen,
|
|
||||||
fields: state.formTemplate.fields,
|
|
||||||
}),
|
|
||||||
setFields: (_edition, body: FormFieldsSet) => {
|
setFields: (_edition, body: FormFieldsSet) => {
|
||||||
state.formTemplate.fields = [...new Set(body.fields)];
|
state.formTemplate.fields = [...new Set(body.fields)];
|
||||||
return delay<FormTemplateRead>({
|
return delay<FormTemplateRead>(formTemplateRead());
|
||||||
edition_id: state.edition.id,
|
|
||||||
is_open: state.formTemplate.isOpen,
|
|
||||||
fields: state.formTemplate.fields,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
setOpen: (_edition, body: FormTemplateUpdate) => {
|
setOpen: (_edition, body: FormTemplateUpdate) => {
|
||||||
state.formTemplate.isOpen = body.is_open;
|
if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open;
|
||||||
return delay<FormTemplateRead>({
|
if (body.description !== undefined) state.formTemplate.description = body.description;
|
||||||
edition_id: state.edition.id,
|
return delay<FormTemplateRead>(formTemplateRead());
|
||||||
is_open: state.formTemplate.isOpen,
|
},
|
||||||
fields: state.formTemplate.fields,
|
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> => {
|
getPublicConfig: (): Promise<PublicFormConfig> => {
|
||||||
const fields = state.formTemplate.fields;
|
const fields = state.formTemplate.fields;
|
||||||
@@ -1504,6 +1509,8 @@ export function createMockApi(): PlanningApi {
|
|||||||
performance_date: a.performance_date,
|
performance_date: a.performance_date,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
|
has_logo: state.formTemplate.logo !== null,
|
||||||
|
description: state.formTemplate.description,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
submitPublic: async (_edition, body: PublicFormSubmission) => {
|
submitPublic: async (_edition, body: PublicFormSubmission) => {
|
||||||
|
|||||||
@@ -571,10 +571,14 @@ export interface FormTemplateRead {
|
|||||||
edition_id: string;
|
edition_id: string;
|
||||||
is_open: boolean;
|
is_open: boolean;
|
||||||
fields: FormFieldType[];
|
fields: FormFieldType[];
|
||||||
|
has_logo: boolean;
|
||||||
|
description: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `undefined`/absent = inchangé pour chaque champ. */
|
||||||
export interface FormTemplateUpdate {
|
export interface FormTemplateUpdate {
|
||||||
is_open: boolean;
|
is_open?: boolean;
|
||||||
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicPositionOption {
|
export interface PublicPositionOption {
|
||||||
@@ -594,6 +598,8 @@ export interface PublicFormConfig {
|
|||||||
fields: FormFieldType[];
|
fields: FormFieldType[];
|
||||||
positions: PublicPositionOption[];
|
positions: PublicPositionOption[];
|
||||||
artists: PublicArtistOption[];
|
artists: PublicArtistOption[];
|
||||||
|
has_logo: boolean;
|
||||||
|
description: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormSubmissionResult {
|
export interface FormSubmissionResult {
|
||||||
|
|||||||
@@ -167,9 +167,12 @@ export interface PlanningApi {
|
|||||||
getTemplate(editionId: string): Promise<FormTemplateRead>;
|
getTemplate(editionId: string): Promise<FormTemplateRead>;
|
||||||
setFields(editionId: string, body: FormFieldsSet): Promise<FormTemplateRead>;
|
setFields(editionId: string, body: FormFieldsSet): Promise<FormTemplateRead>;
|
||||||
setOpen(editionId: string, body: FormTemplateUpdate): 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>;
|
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
|
||||||
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
|
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
|
||||||
|
logoBlob(editionId: string): Promise<Blob | null>;
|
||||||
};
|
};
|
||||||
positionSheets: {
|
positionSheets: {
|
||||||
/** Feuilles de poste PDF (une page par poste et par jour). */
|
/** Feuilles de poste PDF (une page par poste et par jour). */
|
||||||
|
|||||||
@@ -394,6 +394,13 @@
|
|||||||
"form.save": "Enregistrer",
|
"form.save": "Enregistrer",
|
||||||
"form.saving": "Enregistrement…",
|
"form.saving": "Enregistrement…",
|
||||||
"form.saved": "Enregistré ✓",
|
"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.title": "Inscription bénévole",
|
||||||
"publicForm.positionWishes.count": "{count} / {max}",
|
"publicForm.positionWishes.count": "{count} / {max}",
|
||||||
@@ -413,6 +420,9 @@
|
|||||||
"form.invalid_payload": "Données du formulaire invalides.",
|
"form.invalid_payload": "Données du formulaire invalides.",
|
||||||
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||||
"form.photo_too_large": "La photo est trop volumineuse.",
|
"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_position_wishes": "Deux vœux de poste au maximum.",
|
||||||
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
|
"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.",
|
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
||||||
|
|||||||
@@ -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 type { FormFieldType } from '@/api/schema';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
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 { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
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) —
|
// 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.
|
// pas dans cette liste, voir CLAUDE.md § 2 (Volunteer) et app/api/forms.py.
|
||||||
const FIELD_TYPES: FormFieldType[] = [
|
const FIELD_TYPES: FormFieldType[] = [
|
||||||
@@ -48,14 +60,24 @@ const FIELD_HELP_KEY: Record<FormFieldType, string | null> = {
|
|||||||
export function FormBuilderPage() {
|
export function FormBuilderPage() {
|
||||||
const template = useFormTemplate();
|
const template = useFormTemplate();
|
||||||
const setFields = useSetFormFields();
|
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 [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (template.data) setEnabled(new Set(template.data.fields));
|
if (template.data) setEnabled(new Set(template.data.fields));
|
||||||
}, [template.data]);
|
}, [template.data]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (template.data) setDescription(template.data.description ?? '');
|
||||||
|
}, [template.data]);
|
||||||
|
|
||||||
if (template.isLoading || !enabled) return <Loading />;
|
if (template.isLoading || !enabled) return <Loading />;
|
||||||
if (template.error) return <ErrorNote error={template.error} />;
|
if (template.error) return <ErrorNote error={template.error} />;
|
||||||
@@ -105,8 +127,8 @@ export function FormBuilderPage() {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen.mutate({ is_open: !template.data?.is_open })}
|
onClick={() => toggleOpen.mutate({ is_open: !template.data?.is_open })}
|
||||||
disabled={setOpen.isPending}
|
disabled={toggleOpen.isPending}
|
||||||
className={cx(
|
className={cx(
|
||||||
'cursor-pointer border-2 border-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wide',
|
'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',
|
template.data?.is_open ? 'bg-green text-white' : 'bg-orange text-ink',
|
||||||
@@ -152,6 +174,68 @@ export function FormBuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ApiError } from '@/api';
|
import { ApiError } from '@/api';
|
||||||
import { usePublicFormConfig, useSubmitPublicForm } from '@/api/hooks';
|
import { useFormLogoUrl, usePublicFormConfig, useSubmitPublicForm } from '@/api/hooks';
|
||||||
import type { PublicArtistOption } from '@/api/schema';
|
import type { PublicArtistOption } from '@/api/schema';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { Btn, FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
|
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));
|
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() {
|
export function PublicSignupPage() {
|
||||||
const config = usePublicFormConfig();
|
const config = usePublicFormConfig();
|
||||||
const submit = useSubmitPublicForm();
|
const submit = useSubmitPublicForm();
|
||||||
|
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt);
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState('');
|
const [firstName, setFirstName] = useState('');
|
||||||
const [lastName, setLastName] = useState('');
|
const [lastName, setLastName] = useState('');
|
||||||
@@ -165,6 +177,7 @@ export function PublicSignupPage() {
|
|||||||
if (volunteerId) {
|
if (volunteerId) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||||
|
<FormLogo url={logoUrl} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
||||||
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
|
<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) {
|
if (!config.data.is_open) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||||
|
<FormLogo url={logoUrl} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
|
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
|
||||||
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.closed.body')}</p>
|
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.closed.body')}</p>
|
||||||
@@ -236,8 +250,14 @@ export function PublicSignupPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4">
|
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4">
|
||||||
|
<FormLogo url={logoUrl} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>{t('publicForm.title')}</PanelHeader>
|
<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">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
|
|||||||
Reference in New Issue
Block a user