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:
AdamThomas
2026-09-23 21:10:38 +02:00
co-authored by Claude Sonnet 5
parent 8f8350aa1b
commit 5f80f167c8
14 changed files with 181 additions and 17 deletions
+4 -1
View File
@@ -56,6 +56,7 @@ export interface MockState {
logo: string | null; // data URL
logoType: string | null;
description: string | null;
yaplaUrl: string | null;
};
/** volunteerId -> photo (formulaire public), data URL. */
photos: Map<string, string>;
@@ -445,7 +446,9 @@ export function buildMockState(): MockState {
backImageType: null,
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(),
seq,
};
+8
View File
@@ -128,6 +128,7 @@ const formTemplateRead = (): FormTemplateRead => ({
fields: state.formTemplate.fields,
has_logo: state.formTemplate.logo !== null,
description: state.formTemplate.description,
yapla_url: state.formTemplate.yaplaUrl,
});
// PDF valide minimal (une page blanche) — remplace la vraie génération
@@ -1483,6 +1484,12 @@ export function createMockApi(): PlanningApi {
setOpen: (_edition, body: FormTemplateUpdate) => {
if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open;
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());
},
uploadLogo: async (_edition, file: File) => {
@@ -1511,6 +1518,7 @@ export function createMockApi(): PlanningApi {
: [],
has_logo: state.formTemplate.logo !== null,
description: state.formTemplate.description,
yapla_url: state.formTemplate.yaplaUrl,
});
},
submitPublic: async (_edition, body: PublicFormSubmission) => {
+3
View File
@@ -573,12 +573,14 @@ export interface FormTemplateRead {
fields: FormFieldType[];
has_logo: boolean;
description: string | null;
yapla_url: string | null;
}
/** `undefined`/absent = inchangé pour chaque champ. */
export interface FormTemplateUpdate {
is_open?: boolean;
description?: string | null;
yapla_url?: string | null;
}
export interface PublicPositionOption {
@@ -600,6 +602,7 @@ export interface PublicFormConfig {
artists: PublicArtistOption[];
has_logo: boolean;
description: string | null;
yapla_url: string | null;
}
export interface FormSubmissionResult {
+6
View File
@@ -402,12 +402,17 @@
"form.customize.description": "Texte d'introduction",
"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.positionWishes.count": "{count} / {max}",
"publicForm.artistWishes.none": "— Aucun —",
"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.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.submitting": "Envoi…",
"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_empty": "Le logo est vide.",
"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_artists_same_day": "Un seul artiste souhaité par soir de festival.",
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
+37 -1
View File
@@ -60,10 +60,11 @@ const FIELD_HELP_KEY: Record<FormFieldType, string | null> = {
export function FormBuilderPage() {
const template = useFormTemplate();
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.
const toggleOpen = useUpdateFormTemplate();
const saveDescription = useUpdateFormTemplate();
const saveYaplaUrl = useUpdateFormTemplate();
const uploadLogo = useUploadFormLogo();
const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt);
const logoInputRef = useRef<HTMLInputElement>(null);
@@ -71,6 +72,7 @@ export function FormBuilderPage() {
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
const [copied, setCopied] = useState(false);
const [description, setDescription] = useState('');
const [yaplaUrl, setYaplaUrl] = useState('');
useEffect(() => {
if (template.data) setEnabled(new Set(template.data.fields));
@@ -78,6 +80,9 @@ export function FormBuilderPage() {
useEffect(() => {
if (template.data) setDescription(template.data.description ?? '');
}, [template.data]);
useEffect(() => {
if (template.data) setYaplaUrl(template.data.yapla_url ?? '');
}, [template.data]);
if (template.isLoading || !enabled) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
@@ -235,6 +240,37 @@ export function FormBuilderPage() {
</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>
</div>
);
+16
View File
@@ -386,6 +386,22 @@ export function PublicSignupPage() {
</label>
) : 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') ? (
<label className="block">
<FieldLabel>{t('form.field.photo')}</FieldLabel>