Files
fotospiel-app/resources/js/admin/mobile/DashboardPage.tsx
Codex Agent 4ce409e918 Completed the full mobile app polish pass: navigation feel, safe‑area consistency, input styling, list rows, FAB
patterns, skeleton loading, photo selection/bulk actions with shared‑element transitions, notification detail sheet,
  offline banner, maskable manifest icons, and route prefetching.

  Key changes

  - Navigation/shell: press feedback on all header actions, glassy sticky header and tab bar, safer bottom spacing
    (resources/js/admin/mobile/components/MobileShell.tsx, resources/js/admin/mobile/components/BottomNav.tsx).
  - Forms + lists: shared mobile form controls, list‑style rows in settings/profile, consistent inputs across core
    flows (resources/js/admin/mobile/components/FormControls.tsx, resources/js/admin/mobile/SettingsPage.tsx,
    resources/js/admin/mobile/ProfilePage.tsx, resources/js/admin/mobile/EventFormPage.tsx, resources/js/admin/mobile/
    EventMembersPage.tsx, resources/js/admin/mobile/EventTasksPage.tsx, resources/js/admin/mobile/
    EventGuestNotificationsPage.tsx, resources/js/admin/mobile/NotificationsPage.tsx, resources/js/admin/mobile/
    EventPhotosPage.tsx, resources/js/admin/mobile/EventsPage.tsx).
  - Media workflows: shared‑element photo transitions, selection mode + bulk actions bar (resources/js/admin/mobile/
    EventPhotosPage.tsx).
  - Loading UX: shimmering skeletons (resources/css/app.css, resources/js/admin/mobile/components/Primitives.tsx).
  - PWA polish + perf: maskable icons, offline banner hook, and route prefetch (public/manifest.json, resources/js/
    admin/mobile/hooks/useOnlineStatus.tsx, resources/js/admin/mobile/prefetch.ts, resources/js/admin/main.tsx).
2025-12-27 23:55:48 +01:00

