Inscription en deux temps, emails de confirmation et export Excel des bénévoles
Formulaire public — paiement en ligne : - Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la confirmation, webhook ou vérification en direct). - Type de paiement « mécénat » : étape d'adresse postale avant le paiement, conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur. - Page de confirmation au retour de SumUp, avec relance possible après échec/expiration sans ressaisir le formulaire. - « Je paierai sur place » remplace « je ne participe pas » ; tous les champs activés par la coordination sont désormais obligatoires. - La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le résultat réel du paiement (libellé, montant, statut). Emails : - Email de confirmation d'inscription à la création du bénévole (tient lieu de reçu de paiement quand une cotisation est payée en ligne). - Mise en forme HTML au thème de l'application (logo de l'édition en en-tête) pour tous les emails envoyés, texte brut conservé en repli. Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en noms lisibles, cotisation et mécénat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
68d42ee303
commit
a2b748566b
@@ -140,6 +140,7 @@ async function checkPublicForm() {
|
||||
first_name: 'Test',
|
||||
last_name: 'Smoke',
|
||||
hcaptcha_token: 'dev',
|
||||
email: 'test.smoke@example.org',
|
||||
artist_wish_ids: [sameDay[0].id, sameDay[1].id],
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -154,6 +155,7 @@ async function checkPublicForm() {
|
||||
first_name: 'Test',
|
||||
last_name: 'Smoke',
|
||||
hcaptcha_token: 'dev',
|
||||
email: 'test.smoke@example.org',
|
||||
position_wish_ids: positions.slice(0, 3).map((p) => p.id),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -161,6 +163,8 @@ async function checkPublicForm() {
|
||||
}
|
||||
}
|
||||
|
||||
// Paiement en ligne choisi : rien en base tant que la « banque » (mock) ne
|
||||
// confirme pas — voir CLAUDE.md § 2, Paiement de la cotisation.
|
||||
const result = await api.forms.submitPublic(e, {
|
||||
first_name: 'test',
|
||||
last_name: 'smoke',
|
||||
@@ -171,7 +175,22 @@ async function checkPublicForm() {
|
||||
artist_wish_ids: artists[0] ? [artists[0].id] : [],
|
||||
payment_option_id: classique.id,
|
||||
});
|
||||
const created = await api.volunteers.get(e, result.volunteer_id);
|
||||
if (result.volunteer_id || !result.pending_token) {
|
||||
throw new Error('formulaire public : un paiement en ligne ne devrait pas créer le bénévole tout de suite');
|
||||
}
|
||||
const midCount = (await api.volunteers.list(e)).length;
|
||||
if (midCount !== beforeCount) {
|
||||
throw new Error('formulaire public : le bénévole ne devrait pas encore compter en base');
|
||||
}
|
||||
|
||||
let status = await api.forms.getPendingStatus(e, result.pending_token);
|
||||
if (status.status === 'PENDING') {
|
||||
status = await api.forms.getPendingStatus(e, result.pending_token);
|
||||
}
|
||||
if (status.status !== 'PAID' || !status.volunteer_id) {
|
||||
throw new Error('formulaire public : le paiement simulé ne se confirme pas');
|
||||
}
|
||||
const created = await api.volunteers.get(e, status.volunteer_id);
|
||||
const afterCount = (await api.volunteers.list(e)).length;
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
FormPaymentOptionUpdate,
|
||||
FormTemplateUpdate,
|
||||
ImportMapping,
|
||||
MecenatInfoSubmit,
|
||||
PlanningNotificationRequest,
|
||||
PositionCreate,
|
||||
PositionSheetsRequest,
|
||||
@@ -384,6 +385,15 @@ export function useDeleteVolunteer() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Export Excel de la liste des bénévoles ; ne fait que retourner le
|
||||
* fichier (le composant appelant déclenche le téléchargement, voir
|
||||
* `useGenerateBadges`). */
|
||||
export function useExportVolunteers() {
|
||||
return useMutation({
|
||||
mutationFn: () => api.volunteers.exportXlsx(edition),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */
|
||||
export function useReplaceAvailability() {
|
||||
const invalidate = useInvalidate();
|
||||
@@ -713,3 +723,31 @@ export function useSubmitPublicForm() {
|
||||
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitMecenatInfo() {
|
||||
return useMutation({
|
||||
mutationFn: ({ token, body }: { token: string; body: MecenatInfoSubmit }) =>
|
||||
api.forms.submitMecenatInfo(edition, token, body),
|
||||
});
|
||||
}
|
||||
|
||||
/** Statut d'une inscription en attente de paiement en ligne — interrogé en
|
||||
* direct (voir CLAUDE.md § 2, Paiement de la cotisation) : la page de
|
||||
* confirmation reste en attente tant que `status` vaut `PENDING`. */
|
||||
export function usePendingStatus(token: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['pendingStatus', edition, token],
|
||||
queryFn: () => api.forms.getPendingStatus(edition, token as string),
|
||||
enabled: token !== null,
|
||||
// Arrête la vérification automatique après ~2 min (40 x 3 s) : au-delà,
|
||||
// un bouton manuel dans PaymentConfirmationView permet de réessayer.
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.status === 'PENDING' && query.state.dataUpdateCount < 40 ? 3000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRetryPendingPayment() {
|
||||
return useMutation({
|
||||
mutationFn: (token: string) => api.forms.retryPendingPayment(edition, token),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ImportMapping,
|
||||
LoginRequest,
|
||||
MagicLinkRequest,
|
||||
MecenatInfoSubmit,
|
||||
PlanningNotificationRequest,
|
||||
PositionCreate,
|
||||
PositionSheetsRequest,
|
||||
@@ -151,6 +152,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
planning: (editionId, id) =>
|
||||
request(`${base(editionId)}/volunteers/${id}/planning`),
|
||||
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
|
||||
exportXlsx: async (editionId) => {
|
||||
const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
|
||||
if (!blob) throw new ApiError(500, 'error.generic', []);
|
||||
return blob;
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
list: (editionId) => request(`${base(editionId)}/accounts`),
|
||||
@@ -295,9 +301,6 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
if (body.comment) form.append('comment', body.comment);
|
||||
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.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));
|
||||
@@ -323,6 +326,17 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
remove: (editionId, id) =>
|
||||
request(`${base(editionId)}/form/payment-options/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
submitMecenatInfo: (editionId, token, body: MecenatInfoSubmit) =>
|
||||
request(`${base(editionId)}/form/public/pending/${token}/mecenat-info`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
getPendingStatus: (editionId, token) =>
|
||||
request(`${base(editionId)}/form/public/pending/${token}`),
|
||||
retryPendingPayment: (editionId, token) =>
|
||||
request(`${base(editionId)}/form/public/pending/${token}/retry-payment`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
},
|
||||
positionSheets: {
|
||||
generate: async (editionId, body: PositionSheetsRequest) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
BadgeFieldRead,
|
||||
DayPartRead,
|
||||
EditionRead,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
FormPaymentOptionRead,
|
||||
PositionCategory,
|
||||
@@ -16,6 +17,37 @@ import type {
|
||||
VolunteerRead,
|
||||
} from '../schema';
|
||||
|
||||
/** Inscription en attente de paiement en ligne (mode démonstration) — voir
|
||||
* `PendingFormSubmission` côté back-end (CLAUDE.md § 2, Paiement de la
|
||||
* cotisation). `pollCount` simule le délai de confirmation réel (webhook
|
||||
* asynchrone) : la première vérification reste `PENDING`, la suivante
|
||||
* confirme — sans vrai réseau ni vrai SumUp en mode mock. */
|
||||
export interface MockPendingSubmission {
|
||||
id: string;
|
||||
editionId: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
comment: string | null;
|
||||
companionWishNote: string | null;
|
||||
available: boolean;
|
||||
positionIds: string[];
|
||||
artistIds: string[];
|
||||
photo: string | null; // data URL
|
||||
paymentLabel: string;
|
||||
paymentAmount: number;
|
||||
requiresMecenatInfo: boolean;
|
||||
mecenatAddress: string | null;
|
||||
mecenatCity: string | null;
|
||||
mecenatPostalCode: string | null;
|
||||
mecenatCountry: string | null;
|
||||
checkoutId: string | null;
|
||||
status: FeePaymentStatus | null;
|
||||
promotedVolunteerId: string | null;
|
||||
pollCount: number;
|
||||
}
|
||||
|
||||
const PART_LABELS: Record<string, string> = { M: 'Matin', A: 'Après-midi', S: 'Soirée', N: 'Nuit' };
|
||||
const PART_CODES: Record<string, string> = { M: 'MATIN', A: 'APRES_MIDI', S: 'SOIREE', N: 'NUIT' };
|
||||
const hhmm = (h: number) => `${String(h % 24).padStart(2, '0')}:00:00`;
|
||||
@@ -61,6 +93,9 @@ export interface MockState {
|
||||
sumupApiKey: string | null;
|
||||
};
|
||||
formPaymentOptions: FormPaymentOptionRead[];
|
||||
/** Inscriptions en attente de paiement en ligne (voir CLAUDE.md § 2,
|
||||
* Paiement de la cotisation) — clé : jeton public (id). */
|
||||
pendingSubmissions: Map<string, MockPendingSubmission>;
|
||||
/** volunteerId -> photo (formulaire public), data URL. */
|
||||
photos: Map<string, string>;
|
||||
seq: number;
|
||||
@@ -278,6 +313,10 @@ export function buildMockState(): MockState {
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
mecenat_address: null,
|
||||
mecenat_city: null,
|
||||
mecenat_postal_code: null,
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: NOW,
|
||||
@@ -458,6 +497,7 @@ export function buildMockState(): MockState {
|
||||
sumupMerchantCode: null, sumupApiKey: null,
|
||||
},
|
||||
formPaymentOptions: [],
|
||||
pendingSubmissions: new Map(),
|
||||
photos: new Map(),
|
||||
seq,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,8 @@ import type {
|
||||
ImportReport,
|
||||
ImportRowReport,
|
||||
MatrixResponse,
|
||||
MecenatInfoSubmit,
|
||||
PendingSubmissionStatus,
|
||||
PlanningNotificationRequest,
|
||||
PositionSheetsRequest,
|
||||
PublicFormConfig,
|
||||
@@ -45,7 +47,7 @@ import type {
|
||||
} from '../schema';
|
||||
import { getAuthToken } from '../session';
|
||||
import { ApiError, type PlanningApi } from '../types';
|
||||
import { buildMockState, type MockState } from './dataset';
|
||||
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
|
||||
|
||||
// Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le
|
||||
// comportement (chargements, invalidations) proche du back-end réel.
|
||||
@@ -159,6 +161,14 @@ const MOCK_PDF_BLOB = () =>
|
||||
{ type: 'application/pdf' },
|
||||
);
|
||||
|
||||
// Contenu factice (pas un vrai classeur) — la génération réelle (openpyxl)
|
||||
// est réservée au back-end ; en mode démonstration, seul le déclenchement
|
||||
// du téléchargement est simulé.
|
||||
const MOCK_XLSX_BLOB = () =>
|
||||
new Blob(['Export bénévoles (démonstration)'], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
|
||||
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||
|
||||
const addDays = (day: string, n: number): string => {
|
||||
@@ -197,6 +207,74 @@ function resolveWishes<T extends { id: string }>(
|
||||
return wanted;
|
||||
}
|
||||
|
||||
/** Comme `resolveWishes` mais silencieux : filtre les ids toujours valides
|
||||
* sans jamais lever — utilisé à la promotion d'une inscription en attente
|
||||
* (le paiement a déjà eu lieu, voir `promotePending`). */
|
||||
function filterKnownIds<T extends { id: string }>(pool: T[], ids: string[]): string[] {
|
||||
const known = new Set(pool.map((x) => x.id));
|
||||
return [...new Set(ids)].filter((id) => known.has(id));
|
||||
}
|
||||
|
||||
/** Paiement confirmé : crée enfin le `Volunteer` (voir le docstring de
|
||||
* `PendingFormSubmission` côté back-end) puis vide les champs nominatifs de
|
||||
* l'inscription en attente. */
|
||||
function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||
const now = touch();
|
||||
const volunteer: VolunteerRead = {
|
||||
id: `v${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
first_name: pending.firstName ?? '',
|
||||
last_name: pending.lastName ?? '',
|
||||
email: pending.email,
|
||||
phone: pending.phone,
|
||||
comment: pending.comment,
|
||||
artist_ids: filterKnownIds(state.artists, pending.artistIds),
|
||||
wished_position_ids: filterKnownIds(state.positions, pending.positionIds),
|
||||
wished_companion_ids: [],
|
||||
has_photo: pending.photo !== null,
|
||||
companion_wish_note: pending.companionWishNote,
|
||||
self_declared_available: pending.available,
|
||||
willing_to_pay_fee: true,
|
||||
fee_payment_label: pending.paymentLabel,
|
||||
fee_amount: pending.paymentAmount,
|
||||
mecenat_address: pending.mecenatAddress,
|
||||
mecenat_city: pending.mecenatCity,
|
||||
mecenat_postal_code: pending.mecenatPostalCode,
|
||||
mecenat_country: pending.mecenatCountry,
|
||||
fee_payment_status: 'PAID',
|
||||
fee_paid_at: now,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
if (pending.photo) state.photos.set(volunteer.id, pending.photo);
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
|
||||
pending.promotedVolunteerId = volunteer.id;
|
||||
pending.status = 'PAID';
|
||||
pending.firstName = null;
|
||||
pending.lastName = null;
|
||||
pending.email = null;
|
||||
pending.phone = null;
|
||||
pending.comment = null;
|
||||
pending.companionWishNote = null;
|
||||
pending.photo = null;
|
||||
pending.mecenatAddress = null;
|
||||
pending.mecenatCity = null;
|
||||
pending.mecenatPostalCode = null;
|
||||
pending.mecenatCountry = null;
|
||||
return volunteer;
|
||||
}
|
||||
|
||||
/** Simule la création (ou relance) d'un checkout SumUp — pas de vrai appel
|
||||
* réseau en mode démonstration, voir `app/api/forms.py:_create_pending_checkout`. */
|
||||
function createPendingCheckout(pending: MockPendingSubmission): string {
|
||||
pending.checkoutId = `co-mock-${state.seq++}`;
|
||||
pending.status = 'PENDING';
|
||||
pending.pollCount = 0;
|
||||
return `https://checkout.sumup.com/pay/mock-${pending.checkoutId}`;
|
||||
}
|
||||
|
||||
/** Vœux de compagnon : comme `resolveWishes`, plus le rejet de soi-même. */
|
||||
function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
|
||||
const resolved = resolveWishes(state.volunteers, ids, 'volunteer.unknown');
|
||||
@@ -872,6 +950,10 @@ export function createMockApi(): PlanningApi {
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
mecenat_address: null,
|
||||
mecenat_city: null,
|
||||
mecenat_postal_code: null,
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
@@ -935,6 +1017,7 @@ export function createMockApi(): PlanningApi {
|
||||
if (!dataUrl) return null;
|
||||
return (await fetch(dataUrl)).blob();
|
||||
},
|
||||
exportXlsx: () => delay(MOCK_XLSX_BLOB()),
|
||||
planning: (_edition, id) => {
|
||||
if (!state.volunteers.some((v) => v.id === id)) return notFound();
|
||||
const entries = state.assignments
|
||||
@@ -1211,6 +1294,10 @@ export function createMockApi(): PlanningApi {
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
mecenat_address: null,
|
||||
mecenat_city: null,
|
||||
mecenat_postal_code: null,
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
@@ -1545,6 +1632,7 @@ export function createMockApi(): PlanningApi {
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
amount: o.amount,
|
||||
requires_mecenat_info: o.requires_mecenat_info,
|
||||
})),
|
||||
});
|
||||
},
|
||||
@@ -1563,6 +1651,7 @@ export function createMockApi(): PlanningApi {
|
||||
label,
|
||||
amount: body.amount,
|
||||
order: body.order ?? 0,
|
||||
requires_mecenat_info: body.requires_mecenat_info ?? false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1585,6 +1674,9 @@ export function createMockApi(): PlanningApi {
|
||||
label,
|
||||
...(body.amount !== undefined ? { amount: body.amount } : {}),
|
||||
...(body.order !== undefined ? { order: body.order } : {}),
|
||||
...(body.requires_mecenat_info !== undefined
|
||||
? { requires_mecenat_info: body.requires_mecenat_info }
|
||||
: {}),
|
||||
updated_at: touch(),
|
||||
});
|
||||
return delay<FormPaymentOptionRead>(option);
|
||||
@@ -1604,8 +1696,18 @@ export function createMockApi(): PlanningApi {
|
||||
}
|
||||
|
||||
const fields = new Set(state.formTemplate.fields);
|
||||
const email = body.email?.trim() || null;
|
||||
const phone = body.phone?.trim() || null;
|
||||
const available = Boolean(body.available);
|
||||
if (fields.has('EMAIL') && !email) throw new ApiError(422, 'import.row.missing_required_field');
|
||||
if (fields.has('PHONE') && !phone) throw new ApiError(422, 'import.row.missing_required_field');
|
||||
if (fields.has('AVAILABLE') && !available) {
|
||||
throw new ApiError(422, 'import.row.missing_required_field');
|
||||
}
|
||||
|
||||
const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : [];
|
||||
if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes');
|
||||
if (positionIds.length) resolveWishes(state.positions, positionIds, 'position.unknown');
|
||||
const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : [];
|
||||
const artistDates = artistIds.map(
|
||||
(id) => state.artists.find((a) => a.id === id)?.performance_date,
|
||||
@@ -1613,6 +1715,7 @@ export function createMockApi(): PlanningApi {
|
||||
if (new Set(artistDates).size !== artistDates.length) {
|
||||
throw new ApiError(422, 'form.too_many_artists_same_day');
|
||||
}
|
||||
if (artistIds.length) resolveWishes(state.artists, artistIds, 'artist.unknown');
|
||||
|
||||
let paymentOption: FormPaymentOptionRead | null = null;
|
||||
if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) {
|
||||
@@ -1620,53 +1723,157 @@ export function createMockApi(): PlanningApi {
|
||||
if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option');
|
||||
}
|
||||
|
||||
const now = touch();
|
||||
const volunteer: VolunteerRead = {
|
||||
id: `v${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
first_name: titleCase(body.first_name.trim()),
|
||||
last_name: titleCase(body.last_name.trim()),
|
||||
email: fields.has('EMAIL') ? body.email?.trim() || null : null,
|
||||
phone: fields.has('PHONE') ? body.phone?.trim() || null : null,
|
||||
comment: fields.has('COMMENT') ? body.comment?.trim() || null : null,
|
||||
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
|
||||
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
|
||||
wished_companion_ids: [],
|
||||
has_photo: false,
|
||||
companion_wish_note: fields.has('COMPANION_WISH')
|
||||
? body.companion_wish_note?.trim() || null
|
||||
: 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) || paymentOption !== null
|
||||
: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
const comment = fields.has('COMMENT') ? body.comment?.trim() || null : null;
|
||||
const companionWishNote = fields.has('COMPANION_WISH')
|
||||
? body.companion_wish_note?.trim() || null
|
||||
: null;
|
||||
const photo =
|
||||
fields.has('PHOTO') && body.photo ? await fileToDataUrl(body.photo) : null;
|
||||
|
||||
if (paymentOption === null) {
|
||||
// Pas de paiement en ligne (case désactivée, ou « je paierai sur
|
||||
// place ») : rien à attendre, le bénévole est créé tout de suite.
|
||||
const now = touch();
|
||||
const volunteer: VolunteerRead = {
|
||||
id: `v${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
first_name: titleCase(body.first_name.trim()),
|
||||
last_name: titleCase(body.last_name.trim()),
|
||||
email: fields.has('EMAIL') ? email : null,
|
||||
phone: fields.has('PHONE') ? phone : null,
|
||||
comment,
|
||||
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
|
||||
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
|
||||
wished_companion_ids: [],
|
||||
has_photo: photo !== null,
|
||||
companion_wish_note: companionWishNote,
|
||||
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
mecenat_address: null,
|
||||
mecenat_city: null,
|
||||
mecenat_postal_code: null,
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
if (photo) state.photos.set(volunteer.id, photo);
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: volunteer.id,
|
||||
pending_token: null,
|
||||
requires_mecenat_info: false,
|
||||
sumup_checkout_url: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Paiement en ligne choisi : rien en base tant que le paiement n'est
|
||||
// pas confirmé — voir `PendingFormSubmission` côté back-end.
|
||||
const pending: MockPendingSubmission = {
|
||||
id: `pend${state.seq++}`,
|
||||
editionId: state.edition.id,
|
||||
firstName: titleCase(body.first_name.trim()),
|
||||
lastName: titleCase(body.last_name.trim()),
|
||||
email: fields.has('EMAIL') ? email : null,
|
||||
phone: fields.has('PHONE') ? phone : null,
|
||||
comment,
|
||||
companionWishNote,
|
||||
available: fields.has('AVAILABLE') ? available : false,
|
||||
positionIds,
|
||||
artistIds,
|
||||
photo,
|
||||
paymentLabel: paymentOption.label,
|
||||
paymentAmount: paymentOption.amount,
|
||||
requiresMecenatInfo: paymentOption.requires_mecenat_info,
|
||||
mecenatAddress: null,
|
||||
mecenatCity: null,
|
||||
mecenatPostalCode: null,
|
||||
mecenatCountry: null,
|
||||
checkoutId: null,
|
||||
status: null,
|
||||
promotedVolunteerId: null,
|
||||
pollCount: 0,
|
||||
};
|
||||
state.pendingSubmissions.set(pending.id, pending);
|
||||
|
||||
if (fields.has('PHOTO') && body.photo) {
|
||||
volunteer.has_photo = true;
|
||||
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
|
||||
if (pending.requiresMecenatInfo) {
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: null,
|
||||
pending_token: pending.id,
|
||||
requires_mecenat_info: true,
|
||||
sumup_checkout_url: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 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, []);
|
||||
const checkoutUrl = createPendingCheckout(pending);
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: volunteer.id,
|
||||
volunteer_id: null,
|
||||
pending_token: pending.id,
|
||||
requires_mecenat_info: false,
|
||||
sumup_checkout_url: checkoutUrl,
|
||||
});
|
||||
},
|
||||
submitMecenatInfo: (_edition, token, body: MecenatInfoSubmit) => {
|
||||
const pending = state.pendingSubmissions.get(token) ?? notFound();
|
||||
if (pending.promotedVolunteerId !== null) {
|
||||
throw new ApiError(422, 'form.pending_already_paid');
|
||||
}
|
||||
if (!pending.requiresMecenatInfo) {
|
||||
throw new ApiError(422, 'form.pending_not_awaiting_info');
|
||||
}
|
||||
const address = body.address.trim();
|
||||
const city = body.city.trim();
|
||||
const postalCode = body.postal_code.trim();
|
||||
const country = body.country.trim();
|
||||
if (!address || !city || !postalCode || !country) {
|
||||
throw new ApiError(422, 'import.row.missing_required_field');
|
||||
}
|
||||
pending.mecenatAddress = address;
|
||||
pending.mecenatCity = city;
|
||||
pending.mecenatPostalCode = postalCode;
|
||||
pending.mecenatCountry = country;
|
||||
const checkoutUrl = createPendingCheckout(pending);
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: null,
|
||||
pending_token: pending.id,
|
||||
requires_mecenat_info: false,
|
||||
sumup_checkout_url: checkoutUrl,
|
||||
});
|
||||
},
|
||||
getPendingStatus: (_edition, token) => {
|
||||
const pending = state.pendingSubmissions.get(token) ?? notFound();
|
||||
// Simule le délai de confirmation réel (webhook asynchrone) : la
|
||||
// première vérification reste PENDING, la suivante confirme.
|
||||
if (pending.promotedVolunteerId === null && pending.checkoutId !== null) {
|
||||
if (pending.pollCount === 0) {
|
||||
pending.pollCount += 1;
|
||||
} else {
|
||||
promotePending(pending);
|
||||
}
|
||||
}
|
||||
return delay<PendingSubmissionStatus>({
|
||||
status: pending.promotedVolunteerId !== null ? 'PAID' : (pending.status ?? 'PENDING'),
|
||||
label: pending.paymentLabel,
|
||||
amount: pending.paymentAmount,
|
||||
volunteer_id: pending.promotedVolunteerId,
|
||||
});
|
||||
},
|
||||
retryPendingPayment: (_edition, token) => {
|
||||
const pending = state.pendingSubmissions.get(token) ?? notFound();
|
||||
if (pending.promotedVolunteerId !== null) {
|
||||
throw new ApiError(422, 'form.pending_already_paid');
|
||||
}
|
||||
if (pending.requiresMecenatInfo && pending.mecenatAddress === null) {
|
||||
throw new ApiError(422, 'form.pending_not_awaiting_info');
|
||||
}
|
||||
const checkoutUrl = createPendingCheckout(pending);
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: null,
|
||||
pending_token: pending.id,
|
||||
requires_mecenat_info: false,
|
||||
sumup_checkout_url: checkoutUrl,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -121,6 +121,12 @@ export interface VolunteerRead {
|
||||
fee_amount: number | null;
|
||||
fee_payment_status: FeePaymentStatus | null;
|
||||
fee_paid_at: string | null;
|
||||
/** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type
|
||||
* de paiement « mécénat ». */
|
||||
mecenat_address: string | null;
|
||||
mecenat_city: string | null;
|
||||
mecenat_postal_code: string | null;
|
||||
mecenat_country: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -606,6 +612,9 @@ export interface FormPaymentOptionRead {
|
||||
label: string;
|
||||
amount: number;
|
||||
order: number;
|
||||
/** 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;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -614,12 +623,14 @@ export interface FormPaymentOptionCreate {
|
||||
label: string;
|
||||
amount: number;
|
||||
order?: number;
|
||||
requires_mecenat_info?: boolean;
|
||||
}
|
||||
|
||||
export interface FormPaymentOptionUpdate {
|
||||
label?: string;
|
||||
amount?: number;
|
||||
order?: number;
|
||||
requires_mecenat_info?: boolean;
|
||||
}
|
||||
|
||||
export interface PublicPositionOption {
|
||||
@@ -639,6 +650,7 @@ export interface PublicPaymentOption {
|
||||
id: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
requires_mecenat_info: boolean;
|
||||
}
|
||||
|
||||
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
|
||||
@@ -655,12 +667,40 @@ export interface PublicFormConfig {
|
||||
payment_options: PublicPaymentOption[];
|
||||
}
|
||||
|
||||
/** Trois formes possibles, selon le choix de paiement (voir
|
||||
* `app/api/forms.py:submit_public_form`) :
|
||||
* - aucun paiement en ligne (case désactivée, ou « je paierai sur place ») :
|
||||
* `volunteer_id` seul, le bénévole est déjà en base ;
|
||||
* - paiement en ligne, type « mécénat » : `pending_token` +
|
||||
* `requires_mecenat_info=true`, rien en base encore — direction l'étape
|
||||
* d'adresse (`submitMecenatInfo`) ;
|
||||
* - paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`,
|
||||
* direction la page de paiement SumUp. */
|
||||
export interface FormSubmissionResult {
|
||||
volunteer_id: string;
|
||||
/** Lien de paiement hébergé SumUp, si un checkout a pu être créé. */
|
||||
volunteer_id: string | null;
|
||||
pending_token: string | null;
|
||||
requires_mecenat_info: boolean;
|
||||
sumup_checkout_url: string | null;
|
||||
}
|
||||
|
||||
/** Étape d'adresse postale d'un type de paiement « mécénat » — tous
|
||||
* obligatoires, nécessaires à l'édition d'un reçu fiscal (cerfa). */
|
||||
export interface MecenatInfoSubmit {
|
||||
address: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
/** Réponse de la vérification en direct du paiement (page de confirmation) —
|
||||
* interrogée jusqu'à ce que le statut ne soit plus `PENDING`. */
|
||||
export interface PendingSubmissionStatus {
|
||||
status: FeePaymentStatus;
|
||||
label: string;
|
||||
amount: number;
|
||||
volunteer_id: string | null;
|
||||
}
|
||||
|
||||
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
|
||||
* possible). `position_wish_ids`/`artist_wish_ids` : JSON stringifié
|
||||
* (`'["id1","id2"]'`), pas de tableau natif possible en multipart. */
|
||||
@@ -673,9 +713,8 @@ export interface PublicFormSubmission {
|
||||
comment?: string;
|
||||
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). */
|
||||
/** Type de paiement choisi (voir `PublicPaymentOption`) — absent = « je
|
||||
* paierai sur place » (aucun paiement en ligne). */
|
||||
payment_option_id?: string;
|
||||
position_wish_ids?: string[];
|
||||
artist_wish_ids?: string[];
|
||||
|
||||
@@ -37,6 +37,8 @@ import type {
|
||||
LoginRequest,
|
||||
MagicLinkRequest,
|
||||
MatrixResponse,
|
||||
MecenatInfoSubmit,
|
||||
PendingSubmissionStatus,
|
||||
PlanningNotificationRequest,
|
||||
PlanningNotificationResult,
|
||||
PositionCreate,
|
||||
@@ -89,6 +91,8 @@ export interface PlanningApi {
|
||||
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
|
||||
/** Photo transmise via le formulaire public, `null` si absente. */
|
||||
photoBlob(editionId: string, id: string): Promise<Blob | null>;
|
||||
/** Export Excel de la liste des bénévoles (COORDINATION). */
|
||||
exportXlsx(editionId: string): Promise<Blob>;
|
||||
};
|
||||
accounts: {
|
||||
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
||||
@@ -187,6 +191,15 @@ export interface PlanningApi {
|
||||
): Promise<FormPaymentOptionRead>;
|
||||
remove(editionId: string, id: string): Promise<void>;
|
||||
};
|
||||
/** Inscription en attente de paiement en ligne — publics, aucune
|
||||
* authentification (voir CLAUDE.md § 2, Paiement de la cotisation). */
|
||||
submitMecenatInfo(
|
||||
editionId: string,
|
||||
token: string,
|
||||
body: MecenatInfoSubmit,
|
||||
): Promise<FormSubmissionResult>;
|
||||
getPendingStatus(editionId: string, token: string): Promise<PendingSubmissionStatus>;
|
||||
retryPendingPayment(editionId: string, token: string): Promise<FormSubmissionResult>;
|
||||
};
|
||||
positionSheets: {
|
||||
/** Feuilles de poste PDF (une page par poste et par jour). */
|
||||
|
||||
@@ -221,6 +221,8 @@
|
||||
"config.rules.roleVolunteerDesc": "Ses seules données, lien magique par email",
|
||||
"config.vol.addButton": "AJOUTER MANUELLEMENT",
|
||||
"config.vol.importButton2": "IMPORTER UN FICHIER",
|
||||
"config.vol.exportButton": "EXPORTER EN EXCEL",
|
||||
"config.vol.exporting": "EXPORT…",
|
||||
"config.vol.search": "Rechercher un nom, un email…",
|
||||
"config.vol.emptyList": "Aucun bénévole pour l'instant. Ajoutez-en un ou importez un fichier.",
|
||||
"config.vol.noMatch": "Aucun bénévole ne correspond à la recherche.",
|
||||
@@ -385,6 +387,7 @@
|
||||
"config.vol.feeStatus.paid": "Cotisation payée ✓",
|
||||
"config.vol.feeStatus.failed": "Cotisation : paiement échoué",
|
||||
"config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré",
|
||||
"config.vol.mecenatAddress": "Adresse (cerfa)",
|
||||
"form.field.photo": "Une magnifique photo de toi :",
|
||||
"form.field.comment": "Commentaire",
|
||||
"form.link.title": "Lien du formulaire",
|
||||
@@ -415,6 +418,8 @@
|
||||
"form.customize.paymentOptions.title": "Types de paiement",
|
||||
"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.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",
|
||||
"publicForm.positionWishes.count": "{count} / {max}",
|
||||
@@ -422,11 +427,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.none": "Je ne participe pas",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -434,6 +437,25 @@
|
||||
"publicForm.success.body": "Merci ! Ton inscription a bien été enregistrée. La coordination du festival prendra contact avec toi.",
|
||||
"publicForm.closed.title": "Inscriptions fermées",
|
||||
"publicForm.closed.body": "Les inscriptions bénévoles ne sont pas ouvertes pour le moment. Reviens un peu plus tard !",
|
||||
|
||||
"publicForm.mecenat.title": "Reçu fiscal (mécénat)",
|
||||
"publicForm.mecenat.hint": "Ces informations servent uniquement à établir ton reçu fiscal (cerfa) une fois le paiement effectué.",
|
||||
"publicForm.mecenat.afterSubmitHint": "Une étape supplémentaire (adresse) te sera proposée juste après l'envoi de ton inscription, pour établir ton reçu fiscal.",
|
||||
"publicForm.mecenat.address": "Adresse",
|
||||
"publicForm.mecenat.city": "Ville",
|
||||
"publicForm.mecenat.postalCode": "Code postal",
|
||||
"publicForm.mecenat.country": "Pays",
|
||||
"publicForm.mecenat.submit": "Continuer vers le paiement",
|
||||
|
||||
"publicForm.retry.title": "Paiement indisponible",
|
||||
"publicForm.retry.body": "Le paiement en ligne n'a pas pu être initié pour le moment. Tes informations sont conservées : réessaie dans quelques instants.",
|
||||
"publicForm.retry.cta": "Réessayer le paiement",
|
||||
"publicForm.retry.stillUnavailable": "Toujours indisponible pour le moment, réessaie un peu plus tard.",
|
||||
|
||||
"publicForm.confirmation.title": "Confirmation du paiement",
|
||||
"publicForm.confirmation.pending": "Vérification du paiement en cours…",
|
||||
"publicForm.confirmation.paid": "Merci ! Cotisation « {label} » de {amount} € payée. Ton inscription est enregistrée.",
|
||||
"publicForm.confirmation.failed": "Le paiement n'a pas abouti (refusé ou expiré).",
|
||||
"form.closed": "Les inscriptions sont actuellement fermées.",
|
||||
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
|
||||
"form.invalid_payload": "Données du formulaire invalides.",
|
||||
@@ -445,6 +467,8 @@
|
||||
"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.pending_already_paid": "Cette inscription est déjà payée.",
|
||||
"form.pending_not_awaiting_info": "Cette inscription n'attend pas ces informations.",
|
||||
"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.",
|
||||
|
||||
@@ -331,6 +331,7 @@ function PaymentOptionsForm() {
|
||||
const remove = useDeleteFormPaymentOption();
|
||||
const [label, setLabel] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [requiresMecenatInfo, setRequiresMecenatInfo] = useState(false);
|
||||
|
||||
if (options.isLoading) return <div className="font-mono text-[10px] opacity-60">…</div>;
|
||||
if (options.error) return <ErrorNote error={options.error} />;
|
||||
@@ -363,6 +364,20 @@ 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.mecenat.help')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={o.requires_mecenat_info}
|
||||
onChange={(e) =>
|
||||
update.mutate({ id: o.id, body: { requires_mecenat_info: e.target.checked } })
|
||||
}
|
||||
className="h-[13px] w-[13px] accent-ink"
|
||||
/>
|
||||
{t('form.customize.paymentOptions.mecenat')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate(o.id)}
|
||||
@@ -391,13 +406,36 @@ 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.mecenat.help')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={requiresMecenatInfo}
|
||||
onChange={(e) => setRequiresMecenatInfo(e.target.checked)}
|
||||
className="h-[13px] w-[13px] accent-ink"
|
||||
/>
|
||||
{t('form.customize.paymentOptions.mecenat')}
|
||||
</label>
|
||||
<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(''); } },
|
||||
{
|
||||
label: label.trim(),
|
||||
amount: Number(amount),
|
||||
order: rows.length,
|
||||
requires_mecenat_info: requiresMecenatInfo,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLabel('');
|
||||
setAmount('');
|
||||
setRequiresMecenatInfo(false);
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
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"
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ApiError } from '@/api';
|
||||
import { useFormLogoUrl, usePublicFormConfig, useSubmitPublicForm } from '@/api/hooks';
|
||||
import {
|
||||
useFormLogoUrl,
|
||||
usePendingStatus,
|
||||
usePublicFormConfig,
|
||||
useRetryPendingPayment,
|
||||
useSubmitMecenatInfo,
|
||||
useSubmitPublicForm,
|
||||
} from '@/api/hooks';
|
||||
import type { PublicArtistOption } from '@/api/schema';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { Btn, FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
|
||||
@@ -125,7 +132,209 @@ function FormLogo({ url }: { url: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Entrée du formulaire public quand l'URL porte `?paiement=<jeton>` (retour
|
||||
* du bénévole depuis la page de paiement SumUp) — sinon le formulaire
|
||||
* classique (`PublicSignupForm`). Un seul hook ici (lecture de l'URL, figée
|
||||
* au montage) : chaque vue gère ensuite ses propres requêtes. */
|
||||
export function PublicSignupPage() {
|
||||
// `typeof window` : rendu aussi en SSR (voir scripts/smoke.tsx), où
|
||||
// `window` n'existe pas — contrairement aux autres pages, rien ici ne
|
||||
// dépend d'abord du chargement d'une requête pour en différer l'accès.
|
||||
const token = useMemo(
|
||||
() =>
|
||||
typeof window === 'undefined'
|
||||
? null
|
||||
: new URLSearchParams(window.location.search).get('paiement'),
|
||||
[],
|
||||
);
|
||||
if (token) return <PaymentConfirmationView token={token} />;
|
||||
return <PublicSignupForm />;
|
||||
}
|
||||
|
||||
/** Interrogée en direct (voir CLAUDE.md § 2, Paiement de la cotisation)
|
||||
* jusqu'à confirmation — le bénévole n'est créé en base qu'à ce moment-là,
|
||||
* jamais avant. */
|
||||
function PaymentConfirmationView({ token }: { token: string }) {
|
||||
const status = usePendingStatus(token);
|
||||
const retry = useRetryPendingPayment();
|
||||
const [retryError, setRetryError] = useState<string | null>(null);
|
||||
|
||||
const handleRetry = async () => {
|
||||
setRetryError(null);
|
||||
try {
|
||||
const result = await retry.mutateAsync(token);
|
||||
if (result.sumup_checkout_url) {
|
||||
window.location.href = result.sumup_checkout_url;
|
||||
} else {
|
||||
setRetryError(t('publicForm.retry.stillUnavailable'));
|
||||
}
|
||||
} catch (err) {
|
||||
setRetryError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{status.isLoading ? <Loading /> : null}
|
||||
{status.error ? <ErrorNote error={status.error} /> : null}
|
||||
{status.data?.status === 'PENDING' ? (
|
||||
<p className="font-mono text-[12px] leading-relaxed">
|
||||
{t('publicForm.confirmation.pending')}
|
||||
</p>
|
||||
) : null}
|
||||
{status.data?.status === 'PAID' ? (
|
||||
<p className="font-mono text-[12px] leading-relaxed">
|
||||
{t('publicForm.confirmation.paid', {
|
||||
label: status.data.label,
|
||||
amount: status.data.amount,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{status.data && (status.data.status === 'FAILED' || status.data.status === 'EXPIRED') ? (
|
||||
<>
|
||||
<p className="font-mono text-[12px] leading-relaxed">
|
||||
{t('publicForm.confirmation.failed')}
|
||||
</p>
|
||||
{retryError ? (
|
||||
<p className="font-mono text-[11px] font-bold text-orange-dark">{retryError}</p>
|
||||
) : null}
|
||||
<Btn onClick={handleRetry} disabled={retry.isPending}>
|
||||
{retry.isPending ? t('publicForm.submitting') : t('publicForm.retry.cta')}
|
||||
</Btn>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Étape d'adresse postale d'un type de paiement « mécénat », entre la
|
||||
* soumission du formulaire principal et le paiement lui-même — nécessaire à
|
||||
* l'édition d'un reçu fiscal (cerfa) ultérieurement. */
|
||||
function MecenatInfoStep({
|
||||
token,
|
||||
logoUrl,
|
||||
onCheckoutUrl,
|
||||
onUnavailable,
|
||||
}: {
|
||||
token: string;
|
||||
logoUrl: string | null;
|
||||
onCheckoutUrl: (url: string) => void;
|
||||
onUnavailable: () => void;
|
||||
}) {
|
||||
const submitMecenat = useSubmitMecenatInfo();
|
||||
const [address, setAddress] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [postalCode, setPostalCode] = useState('');
|
||||
const [country, setCountry] = useState('France');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSubmit =
|
||||
address.trim().length > 0 &&
|
||||
city.trim().length > 0 &&
|
||||
postalCode.trim().length > 0 &&
|
||||
country.trim().length > 0 &&
|
||||
!submitMecenat.isPending;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
const result = await submitMecenat.mutateAsync({
|
||||
token,
|
||||
body: { address, city, postal_code: postalCode, country },
|
||||
});
|
||||
if (result.sumup_checkout_url) onCheckoutUrl(result.sumup_checkout_url);
|
||||
else onUnavailable();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
<FormLogo url={logoUrl} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
|
||||
<p className="font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('publicForm.mecenat.hint')}
|
||||
</p>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('publicForm.mecenat.address')}</FieldLabel>
|
||||
<TextInput value={address} onChange={setAddress} />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<FieldLabel>{t('publicForm.mecenat.postalCode')}</FieldLabel>
|
||||
<TextInput mono value={postalCode} onChange={setPostalCode} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('publicForm.mecenat.city')}</FieldLabel>
|
||||
<TextInput value={city} onChange={setCity} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('publicForm.mecenat.country')}</FieldLabel>
|
||||
<TextInput value={country} onChange={setCountry} />
|
||||
</label>
|
||||
{error ? (
|
||||
<p className="font-mono text-[11px] font-bold text-orange-dark">{error}</p>
|
||||
) : null}
|
||||
<Btn type="submit" disabled={!canSubmit}>
|
||||
{submitMecenat.isPending ? t('publicForm.submitting') : t('publicForm.mecenat.submit')}
|
||||
</Btn>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Le checkout SumUp n'a pas pu être créé (panne réseau/API au moment de
|
||||
* l'appel) — les informations déjà saisies restent gardées, pas besoin de
|
||||
* tout ressaisir pour relancer. */
|
||||
function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string | null }) {
|
||||
const retry = useRetryPendingPayment();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleRetry = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await retry.mutateAsync(token);
|
||||
if (result.sumup_checkout_url) {
|
||||
window.location.href = result.sumup_checkout_url;
|
||||
} else {
|
||||
setError(t('publicForm.retry.stillUnavailable'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
<FormLogo url={logoUrl} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.retry.title')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<p className="font-mono text-[12px] leading-relaxed">{t('publicForm.retry.body')}</p>
|
||||
{error ? (
|
||||
<p className="font-mono text-[11px] font-bold text-orange-dark">{error}</p>
|
||||
) : null}
|
||||
<Btn onClick={handleRetry} disabled={retry.isPending}>
|
||||
{retry.isPending ? t('publicForm.submitting') : t('publicForm.retry.cta')}
|
||||
</Btn>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicSignupForm() {
|
||||
const config = usePublicFormConfig();
|
||||
const submit = useSubmitPublicForm();
|
||||
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt);
|
||||
@@ -146,7 +355,8 @@ 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 [pendingToken, setPendingToken] = useState<string | null>(null);
|
||||
const [needsMecenatInfo, setNeedsMecenatInfo] = useState(false);
|
||||
|
||||
const artistsByDay = useMemo(
|
||||
() => groupArtistsByDay(config.data?.artists ?? []),
|
||||
@@ -182,29 +392,31 @@ export function PublicSignupPage() {
|
||||
<FormLogo url={logoUrl} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
||||
<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>
|
||||
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingToken && needsMecenatInfo) {
|
||||
return (
|
||||
<MecenatInfoStep
|
||||
token={pendingToken}
|
||||
logoUrl={logoUrl}
|
||||
onCheckoutUrl={(url) => {
|
||||
window.location.href = url;
|
||||
}}
|
||||
onUnavailable={() => setNeedsMecenatInfo(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingToken) {
|
||||
// Le checkout n'a pas pu être créé (formulaire principal ou étape
|
||||
// mécénat) — rien n'a été perdu, on propose de réessayer.
|
||||
return <CheckoutRetryPanel token={pendingToken} logoUrl={logoUrl} />;
|
||||
}
|
||||
|
||||
if (!config.data.is_open) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
||||
@@ -237,9 +449,15 @@ export function PublicSignupPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// Tout champ activé par la coordination est obligatoire — le formulaire
|
||||
// public n'est plus une simple collecte d'infos facultatives.
|
||||
const canSubmit =
|
||||
firstName.trim().length > 0 &&
|
||||
lastName.trim().length > 0 &&
|
||||
(!fields.has('EMAIL') || email.trim().length > 0) &&
|
||||
(!fields.has('PHONE') || phone.trim().length > 0) &&
|
||||
(!fields.has('AVAILABLE') || available) &&
|
||||
(!fields.has('WILLING_TO_PAY_FEE') || config.data.sumup_enabled || willingToPayFee) &&
|
||||
(toggleEnabled ? captchaToken.length > 0 : true) &&
|
||||
!submit.isPending;
|
||||
|
||||
@@ -256,7 +474,6 @@ export function PublicSignupPage() {
|
||||
comment: fields.has('COMMENT') ? comment : undefined,
|
||||
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
|
||||
@@ -265,8 +482,18 @@ export function PublicSignupPage() {
|
||||
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);
|
||||
if (result.volunteer_id) {
|
||||
setVolunteerId(result.volunteer_id);
|
||||
} else if (result.pending_token) {
|
||||
setPendingToken(result.pending_token);
|
||||
if (result.requires_mecenat_info) {
|
||||
setNeedsMecenatInfo(true);
|
||||
} else if (result.sumup_checkout_url) {
|
||||
window.location.href = result.sumup_checkout_url;
|
||||
}
|
||||
// Sinon (checkout indisponible) : le rendu bascule sur
|
||||
// CheckoutRetryPanel au prochain rendu, via `pendingToken`.
|
||||
}
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err));
|
||||
}
|
||||
@@ -410,7 +637,7 @@ export function PublicSignupPage() {
|
||||
onChange={() => setPaymentOptionId('')}
|
||||
className="h-[15px] w-[15px] accent-ink"
|
||||
/>
|
||||
{t('publicForm.paymentOption.none')}
|
||||
{t('publicForm.paymentOption.onSite')}
|
||||
</label>
|
||||
{config.data.payment_options.map((o) => (
|
||||
<label
|
||||
@@ -430,7 +657,10 @@ export function PublicSignupPage() {
|
||||
</div>
|
||||
{paymentOptionId ? (
|
||||
<p className="mt-[6px] font-mono text-[10.5px] opacity-70">
|
||||
{t('publicForm.sumup.afterSubmitHint')}
|
||||
{config.data.payment_options.find((o) => o.id === paymentOptionId)
|
||||
?.requires_mecenat_info
|
||||
? t('publicForm.mecenat.afterSubmitHint')
|
||||
: t('publicForm.sumup.afterSubmitHint')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useDeleteVolunteer,
|
||||
useEdition,
|
||||
useExecuteImport,
|
||||
useExportVolunteers,
|
||||
usePositions,
|
||||
useReplaceAvailability,
|
||||
useUpdateAccount,
|
||||
@@ -187,7 +188,7 @@ function PublicFormAnswers({
|
||||
volunteer.has_photo ||
|
||||
Boolean(volunteer.companion_wish_note) ||
|
||||
volunteer.self_declared_available ||
|
||||
volunteer.willing_to_pay_fee;
|
||||
volunteer.fee_payment_status !== null;
|
||||
if (!hasAnything) return null;
|
||||
|
||||
const feeStatusKey = volunteer.fee_payment_status
|
||||
@@ -230,15 +231,6 @@ function PublicFormAnswers({
|
||||
/>
|
||||
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-[7px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={volunteer.willing_to_pay_fee}
|
||||
onChange={(e) => patch({ willing_to_pay_fee: e.target.checked })}
|
||||
className="h-[15px] w-[15px] accent-ink"
|
||||
/>
|
||||
<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} €) — ` : ''}
|
||||
@@ -246,6 +238,13 @@ function PublicFormAnswers({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{volunteer.mecenat_address ? (
|
||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address}, {volunteer.mecenat_postal_code}{' '}
|
||||
{volunteer.mecenat_city}, {volunteer.mecenat_country}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -489,6 +488,7 @@ export function ConfigVolunteers() {
|
||||
const positions = usePositions();
|
||||
const updateVolunteer = useUpdateVolunteer();
|
||||
const deleteVolunteer = useDeleteVolunteer();
|
||||
const exportVolunteers = useExportVolunteers();
|
||||
|
||||
const [mode, setMode] = useState<Mode>('list');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@@ -522,6 +522,19 @@ export function ConfigVolunteers() {
|
||||
const rows = filtered.slice(0, 60);
|
||||
const patch = (body: Parameters<typeof updateVolunteer.mutate>[0]['body']) =>
|
||||
selected && updateVolunteer.mutate({ id: selected.id, body });
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await exportVolunteers.mutateAsync();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'benevoles.xlsx';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Erreur exposée via exportVolunteers.isError / .error, affichée plus bas.
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === 'add') {
|
||||
return <AddVolunteerForm onClose={() => setMode('list')} />;
|
||||
@@ -556,7 +569,18 @@ export function ConfigVolunteers() {
|
||||
>
|
||||
↓ {t('config.vol.importButton2')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={exportVolunteers.isPending}
|
||||
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-yellow disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
↑ {exportVolunteers.isPending ? t('config.vol.exporting') : t('config.vol.exportButton')}
|
||||
</button>
|
||||
</div>
|
||||
{exportVolunteers.isError ? (
|
||||
<ErrorNote error={exportVolunteers.error} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
|
||||
<div className="font-mono text-[24px] font-bold">{total}</div>
|
||||
|
||||
Reference in New Issue
Block a user