Formulaire public : paiement de la cotisation via l'API SumUp (remplace Yapla)
ci-cd / build-backend (push) Failing after 4m12s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped

Remplace le simple lien Yapla statique par une vraie intégration API SumUp :
checkout hébergé créé à la soumission du formulaire, statut de paiement
(PENDING/PAID/FAILED/EXPIRED) tenu à jour par un webhook SumUp et visible sur
la fiche du bénévole. La cotisation n'est plus un montant unique : la
coordination configure autant de types de paiement qu'elle veut (libellé +
montant, ex. « Cotisation classique », « Mécénat »), et le bénévole en
choisit un à l'inscription — le libellé et le montant choisis sont figés sur
le bénévole, indépendamment d'une modification ultérieure de la config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-29 19:56:04 +02:00
co-authored by Claude Sonnet 5
parent 0be5daa89c
commit 0bd38a6465
25 changed files with 1457 additions and 125 deletions
+24 -13
View File
@@ -92,19 +92,19 @@ async function checkPublicForm() {
const beforeCount = (await api.volunteers.list(e)).length;
await api.forms.setFields(e, {
fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT'],
fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT', 'WILLING_TO_PAY_FEE'],
});
const config = await api.forms.getPublicConfig(e);
if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut');
if (config.positions.length !== positions.length) {
throw new Error('formulaire public : postes manquants dans la config publique');
}
if (config.has_logo !== false || config.description !== null || config.yapla_url !== null) {
throw new Error('formulaire public : logo/description/lien Yapla devraient être vides par défaut');
if (config.has_logo !== false || config.description !== null || config.sumup_enabled !== false) {
throw new Error('formulaire public : logo/description/SumUp 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 et le lien Yapla sont
// qu'exécuté ici) ; seul le texte d'introduction et la config SumUp sont
// vérifiables ici.
await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' });
const configWithDescription = await api.forms.getPublicConfig(e);
@@ -112,16 +112,24 @@ async function checkPublicForm() {
throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
}
let invalidYaplaUrlRejectedWith = 'non';
let invalidSumupAmountRejectedWith = 'non';
try {
await api.forms.setOpen(e, { yapla_url: 'yapla.com/exemple' });
await api.forms.paymentOptions.create(e, { label: 'Test invalide', amount: 0 });
} catch (err) {
invalidYaplaUrlRejectedWith = err instanceof ApiError ? err.code : String(err);
invalidSumupAmountRejectedWith = 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');
await api.forms.setOpen(e, {
sumup_merchant_code: 'MXXXXXXX',
sumup_api_key: 'sup_sk_test',
});
const classique = await api.forms.paymentOptions.create(e, {
label: 'Cotisation classique',
amount: 15,
});
await api.forms.paymentOptions.create(e, { label: 'Mécénat', amount: 50 });
const configWithSumup = await api.forms.getPublicConfig(e);
if (!configWithSumup.sumup_enabled || configWithSumup.payment_options.length !== 2) {
throw new Error('formulaire public : la configuration SumUp ne revient pas correctement');
}
const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date);
@@ -161,6 +169,7 @@ async function checkPublicForm() {
comment: 'Créé par le smoke test',
position_wish_ids: positions.slice(0, 2).map((p) => p.id),
artist_wish_ids: artists[0] ? [artists[0].id] : [],
payment_option_id: classique.id,
});
const created = await api.volunteers.get(e, result.volunteer_id);
const afterCount = (await api.volunteers.list(e)).length;
@@ -170,12 +179,14 @@ async function checkPublicForm() {
{
formOpenByDefault: config.is_open,
descriptionRoundTrips: configWithDescription.description,
invalidYaplaUrlRejectedWith,
yaplaUrlRoundTrips: configWithYapla.yapla_url,
invalidSumupAmountRejectedWith,
sumupEnabledRoundTrips: configWithSumup.sumup_enabled,
tooManyPositionsRejectedWith,
tooManyArtistsRejectedWith,
createdVolunteerName: `${created.first_name} ${created.last_name}`,
createdVolunteerAppearsInList: afterCount === beforeCount + 1,
sumupCheckoutUrlReturned: result.sumup_checkout_url !== null,
createdVolunteerFeeLabel: created.fee_payment_label,
},
null,
2,
+40
View File
@@ -24,6 +24,8 @@ import type {
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionUpdate,
FormTemplateUpdate,
ImportMapping,
PlanningNotificationRequest,
@@ -633,6 +635,44 @@ export function useUploadFormLogo() {
});
}
/** Types de paiement de la cotisation (COORDINATION) — voir `FormPaymentOptionRead`. */
export function useFormPaymentOptions() {
return useQuery({
queryKey: ['formPaymentOptions', edition],
queryFn: () => api.forms.paymentOptions.list(edition),
});
}
function useInvalidateFormPaymentOptions() {
const client = useQueryClient();
return () => client.invalidateQueries({ queryKey: ['formPaymentOptions', edition] });
}
export function useCreateFormPaymentOption() {
const invalidate = useInvalidateFormPaymentOptions();
return useMutation({
mutationFn: (body: FormPaymentOptionCreate) => api.forms.paymentOptions.create(edition, body),
onSuccess: invalidate,
});
}
export function useUpdateFormPaymentOption() {
const invalidate = useInvalidateFormPaymentOptions();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: FormPaymentOptionUpdate }) =>
api.forms.paymentOptions.update(edition, id, body),
onSuccess: invalidate,
});
}
export function useDeleteFormPaymentOption() {
const invalidate = useInvalidateFormPaymentOptions();
return useMutation({
mutationFn: (id: string) => api.forms.paymentOptions.remove(edition, id),
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
+18
View File
@@ -14,6 +14,8 @@ import type {
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionUpdate,
FormTemplateUpdate,
ImportMapping,
LoginRequest,
@@ -296,6 +298,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
if (body.willing_to_pay_fee !== undefined) {
form.append('willing_to_pay_fee', String(body.willing_to_pay_fee));
}
if (body.payment_option_id) form.append('payment_option_id', body.payment_option_id);
if (body.position_wish_ids) {
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
}
@@ -305,6 +308,21 @@ export function createHttpApi(baseUrl: string): PlanningApi {
if (body.photo) form.append('photo', body.photo);
return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form });
},
paymentOptions: {
list: (editionId) => request(`${base(editionId)}/form/payment-options`),
create: (editionId, body: FormPaymentOptionCreate) =>
request(`${base(editionId)}/form/payment-options`, {
method: 'POST',
body: JSON.stringify(body),
}),
update: (editionId, id, body: FormPaymentOptionUpdate) =>
request(`${base(editionId)}/form/payment-options/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (editionId, id) =>
request(`${base(editionId)}/form/payment-options/${id}`, { method: 'DELETE' }),
},
},
positionSheets: {
generate: async (editionId, body: PositionSheetsRequest) => {
+11 -2
View File
@@ -9,6 +9,7 @@ import type {
DayPartRead,
EditionRead,
FormFieldType,
FormPaymentOptionRead,
PositionCategory,
PositionRead,
ShiftRead,
@@ -56,8 +57,10 @@ export interface MockState {
logo: string | null; // data URL
logoType: string | null;
description: string | null;
yaplaUrl: string | null;
sumupMerchantCode: string | null;
sumupApiKey: string | null;
};
formPaymentOptions: FormPaymentOptionRead[];
/** volunteerId -> photo (formulaire public), data URL. */
photos: Map<string, string>;
seq: number;
@@ -273,6 +276,10 @@ export function buildMockState(): MockState {
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: NOW,
updated_at: NOW,
});
@@ -447,8 +454,10 @@ export function buildMockState(): MockState {
fields: [],
},
formTemplate: {
isOpen: true, fields: [], logo: null, logoType: null, description: null, yaplaUrl: null,
isOpen: true, fields: [], logo: null, logoType: null, description: null,
sumupMerchantCode: null, sumupApiKey: null,
},
formPaymentOptions: [],
photos: new Map(),
seq,
};
+107 -9
View File
@@ -20,6 +20,9 @@ import type {
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionRead,
FormPaymentOptionUpdate,
FormSubmissionResult,
FormTemplateRead,
FormTemplateUpdate,
@@ -122,15 +125,26 @@ const fileToDataUrl = (file: File): Promise<string> =>
reader.readAsDataURL(file);
});
const sumupEnabled = (): boolean =>
Boolean(
state.formTemplate.sumupMerchantCode &&
state.formTemplate.sumupApiKey &&
state.formPaymentOptions.length > 0,
);
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,
yapla_url: state.formTemplate.yaplaUrl,
sumup_merchant_code: state.formTemplate.sumupMerchantCode,
sumup_api_key_set: state.formTemplate.sumupApiKey !== null,
});
const sortedPaymentOptions = (): FormPaymentOptionRead[] =>
[...state.formPaymentOptions].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
// PDF valide minimal (une page blanche) — remplace la vraie génération
// (réservée au back-end réel) pour que le téléchargement fonctionne en mode
// démonstration.
@@ -856,6 +870,10 @@ export function createMockApi(): PlanningApi {
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now,
updated_at: now,
};
@@ -1191,6 +1209,10 @@ export function createMockApi(): PlanningApi {
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now,
updated_at: now,
};
@@ -1484,11 +1506,11 @@ 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;
if (body.sumup_merchant_code !== undefined) {
state.formTemplate.sumupMerchantCode = body.sumup_merchant_code;
}
if (body.sumup_api_key !== undefined) {
state.formTemplate.sumupApiKey = body.sumup_api_key || null;
}
return delay<FormTemplateRead>(formTemplateRead());
},
@@ -1518,9 +1540,62 @@ export function createMockApi(): PlanningApi {
: [],
has_logo: state.formTemplate.logo !== null,
description: state.formTemplate.description,
yapla_url: state.formTemplate.yaplaUrl,
sumup_enabled: sumupEnabled(),
payment_options: sortedPaymentOptions().map((o) => ({
id: o.id,
label: o.label,
amount: o.amount,
})),
});
},
paymentOptions: {
list: (): Promise<FormPaymentOptionRead[]> => delay(sortedPaymentOptions()),
create: (_edition, body: FormPaymentOptionCreate) => {
if (body.amount <= 0) throw new ApiError(422, 'form.invalid_sumup_amount');
const label = body.label.trim();
if (state.formPaymentOptions.some((o) => o.label === label)) {
throw new ApiError(409, 'form.duplicate_payment_option_label');
}
const now = touch();
const option: FormPaymentOptionRead = {
id: `pay${state.seq++}`,
edition_id: state.edition.id,
label,
amount: body.amount,
order: body.order ?? 0,
created_at: now,
updated_at: now,
};
state.formPaymentOptions.push(option);
return delay<FormPaymentOptionRead>(option);
},
update: (_edition, id, body: FormPaymentOptionUpdate) => {
const option = state.formPaymentOptions.find((o) => o.id === id) ?? notFound();
if (body.amount !== undefined && body.amount <= 0) {
throw new ApiError(422, 'form.invalid_sumup_amount');
}
const label = body.label !== undefined ? body.label.trim() : option.label;
if (
label !== option.label &&
state.formPaymentOptions.some((o) => o.id !== id && o.label === label)
) {
throw new ApiError(409, 'form.duplicate_payment_option_label');
}
Object.assign(option, {
label,
...(body.amount !== undefined ? { amount: body.amount } : {}),
...(body.order !== undefined ? { order: body.order } : {}),
updated_at: touch(),
});
return delay<FormPaymentOptionRead>(option);
},
remove: (_edition, id) => {
const index = state.formPaymentOptions.findIndex((o) => o.id === id);
if (index === -1) notFound();
state.formPaymentOptions.splice(index, 1);
return delay(undefined);
},
},
submitPublic: async (_edition, body: PublicFormSubmission) => {
if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed');
if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed');
@@ -1539,6 +1614,12 @@ export function createMockApi(): PlanningApi {
throw new ApiError(422, 'form.too_many_artists_same_day');
}
let paymentOption: FormPaymentOptionRead | null = null;
if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) {
paymentOption = state.formPaymentOptions.find((o) => o.id === body.payment_option_id) ?? null;
if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option');
}
const now = touch();
const volunteer: VolunteerRead = {
id: `v${state.seq++}`,
@@ -1557,8 +1638,12 @@ export function createMockApi(): PlanningApi {
: null,
self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE')
? Boolean(body.willing_to_pay_fee)
? Boolean(body.willing_to_pay_fee) || paymentOption !== null
: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now,
updated_at: now,
};
@@ -1568,9 +1653,22 @@ export function createMockApi(): PlanningApi {
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
}
// Simule la création d'un checkout SumUp (pas de vrai appel réseau
// en mode démonstration) — voir `app/api/forms.py:_create_fee_checkout`.
let checkoutUrl: string | null = null;
if (paymentOption && state.formTemplate.sumupMerchantCode && state.formTemplate.sumupApiKey) {
volunteer.fee_payment_label = paymentOption.label;
volunteer.fee_amount = paymentOption.amount;
volunteer.fee_payment_status = 'PENDING';
checkoutUrl = `https://checkout.sumup.com/pay/mock-${volunteer.id}`;
}
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
return delay<FormSubmissionResult>({ volunteer_id: volunteer.id });
return delay<FormSubmissionResult>({
volunteer_id: volunteer.id,
sumup_checkout_url: checkoutUrl,
});
},
},
+59 -4
View File
@@ -113,10 +113,20 @@ export interface VolunteerRead {
companion_wish_note: string | null;
self_declared_available: boolean;
willing_to_pay_fee: boolean;
/** Paiement de la cotisation via SumUp (voir schema forms) —
* `fee_payment_label`/`fee_amount` : copie figée du type de paiement
* choisi (pas la config actuelle). `fee_payment_status` reste `null` tant
* qu'aucun checkout n'a jamais été créé pour ce bénévole. */
fee_payment_label: string | null;
fee_amount: number | null;
fee_payment_status: FeePaymentStatus | null;
fee_paid_at: string | null;
created_at: string;
updated_at: string;
}
export type FeePaymentStatus = 'PENDING' | 'PAID' | 'FAILED' | 'EXPIRED';
export interface VolunteerCreate {
first_name: string;
last_name: string;
@@ -573,14 +583,43 @@ export interface FormTemplateRead {
fields: FormFieldType[];
has_logo: boolean;
description: string | null;
yapla_url: string | null;
sumup_merchant_code: string | null;
/** `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est défini. */
sumup_api_key_set: boolean;
}
/** `undefined`/absent = inchangé pour chaque champ. */
/** `undefined`/absent = inchangé pour chaque champ. Chaîne vide sur
* `sumup_api_key` efface le secret enregistré. */
export interface FormTemplateUpdate {
is_open?: boolean;
description?: string | null;
yapla_url?: string | null;
sumup_merchant_code?: string | null;
sumup_api_key?: string | null;
}
/** Un type de paiement proposé sur le formulaire public (ex. « Cotisation
* classique », « Mécénat ») — libellé + montant fixe, configurés librement
* par la coordination (comme les créneaux types `DayPart`). */
export interface FormPaymentOptionRead {
id: string;
edition_id: string;
label: string;
amount: number;
order: number;
created_at: string;
updated_at: string;
}
export interface FormPaymentOptionCreate {
label: string;
amount: number;
order?: number;
}
export interface FormPaymentOptionUpdate {
label?: string;
amount?: number;
order?: number;
}
export interface PublicPositionOption {
@@ -594,6 +633,14 @@ export interface PublicArtistOption {
performance_date: string;
}
/** Type de paiement affiché sur le formulaire public — un montant n'est pas
* un secret, contrairement aux identifiants SumUp. */
export interface PublicPaymentOption {
id: string;
label: string;
amount: number;
}
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
export interface PublicFormConfig {
is_open: boolean;
@@ -602,11 +649,16 @@ export interface PublicFormConfig {
artists: PublicArtistOption[];
has_logo: boolean;
description: string | null;
yapla_url: string | null;
/** `true` si l'édition a configuré SumUp (identifiants + au moins un type
* de paiement). */
sumup_enabled: boolean;
payment_options: PublicPaymentOption[];
}
export interface FormSubmissionResult {
volunteer_id: string;
/** Lien de paiement hébergé SumUp, si un checkout a pu être créé. */
sumup_checkout_url: string | null;
}
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
@@ -622,6 +674,9 @@ export interface PublicFormSubmission {
companion_wish_note?: string;
available?: boolean;
willing_to_pay_fee?: boolean;
/** Type de paiement choisi (voir `PublicPaymentOption`) — `undefined` =
* aucun (le bénévole ne participe pas à la cotisation). */
payment_option_id?: string;
position_wish_ids?: string[];
artist_wish_ids?: string[];
photo?: File | null;
+14
View File
@@ -24,6 +24,9 @@ import type {
EditionRead,
EditionUpdate,
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionRead,
FormPaymentOptionUpdate,
FormSubmissionResult,
FormTemplateRead,
FormTemplateUpdate,
@@ -173,6 +176,17 @@ export interface PlanningApi {
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
logoBlob(editionId: string): Promise<Blob | null>;
/** Types de paiement de la cotisation (COORDINATION uniquement). */
paymentOptions: {
list(editionId: string): Promise<FormPaymentOptionRead[]>;
create(editionId: string, body: FormPaymentOptionCreate): Promise<FormPaymentOptionRead>;
update(
editionId: string,
id: string,
body: FormPaymentOptionUpdate,
): Promise<FormPaymentOptionRead>;
remove(editionId: string, id: string): Promise<void>;
};
};
positionSheets: {
/** Feuilles de poste PDF (une page par poste et par jour). */
+23 -7
View File
@@ -381,6 +381,10 @@
"form.field.available": "Case « je suis disponible »",
"form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.",
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
"config.vol.feeStatus.pending": "Cotisation : paiement en attente",
"config.vol.feeStatus.paid": "Cotisation payée ✓",
"config.vol.feeStatus.failed": "Cotisation : paiement échoué",
"config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré",
"form.field.photo": "Une magnifique photo de toi :",
"form.field.comment": "Commentaire",
"form.link.title": "Lien du formulaire",
@@ -401,18 +405,28 @@
"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)…",
"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/...",
"form.customize.sumup.title": "Paiement de la cotisation (SumUp)",
"form.customize.sumup.help": "Facultatif : identifiants de votre compte marchand SumUp, et au moins un type de paiement ci-dessous (libellé + montant, ex. « Cotisation classique », « Mécénat »). Une fois les deux renseignés, le bénévole choisit un type à l'inscription et se voit proposer un lien de paiement juste après.",
"form.customize.sumup.merchantCode": "Code marchand",
"form.customize.sumup.merchantCode.placeholder": "MXXXXXXX",
"form.customize.sumup.apiKey": "Clé API",
"form.customize.sumup.apiKey.set": "(déjà enregistrée)",
"form.customize.sumup.apiKey.placeholder": "sup_sk_...",
"form.customize.paymentOptions.title": "Types de paiement",
"form.customize.paymentOptions.label": "Libellé (ex. Cotisation classique, Mécénat…)",
"form.customize.paymentOptions.add": "Ajouter",
"publicForm.title": "Inscription bénévole",
"publicForm.positionWishes.count": "{count} / {max}",
"publicForm.artistWishes.none": "— Aucun —",
"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 de 20€",
"publicForm.field.willingToPayFee": "Je participe à la cotisation bénévole",
"publicForm.field.paymentOption": "Cotisation",
"publicForm.paymentOption.none": "Je ne participe pas",
"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.sumup.afterSubmitHint": "Un lien de paiement en ligne (SumUp) te sera proposé juste après l'envoi de ton inscription.",
"publicForm.sumup.hint": "Le paiement se fait en ligne, via SumUp.",
"publicForm.sumup.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",
@@ -428,7 +442,9 @@
"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.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
"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.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.",
+155 -20
View File
@@ -2,9 +2,13 @@ import { useEffect, useRef, useState } from 'react';
import { ApiError } from '@/api';
import {
useCreateFormPaymentOption,
useDeleteFormPaymentOption,
useFormLogoUrl,
useFormPaymentOptions,
useFormTemplate,
useSetFormFields,
useUpdateFormPaymentOption,
useUpdateFormTemplate,
useUploadFormLogo,
} from '@/api/hooks';
@@ -60,11 +64,11 @@ const FIELD_HELP_KEY: Record<FormFieldType, string | null> = {
export function FormBuilderPage() {
const template = useFormTemplate();
const setFields = useSetFormFields();
// Trois instances indépendantes du même mutateur (même endpoint PATCH) :
// Quatre 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 saveSumUp = useUpdateFormTemplate();
const uploadLogo = useUploadFormLogo();
const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt);
const logoInputRef = useRef<HTMLInputElement>(null);
@@ -72,7 +76,8 @@ export function FormBuilderPage() {
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
const [copied, setCopied] = useState(false);
const [description, setDescription] = useState('');
const [yaplaUrl, setYaplaUrl] = useState('');
const [sumupMerchantCode, setSumupMerchantCode] = useState('');
const [sumupApiKey, setSumupApiKey] = useState('');
useEffect(() => {
if (template.data) setEnabled(new Set(template.data.fields));
@@ -81,7 +86,7 @@ export function FormBuilderPage() {
if (template.data) setDescription(template.data.description ?? '');
}, [template.data]);
useEffect(() => {
if (template.data) setYaplaUrl(template.data.yapla_url ?? '');
if (template.data) setSumupMerchantCode(template.data.sumup_merchant_code ?? '');
}, [template.data]);
if (template.isLoading || !enabled) return <Loading />;
@@ -241,37 +246,167 @@ export function FormBuilderPage() {
</div>
</div>
<div className="flex flex-col gap-2 border-t-2 border-ink p-3">
<div className="flex flex-col gap-3 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')} />
<FieldLabel>{t('form.customize.sumup.title')}</FieldLabel>
<HelpTip text={t('form.customize.sumup.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="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block">
<FieldLabel>{t('form.customize.sumup.merchantCode')}</FieldLabel>
<input
type="text"
value={sumupMerchantCode}
onChange={(e) => setSumupMerchantCode(e.target.value)}
placeholder={t('form.customize.sumup.merchantCode.placeholder')}
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
/>
</label>
<label className="block">
<FieldLabel>
{t('form.customize.sumup.apiKey')}{' '}
{template.data?.sumup_api_key_set ? (
<span className="font-mono text-[9.5px] font-bold text-green">
{t('form.customize.sumup.apiKey.set')}
</span>
) : null}
</FieldLabel>
<input
type="password"
value={sumupApiKey}
onChange={(e) => setSumupApiKey(e.target.value)}
placeholder={t('form.customize.sumup.apiKey.placeholder')}
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
/>
</label>
</div>
<div className="flex items-center gap-3">
<Btn
variant="ghost"
onClick={() => saveYaplaUrl.mutate({ yapla_url: yaplaUrl.trim() || null })}
disabled={saveYaplaUrl.isPending}
onClick={() =>
saveSumUp.mutate(
{
sumup_merchant_code: sumupMerchantCode.trim() || null,
// Laissé vide = inchangé (le secret déjà enregistré n'est
// jamais renvoyé par l'API, donc jamais pré-rempli ici) —
// voir `sumup_api_key_set` pour savoir s'il y en a un.
...(sumupApiKey.trim() ? { sumup_api_key: sumupApiKey.trim() } : {}),
},
{ onSuccess: () => setSumupApiKey('') },
)
}
disabled={saveSumUp.isPending}
>
{saveYaplaUrl.isPending ? t('form.saving') : t('form.save')}
{saveSumUp.isPending ? t('form.saving') : t('form.save')}
</Btn>
{saveYaplaUrl.isSuccess ? (
{saveSumUp.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
) : null}
{saveYaplaUrl.isError ? (
{saveSumUp.isError ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
{errorMessage(saveYaplaUrl.error)}
{errorMessage(saveSumUp.error)}
</span>
) : null}
</div>
<div className="border-t-2 border-ink pt-3">
<FieldLabel>{t('form.customize.paymentOptions.title')}</FieldLabel>
<PaymentOptionsForm />
</div>
</div>
</Panel>
</div>
);
}
/** Types de paiement (libellé + montant fixe) — liste libre, même principe
* que `DayPartsForm` (`ConfigRules.tsx`) : édition en ligne (au blur),
* ajout en bas, suppression immédiate. */
function PaymentOptionsForm() {
const options = useFormPaymentOptions();
const create = useCreateFormPaymentOption();
const update = useUpdateFormPaymentOption();
const remove = useDeleteFormPaymentOption();
const [label, setLabel] = useState('');
const [amount, setAmount] = useState('');
if (options.isLoading) return <div className="font-mono text-[10px] opacity-60">…</div>;
if (options.error) return <ErrorNote error={options.error} />;
const rows = [...(options.data ?? [])].sort(
(a, b) => a.order - b.order || a.label.localeCompare(b.label),
);
return (
<div className="mt-2 flex flex-col gap-[6px]">
{rows.map((o) => (
<div key={o.id} className="flex items-center gap-1">
<input
defaultValue={o.label}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== o.label) update.mutate({ id: o.id, body: { label: v } });
}}
className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold"
/>
<input
type="number"
min="0"
step="0.01"
defaultValue={o.amount}
onBlur={(e) => {
const v = Number(e.target.value);
if (v > 0 && v !== o.amount) update.mutate({ id: o.id, body: { amount: v } });
}}
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>
<button
type="button"
onClick={() => remove.mutate(o.id)}
aria-label={t('action.remove')}
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
>
✕
</button>
</div>
))}
<div className="mt-1 flex items-center gap-1 border-t-2 border-ink pt-2">
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('form.customize.paymentOptions.label')}
className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold"
/>
<input
type="number"
min="0"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="20"
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>
<button
type="button"
disabled={!label.trim() || !(Number(amount) > 0) || create.isPending}
onClick={() =>
create.mutate(
{ label: label.trim(), amount: Number(amount), order: rows.length },
{ onSuccess: () => { setLabel(''); setAmount(''); } },
)
}
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
>
{t('form.customize.paymentOptions.add')}
</button>
</div>
{create.isError ? <ErrorNote error={create.error} /> : null}
{update.isError ? <ErrorNote error={update.error} /> : null}
</div>
);
}
+62 -18
View File
@@ -138,6 +138,7 @@ export function PublicSignupPage() {
const [companionNote, setCompanionNote] = useState('');
const [available, setAvailable] = useState(false);
const [willingToPayFee, setWillingToPayFee] = useState(false);
const [paymentOptionId, setPaymentOptionId] = useState('');
const [positionIds, setPositionIds] = useState<string[]>([]);
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
const [photo, setPhoto] = useState<File | null>(null);
@@ -145,6 +146,7 @@ export function PublicSignupPage() {
const [captchaToken, setCaptchaToken] = useState('');
const [submitError, setSubmitError] = useState<string | null>(null);
const [volunteerId, setVolunteerId] = useState<string | null>(null);
const [sumupCheckoutUrl, setSumupCheckoutUrl] = useState<string | null>(null);
const artistsByDay = useMemo(
() => groupArtistsByDay(config.data?.artists ?? []),
@@ -180,7 +182,24 @@ export function PublicSignupPage() {
<FormLogo url={logoUrl} />
<Panel>
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
<div className="flex flex-col gap-3 p-4">
<p className="font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
{sumupCheckoutUrl ? (
<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.sumup.hint')}
</span>
<a
href={sumupCheckoutUrl}
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.sumup.cta')}
</a>
</div>
) : null}
</div>
</Panel>
</div>
);
@@ -238,11 +257,16 @@ export function PublicSignupPage() {
companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined,
available: fields.has('AVAILABLE') ? available : undefined,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') ? willingToPayFee : undefined,
payment_option_id:
fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled
? paymentOptionId || undefined
: 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,
});
setVolunteerId(result.volunteer_id);
setSumupCheckoutUrl(result.sumup_checkout_url);
} catch (err) {
setSubmitError(errorMessage(err));
}
@@ -374,7 +398,43 @@ export function PublicSignupPage() {
</label>
) : null}
{fields.has('WILLING_TO_PAY_FEE') ? (
{fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled ? (
<div>
<FieldLabel>{t('publicForm.field.paymentOption')}</FieldLabel>
<div className="flex flex-col gap-[6px] border-2 border-ink bg-cream p-[9px]">
<label className="flex cursor-pointer items-center gap-[8px] font-mono text-[12px]">
<input
type="radio"
name="payment_option"
checked={paymentOptionId === ''}
onChange={() => setPaymentOptionId('')}
className="h-[15px] w-[15px] accent-ink"
/>
{t('publicForm.paymentOption.none')}
</label>
{config.data.payment_options.map((o) => (
<label
key={o.id}
className="flex cursor-pointer items-center gap-[8px] font-mono text-[12px]"
>
<input
type="radio"
name="payment_option"
checked={paymentOptionId === o.id}
onChange={() => setPaymentOptionId(o.id)}
className="h-[15px] w-[15px] accent-ink"
/>
{o.label} — {o.amount} €
</label>
))}
</div>
{paymentOptionId ? (
<p className="mt-[6px] font-mono text-[10.5px] opacity-70">
{t('publicForm.sumup.afterSubmitHint')}
</p>
) : null}
</div>
) : fields.has('WILLING_TO_PAY_FEE') ? (
<label className="flex cursor-pointer items-center gap-[8px]">
<input
type="checkbox"
@@ -386,22 +446,6 @@ 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>
@@ -160,6 +160,13 @@ function VolunteerAccessSection({
);
}
const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status']>, string> = {
PENDING: 'config.vol.feeStatus.pending',
PAID: 'config.vol.feeStatus.paid',
FAILED: 'config.vol.feeStatus.failed',
EXPIRED: 'config.vol.feeStatus.expired',
};
/**
* Réponses du formulaire public d'inscription (voir `app/api/forms.py`) —
* note de compagnon en texte libre (à résoudre manuellement en vrai vœu via
@@ -183,6 +190,10 @@ function PublicFormAnswers({
volunteer.willing_to_pay_fee;
if (!hasAnything) return null;
const feeStatusKey = volunteer.fee_payment_status
? FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status]
: null;
return (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>
@@ -228,6 +239,12 @@ function PublicFormAnswers({
/>
<span className="font-mono text-[11px]">{t('form.field.willingToPayFee')}</span>
</label>
{feeStatusKey ? (
<span className="border-2 border-ink bg-cream px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{volunteer.fee_payment_label ? `${volunteer.fee_payment_label} (${volunteer.fee_amount} €) — ` : ''}
{t(feeStatusKey)}
</span>
) : null}
</div>
</div>
);