Badges : photo du bénévole et texte libre

Champ « Photo du bénévole » (PHOTO) : photo du formulaire recadrée au
cadre, orientation EXIF appliquée, réduite à 300 dpi ; cadre vide sans
photo. Champ « Texte libre » (FREE_TEXT) alimenté par le nouveau « Texte
du badge » de la fiche bénévole (Volunteer.badge_text, migration 0024),
repris dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 18:12:09 +01:00
co-authored by Claude Opus 5.5
parent 5b493c9737
commit 54cba467fc
17 changed files with 325 additions and 33 deletions
+1
View File
@@ -289,6 +289,7 @@ export function buildMockState(): MockState {
wished_companion_ids,
has_photo: false,
companion_wish_note: null,
badge_text: null,
self_declared_available: false,
willing_to_pay_fee: false,
fee_payment_label: null,
+5
View File
@@ -234,6 +234,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
wished_companion_ids: [],
has_photo: pending.photo !== null,
companion_wish_note: pending.companionWishNote,
badge_text: null,
self_declared_available: pending.available,
willing_to_pay_fee: true,
fee_payment_label: pending.paymentLabel,
@@ -995,6 +996,7 @@ export function createMockApi(): PlanningApi {
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
has_photo: false,
companion_wish_note: null,
badge_text: null,
self_declared_available: false,
willing_to_pay_fee: false,
fee_payment_label: null,
@@ -1039,6 +1041,7 @@ export function createMockApi(): PlanningApi {
...('companion_wish_note' in body
? { companion_wish_note: body.companion_wish_note ?? null }
: {}),
...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}),
...('self_declared_available' in body && body.self_declared_available != null
? { self_declared_available: body.self_declared_available }
: {}),
@@ -1302,6 +1305,7 @@ export function createMockApi(): PlanningApi {
wished_companion_ids: [],
has_photo: false,
companion_wish_note: null,
badge_text: null,
self_declared_available: false,
willing_to_pay_fee: false,
fee_payment_label: null,
@@ -1772,6 +1776,7 @@ export function createMockApi(): PlanningApi {
wished_companion_ids: [],
has_photo: photo !== null,
companion_wish_note: companionWishNote,
badge_text: null,
self_declared_available: fields.has('AVAILABLE') ? available : false,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
fee_payment_label: null,
+13 -1
View File
@@ -107,6 +107,9 @@ export interface VolunteerRead {
/** Renseignés par le formulaire public d'inscription (voir schema forms). */
has_photo: boolean;
companion_wish_note: string | null;
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
* coordination. */
badge_text: string | null;
self_declared_available: boolean;
willing_to_pay_fee: boolean;
/** Paiement de la cotisation via SumUp (voir schema forms) —
@@ -157,6 +160,7 @@ export interface VolunteerUpdate {
wished_position_ids?: string[] | null;
wished_companion_ids?: string[] | null;
companion_wish_note?: string | null;
badge_text?: string | null;
self_declared_available?: boolean | null;
willing_to_pay_fee?: boolean | null;
}
@@ -419,7 +423,15 @@ export interface MatrixResponse {
// --------------------------------------------------------------------------- //
// Badges (planches PDF) //
// --------------------------------------------------------------------------- //
export type BadgeFieldType = 'FULL_NAME' | 'POSITIONS' | 'CATEGORY' | 'ROLE';
/** `PHOTO` : photo du bénévole ajustée au cadre ; `FREE_TEXT` : texte libre
* de sa fiche (`VolunteerRead.badge_text`), saisi par la coordination. */
export type BadgeFieldType =
| 'FULL_NAME'
| 'POSITIONS'
| 'CATEGORY'
| 'ROLE'
| 'PHOTO'
| 'FREE_TEXT';
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
export interface BadgeFieldIn {
+9 -1
View File
@@ -230,6 +230,8 @@
"config.vol.field.email": "Email (lien magique)",
"config.vol.field.phone": "Téléphone",
"config.vol.field.comment": "Commentaire",
"config.vol.field.badgeText": "Texte du badge",
"config.vol.field.badgeText.help": "Imprimé sur le badge si le champ « Texte libre » y est placé (écran Badges).",
"config.vol.access.title": "Accès de connexion",
"config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.",
"config.vol.access.grant": "Donner un accès de connexion",
@@ -340,7 +342,7 @@
"badges.tab.customize": "PERSONNALISER",
"badges.tab.generate": "GÉNÉRER",
"badges.front": "Recto",
"badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.",
"badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle, photo, texte libre). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.",
"badges.back": "Verso",
"badges.back.help": "Une seule image, la même pour tous les badges de cette édition — ajustée au format défini par le recto.",
"badges.fields": "Champs du recto",
@@ -352,6 +354,12 @@
"badges.field.category.placeholder": "Bar",
"badges.field.role": "Rôle (référent / bénévole)",
"badges.field.role.placeholder": "Référent",
"badges.field.photo": "Photo du bénévole",
"badges.field.photo.placeholder": "Photo",
"badges.field.photo.help": "La photo envoyée avec le formulaire d'inscription, recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
"badges.field.freeText": "Texte libre",
"badges.field.freeText.placeholder": "Accès backstage",
"badges.field.freeText.help": "Texte propre à chaque bénévole, à saisir sur sa fiche (Configuration › Bénévoles, « Texte du badge »). Vide = rien d'imprimé.",
"badges.field.size": "Taille",
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
"badges.size.width": "Largeur du badge (mm)",
+66 -25
View File
@@ -45,13 +45,29 @@ function useEditorScale(badgeWidthMm: number): number {
type FieldState = BadgeFieldIn & { enabled: boolean };
const FIELD_TYPES: BadgeFieldType[] = ['FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE'];
const FIELD_TYPES: BadgeFieldType[] = [
'FULL_NAME',
'POSITIONS',
'CATEGORY',
'ROLE',
'PHOTO',
'FREE_TEXT',
];
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName',
POSITIONS: 'badges.field.positions',
CATEGORY: 'badges.field.category',
ROLE: 'badges.field.role',
PHOTO: 'badges.field.photo',
FREE_TEXT: 'badges.field.freeText',
};
// Indication affichée à côté du nom du champ, pour ceux dont l'origine du
// contenu n'est pas évidente.
const FIELD_HELP_KEY: Partial<Record<BadgeFieldType, string>> = {
PHOTO: 'badges.field.photo.help',
FREE_TEXT: 'badges.field.freeText.help',
};
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
@@ -59,6 +75,8 @@ const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
POSITIONS: 'badges.field.positions.placeholder',
CATEGORY: 'badges.field.category.placeholder',
ROLE: 'badges.field.role.placeholder',
PHOTO: 'badges.field.photo.placeholder',
FREE_TEXT: 'badges.field.freeText.placeholder',
};
// Position/taille par défaut à l'activation d'un champ — empilés
@@ -83,6 +101,16 @@ const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> =
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
},
// Format portrait d'identité (≈ 3,5 × 4,5 cm réduit), en haut à droite.
// Taille de police/couleur sans effet : la photo est une image.
PHOTO: {
x_mm: 58, y_mm: 4, width_mm: 22, height_mm: 28,
font_size: 8, align: 'CENTER', bold: false, color: '#000000',
},
FREE_TEXT: {
x_mm: 5, y_mm: 39, width_mm: 50, height_mm: 7,
font_size: 9, align: 'LEFT', bold: true, color: '#000000',
},
};
function clamp(value: number, min: number, max: number): number {
@@ -445,18 +473,24 @@ function FieldBox({
height: field.height_mm * scale,
}}
>
<span
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
style={{
fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
fontWeight: field.bold ? 800 : 400,
color: field.color,
width: '100%',
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}}
>
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span>
{field.field_type === 'PHOTO' ? (
<span className="pointer-events-none flex h-full w-full items-center justify-center bg-sand-dark/70 font-mono text-[10px] font-bold uppercase text-ink/60">
{t(FIELD_PLACEHOLDER_KEY.PHOTO)}
</span>
) : (
<span
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
style={{
fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
fontWeight: field.bold ? 800 : 400,
color: field.color,
width: '100%',
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}}
>
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span>
)}
<div
onPointerDown={startResize}
onPointerMove={onResizeMove}
@@ -482,19 +516,26 @@ function FieldEditorRow({
}) {
return (
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
<label className="flex items-center gap-[8px]">
<input
type="checkbox"
checked={field.enabled}
onChange={(e) => onChange({ enabled: e.target.checked })}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
{t(FIELD_LABEL_KEY[type])}
</span>
</label>
<div className="flex items-center">
<label className="flex items-center gap-[8px]">
<input
type="checkbox"
checked={field.enabled}
onChange={(e) => onChange({ enabled: e.target.checked })}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
{t(FIELD_LABEL_KEY[type])}
</span>
</label>
{FIELD_HELP_KEY[type] ? (
<HelpTip text={t(FIELD_HELP_KEY[type])} className="ml-[6px]" />
) : null}
</div>
{field.enabled ? (
{/* La photo n'a pas de style de texte : seuls sa place et son cadre
(sur l'aperçu) comptent. */}
{field.enabled && type !== 'PHOTO' ? (
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
<label className="flex items-center gap-[5px]">
<FieldLabel>{t('badges.field.size')}</FieldLabel>
@@ -884,6 +884,16 @@ export function ConfigVolunteers() {
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
/>
</label>
<label className="block">
<FieldLabel>
{t('config.vol.field.badgeText')}
<HelpTip text={t('config.vol.field.badgeText.help')} className="ml-[6px]" />
</FieldLabel>
<TextInput
value={selected.badge_text ?? ''}
onChange={(v) => patch({ badge_text: v || null })}
/>
</label>
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />