Files
fotospiel-app/resources/js/admin/mobile/DashboardPage.tsx
Codex Agent 718c129a8d onboarding tracking is now wired, the tour can be replayed from Settings, install‑banner reset is included, and empty states in Tasks/Members/Guest Messages now have guided CTAs.
What changed:
  - Onboarding tracking: admin_app_opened on first authenticated dashboard load; event_created, branding_configured,
    and invite_created on their respective actions.
  - Tour replay: Settings now has an “Experience” section to replay the tour (clears tour seen flag and opens via ?tour=1).
  - Empty states: Tasks, Members, and Guest Messages now include richer copy + quick actions.
  - New helpers + copy: Tour storage helpers, new translations, and related UI wiring.
2025-12-28 18:59:12 +01:00

1029 lines
36 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 { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Bell, CheckCircle2, Download, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, ShieldCheck, Smartphone, 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 { MobileSheet } from './components/Sheet';
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';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions';
import { useInstallPrompt } from './hooks/useInstallPrompt';
import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from './lib/mobileTour';
import { trackOnboarding } from '../api';
import { useAuth } from '../auth/context';
type DeviceSetupProps = {
installPrompt: ReturnType<typeof useInstallPrompt>;
pushState: ReturnType<typeof useAdminPushSubscription>;
devicePermissions: ReturnType<typeof useDevicePermissions>;
onOpenSettings: () => void;
};
export default function MobileDashboardPage() {
const navigate = useNavigate();
const location = useLocation();
const { t, i18n } = useTranslation('management');
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
const { status } = useAuth();
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [fallbackLoading, setFallbackLoading] = React.useState(false);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const [tourOpen, setTourOpen] = React.useState(false);
const [tourStep, setTourStep] = React.useState(0);
const onboardingTrackedRef = React.useRef(false);
const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions();
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;
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
React.useEffect(() => {
if (status !== 'authenticated' || onboardingTrackedRef.current) {
return;
}
onboardingTrackedRef.current = true;
if (typeof window !== 'undefined') {
try {
const stored = window.localStorage.getItem('admin-onboarding-opened-v1');
if (stored) {
return;
}
window.localStorage.setItem('admin-onboarding-opened-v1', '1');
} catch {
// Ignore storage errors.
}
}
void trackOnboarding('admin_app_opened');
}, [status]);
const forceTour = React.useMemo(() => {
const params = new URLSearchParams(location.search);
return params.get('tour') === '1';
}, [location.search]);
React.useEffect(() => {
if (forceTour) {
setTourStep(0);
setTourOpen(true);
setTourSeen(false);
navigate(location.pathname, { replace: true });
return;
}
if (getTourSeen()) {
return;
}
setTourOpen(true);
}, [forceTour, location.pathname, navigate]);
const markTourSeen = React.useCallback(() => {
if (typeof window === 'undefined') {
return;
}
setTourSeen(true);
}, []);
const closeTour = React.useCallback(() => {
setTourOpen(false);
markTourSeen();
}, [markTourSeen]);
const tourSteps = React.useMemo(() => {
const stepMap: Record<TourStepKey, {
key: TourStepKey;
icon: React.ComponentType<{ size?: number; color?: string }>;
title: string;
body: string;
actionLabel?: string;
action?: () => void;
showAction?: boolean;
}> = {
event: {
key: 'event',
icon: Sparkles,
title: t('mobileTour.eventTitle', 'Create your first event'),
body: t('mobileTour.eventBody', 'Add the date, location, and package to unlock sharing.'),
actionLabel: t('mobileTour.eventAction', 'Create event'),
action: () => {
closeTour();
navigate(adminPath('/mobile/events/new'));
},
showAction: true,
},
qr: {
key: 'qr',
icon: QrCode,
title: t('mobileTour.qrTitle', 'Share your QR code'),
body: t('mobileTour.qrBody', 'Guests join instantly by scanning or using the invite link.'),
actionLabel: t('mobileTour.qrAction', 'Open QR'),
action: () => {
if (!tourTargetSlug) {
return;
}
closeTour();
navigate(adminPath(`/mobile/events/${tourTargetSlug}/qr`));
},
showAction: Boolean(tourTargetSlug),
},
photos: {
key: 'photos',
icon: ImageIcon,
title: t('mobileTour.photosTitle', 'Moderate uploads'),
body: t('mobileTour.photosBody', 'Approve, feature, or hide photos with quick swipes.'),
actionLabel: t('mobileTour.photosAction', 'Review photos'),
action: () => {
if (!tourTargetSlug) {
return;
}
closeTour();
navigate(adminPath(`/mobile/events/${tourTargetSlug}/photos`));
},
showAction: Boolean(tourTargetSlug),
},
push: {
key: 'push',
icon: Bell,
title: t('mobileTour.pushTitle', 'Stay in the loop'),
body: t('mobileTour.pushBody', 'Enable push alerts to catch limits and new uploads fast.'),
actionLabel: t('mobileTour.pushAction', 'Notification settings'),
action: () => {
closeTour();
navigate(adminPath('/mobile/settings'));
},
showAction: true,
},
};
return tourStepKeys.map((key) => stepMap[key]);
}, [closeTour, navigate, t, tourStepKeys, tourTargetSlug]);
const activeTourStep = tourSteps[tourStep] ?? tourSteps[0];
const totalTourSteps = tourSteps.length;
React.useEffect(() => {
if (tourStep >= tourSteps.length) {
setTourStep(0);
}
}, [tourStep, tourSteps.length]);
const handleTourNext = React.useCallback(() => {
if (tourStep >= totalTourSteps - 1) {
closeTour();
return;
}
setTourStep((prev) => prev + 1);
}, [closeTour, tourStep, totalTourSteps]);
const handleTourBack = React.useCallback(() => {
setTourStep((prev) => Math.max(prev - 1, 0));
}, []);
const tourSheet = activeTourStep ? (
<MobileSheet open={tourOpen} title={t('mobileTour.title', 'Quick tour')} onClose={closeTour}>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={muted}>
{t('mobileTour.progress', 'Step {{current}} of {{total}}', {
current: tourStep + 1,
total: totalTourSteps,
})}
</Text>
<Pressable onPress={closeTour}>
<Text fontSize="$xs" color={muted} textDecorationLine="underline">
{t('mobileTour.skip', 'Skip')}
</Text>
</Pressable>
</XStack>
<YStack space="$2">
<XStack alignItems="center" space="$2">
<XStack
width={40}
height={40}
borderRadius={14}
alignItems="center"
justifyContent="center"
backgroundColor={String(theme.blue3?.val ?? '#e0f2fe')}
>
<activeTourStep.icon size={18} color={String(theme.blue10?.val ?? '#2563eb')} />
</XStack>
<Text fontSize="$lg" fontWeight="800" color={text}>
{activeTourStep.title}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{activeTourStep.body}
</Text>
{activeTourStep.showAction && activeTourStep.action && activeTourStep.actionLabel ? (
<CTAButton
label={activeTourStep.actionLabel}
tone="ghost"
fullWidth={false}
onPress={activeTourStep.action}
/>
) : null}
</YStack>
<XStack space="$2" justifyContent="space-between">
{tourStep > 0 ? (
<CTAButton label={t('mobileTour.back', 'Back')} tone="ghost" fullWidth={false} onPress={handleTourBack} />
) : (
<XStack />
)}
<CTAButton
label={tourStep >= totalTourSteps - 1 ? t('mobileTour.done', 'Done') : t('mobileTour.next', 'Next')}
fullWidth={false}
onPress={handleTourNext}
/>
</XStack>
</YStack>
</MobileSheet>
) : null;
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>
{tourSheet}
</MobileShell>
);
}
if (!effectiveHasEvents) {
return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
<OnboardingEmptyState
installPrompt={installPrompt}
pushState={pushState}
devicePermissions={devicePermissions}
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/>
{tourSheet}
</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} />
{tourSheet}
</MobileShell>
);
}
return (
<MobileShell
activeTab="home"
title={resolveEventDisplayName(activeEvent ?? undefined)}
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
>
<DeviceSetupCard
installPrompt={installPrompt}
pushState={pushState}
devicePermissions={devicePermissions}
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/>
<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} />
{tourSheet}
</MobileShell>
);
}
function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#0f172a');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#6b7280');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const accent = String(theme.primary?.val ?? '#2563eb');
const iconBg = String(theme.blue3?.val ?? '#e0f2fe');
const iconColor = String(theme.blue10?.val ?? '#2563eb');
const items: Array<{
key: string;
icon: React.ComponentType<{ size?: number; color?: string }>;
title: string;
description: string;
badge: string;
badgeTone: 'success' | 'warning' | 'muted';
actionLabel?: string;
action?: () => void;
actionDisabled?: boolean;
}> = [];
const showInstall = !installPrompt.isInstalled && (installPrompt.canInstall || installPrompt.isIos);
if (showInstall) {
items.push({
key: 'install',
icon: Smartphone,
title: t('mobileDashboard.setup.installTitle', 'Install app'),
description: installPrompt.isIos
? t('mobileDashboard.setup.installIosHint', 'On iOS: Share → Add to Home Screen.')
: t('mobileDashboard.setup.installDescription', 'Get full-screen access and faster launch.'),
badge: installPrompt.canInstall
? t('mobileDashboard.setup.installReady', 'Ready')
: t('mobileDashboard.setup.installManual', 'Manual'),
badgeTone: installPrompt.canInstall ? 'warning' : 'muted',
actionLabel: installPrompt.canInstall ? t('mobileDashboard.setup.installAction', 'Install now') : undefined,
action: installPrompt.canInstall ? () => void installPrompt.promptInstall() : undefined,
actionDisabled: !installPrompt.canInstall,
});
}
if (pushState.supported && !pushState.subscribed) {
const pushBlocked = pushState.permission === 'denied';
items.push({
key: 'push',
icon: Bell,
title: t('mobileDashboard.setup.pushTitle', 'Enable push notifications'),
description: pushBlocked
? t('mobileDashboard.setup.pushBlockedDesc', 'Notifications are blocked in the browser settings.')
: t('mobileDashboard.setup.pushDescription', 'Stay on top of uploads and quota alerts.'),
badge: pushBlocked
? t('mobileDashboard.setup.pushBlocked', 'Blocked')
: t('mobileDashboard.setup.pushOff', 'Off'),
badgeTone: 'warning',
actionLabel: pushBlocked
? t('mobileDashboard.setup.pushSettings', 'Open settings')
: t('mobileDashboard.setup.pushAction', 'Enable push'),
action: pushBlocked ? onOpenSettings : () => void pushState.enable(),
actionDisabled: pushState.loading,
});
}
if (devicePermissions.storage === 'available') {
items.push({
key: 'storage',
icon: ShieldCheck,
title: t('mobileDashboard.setup.storageTitle', 'Protect offline data'),
description: t('mobileDashboard.setup.storageDescription', 'Keep cached uploads available when you go offline.'),
badge: t('mobileDashboard.setup.storageUnprotected', 'Not protected'),
badgeTone: 'warning',
actionLabel: t('mobileDashboard.setup.storageAction', 'Protect now'),
action: () => void devicePermissions.requestPersistentStorage(),
actionDisabled: devicePermissions.loading,
});
}
if (items.length === 0) {
return null;
}
return (
<MobileCard space="$3" borderColor={border}>
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.setupTitle', 'Set up this device')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.setupSubtitle', 'Finish these steps for the best mobile experience.')}
</Text>
</YStack>
<Download size={18} color={accent} />
</XStack>
<YStack space="$2">
{items.map((item) => (
<YStack key={item.key} space="$1.5" padding="$2" borderRadius={12} borderWidth={1} borderColor={`${border}aa`}>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack alignItems="center" space="$2" flex={1}>
<XStack
width={32}
height={32}
borderRadius={10}
backgroundColor={iconBg}
alignItems="center"
justifyContent="center"
>
<item.icon size={16} color={iconColor} />
</XStack>
<YStack flex={1} space="$0.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{item.title}
</Text>
<Text fontSize="$xs" color={muted}>
{item.description}
</Text>
</YStack>
</XStack>
<PillBadge tone={item.badgeTone}>{item.badge}</PillBadge>
</XStack>
{item.actionLabel && item.action ? (
<Pressable onPress={item.action} disabled={item.actionDisabled}>
<Text
fontSize="$xs"
fontWeight="700"
color={item.actionDisabled ? muted : accent}
textDecorationLine="underline"
>
{item.actionLabel}
</Text>
</Pressable>
) : null}
</YStack>
))}
</YStack>
</MobileCard>
);
}
function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
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">
<DeviceSetupCard
installPrompt={installPrompt}
pushState={pushState}
devicePermissions={devicePermissions}
onOpenSettings={onOpenSettings}
/>
<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>
);
}