288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
import React, { Suspense } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { ChevronDown, ChevronLeft, Bell, QrCode } 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 { useTranslation } from 'react-i18next';
|
|
import { useTheme } from '@tamagui/core';
|
|
import { useEventContext } from '../../context/EventContext';
|
|
import { BottomNav, NavKey } from './BottomNav';
|
|
import { useMobileNav } from '../hooks/useMobileNav';
|
|
import { adminPath } from '../../constants';
|
|
import { MobileSheet } from './Sheet';
|
|
import { MobileCard, PillBadge } from './Primitives';
|
|
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
|
|
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
|
|
import { TenantEvent, getEvents } from '../../api';
|
|
const DevTenantSwitcher = React.lazy(() => import('../../DevTenantSwitcher'));
|
|
|
|
type MobileShellProps = {
|
|
title?: string;
|
|
subtitle?: string;
|
|
children: React.ReactNode;
|
|
activeTab: NavKey;
|
|
onBack?: () => void;
|
|
headerActions?: React.ReactNode;
|
|
};
|
|
|
|
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
|
|
const { events, activeEvent, hasMultipleEvents, hasEvents, selectEvent } = useEventContext();
|
|
const { go } = useMobileNav(activeEvent?.slug);
|
|
const navigate = useNavigate();
|
|
const { t, i18n } = useTranslation('mobile');
|
|
const { count: notificationCount } = useNotificationsBadge();
|
|
const theme = useTheme();
|
|
const backgroundColor = String(theme.background?.val ?? '#f7f8fb');
|
|
const surfaceColor = String(theme.surface?.val ?? '#ffffff');
|
|
const borderColor = String(theme.borderColor?.val ?? '#e5e7eb');
|
|
const textColor = String(theme.color?.val ?? '#111827');
|
|
const mutedText = String(theme.gray?.val ?? '#6b7280');
|
|
const [pickerOpen, setPickerOpen] = React.useState(false);
|
|
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
|
|
const [loadingEvents, setLoadingEvents] = React.useState(false);
|
|
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
|
|
const showDevTenantSwitcher = import.meta.env.DEV && import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true';
|
|
|
|
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
|
const effectiveEvents = events.length ? events : fallbackEvents;
|
|
const effectiveHasMultiple = hasMultipleEvents || effectiveEvents.length > 1;
|
|
const effectiveHasEvents = hasEvents || effectiveEvents.length > 0;
|
|
const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
|
|
|
|
React.useEffect(() => {
|
|
if (events.length || loadingEvents || attemptedFetch) {
|
|
return;
|
|
}
|
|
setAttemptedFetch(true);
|
|
setLoadingEvents(true);
|
|
getEvents({ force: true })
|
|
.then((list) => {
|
|
setFallbackEvents(list ?? []);
|
|
if (!activeEvent && list?.length === 1) {
|
|
selectEvent(list[0]?.slug ?? null);
|
|
}
|
|
})
|
|
.catch(() => setFallbackEvents([]))
|
|
.finally(() => setLoadingEvents(false));
|
|
}, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]);
|
|
|
|
React.useEffect(() => {
|
|
if (!pickerOpen) return;
|
|
if (effectiveEvents.length) return;
|
|
setLoadingEvents(true);
|
|
getEvents({ force: true })
|
|
.then((list) => setFallbackEvents(list ?? []))
|
|
.catch(() => setFallbackEvents([]))
|
|
.finally(() => setLoadingEvents(false));
|
|
}, [pickerOpen, effectiveEvents.length]);
|
|
|
|
const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin'));
|
|
const subtitleText =
|
|
subtitle ??
|
|
(effectiveActive?.event_date
|
|
? formatEventDate(effectiveActive.event_date, locale) ?? ''
|
|
: effectiveHasEvents
|
|
? t('header.selectEvent', 'Select an event to continue')
|
|
: t('header.empty', 'Create your first event to get started'));
|
|
|
|
const showEventSwitcher = effectiveHasMultiple;
|
|
const showQr = Boolean(effectiveActive?.slug);
|
|
|
|
return (
|
|
<YStack backgroundColor={backgroundColor} minHeight="100vh">
|
|
<YStack
|
|
backgroundColor={surfaceColor}
|
|
borderBottomWidth={1}
|
|
borderColor={borderColor}
|
|
paddingHorizontal="$4"
|
|
paddingTop="$4"
|
|
paddingBottom="$3"
|
|
shadowColor="#0f172a"
|
|
shadowOpacity={0.06}
|
|
shadowRadius={10}
|
|
shadowOffset={{ width: 0, height: 4 }}
|
|
>
|
|
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
|
{onBack ? (
|
|
<Pressable onPress={onBack}>
|
|
<XStack alignItems="center" space="$1.5">
|
|
<ChevronLeft size={28} color="#007AFF" strokeWidth={2.5} />
|
|
</XStack>
|
|
</Pressable>
|
|
) : (
|
|
<XStack width={28} />
|
|
)}
|
|
|
|
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end">
|
|
<XStack alignItems="center" space="$1" maxWidth="55%">
|
|
<Pressable
|
|
disabled={!showEventSwitcher}
|
|
onPress={() => setPickerOpen(true)}
|
|
style={{ alignItems: 'flex-end' }}
|
|
>
|
|
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
|
|
{eventTitle}
|
|
</Text>
|
|
{subtitleText ? (
|
|
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
|
|
{subtitleText}
|
|
</Text>
|
|
) : null}
|
|
</Pressable>
|
|
{showEventSwitcher ? <ChevronDown size={14} color={textColor} /> : null}
|
|
</XStack>
|
|
|
|
<XStack alignItems="center" space="$2">
|
|
<Pressable onPress={() => navigate(adminPath('/mobile/notifications'))}>
|
|
<XStack
|
|
width={34}
|
|
height={34}
|
|
borderRadius={12}
|
|
backgroundColor={surfaceColor}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
position="relative"
|
|
>
|
|
<Bell size={16} color={textColor} />
|
|
{notificationCount > 0 ? (
|
|
<YStack
|
|
position="absolute"
|
|
top={-4}
|
|
right={-4}
|
|
minWidth={18}
|
|
height={18}
|
|
paddingHorizontal={6}
|
|
borderRadius={999}
|
|
backgroundColor="#ef4444"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
>
|
|
<Text fontSize={10} color="white" fontWeight="700">
|
|
{notificationCount > 9 ? '9+' : notificationCount}
|
|
</Text>
|
|
</YStack>
|
|
) : null}
|
|
</XStack>
|
|
</Pressable>
|
|
{showQr ? (
|
|
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}>
|
|
<XStack
|
|
height={34}
|
|
paddingHorizontal="$3"
|
|
borderRadius={12}
|
|
backgroundColor="#0ea5e9"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
space="$1.5"
|
|
>
|
|
<QrCode size={16} color="white" />
|
|
<Text fontSize="$xs" fontWeight="800" color="white">
|
|
{t('header.quickQr', 'Quick QR')}
|
|
</Text>
|
|
</XStack>
|
|
</Pressable>
|
|
) : null}
|
|
{headerActions ?? null}
|
|
{showDevTenantSwitcher ? (
|
|
<Suspense fallback={null}>
|
|
<DevTenantSwitcher variant="inline" />
|
|
</Suspense>
|
|
) : null}
|
|
</XStack>
|
|
</XStack>
|
|
</XStack>
|
|
</YStack>
|
|
|
|
<YStack flex={1} padding="$4" paddingBottom="$10" space="$3">
|
|
{children}
|
|
</YStack>
|
|
|
|
<BottomNav active={activeTab} onNavigate={go} />
|
|
|
|
<MobileSheet
|
|
open={pickerOpen}
|
|
onClose={() => setPickerOpen(false)}
|
|
title={t('header.eventSwitcher', 'Choose an event')}
|
|
footer={null}
|
|
bottomOffsetPx={110}
|
|
>
|
|
<YStack space="$2">
|
|
{effectiveEvents.length === 0 ? (
|
|
<MobileCard alignItems="flex-start" space="$2">
|
|
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
|
{t('header.noEventsTitle', 'Create your first event')}
|
|
</Text>
|
|
<Text fontSize="$xs" color={mutedText}>
|
|
{t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')}
|
|
</Text>
|
|
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
|
|
<XStack alignItems="center" space="$2">
|
|
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
|
|
{t('header.createEvent', 'Create event')}
|
|
</Text>
|
|
</XStack>
|
|
</Pressable>
|
|
</MobileCard>
|
|
) : (
|
|
effectiveEvents.map((event) => (
|
|
<Pressable
|
|
key={event.slug}
|
|
onPress={() => {
|
|
const targetSlug = event.slug ?? null;
|
|
selectEvent(targetSlug);
|
|
setPickerOpen(false);
|
|
if (targetSlug) {
|
|
navigate(adminPath(`/mobile/events/${targetSlug}`));
|
|
}
|
|
}}
|
|
>
|
|
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
|
<YStack space="$0.5">
|
|
<Text fontSize="$sm" fontWeight="700" color={textColor}>
|
|
{resolveEventDisplayName(event)}
|
|
</Text>
|
|
<Text fontSize="$xs" color={mutedText}>
|
|
{formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')}
|
|
</Text>
|
|
</YStack>
|
|
<PillBadge tone={event.slug === activeEvent?.slug ? 'success' : 'muted'}>
|
|
{event.slug === activeEvent?.slug
|
|
? t('header.active', 'Active')
|
|
: (event.status ?? '—')}
|
|
</PillBadge>
|
|
</XStack>
|
|
</Pressable>
|
|
))
|
|
)}
|
|
{activeEvent ? (
|
|
<Pressable
|
|
onPress={() => {
|
|
selectEvent(null);
|
|
setPickerOpen(false);
|
|
}}
|
|
>
|
|
<Text fontSize="$xs" color="#6b7280" textAlign="center">
|
|
{t('header.clearSelection', 'Clear selection')}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</YStack>
|
|
</MobileSheet>
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
export function renderEventLocation(event?: TenantEvent | null): string {
|
|
if (!event) return '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 'Location';
|
|
}
|