Formulaire public : paiement de la cotisation via l'API SumUp (remplace Yapla)
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:
co-authored by
Claude Sonnet 5
parent
0be5daa89c
commit
0bd38a6465
+24
-13
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user