Écrans bénévole et référent redessinés, messages aux bénévoles
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 12s

Étape 55 : écrans /mobile-benevole et /mobile-referent d'après la maquette
(design/Planning bénévole mobile) — créneau en cours ou prochain mis en
avant, planning par journée de festival avec frise horaire, compteurs et
pointage du référent par créneau.

Étape 56 : message de l'organisation (Edition.volunteer_message, onglet
« Message aux bénévoles ») et message de chaque poste (Position.message,
saisi par le référent), migration 0029, affichés sur l'écran bénévole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 23:17:14 +01:00
co-authored by Claude Opus 5.5
parent 4a3f93e137
commit 16d8fb44a7
21 changed files with 1305 additions and 274 deletions
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { ApiError } from '@/api';
import { MESSAGE_MAX_LENGTH } from '@/api/schema';
import { Btn } from '@/components/ui';
import { cx } from '@/lib/cx';
import { messageDateLabel } from '@/lib/slotTimeline';
import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n';
/**
* Saisie d'un message montré aux bénévoles — celui de l'organisation
* (coordination, `ConfigMessage`) ou celui d'un poste (référent,
* `MobileLeadPage`). Enregistré sur demande ; vider le champ retire le
* message.
*/
export function MessageEditor({
value,
updatedAt,
placeholder,
saving,
error,
onSave,
dark = false,
}: {
value: string | null;
updatedAt: string | null;
placeholder: string;
saving: boolean;
error: unknown;
onSave: (text: string) => Promise<unknown>;
/** Sur fond sombre (en-tête de l'écran référent). */
dark?: boolean;
}) {
const [draft, setDraft] = useState(value ?? '');
const [saved, setSaved] = useState(false);
const now = useNowMinutes();
// Le message enregistré change (autre poste choisi, autre appareil) :
// le brouillon repart de lui.
useEffect(() => setDraft(value ?? ''), [value]);
const dirty = draft.trim() !== (value ?? '');
const tooLong = draft.length > MESSAGE_MAX_LENGTH;
return (
<div className="flex flex-col gap-[7px]">
<textarea
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setSaved(false);
}}
rows={4}
placeholder={placeholder}
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[14px] font-semibold leading-snug text-ink"
/>
<div className="flex flex-wrap items-center gap-2">
<Btn
disabled={!dirty || tooLong || saving}
onClick={() => {
void onSave(draft.trim()).then(() => setSaved(true), () => undefined);
}}
>
{saving ? t('message.saving') : draft.trim() ? t('message.save') : t('message.remove')}
</Btn>
<span
className={cx(
'font-mono text-[10px] leading-snug',
tooLong ? 'font-bold text-orange' : 'opacity-65',
)}
>
{saved && !dirty
? t('message.saved')
: value && updatedAt && !dirty
? t('message.updatedAt', { when: messageDateLabel(updatedAt, now) })
: t('message.length', { count: draft.length, max: MESSAGE_MAX_LENGTH })}
</span>
</div>
{error ? (
<div className={cx('font-mono text-[10.5px] font-bold', dark ? 'text-orange' : 'text-orange-dark')}>
{error instanceof ApiError ? t(error.code) : t('error.generic')}
</div>
) : null}
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
import { t } from '@/i18n';
/** Repère horizontal « MAINTENANT », à insérer dans une liste triée
* chronologiquement (créneaux d'un bénévole, créneaux d'un poste…) au point
* correspondant à l'heure réelle. Purement visuel. */
export function NowLine() {
return (
<div className="flex items-center gap-2 px-1" aria-hidden="true">
<div className="h-[3px] flex-1 bg-orange" />
<div className="bg-orange px-[7px] py-[3px] font-mono text-[10px] font-bold text-white">
{t('common.now')}
</div>
<div className="h-[3px] flex-1 bg-orange" />
</div>
);
}