Unify admin home with event overview

This commit is contained in:
Codex Agent
2026-01-12 10:31:05 +01:00
parent 78f2dc4e74
commit 2cb5171420
4 changed files with 254 additions and 492 deletions

View File

@@ -1,12 +1,12 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation, useNavigate, useParams } 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, TrendingUp } from 'lucide-react';
import { Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } 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 { MobileShell } 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';
@@ -21,6 +21,7 @@ import { collectPackageFeatures, formatPackageLimit, getPackageFeatureLabel, get
import { trackOnboarding } from '../api';
import { useAuth } from '../auth/context';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
import { isPastEvent } from './eventDate';
type DeviceSetupProps = {
installPrompt: ReturnType<typeof useInstallPrompt>;
@@ -32,6 +33,7 @@ type DeviceSetupProps = {
export default function MobileDashboardPage() {
const navigate = useNavigate();
const location = useLocation();
const { slug: slugParam } = useParams<{ slug?: string }>();
const { t, i18n } = useTranslation('management');
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
const { status } = useAuth();
@@ -42,11 +44,12 @@ export default function MobileDashboardPage() {
const [tourStep, setTourStep] = React.useState(0);
const [summaryOpen, setSummaryOpen] = React.useState(false);
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
const [eventSwitcherOpen, setEventSwitcherOpen] = React.useState(false);
const onboardingTrackedRef = React.useRef(false);
const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions();
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const { textStrong, muted, accentSoft, primary } = useAdminTheme();
const text = textStrong;
const accentText = primary;
@@ -84,6 +87,14 @@ export default function MobileDashboardPage() {
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
React.useEffect(() => {
if (!slugParam || slugParam === activeEvent?.slug) {
return;
}
selectEvent(slugParam);
}, [activeEvent?.slug, selectEvent, slugParam]);
React.useEffect(() => {
if (status !== 'authenticated' || onboardingTrackedRef.current) {
return;
@@ -424,7 +435,7 @@ export default function MobileDashboardPage() {
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} />
<EventPickerList events={effectiveEvents} locale={locale} navigateOnSelect={false} />
{tourSheet}
{packageSummarySheet}
</MobileShell>
@@ -434,8 +445,7 @@ export default function MobileDashboardPage() {
return (
<MobileShell
activeTab="home"
title={resolveEventDisplayName(activeEvent ?? undefined)}
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
title={t('mobileDashboard.title', 'Dashboard')}
>
{showPackageSummaryBanner ? (
<PackageSummaryBanner
@@ -443,28 +453,18 @@ export default function MobileDashboardPage() {
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<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
<EventHeaderCard
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}`))}
onAnalytics={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/analytics`))}
locale={locale}
canSwitch={effectiveMultiple}
onSwitch={() => setEventSwitcherOpen(true)}
onEdit={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))}
/>
<EventManagementGrid
event={activeEvent}
tasksEnabled={tasksEnabled}
onNavigate={(path) => navigate(path)}
/>
<KpiStrip
event={activeEvent}
stats={stats}
@@ -474,8 +474,20 @@ export default function MobileDashboardPage() {
/>
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
<DeviceSetupCard
installPrompt={installPrompt}
pushState={pushState}
devicePermissions={devicePermissions}
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/>
{tourSheet}
{packageSummarySheet}
<EventSwitcherSheet
open={eventSwitcherOpen}
onClose={() => setEventSwitcherOpen(false)}
events={effectiveEvents}
locale={locale}
/>
</MobileShell>
);
}
@@ -976,8 +988,20 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
);
}
function EventPickerList({ events, locale, text, muted, border }: { events: TenantEvent[]; locale: string; text: string; muted: string; border: string }) {
function EventPickerList({
events,
locale,
onPick,
navigateOnSelect = true,
}: {
events: TenantEvent[];
locale: string;
onPick?: (event: TenantEvent) => void;
navigateOnSelect?: boolean;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border } = useAdminTheme();
const text = textStrong;
const { selectEvent } = useEventContext();
const navigate = useNavigate();
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
@@ -1008,7 +1032,8 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
key={event.slug}
onPress={() => {
selectEvent(event.slug ?? null);
if (event.slug) {
onPick?.(event);
if (navigateOnSelect && event.slug) {
navigate(adminPath(`/mobile/events/${event.slug}`));
}
}}
@@ -1036,140 +1061,232 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
);
}
function FeaturedActions({
tasksEnabled,
onReviewPhotos,
onManageTasks,
onShowQr,
function EventSwitcherSheet({
open,
onClose,
events,
locale,
}: {
tasksEnabled: boolean;
onReviewPhotos: () => void;
onManageTasks: () => void;
onShowQr: () => void;
open: boolean;
onClose: () => void;
events: TenantEvent[];
locale: string;
}) {
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>
<MobileSheet open={open} title={t('mobileDashboard.pickEvent', 'Select an event')} onClose={onClose}>
<EventPickerList events={events} locale={locale} navigateOnSelect={false} onPick={onClose} />
</MobileSheet>
);
}
function SecondaryGrid({
function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string {
if (!event) return t('events.detail.locationPlaceholder', 'Location');
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (candidate && candidate.trim()) {
return candidate;
}
return t('events.detail.locationPlaceholder', 'Location');
}
function EventHeaderCard({
event,
onGuests,
onPrint,
onInvites,
onSettings,
onAnalytics,
locale,
canSwitch,
onSwitch,
onEdit,
}: {
event: TenantEvent | null;
onGuests: () => void;
onPrint: () => void;
onInvites: () => void;
onSettings: () => void;
onAnalytics: () => void;
locale: string;
canSwitch: boolean;
onSwitch: () => void;
onEdit: () => void;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
if (!event) {
return null;
}
const dateLabel = formatEventDate(event.event_date, locale) ?? t('events.detail.dateTbd', 'Date tbd');
const locationLabel = resolveLocation(event, t);
return (
<MobileCard space="$3" borderColor={border} backgroundColor={surface} position="relative">
<XStack alignItems="center" justifyContent="space-between" space="$2">
{canSwitch ? (
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
<XStack alignItems="center" space="$2">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Pressable>
) : (
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
)}
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published'
? t('events.status.published', 'Live')
: t('events.status.draft', 'Draft')}
</PillBadge>
</XStack>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{dateLabel}
</Text>
<MapPin size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{locationLabel}
</Text>
</XStack>
<Pressable
aria-label={t('mobileEvents.edit', 'Edit event')}
onPress={onEdit}
style={{
position: 'absolute',
right: 16,
top: 16,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: accentSoft,
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
}}
>
<Pencil size={18} color={primary} />
</Pressable>
</MobileCard>
);
}
function EventManagementGrid({
event,
tasksEnabled,
onNavigate,
}: {
event: TenantEvent | null;
tasksEnabled: boolean;
onNavigate: (path: string) => void;
}) {
const { t } = useTranslation('management');
const { textStrong } = useAdminTheme();
const slug = event?.slug ?? null;
const brandingAllowed = isBrandingAllowed(event ?? null);
if (!event) {
return null;
}
const tiles = [
{
icon: Pencil,
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
color: ADMIN_ACTION_COLORS.settings,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/edit`)) : undefined,
disabled: !slug,
},
{
icon: Sparkles,
label: tasksEnabled
? t('events.quick.tasks', 'Tasks & Checklists')
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`,
color: ADMIN_ACTION_COLORS.tasks,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined,
disabled: !tasksEnabled || !slug,
},
{
icon: QrCode,
label: t('events.quick.qr', 'QR Code Layouts'),
color: ADMIN_ACTION_COLORS.qr,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/qr`)) : undefined,
disabled: !slug,
},
{
icon: ImageIcon,
label: t('events.quick.images', 'Image Management'),
color: ADMIN_ACTION_COLORS.images,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photos`)) : undefined,
disabled: !slug,
},
{
icon: Tv,
label: t('events.quick.liveShow', 'Live Show queue'),
color: ADMIN_ACTION_COLORS.images,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show`)) : undefined,
disabled: !slug,
},
{
icon: Settings,
label: t('events.quick.liveShowSettings', 'Live Show settings'),
color: ADMIN_ACTION_COLORS.images,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
disabled: !slug,
},
{
icon: Users,
label: t('mobileDashboard.shortcutGuests', 'Guest management'),
label: t('events.quick.guests', 'Guest Management'),
color: ADMIN_ACTION_COLORS.guests,
action: onGuests,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/members`)) : undefined,
disabled: !slug,
},
{
icon: Megaphone,
label: t('events.quick.guestMessages', 'Guest messages'),
color: ADMIN_ACTION_COLORS.guestMessages,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/guest-notifications`)) : undefined,
disabled: !slug,
},
{
icon: Layout,
label: t('events.quick.branding', 'Branding & Theme'),
color: ADMIN_ACTION_COLORS.branding,
onPress: slug && brandingAllowed ? () => onNavigate(adminPath(`/mobile/events/${slug}/branding`)) : undefined,
disabled: !brandingAllowed || !slug,
},
{
icon: Camera,
label: t('events.quick.photobooth', 'Photobooth'),
color: ADMIN_ACTION_COLORS.photobooth,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photobooth`)) : undefined,
disabled: !slug,
},
{
icon: TrendingUp,
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
color: ADMIN_ACTION_COLORS.analytics,
action: onAnalytics,
},
{
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.settings,
action: onSettings,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
color: ADMIN_ACTION_COLORS.branding,
action: brandingAllowed ? onSettings : undefined,
disabled: !brandingAllowed,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/analytics`)) : undefined,
disabled: !slug,
},
];
if (event && isPastEvent(event.event_date)) {
tiles.push({
icon: Sparkles,
label: t('events.quick.recap', 'Recap & Archive'),
color: ADMIN_ACTION_COLORS.recap,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/recap`)) : undefined,
disabled: !slug,
});
}
return (
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')}
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('events.detail.managementTitle', 'Event management')}
</Text>
<XStack flexWrap="wrap" space="$2">
{tiles.map((tile, index) => (
@@ -1178,22 +1295,12 @@ function SecondaryGrid({
icon={tile.icon}
label={tile.label}
color={tile.color}
onPress={tile.action}
onPress={tile.onPress}
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>
);
}