Type de paiement à montant libre (pensé pour le mécénat)
FormPaymentOption.free_amount (migration 0021) : case « Montant libre » sur l'écran Inscriptions ; le montant configuré devient le minimum et le bénévole saisit le sien sur le formulaire public (payment_amount, virgule ou point, 2 décimales max, plafond 10 000 € contre une faute de frappe — 422 form.invalid_free_amount sinon). Validé côté client avant l'envoi, le serveur restant seul juge. Le montant choisi est celui du checkout SumUp, recopié sur la fiche, l'email de confirmation et le cerfa. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ccd769d04e
commit
c49ce520e3
@@ -312,6 +312,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
if (body.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note);
|
||||
if (body.available !== undefined) form.append('available', String(body.available));
|
||||
if (body.payment_option_id) form.append('payment_option_id', body.payment_option_id);
|
||||
if (body.payment_amount) form.append('payment_amount', body.payment_amount);
|
||||
if (body.position_wish_ids) {
|
||||
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
|
||||
}
|
||||
|
||||
@@ -116,6 +116,16 @@ const imageSizeMm = async (file: File): Promise<{ width_mm: number; height_mm: n
|
||||
|
||||
// Approche de `str.title()` (Python) utilisé côté back-end à la soumission
|
||||
// du formulaire public — capitalise chaque mot (« - » compris).
|
||||
/** Miroir de `app/api/forms.py:_free_amount` (montant libre). */
|
||||
const freeAmount = (raw: string | undefined, minimum: number): number => {
|
||||
const text = (raw ?? '').trim().replace(',', '.');
|
||||
const value = Number(text);
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(text) || value < minimum || value > 10000) {
|
||||
throw new ApiError(422, 'form.invalid_free_amount');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const titleCase = (s: string): string =>
|
||||
s.toLowerCase().replace(/\b\p{L}/gu, (c) => c.toUpperCase());
|
||||
|
||||
@@ -1647,6 +1657,7 @@ export function createMockApi(): PlanningApi {
|
||||
label: o.label,
|
||||
amount: o.amount,
|
||||
requires_mecenat_info: o.requires_mecenat_info,
|
||||
free_amount: o.free_amount,
|
||||
})),
|
||||
});
|
||||
},
|
||||
@@ -1666,6 +1677,7 @@ export function createMockApi(): PlanningApi {
|
||||
amount: body.amount,
|
||||
order: body.order ?? 0,
|
||||
requires_mecenat_info: body.requires_mecenat_info ?? false,
|
||||
free_amount: body.free_amount ?? false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1691,6 +1703,7 @@ export function createMockApi(): PlanningApi {
|
||||
...(body.requires_mecenat_info !== undefined
|
||||
? { requires_mecenat_info: body.requires_mecenat_info }
|
||||
: {}),
|
||||
...(body.free_amount !== undefined ? { free_amount: body.free_amount } : {}),
|
||||
updated_at: touch(),
|
||||
});
|
||||
return delay<FormPaymentOptionRead>(option);
|
||||
@@ -1802,7 +1815,9 @@ export function createMockApi(): PlanningApi {
|
||||
artistIds,
|
||||
photo,
|
||||
paymentLabel: paymentOption.label,
|
||||
paymentAmount: paymentOption.amount,
|
||||
paymentAmount: paymentOption.free_amount
|
||||
? freeAmount(body.payment_amount, paymentOption.amount)
|
||||
: paymentOption.amount,
|
||||
requiresMecenatInfo: paymentOption.requires_mecenat_info,
|
||||
mecenatAddress: null,
|
||||
mecenatCity: null,
|
||||
|
||||
@@ -618,6 +618,8 @@ export interface FormPaymentOptionRead {
|
||||
/** Type « mécénat » : le bénévole passe par une étape d'adresse postale
|
||||
* avant le paiement (reçu fiscal / cerfa). */
|
||||
requires_mecenat_info: boolean;
|
||||
/** Montant libre : le bénévole saisit son montant, `amount` = minimum. */
|
||||
free_amount: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -627,6 +629,7 @@ export interface FormPaymentOptionCreate {
|
||||
amount: number;
|
||||
order?: number;
|
||||
requires_mecenat_info?: boolean;
|
||||
free_amount?: boolean;
|
||||
}
|
||||
|
||||
export interface FormPaymentOptionUpdate {
|
||||
@@ -634,6 +637,7 @@ export interface FormPaymentOptionUpdate {
|
||||
amount?: number;
|
||||
order?: number;
|
||||
requires_mecenat_info?: boolean;
|
||||
free_amount?: boolean;
|
||||
}
|
||||
|
||||
export interface PublicPositionOption {
|
||||
@@ -652,8 +656,10 @@ export interface PublicArtistOption {
|
||||
export interface PublicPaymentOption {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Montant fixe, ou minimum si `free_amount`. */
|
||||
amount: number;
|
||||
requires_mecenat_info: boolean;
|
||||
free_amount: boolean;
|
||||
}
|
||||
|
||||
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
|
||||
@@ -719,6 +725,8 @@ export interface PublicFormSubmission {
|
||||
/** Type de paiement choisi (voir `PublicPaymentOption`) — absent = « je
|
||||
* paierai sur place » (aucun paiement en ligne). */
|
||||
payment_option_id?: string;
|
||||
/** Montant saisi, pour un type de paiement à montant libre seulement. */
|
||||
payment_amount?: string;
|
||||
position_wish_ids?: string[];
|
||||
artist_wish_ids?: string[];
|
||||
photo?: File | null;
|
||||
|
||||
@@ -423,6 +423,8 @@
|
||||
"form.customize.paymentOptions.label": "Libellé (ex. Cotisation classique, Mécénat…)",
|
||||
"form.customize.paymentOptions.add": "Ajouter",
|
||||
"form.customize.paymentOptions.mecenat": "Mécénat",
|
||||
"form.customize.paymentOptions.free": "Montant libre",
|
||||
"form.customize.paymentOptions.free.help": "Le bénévole choisit lui-même son montant ; le montant indiqué ici devient le minimum.",
|
||||
"form.customize.paymentOptions.mecenat.help": "Avant de payer, le bénévole passe par une étape d'adresse postale (reçu fiscal / cerfa).",
|
||||
|
||||
"publicForm.title": "Inscription bénévole",
|
||||
@@ -431,6 +433,9 @@
|
||||
"publicForm.field.available": "Je suis bénévole les deux jours (vendredi 30 et samedi 31 oct.)",
|
||||
"publicForm.field.willingToPayFee": "Je participe à la cotisation bénévole",
|
||||
"publicForm.field.paymentOption": "Cotisation",
|
||||
"publicForm.paymentOption.from": "{label} — à partir de {amount} €",
|
||||
"publicForm.freeAmount.label": "Montant de ta contribution (€)",
|
||||
"publicForm.freeAmount.hint": "Minimum {min} €.",
|
||||
"publicForm.paymentOption.onSite": "Je paierai sur place",
|
||||
"publicForm.photo.squareHint": "Recadrée automatiquement en carré.",
|
||||
"publicForm.sumup.afterSubmitHint": "Un lien de paiement en ligne (SumUp) te sera proposé juste après l'envoi de ton inscription.",
|
||||
@@ -470,6 +475,7 @@
|
||||
"form.logo_empty": "Le logo est vide.",
|
||||
"form.logo_too_large": "Le logo est trop volumineux.",
|
||||
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
|
||||
"form.invalid_free_amount": "Le montant saisi n'est pas valide (au-dessus du minimum indiqué, 10 000 € au plus, deux décimales au maximum).",
|
||||
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
|
||||
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
|
||||
"form.pending_already_paid": "Cette inscription est déjà payée.",
|
||||
|
||||
@@ -354,6 +354,7 @@ function PaymentOptionsForm() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [requiresMecenatInfo, setRequiresMecenatInfo] = useState(false);
|
||||
const [freeAmount, setFreeAmount] = useState(false);
|
||||
|
||||
if (options.isLoading) return <div className="font-mono text-[10px] opacity-60">…</div>;
|
||||
if (options.error) return <ErrorNote error={options.error} />;
|
||||
@@ -386,6 +387,18 @@ function PaymentOptionsForm() {
|
||||
className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
|
||||
/>
|
||||
<span className="font-mono text-[10px] opacity-60">€</span>
|
||||
<label
|
||||
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
|
||||
title={t('form.customize.paymentOptions.free.help')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={o.free_amount}
|
||||
onChange={(e) => update.mutate({ id: o.id, body: { free_amount: e.target.checked } })}
|
||||
className="h-[13px] w-[13px] accent-ink"
|
||||
/>
|
||||
{t('form.customize.paymentOptions.free')}
|
||||
</label>
|
||||
<label
|
||||
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
|
||||
title={t('form.customize.paymentOptions.mecenat.help')}
|
||||
@@ -428,6 +441,18 @@ function PaymentOptionsForm() {
|
||||
className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
|
||||
/>
|
||||
<span className="font-mono text-[10px] opacity-60">€</span>
|
||||
<label
|
||||
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
|
||||
title={t('form.customize.paymentOptions.free.help')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={freeAmount}
|
||||
onChange={(e) => setFreeAmount(e.target.checked)}
|
||||
className="h-[13px] w-[13px] accent-ink"
|
||||
/>
|
||||
{t('form.customize.paymentOptions.free')}
|
||||
</label>
|
||||
<label
|
||||
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
|
||||
title={t('form.customize.paymentOptions.mecenat.help')}
|
||||
@@ -450,12 +475,14 @@ function PaymentOptionsForm() {
|
||||
amount: Number(amount),
|
||||
order: rows.length,
|
||||
requires_mecenat_info: requiresMecenatInfo,
|
||||
free_amount: freeAmount,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLabel('');
|
||||
setAmount('');
|
||||
setRequiresMecenatInfo(false);
|
||||
setFreeAmount(false);
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -107,6 +107,17 @@ async function cropToSquareJpeg(file: File): Promise<File> {
|
||||
}
|
||||
}
|
||||
|
||||
// Miroir de `app/api/forms.py:_free_amount` — validé ici aussi pour
|
||||
// signaler l'erreur avant l'envoi, le serveur reste seul juge.
|
||||
const MAX_FREE_AMOUNT = 10000;
|
||||
|
||||
function isValidFreeAmount(raw: string, minimum: number): boolean {
|
||||
const text = raw.trim().replace(',', '.');
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(text)) return false;
|
||||
const value = Number(text);
|
||||
return value >= minimum && value <= MAX_FREE_AMOUNT;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) return t(error.code);
|
||||
return t('error.generic');
|
||||
@@ -354,6 +365,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [willingToPayFee, setWillingToPayFee] = useState(false);
|
||||
const [paymentOptionId, setPaymentOptionId] = useState('');
|
||||
const [freeAmount, setFreeAmount] = useState('');
|
||||
const [positionIds, setPositionIds] = useState<string[]>([]);
|
||||
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
|
||||
const [photo, setPhoto] = useState<File | null>(null);
|
||||
@@ -455,6 +467,10 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
});
|
||||
};
|
||||
|
||||
const selectedOption = config.data.sumup_enabled
|
||||
? config.data.payment_options.find((o) => o.id === paymentOptionId)
|
||||
: undefined;
|
||||
|
||||
// Tout champ activé par la coordination est obligatoire — le formulaire
|
||||
// public n'est plus une simple collecte d'infos facultatives.
|
||||
const canSubmit =
|
||||
@@ -464,6 +480,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
(!fields.has('PHONE') || phone.trim().length > 0) &&
|
||||
(!fields.has('AVAILABLE') || available) &&
|
||||
(!fields.has('WILLING_TO_PAY_FEE') || config.data.sumup_enabled || willingToPayFee) &&
|
||||
(!selectedOption?.free_amount || isValidFreeAmount(freeAmount, selectedOption.amount)) &&
|
||||
(toggleEnabled ? captchaToken.length > 0 : true) &&
|
||||
!submit.isPending;
|
||||
|
||||
@@ -484,6 +501,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled
|
||||
? paymentOptionId || undefined
|
||||
: undefined,
|
||||
payment_amount: selectedOption?.free_amount ? freeAmount : undefined,
|
||||
position_wish_ids: fields.has('POSITION_WISHES') ? positionIds : undefined,
|
||||
artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined,
|
||||
photo: fields.has('PHOTO') ? photo : undefined,
|
||||
@@ -684,14 +702,39 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
onChange={() => setPaymentOptionId(o.id)}
|
||||
className="h-[15px] w-[15px] accent-ink"
|
||||
/>
|
||||
{o.label} — {o.amount} €
|
||||
{o.free_amount
|
||||
? t('publicForm.paymentOption.from', { label: o.label, amount: o.amount })
|
||||
: `${o.label} — ${o.amount} €`}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{paymentOptionId ? (
|
||||
{selectedOption?.free_amount ? (
|
||||
<label className="mt-[8px] block">
|
||||
<FieldLabel required>{t('publicForm.freeAmount.label')}</FieldLabel>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={selectedOption.amount}
|
||||
max={MAX_FREE_AMOUNT}
|
||||
step="0.01"
|
||||
value={freeAmount}
|
||||
onChange={(e) => setFreeAmount(e.target.value)}
|
||||
placeholder={String(selectedOption.amount)}
|
||||
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
|
||||
/>
|
||||
<span className="mt-[4px] block font-mono text-[10px] opacity-60">
|
||||
{t('publicForm.freeAmount.hint', { min: selectedOption.amount })}
|
||||
</span>
|
||||
{freeAmount && !isValidFreeAmount(freeAmount, selectedOption.amount) ? (
|
||||
<span className="mt-[4px] block font-mono text-[10.5px] font-bold text-orange-dark">
|
||||
{t('form.invalid_free_amount')}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
) : null}
|
||||
{selectedOption ? (
|
||||
<p className="mt-[6px] font-mono text-[10.5px] opacity-70">
|
||||
{config.data.payment_options.find((o) => o.id === paymentOptionId)
|
||||
?.requires_mecenat_info
|
||||
{selectedOption.requires_mecenat_info
|
||||
? t('publicForm.mecenat.afterSubmitHint')
|
||||
: t('publicForm.sumup.afterSubmitHint')}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user