Files
fotospiel-app/resources/js/admin/mobile/DashboardPage.tsx
2026-01-06 11:57:30 +01:00

1208 lines
43 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 { fetchOnboardingStatus, getEventStats, EventStats, TenantEvent, getEvents, getTenantPackagesOverview } from '../api';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
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';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
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 [summaryOpen, setSummaryOpen] = React.useState(false);
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
const onboardingTrackedRef = React.useRef(false);
const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions();
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
const accentText = primary;
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 { data: onboardingStatus, isLoading: onboardingLoading } = useQuery({
queryKey: ['mobile', 'onboarding', 'status'],
queryFn: fetchOnboardingStatus,
staleTime: 60_000,
});
const { data: packagesOverview, isLoading: packagesLoading, isError: packagesError } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-overview'],
queryFn: () => getTenantPackagesOverview({ 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 activePackage =
packagesOverview?.activePackage ?? packagesOverview?.packages?.find((pkg) => pkg.active) ?? null;
const remainingEvents = activePackage?.remaining_events ?? null;
const summarySeenPackageId =
summarySeenOverride ?? onboardingStatus?.steps?.summary_seen_package_id ?? null;
const hasSummaryPackage =
Boolean(activePackage?.id && activePackage.id !== summarySeenPackageId);
const shouldRedirectToBilling =
!packagesLoading &&
!packagesError &&
!effectiveHasEvents &&
(activePackage === null || (remainingEvents !== null && remainingEvents <= 0));
React.useEffect(() => {
if (packagesLoading || !shouldRedirectToBilling) {
return;
}
navigate(adminPath('/mobile/billing#packages'), { replace: true });
}, [navigate, packagesLoading, shouldRedirectToBilling]);
React.useEffect(() => {
if (packagesLoading || packagesError || onboardingLoading) {
return;
}
if (shouldRedirectToBilling) {
return;
}
if (hasSummaryPackage) {
setSummaryOpen(true);
}
}, [hasSummaryPackage, onboardingLoading, packagesLoading, shouldRedirectToBilling]);
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={accentSoft}
>
<activeTourStep.icon size={18} color={accentText} />
</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;
const handleSummaryClose = React.useCallback(() => {
setSummaryOpen(false);
if (activePackage?.id) {
setSummarySeenOverride(activePackage.id);
void trackOnboarding('summary_seen', { package_id: activePackage.id });
}
}, [activePackage?.id]);
const packageSummarySheet = activePackage ? (
<PackageSummarySheet
open={summaryOpen}
onClose={handleSummaryClose}
onContinue={() => {
handleSummaryClose();
navigate(adminPath('/mobile/events/new'));
}}
packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')}
remainingEvents={remainingEvents}
purchasedAt={activePackage.purchased_at}
expiresAt={activePackage.expires_at}
limits={activePackage.package_limits ?? null}
features={activePackage.features ?? null}
locale={locale}
/>
) : 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}
{packageSummarySheet}
</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}
{packageSummarySheet}
</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}
{packageSummarySheet}
</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}
{packageSummarySheet}
</MobileShell>
);
}
function PackageSummarySheet({
open,
onClose,
onContinue,
packageName,
remainingEvents,
purchasedAt,
expiresAt,
limits,
features,
locale,
}: {
open: boolean;
onClose: () => void;
onContinue: () => void;
packageName: string;
remainingEvents: number | null | undefined;
purchasedAt: string | null | undefined;
expiresAt: string | null | undefined;
limits: Record<string, unknown> | null;
features: string[] | null;
locale: string;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
const maxPhotos = (limits as Record<string, number | null> | null)?.max_photos ?? null;
const maxGuests = (limits as Record<string, number | null> | null)?.max_guests ?? null;
const galleryDays = (limits as Record<string, number | null> | null)?.gallery_days ?? null;
const hasFeatures = Array.isArray(features) && features.length > 0;
const formatLimit = (value: number | null) =>
value === null || value === undefined
? t('mobileDashboard.packageSummary.unlimited', 'Unlimited')
: String(value);
const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown');
return (
<MobileSheet open={open} title={t('mobileDashboard.packageSummary.title', 'Your package summary')} onClose={onClose}>
<YStack space="$3">
<MobileCard space="$2.5" borderColor={border} backgroundColor={surface}>
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{packageName}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.subtitle', 'Here is a quick overview of your active package.')}
</Text>
</YStack>
<YStack space="$2" marginTop="$2">
<SummaryRow label={t('mobileDashboard.packageSummary.limitPhotos', 'Photos')} value={formatLimit(maxPhotos)} />
<SummaryRow label={t('mobileDashboard.packageSummary.limitGuests', 'Guests')} value={formatLimit(maxGuests)} />
<SummaryRow label={t('mobileDashboard.packageSummary.limitDays', 'Gallery days')} value={formatLimit(galleryDays)} />
<SummaryRow
label={t('mobileDashboard.packageSummary.remaining', 'Remaining events')}
value={remainingEvents === null || remainingEvents === undefined ? t('mobileDashboard.packageSummary.unlimited', 'Unlimited') : String(remainingEvents)}
/>
<SummaryRow label={t('mobileDashboard.packageSummary.purchased', 'Purchased')} value={formatDate(purchasedAt)} />
{expiresAt ? (
<SummaryRow label={t('mobileDashboard.packageSummary.expires', 'Expires')} value={formatDate(expiresAt)} />
) : null}
</YStack>
</MobileCard>
{hasFeatures ? (
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.packageSummary.featuresTitle', 'Included features')}
</Text>
<YStack space="$1.5" marginTop="$2">
{features?.map((feature) => (
<XStack key={feature} alignItems="center" space="$2">
<XStack width={24} height={24} borderRadius={8} backgroundColor={accentSoft} alignItems="center" justifyContent="center">
<Sparkles size={14} color={primary} />
</XStack>
<Text fontSize="$xs" color={text}>
{t(`mobileDashboard.packageSummary.feature.${feature}`, feature)}
</Text>
</XStack>
))}
</YStack>
</MobileCard>
) : null}
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.hint', 'You can revisit billing any time to review or upgrade your package.')}
</Text>
</MobileCard>
<XStack space="$2">
<CTAButton label={t('mobileDashboard.packageSummary.continue', 'Continue to event setup')} onPress={onContinue} fullWidth={false} />
<CTAButton label={t('mobileDashboard.packageSummary.dismiss', 'Close')} tone="ghost" onPress={onClose} fullWidth={false} />
</XStack>
</YStack>
</MobileSheet>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
const { textStrong, muted } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={textStrong}>
{label}
</Text>
<Text fontSize="$xs" color={muted}>
{value}
</Text>
</XStack>
);
}
function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const text = textStrong;
const accent = primary;
const iconBg = accentSoft;
const iconColor = primary;
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 { textStrong, muted, border, accentSoft, accentStrong, surfaceMuted, primary, shadow } = useAdminTheme();
const text = textStrong;
const accent = primary;
const stepBg = surfaceMuted;
const stepBorder = border;
const supportBg = surfaceMuted;
const supportBorder = border;
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={shadow}
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 { textStrong, muted, subtle } = useAdminTheme();
const text = textStrong;
const cards = [
{
key: 'photos',
label: t('mobileDashboard.photosLabel', 'Review photos'),
desc: t('mobileDashboard.photosDesc', 'Moderate uploads and highlights'),
icon: ImageIcon,
color: ADMIN_ACTION_COLORS.images,
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: ADMIN_ACTION_COLORS.tasks,
action: onManageTasks,
},
{
key: 'qr',
label: t('mobileDashboard.qrLabel', 'Show / share QR code'),
desc: t('mobileDashboard.qrDesc', 'Posters, cards, and links'),
icon: QrCode,
color: ADMIN_ACTION_COLORS.qr,
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={subtle}>
˃
</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 { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
const brandingAllowed = isBrandingAllowed(event ?? null);
const tiles = [
{
icon: Users,
label: t('mobileDashboard.shortcutGuests', 'Guest management'),
color: ADMIN_ACTION_COLORS.guests,
action: onGuests,
},
{
icon: QrCode,
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
color: ADMIN_ACTION_COLORS.qr,
action: onPrint,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutInvites', 'Team / helper invites'),
color: ADMIN_ACTION_COLORS.invites,
action: onInvites,
},
{
icon: Settings,
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
color: ADMIN_ACTION_COLORS.success,
action: onSettings,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
color: ADMIN_ACTION_COLORS.branding,
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, index) => (
<ActionTile
key={tile.label}
icon={tile.icon}
label={tile.label}
color={tile.color}
onPress={tile.action}
disabled={tile.disabled}
delayMs={index * ADMIN_MOTION.tileStaggerMs}
/>
))}
</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 { textStrong, muted } = useAdminTheme();
const text = textStrong;
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 { textStrong, warningBg, warningBorder, warningText } = useAdminTheme();
const text = textStrong;
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>
);
}