import { useMemo } from 'react'; import { ApiError } from '@/api'; import { useAssignments, usePositions, useShifts, useVolunteer } from '@/api/hooks'; import { useAuth } from '@/auth/auth-context'; import { ErrorNote, Loading } from '@/components/state'; import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time'; import { t } from '@/i18n'; // Page réellement consultée par le bénévole (accueil de son rôle, cf. // `auth/roles.ts:homePath`) — sur son propre téléphone la plupart du temps, // mais aussi possible depuis un ordinateur : le rendu ci-dessous s'adapte // (mobile d'abord, une seule colonne pleine largeur ; élargi et centré à // partir de `sm:`), sans jamais simuler un cadre de téléphone. Repères de // conception conservés : fonds opaques, texte noir sur jaune ou blanc (pas // de gris clair, lisibilité plein soleil), cibles tactiles ≥ 52 px de haut, // une seule action par créneau. export function MobileVolunteerPage() { const { account } = useAuth(); const volunteerId = account?.volunteer_id ?? null; const meVolunteer = useVolunteer(volunteerId); const assignments = useAssignments(volunteerId ? { volunteerId } : undefined, { enabled: Boolean(volunteerId), }); const shifts = useShifts(); const positions = usePositions(); const shiftsById = useMemo( () => new Map((shifts.data ?? []).map((s) => [s.id, s])), [shifts.data], ); const positionsById = useMemo( () => new Map((positions.data ?? []).map((p) => [p.id, p])), [positions.data], ); if (!volunteerId) { return (
{t('mobileVol.noAccount')}
); } if (assignments.isLoading) return ; if (assignments.error) return ; const firstName = meVolunteer.data?.first_name ?? account?.email.split('@')[0] ?? ''; // shifts / positions peuvent être refusés (403) à un bénévole côté back-end réel : // on affiche alors les créneaux sans détail plutôt que de tomber en erreur. const detailsUnavailable = (shifts.error instanceof ApiError && shifts.error.status === 403) || (positions.error instanceof ApiError && positions.error.status === 403); const slots = (assignments.data ?? []) .map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null })) .sort((a, b) => { const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0; const bv = b.shift ? naiveMinutes(b.shift.starts_at) : 0; return av - bv; }); const totalHours = Math.round( slots.reduce( (sum, s) => sum + (s.shift ? hoursBetween(s.shift.starts_at, s.shift.ends_at) : 0), 0, ) * 10, ) / 10; return (
{t('mobileVol.kicker')}
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
{t('mobileVol.slots', { count: slots.length })}
{!detailsUnavailable ? (
{t('mobileVol.hours', { count: totalHours })}
) : null}
{detailsUnavailable ? (
{t('mobileVol.detailsUnavailable')}
) : null} {slots.map(({ assignment, shift }) => { const position = shift ? positionsById.get(shift.position_id) : undefined; return (
{shift ? (
{longDayLabel(shift.starts_at)} {hourRangeLabel(shift.starts_at, shift.ends_at)}
) : null}
{position ? (
{position.name.toUpperCase()}
) : null} {position?.location ? (
{t('mobileVol.location', { location: position.location })}
) : null}
{t('mobileVol.slotAssigned')}
); })} {slots.length === 0 ? (
{t('mobileVol.noSlots')}
) : null}
); }