639 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { CheckCircle2, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, Users, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, renderEventLocation } from './components/MobileShell';
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
import { adminPath } from '../constants';
import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { useTheme } from '@tamagui/core';
export default function MobileDashboardPage() {
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [fallbackLoading, setFallbackLoading] = React.useState(false);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const border = String(theme.borderColor?.val ?? '#334155');
const surface = String(theme.surface?.val ?? '#ffffff');
const accentSoft = String(theme.blue3?.val ?? '#e0f2fe');
const accentText = String(theme.primary?.val ?? '#3b82f6');
const { data: stats, isLoading: statsLoading } = useQuery<EventStats | null>({
queryKey: ['mobile', 'dashboard', 'stats', activeEvent?.slug],
enabled: Boolean(activeEvent?.slug),
queryFn: async () => {
if (!activeEvent?.slug) return null;
return await getEventStats(activeEvent.slug);
},
});
const tasksEnabled =
resolveEngagementMode(activeEvent ?? undefined) !== 'photo_only';
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const { data: dashboardEvents } = useQuery<TenantEvent[]>({
queryKey: ['mobile', 'dashboard', 'events'],
queryFn: () => getEvents({ force: true }),
staleTime: 60_000,
});
const effectiveEvents = events.length ? events : dashboardEvents?.length ? dashboardEvents : fallbackEvents;
const effectiveHasEvents = hasEvents || Boolean(dashboardEvents?.length) || fallbackEvents.length > 0;
const effectiveMultiple =
hasMultipleEvents || (dashboardEvents?.length ?? 0) > 1 || fallbackEvents.length > 1;
React.useEffect(() => {
if (events.length || isLoading || fallbackLoading || fallbackAttempted) {
return;
}
setFallbackAttempted(true);
setFallbackLoading(true);
getEvents({ force: true })
.then((list: TenantEvent[]) => {
setFallbackEvents(list ?? []);
if (list?.length === 1 && !activeEvent) {
selectEvent(list[0]?.slug ?? null);
}
})
.catch(() => {
setFallbackEvents([]);
})
.finally(() => setFallbackLoading(false));
}, [events.length, isLoading, activeEvent, selectEvent, fallbackLoading, fallbackAttempted]);
if (isLoading || fallbackLoading) {
return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`sk-${idx}`} height={110} />
))}
</YStack>
</MobileShell>
);
}
if (!effectiveHasEvents) {
return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
<OnboardingEmptyState />
</MobileShell>
);
}
if (effectiveMultiple && !activeEvent) {
return (
<MobileShell
activeTab="home"
title={t('mobileDashboard.title', 'Dashboard')}
subtitle={t('mobileDashboard.selectEvent', 'Select an event to continue')}
>
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} />
</MobileShell>
);
}
return (
<MobileShell
activeTab="home"
title={resolveEventDisplayName(activeEvent ?? undefined)}
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
>
<FeaturedActions
tasksEnabled={tasksEnabled}
onReviewPhotos={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))}
onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))}
onShowQr={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
/>
<SecondaryGrid
event={activeEvent}
onGuests={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))}
/>
<KpiStrip
event={activeEvent}
stats={stats}
loading={statsLoading}
locale={locale}
tasksEnabled={tasksEnabled}
/>
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
</MobileShell>
);
}
function OnboardingEmptyState() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const border = String(theme.borderColor?.val ?? '#cbd5e1');
const accent = String(theme.primary?.val ?? '#2563eb');
const accentSoft = String(theme.blue3?.val ?? '#e0f2fe');
const accentStrong = String(theme.blue10?.val ?? '#1d4ed8');
const stepBg = String(theme.gray2?.val ?? '#f8fafc');
const stepBorder = String(theme.gray5?.val ?? '#e2e8f0');
const supportBg = String(theme.gray2?.val ?? '#f8fafc');
const supportBorder = String(theme.gray5?.val ?? '#e2e8f0');
const steps = [
t('mobileDashboard.emptyStepDetails', 'Add name & date'),
t('mobileDashboard.emptyStepQr', 'Share your QR poster'),
t('mobileDashboard.emptyStepReview', 'Review first uploads'),
];
const previews = [
{
icon: QrCode,
title: t('mobileDashboard.emptyPreviewQr', 'Share QR poster'),
desc: t('mobileDashboard.emptyPreviewQrDesc', 'Print-ready codes for guests and crew.'),
},
{
icon: ImageIcon,
title: t('mobileDashboard.emptyPreviewGallery', 'Gallery & highlights'),
desc: t('mobileDashboard.emptyPreviewGalleryDesc', 'Moderate uploads, feature the best moments.'),
},
{
icon: ListTodo,
title: t('mobileDashboard.emptyPreviewTasks', 'Tasks & challenges'),
desc: t('mobileDashboard.emptyPreviewTasksDesc', 'Guide guests with playful prompts.'),
},
];
return (
<YStack space="$3">
<MobileCard
padding="$4"
borderColor="transparent"
overflow="hidden"
backgroundColor="linear-gradient(140deg, rgba(14,165,233,0.16), rgba(79,70,229,0.22))"
>
<YStack position="absolute" top={-10} right={-10} opacity={0.16} scale={1.2}>
<Sparkles size={72} color={accentStrong} />
</YStack>
<YStack position="absolute" bottom={-14} left={-8} opacity={0.14}>
<QrCode size={96} color={accentStrong} />
</YStack>
<YStack space="$2" zIndex={1}>
<PillBadge tone="muted">{t('mobileDashboard.emptyBadge', 'Welcome aboard')}</PillBadge>
<Text fontSize="$xl" fontWeight="900" color={text}>
{t('mobileDashboard.emptyTitle', "Welcome! Let's launch your first event")}
</Text>
<Text fontSize="$sm" color={text} opacity={0.9}>
{t('mobileDashboard.emptyBody', 'Print a QR, collect uploads, and start moderating in minutes.')}
</Text>
<CTAButton label={t('mobileDashboard.ctaCreate', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
</YStack>
</MobileCard>
<MobileCard space="$2.5" borderColor={border} backgroundColor={stepBg}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.emptyChecklistTitle', 'Quick steps to go live')}
</Text>
<PillBadge tone="muted">
{t('mobileDashboard.emptyChecklistProgress', '{{done}}/{{total}} steps', { done: 0, total: steps.length })}
</PillBadge>
</XStack>
<YStack space="$2">
{steps.map((label) => (
<XStack
key={label}
alignItems="center"
space="$2"
padding="$2"
borderRadius={12}
backgroundColor="rgba(255,255,255,0.5)"
borderWidth={1}
borderColor={stepBorder}
>
<XStack
width={34}
height={34}
borderRadius={12}
alignItems="center"
justifyContent="center"
backgroundColor={accentSoft}
borderWidth={1}
borderColor={`${accentStrong}33`}
>
<CheckCircle2 size={18} color={accent} />
</XStack>
<Text fontSize="$sm" color={text} flex={1}>
{label}
</Text>
</XStack>
))}
</YStack>
</MobileCard>
<MobileCard space="$2" borderColor={border}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.emptyPreviewTitle', "Here's what awaits")}
</Text>
<XStack space="$2" flexWrap="wrap">
{previews.map(({ icon: Icon, title, desc }) => (
<YStack
key={title}
width="48%"
minWidth={160}
space="$1.5"
padding="$3"
borderRadius={14}
borderWidth={1}
borderColor={`${border}aa`}
backgroundColor="rgba(255,255,255,0.6)"
shadowColor="#0f172a"
shadowOpacity={0.04}
shadowRadius={10}
shadowOffset={{ width: 0, height: 6 }}
>
<XStack
width={36}
height={36}
borderRadius={12}
backgroundColor={accentSoft}
alignItems="center"
justifyContent="center"
>
<Icon size={18} color={accent} />
</XStack>
<Text fontSize="$sm" fontWeight="700" color={text}>
{title}
</Text>
<Text fontSize="$xs" color={muted}>
{desc}
</Text>
</YStack>
))}
</XStack>
</MobileCard>
<MobileCard space="$2" backgroundColor={supportBg} borderColor={supportBorder}>
<XStack alignItems="center" space="$2">
<XStack
width={36}
height={36}
borderRadius={12}
backgroundColor={accentSoft}
alignItems="center"
justifyContent="center"
>
<MessageCircle size={18} color={accent} />
</XStack>
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.emptySupportTitle', 'Need help?')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.emptySupportBody', 'We are here if you need a hand getting started.')}
</Text>
</YStack>
</XStack>
<XStack space="$3">
<Text fontSize="$xs" color={accent} textDecorationLine="underline">
{t('mobileDashboard.emptySupportDocs', 'Docs: Getting started')}
</Text>
<Text fontSize="$xs" color={accent} textDecorationLine="underline">
{t('mobileDashboard.emptySupportEmail', 'Email support')}
</Text>
</XStack>
</MobileCard>
</YStack>
);
}
function EventPickerList({ events, locale, text, muted, border }: { events: TenantEvent[]; locale: string; text: string; muted: string; border: string }) {
const { t } = useTranslation('management');
const { selectEvent } = useEventContext();
const navigate = useNavigate();
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
setLocalEvents(events);
}, [events]);
React.useEffect(() => {
if (events.length > 0 || loading) {
return;
}
setLoading(true);
getEvents({ force: true })
.then((list) => setLocalEvents(list ?? []))
.catch(() => setLocalEvents([]))
.finally(() => setLoading(false));
}, [events.length, loading]);
return (
<YStack space="$2">
<Text fontSize="$sm" color={text} fontWeight="700">
{t('mobileDashboard.pickEvent', 'Select an event')}
</Text>
{localEvents.map((event) => (
<Pressable
key={event.slug}
onPress={() => {
selectEvent(event.slug ?? null);
if (event.slug) {
navigate(adminPath(`/mobile/events/${event.slug}`));
}
}}
>
<MobileCard borderColor={border} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
</Text>
</YStack>
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published'
? t('mobileDashboard.status.published', 'Live')
: t('mobileDashboard.status.draft', 'Draft')}
</PillBadge>
</XStack>
</MobileCard>
</Pressable>
))}
</YStack>
);
}
function FeaturedActions({
tasksEnabled,
onReviewPhotos,
onManageTasks,
onShowQr,
}: {
tasksEnabled: boolean;
onReviewPhotos: () => void;
onManageTasks: () => void;
onShowQr: () => void;
}) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const cards = [
{
key: 'photos',
label: t('mobileDashboard.photosLabel', 'Review photos'),
desc: t('mobileDashboard.photosDesc', 'Moderate uploads and highlights'),
icon: ImageIcon,
color: '#0ea5e9',
action: onReviewPhotos,
},
{
key: 'tasks',
label: t('mobileDashboard.tasksLabel', 'Manage tasks & challenges'),
desc: tasksEnabled
? t('mobileDashboard.tasksDesc', 'Assign and track progress')
: t('mobileDashboard.tasksDisabledDesc', 'Guests do not see tasks (task mode off)'),
icon: ListTodo,
color: '#22c55e',
action: onManageTasks,
},
{
key: 'qr',
label: t('mobileDashboard.qrLabel', 'Show / share QR code'),
desc: t('mobileDashboard.qrDesc', 'Posters, cards, and links'),
icon: QrCode,
color: '#f59e0b',
action: onShowQr,
},
];
return (
<YStack space="$2">
{cards.map((card) => (
<Pressable key={card.key} onPress={card.action}>
<MobileCard borderColor={`${card.color}44`} backgroundColor={`${card.color}0f`} space="$2.5">
<XStack alignItems="center" space="$3">
<XStack width={44} height={44} borderRadius={14} backgroundColor={card.color} alignItems="center" justifyContent="center">
<card.icon size={20} color="white" />
</XStack>
<YStack space="$1" flex={1}>
<Text fontSize="$md" fontWeight="800" color={text}>
{card.label}
</Text>
<Text fontSize="$xs" color={muted}>
{card.desc}
</Text>
</YStack>
<Text fontSize="$xl" color={String(theme.gray9?.val ?? '#94a3b8')}>
˃
</Text>
</XStack>
</MobileCard>
</Pressable>
))}
</YStack>
);
}
function SecondaryGrid({
event,
onGuests,
onPrint,
onInvites,
onSettings,
}: {
event: TenantEvent | null;
onGuests: () => void;
onPrint: () => void;
onInvites: () => void;
onSettings: () => void;
}) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const border = String(theme.borderColor?.val ?? '#334155');
const surface = String(theme.surface?.val ?? '#0b1220');
const brandingAllowed = isBrandingAllowed(event ?? null);
const tiles = [
{
icon: Users,
label: t('mobileDashboard.shortcutGuests', 'Guest management'),
color: '#60a5fa',
action: onGuests,
},
{
icon: QrCode,
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
color: '#fbbf24',
action: onPrint,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutInvites', 'Team / helper invites'),
color: '#a855f7',
action: onInvites,
},
{
icon: Settings,
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
color: '#10b981',
action: onSettings,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
color: '#22d3ee',
action: brandingAllowed ? onSettings : undefined,
disabled: !brandingAllowed,
},
];
return (
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')}
</Text>
<XStack flexWrap="wrap" space="$2">
{tiles.map((tile) => (
<ActionTile
key={tile.label}
icon={tile.icon}
label={tile.label}
color={tile.color}
onPress={tile.action}
disabled={tile.disabled}
/>
))}
</XStack>
{event ? (
<MobileCard backgroundColor={surface} borderColor={border} space="$1.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color={muted}>
{renderEventLocation(event)}
</Text>
</MobileCard>
) : null}
</YStack>
);
}
function KpiStrip({
event,
stats,
loading,
locale,
tasksEnabled,
}: {
event: TenantEvent | null;
stats: EventStats | null | undefined;
loading: boolean;
locale: string;
tasksEnabled: boolean;
}) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
if (!event) return null;
const kpis = [
{
label: t('mobileDashboard.kpiPhotos', 'Photos'),
value: stats?.uploads_total ?? event.photo_count ?? '—',
icon: ImageIcon,
},
{
label: t('mobileDashboard.kpiGuests', 'Guests'),
value: event.active_invites_count ?? event.total_invites_count ?? '—',
icon: Users,
},
];
if (tasksEnabled) {
kpis.unshift({
label: t('mobileDashboard.kpiTasks', 'Open tasks'),
value: event.tasks_count ?? '—',
icon: ListTodo,
});
}
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.kpiTitle', 'Key performance indicators')}
</Text>
{loading ? (
<XStack space="$2" flexWrap="wrap">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
))}
</XStack>
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value ?? '—'} />
))}
</XStack>
)}
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? ''}
</Text>
</YStack>
);
}
function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | null; stats: EventStats | null | undefined; tasksEnabled: boolean }) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const warningBg = String(theme.yellow3?.val ?? '#fff7ed');
const warningBorder = String(theme.yellow6?.val ?? '#fed7aa');
const warningText = String(theme.yellow11?.val ?? '#9a3412');
if (!event) return null;
const alerts: string[] = [];
if (stats?.pending_photos) {
alerts.push(t('mobileDashboard.alertPending', '{{count}} new uploads awaiting moderation', { count: stats.pending_photos }));
}
if (tasksEnabled && event.tasks_count) {
alerts.push(t('mobileDashboard.alertTasks', '{{count}} tasks due or open', { count: event.tasks_count }));
}
if (alerts.length === 0) {
return null;
}
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.alertsTitle', 'Alerts')}
</Text>
{alerts.map((alert) => (
<MobileCard key={alert} backgroundColor={warningBg} borderColor={warningBorder} space="$2">
<Text fontSize="$sm" color={warningText}>
{alert}
</Text>
</MobileCard>
))}
</YStack>
);
}