Badges : texte libre saisi dans l'éditeur, champs ramenés dans le badge

Texte libre commun à tous les badges, saisi dans l'éditeur
(BadgeField.text, migration 0025) ; le « Texte du badge » de la fiche le
remplace pour un bénévole. Champs ramenés à l'intérieur du badge et champ
sélectionné au premier plan : la photo restait bloquée hors d'un badge
plus étroit que le CR80 (bornes de glisser-déposer négatives).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 18:30:34 +01:00
co-authored by Claude Opus 5.5
parent 6892e0307d
commit cfed76c239
11 changed files with 134 additions and 22 deletions
+13 -4
View File
@@ -86,9 +86,10 @@ les tables sauf les tables d'association.
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/ cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le `fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
paiement SumUp plus bas). `badge_text` : texte libre imprimé sur le badge paiement SumUp plus bas). `badge_text` : texte du badge propre à ce bénévole
(champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la (champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la
fiche (« Texte du badge ») — jamais par le formulaire public. fiche (« Texte du badge ») — jamais par le formulaire public — et qui
remplace pour lui le texte commun de l'éditeur.
- **`Artist`** : programmation de l'édition (`name`, `performance_date`, - **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La lendemain). Unique (`edition_id`, `name`, `performance_date`). La
@@ -176,13 +177,20 @@ les tables sauf les tables d'association.
un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes, un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes,
`\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ; `\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ; `ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ;
`FREE_TEXT` est `Volunteer.badge_text` (vide ⇒ rien) ; `PHOTO` imprime `FREE_TEXT` imprime `Volunteer.badge_text` s'il est renseigné, sinon le
texte commun saisi dans l'éditeur (`BadgeField.text`, ignoré pour les
autres types), sinon rien ; `PHOTO` imprime
`Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée `Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée
au ratio du cadre sans déformation, orientation EXIF appliquée et réduite au ratio du cadre sans déformation, orientation EXIF appliquée et réduite
à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de
plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou
photo illisible, le cadre reste vide plutôt que de faire échouer la photo illisible, le cadre reste vide plutôt que de faire échouer la
planche. planche. L'éditeur ramène tout champ à l'intérieur du badge
(`BadgesPage.tsx:fitToBadge`) et met le champ sélectionné au premier
plan : les positions par défaut visent un CR80 paysage, et sur un badge
plus étroit un champ pouvait tomber hors du badge, avec des bornes de
glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo
impossible à déplacer ni redimensionner).
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` — `POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
@@ -933,6 +941,7 @@ Aucune dépendance locale requise hors Docker.
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider | | 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider | | 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider |
| 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider | | 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider |
| 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+3 -1
View File
@@ -280,7 +280,9 @@ def _field_text(
if field.field_type == BadgeFieldType.FULL_NAME: if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}" return f"{volunteer.first_name} {volunteer.last_name}"
if field.field_type == BadgeFieldType.FREE_TEXT: if field.field_type == BadgeFieldType.FREE_TEXT:
return volunteer.badge_text or "" # Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
# le « Texte du badge » de sa fiche s'il en a un.
return volunteer.badge_text or field.text or ""
assignments = assignments_by_volunteer.get(volunteer.id, []) assignments = assignments_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS: if field.field_type == BadgeFieldType.POSITIONS:
return "\n".join( return "\n".join(
+2
View File
@@ -355,6 +355,8 @@ class BadgeFieldIn(ApiModel):
align: BadgeAlign = BadgeAlign.LEFT align: BadgeAlign = BadgeAlign.LEFT
bold: bool = False bold: bool = False
color: str = "#000000" color: str = "#000000"
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
text: str | None = None
class BadgeFieldsSet(ApiModel): class BadgeFieldsSet(ApiModel):
@@ -0,0 +1,28 @@
"""Texte commun du champ libre des badges
Revision ID: 0025_badge_field_text
Revises: 0024_volunteer_badge_text
Create Date: 2026-10-27
`badge_field.text` : texte du champ `FREE_TEXT` saisi dans l'éditeur de
badges, imprimé sur tous les badges — sauf ceux dont la fiche porte un
`volunteer.badge_text`, qui le remplace pour ce bénévole.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0025_badge_field_text"
down_revision: str | None = "0024_volunteer_badge_text"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("badge_field", sa.Column("text", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("badge_field", "text")
+7 -2
View File
@@ -183,8 +183,9 @@ class Volunteer(TimestampMixin, table=True):
# cette note sur la fiche et la transforme, si besoin, en un vrai # cette note sur la fiche et la transforme, si besoin, en un vrai
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits. # `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
companion_wish_note: str | None = None companion_wish_note: str | None = None
# Texte libre imprimé sur le badge (champ `BadgeFieldType.FREE_TEXT`), # Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`),
# saisi par la coordination sur la fiche — ex. « Accès backstage ». # saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
badge_text: str | None = None badge_text: str | None = None
# Cases à cocher du formulaire public — purement informatives : aucune # Cases à cocher du formulaire public — purement informatives : aucune
# logique n'y est attachée (pas de workflow de paiement). # logique n'y est attachée (pas de workflow de paiement).
@@ -466,6 +467,10 @@ class BadgeField(TimestampMixin, table=True):
sa_column=Column(Boolean, nullable=False, server_default="false"), sa_column=Column(Boolean, nullable=False, server_default="false"),
) )
color: str = Field(default="#000000") color: str = Field(default="#000000")
# Champ `FREE_TEXT` seulement : texte saisi dans l'éditeur, imprimé sur
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
text: str | None = None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+3 -2
View File
@@ -39,8 +39,9 @@ class BadgeFieldType(enum.StrEnum):
ROLE = "ROLE" ROLE = "ROLE"
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ. # Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
PHOTO = "PHOTO" PHOTO = "PHOTO"
# Texte libre propre à chaque bénévole, saisi par la coordination sur sa # Texte libre saisi par la coordination dans l'éditeur (`BadgeField.text`,
# fiche (`Volunteer.badge_text`). # commun à tous les badges), remplacé pour un bénévole par le « Texte du
# badge » de sa fiche (`Volunteer.badge_text`) s'il en a un.
FREE_TEXT = "FREE_TEXT" FREE_TEXT = "FREE_TEXT"
+4 -1
View File
@@ -221,12 +221,15 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session):
{ {
"field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40, "field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40,
"width_mm": 50, "height_mm": 8, "font_size": 9, "width_mm": 50, "height_mm": 8, "font_size": 9,
"text": "Staff 2026",
}, },
] ]
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert {f["field_type"] for f in resp.json()} == {"PHOTO", "FREE_TEXT"} by_type = {f["field_type"]: f for f in resp.json()}
assert set(by_type) == {"PHOTO", "FREE_TEXT"}
assert by_type["FREE_TEXT"]["text"] == "Staff 2026"
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin") with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
with_photo.photo = _PNG with_photo.photo = _PNG
+13
View File
@@ -111,3 +111,16 @@ def test_field_text_free_text_is_the_volunteer_badge_text():
assert _field_text(field, with_text, {}, set()) == "Accès backstage" assert _field_text(field, with_text, {}, set()) == "Accès backstage"
assert _field_text(field, without, {}, set()) == "" assert _field_text(field, without, {}, set()) == ""
def test_field_text_free_text_falls_back_to_the_common_editor_text():
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
text="Staff 2026",
)
own = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
other = _volunteer(first_name="Sam", last_name="Nuit")
assert _field_text(field, own, {}, set()) == "Accès backstage"
assert _field_text(field, other, {}, set()) == "Staff 2026"
+3
View File
@@ -444,6 +444,9 @@ export interface BadgeFieldIn {
align: BadgeAlign; align: BadgeAlign;
bold: boolean; bold: boolean;
color: string; color: string;
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
text?: string | null;
} }
export interface BadgeFieldsSet { export interface BadgeFieldsSet {
+3 -2
View File
@@ -231,7 +231,7 @@
"config.vol.field.phone": "Téléphone", "config.vol.field.phone": "Téléphone",
"config.vol.field.comment": "Commentaire", "config.vol.field.comment": "Commentaire",
"config.vol.field.badgeText": "Texte du badge", "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.field.badgeText.help": "Remplace, pour ce bénévole seulement, le texte libre commun saisi dans l'écran Badges. Vide = texte commun.",
"config.vol.photo.title": "Photo", "config.vol.photo.title": "Photo",
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.", "config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.",
"config.vol.photo.none": "Aucune photo", "config.vol.photo.none": "Aucune photo",
@@ -366,7 +366,8 @@
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.", "badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), 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": "Texte libre",
"badges.field.freeText.placeholder": "Accès backstage", "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.freeText.help": "Texte saisi ci-dessous, imprimé sur tous les badges. Pour un bénévole en particulier, le « Texte du badge » de sa fiche (Configuration › Bénévoles) le remplace.",
"badges.field.freeText.input": "Texte à imprimer (ex. STAFF 2026)",
"badges.field.size": "Taille", "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.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)", "badges.size.width": "Largeur du badge (mm)",
+55 -10
View File
@@ -117,6 +117,24 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand,
* décalé s'il dépasse). Les positions par défaut sont pensées pour un CR80
* paysage (85,6 × 54 mm) : sur un badge plus étroit (portrait…), un champ
* activé pouvait tomber hors du badge — invisible, et les bornes négatives
* du glisser-déposer le bloquaient (bug corrigé une fois : photo
* impossible à déplacer ni redimensionner). */
function fitToBadge<F extends BadgeFieldIn>(field: F, badgeW: number, badgeH: number): F {
const width_mm = Math.min(field.width_mm, badgeW);
const height_mm = Math.min(field.height_mm, badgeH);
return {
...field,
width_mm,
height_mm,
x_mm: clamp(field.x_mm, 0, badgeW - width_mm),
y_mm: clamp(field.y_mm, 0, badgeH - height_mm),
};
}
function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> { function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
const byType = new Map(saved.map((f) => [f.field_type, f])); const byType = new Map(saved.map((f) => [f.field_type, f]));
const out = {} as Record<BadgeFieldType, FieldState>; const out = {} as Record<BadgeFieldType, FieldState>;
@@ -168,19 +186,28 @@ function BadgeCustomizeTab() {
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null); const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
const [selected, setSelected] = useState<BadgeFieldType | null>(null); const [selected, setSelected] = useState<BadgeFieldType | null>(null);
useEffect(() => {
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
}, [template.data]);
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM; const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM; const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
useEffect(() => {
if (!template.data) return;
const loaded = fieldsFromServer(template.data.fields);
for (const type of FIELD_TYPES) loaded[type] = fitToBadge(loaded[type], badgeW, badgeH);
setFieldsState(loaded);
}, [template.data, badgeW, badgeH]);
const scale = useEditorScale(badgeW); const scale = useEditorScale(badgeW);
if (template.isLoading || !fields) return <Loading />; if (template.isLoading || !fields) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />; if (template.error) return <ErrorNote error={template.error} />;
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => { const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev)); setFieldsState((prev) =>
prev
? { ...prev, [type]: fitToBadge({ ...prev[type], ...patch }, badgeW, badgeH) }
: prev,
);
// Un champ qu'on vient d'activer passe au premier plan, sélectionné.
if (patch.enabled) setSelected(type);
}; };
const save = () => { const save = () => {
@@ -197,6 +224,7 @@ function BadgeCustomizeTab() {
align: f.align, align: f.align,
bold: f.bold, bold: f.bold,
color: f.color, color: f.color,
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
}; };
}, },
); );
@@ -434,8 +462,8 @@ function FieldBox({
const dxMm = (e.clientX - d.pointerXPx) / scale; const dxMm = (e.clientX - d.pointerXPx) / scale;
const dyMm = (e.clientY - d.pointerYPx) / scale; const dyMm = (e.clientY - d.pointerYPx) / scale;
onChange({ onChange({
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm), x_mm: clamp(d.startXMm + dxMm, 0, Math.max(0, badgeW - field.width_mm)),
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm), y_mm: clamp(d.startYMm + dyMm, 0, Math.max(0, badgeH - field.height_mm)),
}); });
}; };
@@ -451,9 +479,11 @@ function FieldBox({
const d = resizeRef.current; const d = resizeRef.current;
const dwMm = (e.clientX - d.pointerXPx) / scale; const dwMm = (e.clientX - d.pointerXPx) / scale;
const dhMm = (e.clientY - d.pointerYPx) / scale; const dhMm = (e.clientY - d.pointerYPx) / scale;
const maxW = badgeW - field.x_mm;
const maxH = badgeH - field.y_mm;
onChange({ onChange({
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm), width_mm: clamp(d.startWMm + dwMm, Math.min(10, maxW), maxW),
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm), height_mm: clamp(d.startHMm + dhMm, Math.min(5, maxH), maxH),
}); });
}; };
@@ -471,6 +501,9 @@ function FieldBox({
top: field.y_mm * scale, top: field.y_mm * scale,
width: field.width_mm * scale, width: field.width_mm * scale,
height: field.height_mm * scale, height: field.height_mm * scale,
// Le champ sélectionné passe devant les autres : sinon un champ
// recouvert (ex. la photo sous un texte) ne se saisit plus.
zIndex: selected ? 10 : undefined,
}} }}
> >
{field.field_type === 'PHOTO' ? ( {field.field_type === 'PHOTO' ? (
@@ -488,7 +521,9 @@ function FieldBox({
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left', textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}} }}
> >
{t(FIELD_PLACEHOLDER_KEY[field.field_type])} {field.field_type === 'FREE_TEXT' && field.text?.trim()
? field.text
: t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span> </span>
)} )}
<div <div
@@ -533,6 +568,16 @@ function FieldEditorRow({
) : null} ) : null}
</div> </div>
{field.enabled && type === 'FREE_TEXT' ? (
<input
type="text"
value={field.text ?? ''}
onChange={(e) => onChange({ text: e.target.value })}
placeholder={t('badges.field.freeText.input')}
className="ml-[24px] border-2 border-ink bg-cream px-[8px] py-[5px] font-display text-[12px] font-bold"
/>
) : null}
{/* La photo n'a pas de style de texte : seuls sa place et son cadre {/* La photo n'a pas de style de texte : seuls sa place et son cadre
(sur l'aperçu) comptent. */} (sur l'aperçu) comptent. */}
{field.enabled && type !== 'PHOTO' ? ( {field.enabled && type !== 'PHOTO' ? (