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:
co-authored by
Claude Opus 5.5
parent
096b7cd26a
commit
5675e1d220
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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
|
**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 +
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<span className="mb-[5px] block font-display text-[9px] font-extrabold uppercase tracking-widest">
|
<span className="mb-[5px] block font-display text-[9px] font-extrabold uppercase tracking-widest">
|
||||||
{children}
|
{children}
|
||||||
|
{required ? <RequiredMark /> : null}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,7 +390,7 @@
|
|||||||
"config.vol.feeStatus.failed": "Cotisation : paiement échoué",
|
"config.vol.feeStatus.failed": "Cotisation : paiement échoué",
|
||||||
"config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré",
|
"config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré",
|
||||||
"config.vol.mecenatAddress": "Adresse (cerfa)",
|
"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.field.comment": "Commentaire",
|
||||||
"form.link.title": "Lien du formulaire",
|
"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.",
|
"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.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.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
|
||||||
"publicForm.submitting": "Envoi…",
|
"publicForm.submitting": "Envoi…",
|
||||||
|
"publicForm.requiredLegend": "champ obligatoire",
|
||||||
"publicForm.submit": "Envoyer mon inscription",
|
"publicForm.submit": "Envoyer mon inscription",
|
||||||
"publicForm.success.title": "Inscription reçue",
|
"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.",
|
"publicForm.success.body": "Merci ! Ton inscription a bien été enregistrée. La coordination du festival prendra contact avec toi.",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@/api/hooks';
|
} from '@/api/hooks';
|
||||||
import type { PublicArtistOption } from '@/api/schema';
|
import type { PublicArtistOption } from '@/api/schema';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
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 { t } from '@/i18n';
|
||||||
import { ENV } from '@/lib/env';
|
import { ENV } from '@/lib/env';
|
||||||
import { longDayLabel } from '@/lib/time';
|
import { longDayLabel } from '@/lib/time';
|
||||||
@@ -271,21 +271,21 @@ function MecenatInfoStep({
|
|||||||
{t('publicForm.mecenat.hint')}
|
{t('publicForm.mecenat.hint')}
|
||||||
</p>
|
</p>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('publicForm.mecenat.address')}</FieldLabel>
|
<FieldLabel required>{t('publicForm.mecenat.address')}</FieldLabel>
|
||||||
<TextInput value={address} onChange={setAddress} />
|
<TextInput value={address} onChange={setAddress} />
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('publicForm.mecenat.postalCode')}</FieldLabel>
|
<FieldLabel required>{t('publicForm.mecenat.postalCode')}</FieldLabel>
|
||||||
<TextInput mono value={postalCode} onChange={setPostalCode} />
|
<TextInput mono value={postalCode} onChange={setPostalCode} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('publicForm.mecenat.city')}</FieldLabel>
|
<FieldLabel required>{t('publicForm.mecenat.city')}</FieldLabel>
|
||||||
<TextInput value={city} onChange={setCity} />
|
<TextInput value={city} onChange={setCity} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('publicForm.mecenat.country')}</FieldLabel>
|
<FieldLabel required>{t('publicForm.mecenat.country')}</FieldLabel>
|
||||||
<TextInput value={country} onChange={setCountry} />
|
<TextInput value={country} onChange={setCountry} />
|
||||||
</label>
|
</label>
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -516,27 +516,31 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
|
<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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
|
<FieldLabel required>{t('config.vol.field.firstName')}</FieldLabel>
|
||||||
<TextInput value={firstName} onChange={setFirstName} />
|
<TextInput value={firstName} onChange={setFirstName} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
|
<FieldLabel required>{t('config.vol.field.lastName')}</FieldLabel>
|
||||||
<TextInput value={lastName} onChange={setLastName} />
|
<TextInput value={lastName} onChange={setLastName} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fields.has('EMAIL') ? (
|
{fields.has('EMAIL') ? (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('form.field.email')}</FieldLabel>
|
<FieldLabel required>{t('form.field.email')}</FieldLabel>
|
||||||
<TextInput mono value={email} onChange={setEmail} />
|
<TextInput mono value={email} onChange={setEmail} />
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{fields.has('PHONE') ? (
|
{fields.has('PHONE') ? (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('form.field.phone')}</FieldLabel>
|
<FieldLabel required>{t('form.field.phone')}</FieldLabel>
|
||||||
<TextInput mono value={phone} onChange={setPhone} />
|
<TextInput mono value={phone} onChange={setPhone} />
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -607,6 +611,26 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
|||||||
</label>
|
</label>
|
||||||
) : null}
|
) : 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') ? (
|
{fields.has('COMMENT') ? (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('form.field.comment')}</FieldLabel>
|
<FieldLabel>{t('form.field.comment')}</FieldLabel>
|
||||||
@@ -627,7 +651,10 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
|||||||
onChange={(e) => setAvailable(e.target.checked)}
|
onChange={(e) => setAvailable(e.target.checked)}
|
||||||
className="h-[16px] w-[16px] accent-ink"
|
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>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -678,30 +705,10 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
|||||||
onChange={(e) => setWillingToPayFee(e.target.checked)}
|
onChange={(e) => setWillingToPayFee(e.target.checked)}
|
||||||
className="h-[16px] w-[16px] accent-ink"
|
className="h-[16px] w-[16px] accent-ink"
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-[12px]">{t('publicForm.field.willingToPayFee')}</span>
|
<span className="font-mono text-[12px]">
|
||||||
</label>
|
{t('publicForm.field.willingToPayFee')}
|
||||||
) : null}
|
<RequiredMark />
|
||||||
|
</span>
|
||||||
{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>
|
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user