Files
fotospiel-app/resources/js/admin/mobile/DashboardPage.tsx
Codex Agent cf73f408b2 Navigation now feels more “app‑like” with
stateful tabs and reliable back behavior, and a full onboarding flow is wired in with conditional package selection
  (skips when an active package exists).

  What changed

  - Added per‑tab history + back navigation fallback to make tab switching/Back feel native (resources/js/admin/mobile/
    lib/tabHistory.ts, resources/js/admin/mobile/hooks/useBackNavigation.ts, resources/js/admin/mobile/hooks/
    useMobileNav.ts, resources/js/admin/mobile/components/MobileShell.tsx + updates across mobile pages).
  - Implemented onboarding flow pages + shared shell, and wired new routes/prefetch (resources/js/admin/mobile/welcome/
    WelcomeLandingPage.tsx, resources/js/admin/mobile/welcome/WelcomePackagesPage.tsx, resources/js/admin/mobile/
    welcome/WelcomeSummaryPage.tsx, resources/js/admin/mobile/welcome/WelcomeEventPage.tsx, resources/js/admin/mobile/
    components/OnboardingShell.tsx, resources/js/admin/router.tsx, resources/js/admin/mobile/prefetch.ts).
  - Conditional package step: packages page redirects to event setup if activePackage exists; selection stored locally
    for summary (resources/js/admin/mobile/lib/onboardingSelection.ts, resources/js/admin/mobile/welcome/
    WelcomePackagesPage.tsx).
  - Added a “Start welcome journey” CTA in the empty dashboard state (resources/js/admin/mobile/DashboardPage.tsx).
  - Added translations for onboarding shell + selected package + dashboard CTA (resources/js/admin/i18n/locales/en/
    onboarding.json, resources/js/admin/i18n/locales/de/onboarding.json, resources/js/admin/i18n/locales/en/
    management.json, resources/js/admin/i18n/locales/de/management.json).
  - Tests for new helpers/hooks (resources/js/admin/mobile/lib/tabHistory.test.ts, resources/js/admin/mobile/lib/
    onboardingSelection.test.ts, resources/js/admin/mobile/hooks/useBackNavigation.test.tsx).
2025-12-28 19:51:57 +01:00

1034 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, ADMIN_WELCOME_BASE_PATH } 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'))} />
<CTAButton
label={t('mobileDashboard.ctaWelcome', 'Start welcome journey')}
tone="ghost"
onPress={() => navigate(ADMIN_WELCOME_BASE_PATH)}
/>
</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>
);
}