Implemented a shared mobile shell and navigation aligned to the new architecture, plus refactored the dashboard and
tab flows.
- Added a dynamic MobileShell with sticky header (notification bell with badge, quick QR when an event is
active, event switcher for multi-event users) and stabilized bottom tabs (home, tasks, uploads, profile)
driven by useMobileNav (resources/js/admin/mobile/components/MobileShell.tsx, components/BottomNav.tsx, hooks/
useMobileNav.ts).
- Centralized event handling now supports 0/1/many-event states without auto-selecting in multi-tenant mode and
exposes helper flags/activeSlug for consumers (resources/js/admin/context/EventContext.tsx).
- Rebuilt the mobile dashboard into explicit states: onboarding/no-event, single-event focus, and multi-event picker
with featured/secondary actions, KPI strip, and alerts (resources/js/admin/mobile/DashboardPage.tsx).
- Introduced tab entry points that respect event context and prompt selection when needed (resources/js/admin/
mobile/TasksTabPage.tsx, UploadsTabPage.tsx). Refreshed tasks/uploads detail screens to use the new shell and sync
event selection (resources/js/admin/mobile/EventTasksPage.tsx, EventPhotosPage.tsx).
- Updated mobile routes and existing screens to the new tab keys and header/footer behavior (resources/js/admin/
router.tsx, mobile/* pages, i18n nav/header strings).
This commit is contained in:
231
resources/js/admin/mobile/components/MobileShell.tsx
Normal file
231
resources/js/admin/mobile/components/MobileShell.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React 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 { 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 { useAlertsBadge } from '../hooks/useAlertsBadge';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
|
||||
import { TenantEvent } from '../../api';
|
||||
|
||||
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: alertCount } = useAlertsBadge();
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
|
||||
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||||
const eventTitle = title ?? (activeEvent ? resolveEventDisplayName(activeEvent) : t('header.appName', 'Event Admin'));
|
||||
const subtitleText =
|
||||
subtitle ??
|
||||
(activeEvent?.event_date
|
||||
? formatEventDate(activeEvent.event_date, locale) ?? ''
|
||||
: hasEvents
|
||||
? t('header.selectEvent', 'Select an event to continue')
|
||||
: t('header.empty', 'Create your first event to get started'));
|
||||
|
||||
const showEventSwitcher = hasMultipleEvents;
|
||||
const showQr = Boolean(activeEvent?.slug);
|
||||
|
||||
return (
|
||||
<YStack backgroundColor="#f7f8fb" minHeight="100vh">
|
||||
<YStack
|
||||
backgroundColor="white"
|
||||
borderBottomWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
paddingHorizontal="$4"
|
||||
paddingTop="$4"
|
||||
paddingBottom="$3"
|
||||
shadowColor="#0f172a"
|
||||
shadowOpacity={0.06}
|
||||
shadowRadius={10}
|
||||
shadowOffset={{ width: 0, height: 4 }}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" space="$3">
|
||||
<XStack alignItems="center" space="$2">
|
||||
{onBack ? (
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<ChevronLeft size={18} color="#007AFF" />
|
||||
<Text fontSize="$sm" color="#007AFF" fontWeight="600">
|
||||
{t('actions.back', 'Back')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
disabled={!showEventSwitcher}
|
||||
onPress={() => setPickerOpen(true)}
|
||||
style={{ alignItems: 'flex-start' }}
|
||||
>
|
||||
<Text fontSize="$lg" fontWeight="800" color="#111827">
|
||||
{eventTitle}
|
||||
</Text>
|
||||
{subtitleText ? (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{subtitleText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{showEventSwitcher ? <ChevronDown size={14} color="#111827" /> : null}
|
||||
</XStack>
|
||||
<XStack alignItems="center" space="$2.5">
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/alerts'))}>
|
||||
<XStack
|
||||
width={34}
|
||||
height={34}
|
||||
borderRadius={12}
|
||||
backgroundColor="#f4f5f7"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
>
|
||||
<Bell size={16} color="#111827" />
|
||||
{alertCount > 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">
|
||||
{alertCount > 9 ? '9+' : alertCount}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
</Pressable>
|
||||
{showQr ? (
|
||||
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${activeEvent?.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}
|
||||
</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">
|
||||
{events.length === 0 ? (
|
||||
<MobileCard alignItems="flex-start" space="$2">
|
||||
<Text fontSize="$sm" color="#111827" fontWeight="700">
|
||||
{t('header.noEventsTitle', 'Create your first event')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#4b5563">
|
||||
{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>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<Pressable
|
||||
key={event.slug}
|
||||
onPress={() => {
|
||||
selectEvent(event.slug ?? null);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<YStack space="$0.5">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{resolveEventDisplayName(event)}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{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';
|
||||
}
|
||||
Reference in New Issue
Block a user