Files
benevoles/frontend/src/pages/MobileVolunteerPage.tsx
T
AdamThomasandClaude Sonnet 5 ac469f0cd1 Frontend : intégration du design Claude Design (6 écrans)
Portage du projet « Bénévoles Festival.dc.html » en implémentation statique
pilotée par un jeu de démonstration déterministe (portage du générateur du
design : 15 postes, 64 bénévoles, ~209 affectations).

- Système visuel néo-brutaliste : palette + primitives Tailwind, polices
  Archivo / DM Mono, primitives dans components/ui.tsx.
- Domaine (aligné CLAUDE.md) : types, jours/plages, moteur de validation
  (mêmes codes BLOCKING/WARNING que le back-end), sélecteurs, différentiel
  de publication.
- État applicatif : PlanningProvider (reducer) — affecter / désaffecter /
  publier / confirmer / éditer besoins et bénévoles / import simulé / pointage.
- Écrans : 01 Grille des besoins + panneau d'affectation (candidats triés,
  alertes), 02 Vue par bénévole, 03 Publication, 04 Mobile bénévole,
  05 Mobile référent, 06 Configuration (4 sous-onglets).
- i18n : aucun texte d'interface en dur, locales/fr.json.
- Vérifs : build tsc + vite, eslint (0 erreur), rendu headless des 6 écrans
  (npm run smoke).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
2026-09-09 23:16:38 +02:00

133 lines
5.9 KiB
TypeScript

import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { absoluteWindow, dayPart, festivalDay } from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { t } from '@/i18n';
export function MobileVolunteerPage() {
const { state, dispatch } = usePlanning();
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
);
const me = state.volunteers[0];
const slots = (index.byVolunteer.get(me.id) ?? [])
.slice()
.sort(
(a, b) =>
absoluteWindow(a.dayId, a.dayPartCode).start - absoluteWindow(b.dayId, b.dayPartCode).start,
)
.map((assignment) => {
const position = state.positions.find((p) => p.id === assignment.positionId)!;
const positionIndex = state.positions.findIndex((p) => p.id === assignment.positionId);
const lead = state.volunteers[(positionIndex * 5 + 3) % state.volunteers.length];
return {
assignmentId: assignment.id,
day: festivalDay(assignment.dayId).longLabel,
hours: dayPart(assignment.dayPartCode).label,
position: position.name.toUpperCase(),
zone: position.zone,
location: position.location,
leadName: lead.fullName,
leadPhone: lead.phone,
leadPhoneRaw: lead.phone.replace(/\s/g, ''),
confirmed: assignment.status === 'CONFIRMED',
};
});
const toConfirm = slots.filter((s) => !s.confirmed).length;
const totalHours = index.totalHours.get(me.id) ?? 0;
return (
<div className="flex flex-wrap items-start gap-[26px] p-6">
<PhoneFrame time={t('mobileVol.statusTime')}>
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
{t('mobileVol.greeting', { name: me.firstName.toUpperCase() })}
</div>
<div className="mt-3 flex gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileVol.slots', { count: slots.length })}
</div>
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileVol.hours', { count: totalHours })}
</div>
</div>
</div>
{toConfirm > 0 ? (
<div className="border-b-[3px] border-ink bg-orange px-4 py-[11px] font-display text-[14px] font-black leading-snug">
{t('mobileVol.toConfirm', { count: toConfirm })}
</div>
) : null}
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
{slots.map((slot) => (
<div key={slot.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">{slot.day}</span>
<span className="ml-auto font-mono text-[17px] font-bold text-yellow">
{slot.hours}
</span>
</div>
<div className="p-[11px]">
<div className="font-display text-[25px] font-black leading-tight tracking-tight">
{slot.position}
</div>
<div className="font-display text-[14px] font-semibold opacity-75">{slot.zone}</div>
<div className="mt-[9px] flex flex-col gap-[5px] border-t-2 border-ink pt-[9px]">
<div className="font-mono text-[12px]">
{t('mobileVol.location', { location: slot.location })}
</div>
<div className="font-mono text-[12px]">
{t('mobileVol.lead', { name: slot.leadName })}
</div>
</div>
<div className="mt-[11px] flex gap-[9px]">
<a
href={`tel:${slot.leadPhoneRaw}`}
className="flex min-h-[52px] flex-none items-center justify-center border-[3px] border-ink bg-cream px-[14px] font-display text-[13px] font-extrabold no-underline"
>
{t('mobileVol.call', { phone: slot.leadPhone })}
</a>
</div>
{slot.confirmed ? (
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
{t('mobileVol.confirmed')}
</div>
) : (
<button
type="button"
onClick={() => dispatch({ type: 'confirmSlot', assignmentId: slot.assignmentId })}
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white"
>
{t('mobileVol.confirm')}
</button>
)}
</div>
</div>
))}
<div className="border-[3px] border-ink bg-blue p-3 shadow-brutal-lg">
<div className="font-display text-[12px] font-black uppercase tracking-widest">
{t('mobileVol.orgMessageTitle')}
</div>
<p className="mt-[7px] font-display text-[14px] font-semibold leading-relaxed">
{t('mobileVol.orgMessageBody')}
</p>
<div className="mt-2 font-mono text-[10px] opacity-70">
{t('mobileVol.orgMessageMeta')}
</div>
</div>
</div>
</PhoneFrame>
<DesignNote>{t('mobileVol.designNote')}</DesignNote>
</div>
);
}