Unify admin home with event overview
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone, Tv } 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, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives';
|
||||
import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api';
|
||||
import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_LIVE_SHOW_PATH, ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
|
||||
import { isPastEvent } from './eventDate';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileEventDetailPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [stats, setStats] = React.useState<EventStats | null>(null);
|
||||
const [toolkit, setToolkit] = React.useState<EventToolkit | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { events, activeEvent, selectEvent } = useEventContext();
|
||||
const [showEventPicker, setShowEventPicker] = React.useState(false);
|
||||
const back = useBackNavigation(adminPath('/mobile/events'));
|
||||
const { textStrong, text, muted, danger, accentSoft } = useAdminTheme();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) return;
|
||||
selectEvent(slug);
|
||||
}, [slug, selectEvent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) return;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [eventData, statsData, toolkitData] = await Promise.all([getEvent(slug), getEventStats(slug), getEventToolkit(slug)]);
|
||||
setEvent(eventData);
|
||||
setStats(statsData);
|
||||
setToolkit(toolkitData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
try {
|
||||
const list = await getEvents({ force: true });
|
||||
const fallback = list.find((ev: TenantEvent) => ev.slug === slug) ?? null;
|
||||
if (fallback) {
|
||||
setEvent(fallback);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
setError(getApiErrorMessage(fallbackErr, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [slug, t]);
|
||||
|
||||
const tasksEnabled = resolveEngagementMode(event ?? activeEvent ?? null) !== 'photo_only';
|
||||
const brandingAllowed = isBrandingAllowed(event ?? activeEvent ?? null);
|
||||
|
||||
const kpis = [
|
||||
{
|
||||
label: t('events.detail.kpi.guests', 'Guests Registered'),
|
||||
value: toolkit?.invites?.summary.total ?? event?.active_invites_count ?? '—',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: t('events.detail.kpi.photos', 'Images Uploaded'),
|
||||
value: stats?.uploads_total ?? event?.photo_count ?? '—',
|
||||
icon: Camera,
|
||||
},
|
||||
];
|
||||
|
||||
if (tasksEnabled) {
|
||||
kpis.unshift({
|
||||
label: t('events.detail.kpi.tasks', 'Active Tasks'),
|
||||
value: event?.tasks_count ?? toolkit?.tasks?.summary?.total ?? '—',
|
||||
icon: Sparkles,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={resolveEventDisplayName(event ?? activeEvent ?? undefined)}
|
||||
subtitle={
|
||||
event?.event_date || activeEvent?.event_date
|
||||
? formatDate(event?.event_date ?? activeEvent?.event_date, t)
|
||||
: undefined
|
||||
}
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<XStack space="$3" alignItems="center">
|
||||
<HeaderActionButton onPress={() => navigate(adminPath('/mobile/settings'))} ariaLabel={t('mobileSettings.title', 'Settings')}>
|
||||
<Settings size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
<HeaderActionButton onPress={() => navigate(0)} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
</XStack>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||
{event ? renderName(event.name, t) : t('events.placeholders.untitled', 'Unbenanntes Event')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<CalendarDays size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{formatDate(event?.event_date, t)}
|
||||
</Text>
|
||||
<MapPin size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{resolveLocation(event, t)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<PillBadge tone={event?.status === 'published' ? 'success' : 'warning'}>
|
||||
{event?.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')}
|
||||
</PillBadge>
|
||||
<Pressable
|
||||
aria-label={t('mobileEvents.edit', 'Edit event')}
|
||||
onPress={() => slug && navigate(adminPath(`/mobile/events/${slug}/edit`))}
|
||||
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={textStrong} />
|
||||
</Pressable>
|
||||
</MobileCard>
|
||||
|
||||
<YStack space="$2">
|
||||
{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>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<MobileSheet
|
||||
open={showEventPicker}
|
||||
onClose={() => setShowEventPicker(false)}
|
||||
title={t('events.detail.pickEvent', 'Event wählen')}
|
||||
footer={null}
|
||||
bottomOffsetPx={120}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{events.length === 0 ? (
|
||||
<Text fontSize={12.5} color={muted}>
|
||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
||||
</Text>
|
||||
) : (
|
||||
events.map((ev) => (
|
||||
<Pressable
|
||||
key={ev.slug}
|
||||
onPress={() => {
|
||||
selectEvent(ev.slug ?? null);
|
||||
setShowEventPicker(false);
|
||||
navigate(adminPath(`/mobile/events/${ev.slug}`));
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<YStack space="$1">
|
||||
<Text fontSize={13} fontWeight="700" color={textStrong}>
|
||||
{renderName(ev.name, t)}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<CalendarDays size={14} color={muted} />
|
||||
<Text fontSize={12} color={muted}>
|
||||
{formatDate(ev.event_date, t)}
|
||||
</Text>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<PillBadge tone={ev.slug === activeEvent?.slug ? 'success' : 'muted'}>
|
||||
{ev.slug === activeEvent?.slug ? t('events.detail.active', 'Aktiv') : t('events.actions.open', 'Öffnen')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.detail.managementTitle', 'Event Management')}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" space="$2">
|
||||
<ActionTile
|
||||
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={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))}
|
||||
delayMs={0}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={QrCode}
|
||||
label={t('events.quick.qr', 'QR Code Layouts')}
|
||||
color={ADMIN_ACTION_COLORS.qr}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/qr`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Image}
|
||||
label={t('events.quick.images', 'Image Management')}
|
||||
color={ADMIN_ACTION_COLORS.images}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 2}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Tv}
|
||||
label={t('events.quick.liveShow', 'Live Show queue')}
|
||||
color={ADMIN_ACTION_COLORS.images}
|
||||
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_PATH(slug))}
|
||||
disabled={!slug}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Settings}
|
||||
label={t('events.quick.liveShowSettings', 'Live Show settings')}
|
||||
color={ADMIN_ACTION_COLORS.images}
|
||||
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH(slug))}
|
||||
disabled={!slug}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Users}
|
||||
label={t('events.quick.guests', 'Guest Management')}
|
||||
color={ADMIN_ACTION_COLORS.guests}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Megaphone}
|
||||
label={t('events.quick.guestMessages', 'Guest messages')}
|
||||
color={ADMIN_ACTION_COLORS.guestMessages}
|
||||
onPress={() => slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))}
|
||||
disabled={!slug}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Layout}
|
||||
label={t('events.quick.branding', 'Branding & Theme')}
|
||||
color={ADMIN_ACTION_COLORS.branding}
|
||||
onPress={
|
||||
brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined
|
||||
}
|
||||
disabled={!brandingAllowed}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Camera}
|
||||
label={t('events.quick.photobooth', 'Photobooth')}
|
||||
color={ADMIN_ACTION_COLORS.photobooth}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 8}
|
||||
/>
|
||||
{isPastEvent(event?.event_date) ? (
|
||||
<ActionTile
|
||||
icon={Sparkles}
|
||||
label={t('events.quick.recap', 'Recap & Archive')}
|
||||
color={ADMIN_ACTION_COLORS.recap}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 9}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name'], t: (key: string, fallback: string) => string): string {
|
||||
const fallback = t('events.placeholders.untitled', 'Untitled event');
|
||||
if (typeof name === 'string' && name.trim()) return name;
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null | undefined, t: (key: string, fallback: string) => string): string {
|
||||
if (!iso) return t('events.detail.dateTbd', 'Date tbd');
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return t('events.detail.dateTbd', 'Date tbd');
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export function prefetchMobileRoutes() {
|
||||
schedule(() => {
|
||||
void import('./DashboardPage');
|
||||
void import('./EventsPage');
|
||||
void import('./EventDetailPage');
|
||||
void import('./EventPhotosPage');
|
||||
void import('./EventTasksPage');
|
||||
void import('./NotificationsPage');
|
||||
|
||||
@@ -20,7 +20,6 @@ const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
|
||||
const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage'));
|
||||
const LogoutPage = React.lazy(() => import('./mobile/LogoutPage'));
|
||||
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
|
||||
const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'));
|
||||
const MobileEventPhotoboothPage = React.lazy(() => import('./mobile/EventPhotoboothPage'));
|
||||
const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage'));
|
||||
const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
|
||||
@@ -195,7 +194,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'events/:slug/guest-notifications', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/guest-notifications`} /> },
|
||||
{ path: 'events/:slug/toolkit', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
|
||||
{ path: 'mobile/events', element: <MobileEventsPage /> },
|
||||
{ path: 'mobile/events/:slug', element: <MobileEventDetailPage /> },
|
||||
{ path: 'mobile/events/:slug', element: <MobileDashboardPage /> },
|
||||
{ path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
|
||||
|
||||
Reference in New Issue
Block a user