Formulaire public : astérisque rouge sur les champs obligatoires

Composant RequiredMark et prop `required` sur FieldLabel ; marqués
uniquement les champs réellement exigés à la soumission (nom, prénom,
email/téléphone/disponibilité/cotisation s'ils sont activés, étape
d'adresse mécénat), avec une légende « * champ obligatoire » en tête du
formulaire. Champ photo déplacé avant la disponibilité, libellé précisé
(« pour ton badge »).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-03 13:47:24 +02:00
co-authored by Claude Opus 5.5
parent 096b7cd26a
commit 5675e1d220
4 changed files with 55 additions and 36 deletions
+1
View File
@@ -821,6 +821,7 @@ Aucune dépendance locale requise hors Docker.
| 29 | Jeu de démonstration : 8 bénévoles avec une cotisation classique payée, 4 avec une cotisation mécénat payée (adresse complète) — `app/seed.py:_add_demo_payments` — pour que l'export Excel et les reçus fiscaux mécénat (étape 28) aient de quoi montrer quelque chose d'emblée | à valider |
| 30 | Infobulles (`HelpTip` et détail de cellule de la grille des besoins) repositionnées en `position: fixed` via JS (`lib/useTooltipPosition.ts`), bornées à l'intérieur de l'écran et basculant de côté si la place manque — corrige un centrage `absolute` qui les faisait déborder de l'écran près d'un bord, et une isolation `z-index` qui rendait celle de la grille invisible (coincée sous les en-têtes figés) | à valider |
| 31 | Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le `access_token` du formulaire public à tout destinataire — régression de l'étape 27, aucun test ne couvrait le rendu HTML du logo), sujet retiré du log de remise des emails (pouvait porter le nom d'un bénévole via un modèle personnalisé), image du formulaire public revalidée par décodage réel (`PIL`, pas seulement le `Content-Type` déclaré) avant stockage — évite qu'une fausse image fasse échouer la génération des badges/feuilles de poste, export Excel des bénévoles protégé contre l'injection de formule (CWE-1236, `app/exporting/xlsx.py:_safe_text`), sondage du statut de paiement en attente limité en fréquence par jeton (`pending_status_limiter`, évite un abus de l'API SumUp), `pyjwt` mis à jour (CVE listées non exploitables ici — HS256 à secret statique uniquement, jamais de JWKS — mais autant ne pas rester sur une version vulnérable) | à valider |
| 32 | Formulaire public : astérisque rouge sur les champs obligatoires (`RequiredMark`, `FieldLabel required`) et légende « * champ obligatoire » | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+11 -1
View File
@@ -225,10 +225,20 @@ export function HelpTip({
);
}
export function FieldLabel({ children }: { children: ReactNode }) {
/** Astérisque rouge des champs obligatoires (purement visuel : la règle vit ailleurs). */
export function RequiredMark() {
return (
<span aria-hidden="true" className="ml-[3px] text-red-600">
*
</span>
);
}
export function FieldLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="mb-[5px] block font-display text-[9px] font-extrabold uppercase tracking-widest">
{children}
{required ? <RequiredMark /> : null}
</span>
);
}
+2 -1
View File
@@ -390,7 +390,7 @@
"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.photo": "Une magnifique photo de toi pour ton badge :",
"form.field.comment": "Commentaire",
"form.link.title": "Lien du formulaire",
"form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire, mais lui seul : sans ce lien précis, impossible de tomber sur le formulaire.",
@@ -436,6 +436,7 @@
"publicForm.sumup.afterSubmitHint": "Un lien de paiement en ligne (SumUp) te sera proposé juste après l'envoi de ton inscription.",
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
"publicForm.submitting": "Envoi…",
"publicForm.requiredLegend": "champ obligatoire",
"publicForm.submit": "Envoyer mon inscription",
"publicForm.success.title": "Inscription reçue",
"publicForm.success.body": "Merci ! Ton inscription a bien été enregistrée. La coordination du festival prendra contact avec toi.",
+41 -34
View File
@@ -12,7 +12,7 @@ import {
} from '@/api/hooks';
import type { PublicArtistOption } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state';
import { Btn, FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
import { Btn, FieldLabel, Panel, PanelHeader, RequiredMark, TextInput } from '@/components/ui';
import { t } from '@/i18n';
import { ENV } from '@/lib/env';
import { longDayLabel } from '@/lib/time';
@@ -271,21 +271,21 @@ function MecenatInfoStep({
{t('publicForm.mecenat.hint')}
</p>
<label className="block">
<FieldLabel>{t('publicForm.mecenat.address')}</FieldLabel>
<FieldLabel required>{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>
<FieldLabel required>{t('publicForm.mecenat.postalCode')}</FieldLabel>
<TextInput mono value={postalCode} onChange={setPostalCode} />
</label>
<label className="block">
<FieldLabel>{t('publicForm.mecenat.city')}</FieldLabel>
<FieldLabel required>{t('publicForm.mecenat.city')}</FieldLabel>
<TextInput value={city} onChange={setCity} />
</label>
</div>
<label className="block">
<FieldLabel>{t('publicForm.mecenat.country')}</FieldLabel>
<FieldLabel required>{t('publicForm.mecenat.country')}</FieldLabel>
<TextInput value={country} onChange={setCountry} />
</label>
{error ? (
@@ -516,27 +516,31 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
</p>
) : null}
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
<p className="font-mono text-[10.5px] opacity-70">
<span className="mr-[3px] text-red-600">*</span>
{t('publicForm.requiredLegend')}
</p>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
<FieldLabel required>{t('config.vol.field.firstName')}</FieldLabel>
<TextInput value={firstName} onChange={setFirstName} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
<FieldLabel required>{t('config.vol.field.lastName')}</FieldLabel>
<TextInput value={lastName} onChange={setLastName} />
</label>
</div>
{fields.has('EMAIL') ? (
<label className="block">
<FieldLabel>{t('form.field.email')}</FieldLabel>
<FieldLabel required>{t('form.field.email')}</FieldLabel>
<TextInput mono value={email} onChange={setEmail} />
</label>
) : null}
{fields.has('PHONE') ? (
<label className="block">
<FieldLabel>{t('form.field.phone')}</FieldLabel>
<FieldLabel required>{t('form.field.phone')}</FieldLabel>
<TextInput mono value={phone} onChange={setPhone} />
</label>
) : null}
@@ -607,6 +611,26 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
</label>
) : null}
{fields.has('PHOTO') ? (
<label className="block">
<FieldLabel>{t('form.field.photo')}</FieldLabel>
{photoPreviewUrl ? (
<img
src={photoPreviewUrl}
alt=""
className="mb-[8px] h-[180px] w-[180px] border-2 border-ink object-cover shadow-brutal-sm"
/>
) : null}
<input
type="file"
accept="image/*"
capture="environment"
onChange={(e) => handlePhotoChange(e.target.files?.[0] ?? null)}
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[11px]"
/>
</label>
) : null}
{fields.has('COMMENT') ? (
<label className="block">
<FieldLabel>{t('form.field.comment')}</FieldLabel>
@@ -627,7 +651,10 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
onChange={(e) => setAvailable(e.target.checked)}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-mono text-[12px]">{t('publicForm.field.available')}</span>
<span className="font-mono text-[12px]">
{t('publicForm.field.available')}
<RequiredMark />
</span>
</label>
) : null}
@@ -678,30 +705,10 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
onChange={(e) => setWillingToPayFee(e.target.checked)}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-mono text-[12px]">{t('publicForm.field.willingToPayFee')}</span>
</label>
) : null}
{fields.has('PHOTO') ? (
<label className="block">
<FieldLabel>{t('form.field.photo')}</FieldLabel>
{photoPreviewUrl ? (
<img
src={photoPreviewUrl}
alt=""
className="mb-[8px] h-[180px] w-[180px] border-2 border-ink object-cover shadow-brutal-sm"
/>
) : null}
<input
type="file"
accept="image/*"
capture="environment"
onChange={(e) => handlePhotoChange(e.target.files?.[0] ?? null)}
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[11px]"
/>
<p className="mt-[4px] font-mono text-[9.5px] opacity-50">
{t('publicForm.photo.squareHint')}
</p>
<span className="font-mono text-[12px]">
{t('publicForm.field.willingToPayFee')}
<RequiredMark />
</span>
</label>
) : null}