Formulaire public : encart de paiement de la cotisation via Yapla
FormTemplate.yapla_url (lien vers la page de cotisation Yapla, doit commencer par http(s)://, PATCH .../form comme description/is_open) fait apparaître un encart « Payer ma cotisation » sous la case WILLING_TO_PAY_FEE, sur le formulaire public. Configurable depuis le panneau « Personnalisation » de l'écran /inscriptions. La case reste une simple déclaration : rien ne vérifie que le paiement a eu lieu. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8f8350aa1b
commit
5f80f167c8
@@ -85,8 +85,11 @@ les tables sauf les tables d'association.
|
|||||||
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
|
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
|
||||||
main depuis la fiche une fois les deux bénévoles inscrits),
|
main depuis la fiche une fois les deux bénévoles inscrits),
|
||||||
`self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement
|
`self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement
|
||||||
informatives**, sans lien avec `Availability` ni aucun workflow de
|
informatives** : rien ne vérifie qu'un paiement a réellement eu lieu,
|
||||||
paiement).
|
aucun lien avec `Availability`). Si `FormTemplate.yapla_url` (ci-dessous)
|
||||||
|
est configuré, un encart « Payer ma cotisation » apparaît sous la case
|
||||||
|
`WILLING_TO_PAY_FEE`, vers la page de cotisation Yapla (externe) — la
|
||||||
|
déclaration et le paiement restent deux choses distinctes, non reliées.
|
||||||
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
||||||
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
||||||
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
||||||
@@ -205,11 +208,16 @@ les tables sauf les tables d'association.
|
|||||||
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. `logo`/
|
nouvelles soumissions sans effacer la configuration. `logo`/
|
||||||
`logo_content_type` (image, `PUT .../form/logo`, stockée en base comme les
|
`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
|
autres images de l'appli), `description` (texte libre) et `yapla_url`
|
||||||
publique — aucun rapport avec les champs collectés (`FormField`
|
habillent la page publique — aucun rapport avec les champs collectés
|
||||||
ci-dessous) : affichés au-dessus/dans le formulaire, jamais soumis par le
|
(`FormField` ci-dessous) : affichés au-dessus/dans le formulaire, jamais
|
||||||
bénévole. `PATCH /editions/{id}/form` prend `is_open`/`description`,
|
soumis par le bénévole. `yapla_url` : lien vers la page de cotisation sur
|
||||||
chacun optionnel (absent = inchangé).
|
Yapla (yapla.com, plateforme externe de paiement pour associations) —
|
||||||
|
affiché en encart sous la case `WILLING_TO_PAY_FEE` si activée (voir
|
||||||
|
`Volunteer` ci-dessus) ; doit commencer par `http://`/`https://`
|
||||||
|
(422 `form.invalid_yapla_url` sinon), aucune autre vérification (rien ne
|
||||||
|
confirme qu'un paiement a eu lieu). `PATCH /editions/{id}/form` prend
|
||||||
|
`is_open`/`description`/`yapla_url`, 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
|
||||||
@@ -655,6 +663,7 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 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 |
|
| 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 |
|
||||||
| 26 | Feuilles de poste : programmation du jour (horaires des artistes, tous postes confondus) sous le référent | à valider |
|
| 26 | Feuilles de poste : programmation du jour (horaires des artistes, tous postes confondus) sous le référent | à valider |
|
||||||
|
| 27 | Formulaire public : encart de paiement de la cotisation via Yapla (lien externe configurable), affiché sous la case correspondante | à 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 +
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormT
|
|||||||
fields=fields,
|
fields=fields,
|
||||||
has_logo=template.logo is not None,
|
has_logo=template.logo is not None,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
|
yapla_url=template.yapla_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -138,7 +139,13 @@ def update_form(
|
|||||||
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)
|
||||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
if changes.get("yapla_url"):
|
||||||
|
if not changes["yapla_url"].startswith(("http://", "https://")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "form.invalid_yapla_url"}
|
||||||
|
)
|
||||||
|
for field, value in changes.items():
|
||||||
setattr(template, field, value)
|
setattr(template, field, value)
|
||||||
session.add(template)
|
session.add(template)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -233,6 +240,7 @@ def get_public_form(
|
|||||||
artists=artists,
|
artists=artists,
|
||||||
has_logo=template.logo is not None,
|
has_logo=template.logo is not None,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
|
yapla_url=template.yapla_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -493,14 +493,16 @@ class FormTemplateRead(ApiModel):
|
|||||||
fields: list[FormFieldType]
|
fields: list[FormFieldType]
|
||||||
has_logo: bool
|
has_logo: bool
|
||||||
description: str | None
|
description: str | None
|
||||||
|
yapla_url: str | None
|
||||||
|
|
||||||
|
|
||||||
class FormTemplateUpdate(ApiModel):
|
class FormTemplateUpdate(ApiModel):
|
||||||
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet
|
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet
|
||||||
de changer `is_open` sans toucher `description`, et inversement."""
|
de changer `is_open` sans toucher `description`/`yapla_url`, etc."""
|
||||||
|
|
||||||
is_open: bool | None = None
|
is_open: bool | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
yapla_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class PublicPositionOption(ApiModel):
|
class PublicPositionOption(ApiModel):
|
||||||
@@ -525,6 +527,7 @@ class PublicFormConfig(ApiModel):
|
|||||||
artists: list[PublicArtistOption]
|
artists: list[PublicArtistOption]
|
||||||
has_logo: bool
|
has_logo: bool
|
||||||
description: str | None
|
description: str | None
|
||||||
|
yapla_url: str | None
|
||||||
|
|
||||||
|
|
||||||
class FormSubmissionResult(ApiModel):
|
class FormSubmissionResult(ApiModel):
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||||
"form.logo_empty": "Le logo est vide.",
|
"form.logo_empty": "Le logo est vide.",
|
||||||
"form.logo_too_large": "Le logo est trop volumineux.",
|
"form.logo_too_large": "Le logo est trop volumineux.",
|
||||||
|
"form.invalid_yapla_url": "Le lien Yapla doit commencer par http:// ou https://.",
|
||||||
"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,28 @@
|
|||||||
|
"""Lien de paiement Yapla
|
||||||
|
|
||||||
|
Revision ID: 0015_form_yapla_url
|
||||||
|
Revises: 0014_form_logo
|
||||||
|
Create Date: 2026-09-23
|
||||||
|
|
||||||
|
Encart affiché sur la page publique (`/inscription`), près de la case
|
||||||
|
« je participe à la cotisation » — lien vers la page de cotisation sur
|
||||||
|
Yapla (yapla.com, plateforme externe). Voir `app/api/forms.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0015_form_yapla_url"
|
||||||
|
down_revision: str | None = "0014_form_logo"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("form_template", sa.Column("yapla_url", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("form_template", "yapla_url")
|
||||||
@@ -467,9 +467,9 @@ 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
|
`logo`/`logo_content_type`, `description` et `yapla_url` habillent la
|
||||||
(`/inscription`) — aucun rapport avec les champs collectés par
|
page publique (`/inscription`) — aucun rapport avec les champs collectés
|
||||||
`FormField` ; stockage en base comme les autres images de l'appli
|
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."""
|
(badges, photo de bénévole), pas de volume de fichiers à gérer."""
|
||||||
|
|
||||||
__tablename__ = "form_template"
|
__tablename__ = "form_template"
|
||||||
@@ -485,6 +485,12 @@ class FormTemplate(TimestampMixin, table=True):
|
|||||||
logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||||
logo_content_type: str | None = None
|
logo_content_type: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
# Lien vers la page de cotisation Yapla (yapla.com, plateforme externe de
|
||||||
|
# paiement pour associations) — encart affiché sur `/inscription` près de
|
||||||
|
# la case « je participe à la cotisation » (`FormFieldType.
|
||||||
|
# WILLING_TO_PAY_FEE`). Rien n'est vérifié côté appli : la case reste
|
||||||
|
# une déclaration, le paiement se passe entièrement chez Yapla.
|
||||||
|
yapla_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class FormField(SQLModel, table=True):
|
class FormField(SQLModel, table=True):
|
||||||
|
|||||||
@@ -74,6 +74,28 @@ def test_clearing_description_with_an_explicit_null(client, edition):
|
|||||||
assert resp.json()["description"] is None
|
assert resp.json()["description"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_yapla_link_by_default(client, raw_client, edition):
|
||||||
|
assert client.get(_form_url(edition)).json()["yapla_url"] is None
|
||||||
|
assert raw_client.get(_form_url(edition, "/public")).json()["yapla_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_the_yapla_link(client, raw_client, edition):
|
||||||
|
resp = client.patch(
|
||||||
|
_form_url(edition), json={"yapla_url": "https://yapla.com/exemple/cotisation"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["yapla_url"] == "https://yapla.com/exemple/cotisation"
|
||||||
|
|
||||||
|
body = raw_client.get(_form_url(edition, "/public")).json()
|
||||||
|
assert body["yapla_url"] == "https://yapla.com/exemple/cotisation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_a_yapla_link_without_a_scheme(client, edition):
|
||||||
|
resp = client.patch(_form_url(edition), json={"yapla_url": "yapla.com/exemple"})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "form.invalid_yapla_url"
|
||||||
|
|
||||||
|
|
||||||
def test_logo_is_coordinator_only(edition, make_account, as_account):
|
def test_logo_is_coordinator_only(edition, make_account, as_account):
|
||||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
lead_client = as_account(lead)
|
lead_client = as_account(lead)
|
||||||
|
|||||||
@@ -99,18 +99,31 @@ 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) {
|
if (config.has_logo !== false || config.description !== null || config.yapla_url !== null) {
|
||||||
throw new Error('formulaire public : logo/description devraient être vides par défaut');
|
throw new Error('formulaire public : logo/description/lien Yapla devraient être vides par défaut');
|
||||||
}
|
}
|
||||||
// Le recadrage carré (canvas) et l'upload du logo (FileReader) sont des
|
// Le recadrage carré (canvas) et l'upload du logo (FileReader) sont des
|
||||||
// API navigateur — non testables sous Node (voir scripts/smoke.tsx tel
|
// API navigateur — non testables sous Node (voir scripts/smoke.tsx tel
|
||||||
// qu'exécuté ici) ; seul le texte d'introduction est vérifiable ici.
|
// qu'exécuté ici) ; seul le texte d'introduction et le lien Yapla sont
|
||||||
|
// vérifiables ici.
|
||||||
await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' });
|
await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' });
|
||||||
const configWithDescription = await api.forms.getPublicConfig(e);
|
const configWithDescription = await api.forms.getPublicConfig(e);
|
||||||
if (configWithDescription.description !== 'Bienvenue au festival de test !') {
|
if (configWithDescription.description !== 'Bienvenue au festival de test !') {
|
||||||
throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
|
throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let invalidYaplaUrlRejectedWith = 'non';
|
||||||
|
try {
|
||||||
|
await api.forms.setOpen(e, { yapla_url: 'yapla.com/exemple' });
|
||||||
|
} catch (err) {
|
||||||
|
invalidYaplaUrlRejectedWith = err instanceof ApiError ? err.code : String(err);
|
||||||
|
}
|
||||||
|
await api.forms.setOpen(e, { yapla_url: 'https://www.yapla.com/fr/exemple' });
|
||||||
|
const configWithYapla = await api.forms.getPublicConfig(e);
|
||||||
|
if (configWithYapla.yapla_url !== 'https://www.yapla.com/fr/exemple') {
|
||||||
|
throw new Error('formulaire public : le lien Yapla 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';
|
||||||
if (sameDay.length >= 2) {
|
if (sameDay.length >= 2) {
|
||||||
@@ -157,6 +170,8 @@ async function checkPublicForm() {
|
|||||||
{
|
{
|
||||||
formOpenByDefault: config.is_open,
|
formOpenByDefault: config.is_open,
|
||||||
descriptionRoundTrips: configWithDescription.description,
|
descriptionRoundTrips: configWithDescription.description,
|
||||||
|
invalidYaplaUrlRejectedWith,
|
||||||
|
yaplaUrlRoundTrips: configWithYapla.yapla_url,
|
||||||
tooManyPositionsRejectedWith,
|
tooManyPositionsRejectedWith,
|
||||||
tooManyArtistsRejectedWith,
|
tooManyArtistsRejectedWith,
|
||||||
createdVolunteerName: `${created.first_name} ${created.last_name}`,
|
createdVolunteerName: `${created.first_name} ${created.last_name}`,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface MockState {
|
|||||||
logo: string | null; // data URL
|
logo: string | null; // data URL
|
||||||
logoType: string | null;
|
logoType: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
yaplaUrl: string | null;
|
||||||
};
|
};
|
||||||
/** volunteerId -> photo (formulaire public), data URL. */
|
/** volunteerId -> photo (formulaire public), data URL. */
|
||||||
photos: Map<string, string>;
|
photos: Map<string, string>;
|
||||||
@@ -445,7 +446,9 @@ export function buildMockState(): MockState {
|
|||||||
backImageType: null,
|
backImageType: null,
|
||||||
fields: [],
|
fields: [],
|
||||||
},
|
},
|
||||||
formTemplate: { isOpen: true, fields: [], logo: null, logoType: null, description: null },
|
formTemplate: {
|
||||||
|
isOpen: true, fields: [], logo: null, logoType: null, description: null, yaplaUrl: null,
|
||||||
|
},
|
||||||
photos: new Map(),
|
photos: new Map(),
|
||||||
seq,
|
seq,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ const formTemplateRead = (): FormTemplateRead => ({
|
|||||||
fields: state.formTemplate.fields,
|
fields: state.formTemplate.fields,
|
||||||
has_logo: state.formTemplate.logo !== null,
|
has_logo: state.formTemplate.logo !== null,
|
||||||
description: state.formTemplate.description,
|
description: state.formTemplate.description,
|
||||||
|
yapla_url: state.formTemplate.yaplaUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
// PDF valide minimal (une page blanche) — remplace la vraie génération
|
// PDF valide minimal (une page blanche) — remplace la vraie génération
|
||||||
@@ -1483,6 +1484,12 @@ export function createMockApi(): PlanningApi {
|
|||||||
setOpen: (_edition, body: FormTemplateUpdate) => {
|
setOpen: (_edition, body: FormTemplateUpdate) => {
|
||||||
if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open;
|
if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open;
|
||||||
if (body.description !== undefined) state.formTemplate.description = body.description;
|
if (body.description !== undefined) state.formTemplate.description = body.description;
|
||||||
|
if (body.yapla_url !== undefined) {
|
||||||
|
if (body.yapla_url && !/^https?:\/\//.test(body.yapla_url)) {
|
||||||
|
throw new ApiError(422, 'form.invalid_yapla_url');
|
||||||
|
}
|
||||||
|
state.formTemplate.yaplaUrl = body.yapla_url;
|
||||||
|
}
|
||||||
return delay<FormTemplateRead>(formTemplateRead());
|
return delay<FormTemplateRead>(formTemplateRead());
|
||||||
},
|
},
|
||||||
uploadLogo: async (_edition, file: File) => {
|
uploadLogo: async (_edition, file: File) => {
|
||||||
@@ -1511,6 +1518,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
: [],
|
: [],
|
||||||
has_logo: state.formTemplate.logo !== null,
|
has_logo: state.formTemplate.logo !== null,
|
||||||
description: state.formTemplate.description,
|
description: state.formTemplate.description,
|
||||||
|
yapla_url: state.formTemplate.yaplaUrl,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
submitPublic: async (_edition, body: PublicFormSubmission) => {
|
submitPublic: async (_edition, body: PublicFormSubmission) => {
|
||||||
|
|||||||
@@ -573,12 +573,14 @@ export interface FormTemplateRead {
|
|||||||
fields: FormFieldType[];
|
fields: FormFieldType[];
|
||||||
has_logo: boolean;
|
has_logo: boolean;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
yapla_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `undefined`/absent = inchangé pour chaque champ. */
|
/** `undefined`/absent = inchangé pour chaque champ. */
|
||||||
export interface FormTemplateUpdate {
|
export interface FormTemplateUpdate {
|
||||||
is_open?: boolean;
|
is_open?: boolean;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
yapla_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicPositionOption {
|
export interface PublicPositionOption {
|
||||||
@@ -600,6 +602,7 @@ export interface PublicFormConfig {
|
|||||||
artists: PublicArtistOption[];
|
artists: PublicArtistOption[];
|
||||||
has_logo: boolean;
|
has_logo: boolean;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
yapla_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormSubmissionResult {
|
export interface FormSubmissionResult {
|
||||||
|
|||||||
@@ -402,12 +402,17 @@
|
|||||||
"form.customize.description": "Texte d'introduction",
|
"form.customize.description": "Texte d'introduction",
|
||||||
"form.customize.description.placeholder": "Un mot de bienvenue affiché en haut du formulaire (facultatif)…",
|
"form.customize.description.placeholder": "Un mot de bienvenue affiché en haut du formulaire (facultatif)…",
|
||||||
|
|
||||||
|
"form.customize.yaplaUrl": "Lien de cotisation Yapla",
|
||||||
|
"form.customize.yaplaUrl.help": "Facultatif : lien vers votre page de cotisation sur Yapla. Un encart « Payer ma cotisation » apparaît alors sur le formulaire public, près de la case correspondante.",
|
||||||
|
"form.customize.yaplaUrl.placeholder": "https://www.yapla.com/fr/...",
|
||||||
"publicForm.title": "Inscription bénévole",
|
"publicForm.title": "Inscription bénévole",
|
||||||
"publicForm.positionWishes.count": "{count} / {max}",
|
"publicForm.positionWishes.count": "{count} / {max}",
|
||||||
"publicForm.artistWishes.none": "— Aucun —",
|
"publicForm.artistWishes.none": "— Aucun —",
|
||||||
"publicForm.field.available": "Je suis disponible et prêt·e à filer un coup de main !",
|
"publicForm.field.available": "Je suis disponible et prêt·e à filer un coup de main !",
|
||||||
"publicForm.field.willingToPayFee": "Ça ne me dérange pas de participer à la cotisation de l'association",
|
"publicForm.field.willingToPayFee": "Ça ne me dérange pas de participer à la cotisation de l'association",
|
||||||
"publicForm.photo.squareHint": "Recadrée automatiquement en carré.",
|
"publicForm.photo.squareHint": "Recadrée automatiquement en carré.",
|
||||||
|
"publicForm.yapla.hint": "Le paiement se fait en ligne, via notre page Yapla.",
|
||||||
|
"publicForm.yapla.cta": "Payer ma cotisation ↗",
|
||||||
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
|
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
|
||||||
"publicForm.submitting": "Envoi…",
|
"publicForm.submitting": "Envoi…",
|
||||||
"publicForm.submit": "Envoyer mon inscription",
|
"publicForm.submit": "Envoyer mon inscription",
|
||||||
@@ -423,6 +428,7 @@
|
|||||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||||
"form.logo_empty": "Le logo est vide.",
|
"form.logo_empty": "Le logo est vide.",
|
||||||
"form.logo_too_large": "Le logo est trop volumineux.",
|
"form.logo_too_large": "Le logo est trop volumineux.",
|
||||||
|
"form.invalid_yapla_url": "Le lien Yapla doit commencer par http:// ou https://.",
|
||||||
"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.",
|
||||||
|
|||||||
@@ -60,10 +60,11 @@ 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();
|
||||||
// Deux instances indépendantes du même mutateur (même endpoint PATCH) :
|
// Trois 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.
|
// le statut (pending/success) de l'une ne doit pas déteindre sur l'autre.
|
||||||
const toggleOpen = useUpdateFormTemplate();
|
const toggleOpen = useUpdateFormTemplate();
|
||||||
const saveDescription = useUpdateFormTemplate();
|
const saveDescription = useUpdateFormTemplate();
|
||||||
|
const saveYaplaUrl = useUpdateFormTemplate();
|
||||||
const uploadLogo = useUploadFormLogo();
|
const uploadLogo = useUploadFormLogo();
|
||||||
const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt);
|
const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt);
|
||||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -71,6 +72,7 @@ export function FormBuilderPage() {
|
|||||||
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('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [yaplaUrl, setYaplaUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (template.data) setEnabled(new Set(template.data.fields));
|
if (template.data) setEnabled(new Set(template.data.fields));
|
||||||
@@ -78,6 +80,9 @@ export function FormBuilderPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (template.data) setDescription(template.data.description ?? '');
|
if (template.data) setDescription(template.data.description ?? '');
|
||||||
}, [template.data]);
|
}, [template.data]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (template.data) setYaplaUrl(template.data.yapla_url ?? '');
|
||||||
|
}, [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} />;
|
||||||
@@ -235,6 +240,37 @@ export function FormBuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t-2 border-ink p-3">
|
||||||
|
<div className="flex items-center gap-[6px]">
|
||||||
|
<FieldLabel>{t('form.customize.yaplaUrl')}</FieldLabel>
|
||||||
|
<HelpTip text={t('form.customize.yaplaUrl.help')} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={yaplaUrl}
|
||||||
|
onChange={(e) => setYaplaUrl(e.target.value)}
|
||||||
|
placeholder={t('form.customize.yaplaUrl.placeholder')}
|
||||||
|
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Btn
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => saveYaplaUrl.mutate({ yapla_url: yaplaUrl.trim() || null })}
|
||||||
|
disabled={saveYaplaUrl.isPending}
|
||||||
|
>
|
||||||
|
{saveYaplaUrl.isPending ? t('form.saving') : t('form.save')}
|
||||||
|
</Btn>
|
||||||
|
{saveYaplaUrl.isSuccess ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
|
||||||
|
) : null}
|
||||||
|
{saveYaplaUrl.isError ? (
|
||||||
|
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
||||||
|
{errorMessage(saveYaplaUrl.error)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -386,6 +386,22 @@ export function PublicSignupPage() {
|
|||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{fields.has('WILLING_TO_PAY_FEE') && config.data.yapla_url ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-[10px] border-2 border-ink bg-yellow-soft px-[12px] py-[10px]">
|
||||||
|
<span className="min-w-0 flex-1 font-mono text-[11px] leading-relaxed">
|
||||||
|
{t('publicForm.yapla.hint')}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={config.data.yapla_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex flex-none cursor-pointer items-center justify-center gap-2 border-2 border-ink bg-yellow px-3 py-2 font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal-sm hover:bg-green hover:text-white"
|
||||||
|
>
|
||||||
|
{t('publicForm.yapla.cta')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{fields.has('PHOTO') ? (
|
{fields.has('PHOTO') ? (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('form.field.photo')}</FieldLabel>
|
<FieldLabel>{t('form.field.photo')}</FieldLabel>
|
||||||
|
|||||||
Reference in New Issue
Block a user