Refresh mobile dashboard and header

This commit is contained in:
Codex Agent
2026-01-16 22:06:41 +01:00
parent b53f809769
commit d6ee372671
6 changed files with 511 additions and 100 deletions

View File

@@ -1,6 +1,6 @@
import React, { Suspense } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ChevronLeft, Bell, QrCode } from 'lucide-react';
import { ChevronLeft, Bell, QrCode, ChevronsUpDown } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -8,7 +8,7 @@ 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 { ADMIN_EVENTS_PATH, adminPath } from '../../constants';
import { MobileCard, CTAButton } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
@@ -31,7 +31,7 @@ type MobileShellProps = {
};
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
const { events, activeEvent, selectEvent } = useEventContext();
const { events, activeEvent, hasMultipleEvents, selectEvent } = useEventContext();
const { user } = useAuth();
const { go } = useMobileNav(activeEvent?.slug, activeTab);
const navigate = useNavigate();
@@ -137,7 +137,13 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
}, []);
const pageTitle = title ?? t('header.appName', 'Event Admin');
const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null;
const eventContext = !isCompactHeader
? effectiveActive
? resolveEventDisplayName(effectiveActive)
: hasMultipleEvents
? t('header.selectEvent', 'Select an event')
: null
: null;
const subtitleText = subtitle ?? eventContext ?? '';
const isMember = user?.role === 'member';
const memberPermissions = Array.isArray(effectiveActive?.member_permissions) ? effectiveActive?.member_permissions ?? [] : [];
@@ -164,22 +170,55 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
) : (
<XStack width={28} />
);
const headerTitle = (
<XStack alignItems="center" space="$1" flex={1} minWidth={0} justifyContent="flex-end">
<YStack alignItems="flex-end" maxWidth="100%">
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
{pageTitle}
const headerTitleRight = (
<YStack alignItems="flex-end" maxWidth="100%">
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
{pageTitle}
</Text>
{subtitleText ? (
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
{subtitleText}
</Text>
{subtitleText ? (
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
{subtitleText}
</Text>
) : null}
</YStack>
</XStack>
) : null}
</YStack>
);
const headerTitleCenter = (
<YStack alignItems="center" maxWidth="100%">
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="center" numberOfLines={1}>
{pageTitle}
</Text>
{subtitleText ? (
<Text fontSize="$xs" color={mutedText} textAlign="center" numberOfLines={1} fontFamily="$body">
{subtitleText}
</Text>
) : null}
</YStack>
);
const isEventsIndex = location.pathname === ADMIN_EVENTS_PATH;
const canSwitchEvents = hasMultipleEvents && !isEventsIndex;
const headerActionsRow = (
<XStack alignItems="center" space="$2">
{canSwitchEvents ? (
<HeaderActionButton onPress={() => navigate(ADMIN_EVENTS_PATH)} ariaLabel={t('header.switchEvent', 'Switch event')}>
<XStack
width={34}
height={34}
borderRadius={12}
backgroundColor={actionSurface}
borderWidth={1}
borderColor={actionBorder}
alignItems="center"
justifyContent="center"
style={{
boxShadow: `0 10px 18px ${actionShadow}`,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
>
<ChevronsUpDown size={16} color={textColor} />
</XStack>
</HeaderActionButton>
) : null}
<HeaderActionButton
onPress={() => navigate(adminPath('/mobile/notifications'))}
ariaLabel={t('mobile.notifications', 'Notifications')}
@@ -273,22 +312,28 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
}}
>
{isCompactHeader ? (
<YStack space="$2">
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{headerBackButton}
<XStack flex={1} minWidth={0} justifyContent="flex-end">
{headerTitle}
</XStack>
<XStack
alignItems="center"
justifyContent="space-between"
minHeight={48}
space="$3"
flexWrap="wrap"
>
{headerBackButton}
<XStack flex={1} minWidth={120} justifyContent="center">
{headerTitleCenter}
</XStack>
<XStack alignItems="center" justifyContent="flex-end">
<XStack justifyContent="flex-end" flexShrink={0} style={{ marginLeft: 'auto' }}>
{headerActionsRow}
</XStack>
</YStack>
</XStack>
) : (
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{headerBackButton}
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end" minWidth={0}>
{headerTitle}
<XStack flex={1} minWidth={0} justifyContent="flex-end">
{headerTitleRight}
</XStack>
{headerActionsRow}
</XStack>
</XStack>

View File

@@ -152,10 +152,12 @@ export function KpiTile({
icon: IconCmp,
label,
value,
note,
}: {
icon: React.ComponentType<{ size?: number; color?: string }>;
label: string;
value: string | number;
note?: string;
}) {
const { accentSoft, primary, text } = useAdminTheme();
return (
@@ -178,6 +180,11 @@ export function KpiTile({
<Text fontSize="$xl" fontWeight="800" color={text}>
{value}
</Text>
{note ? (
<Text fontSize="$xs" color={text} opacity={0.7}>
{note}
</Text>
) : null}
</MobileCard>
);
}

View File

@@ -42,14 +42,16 @@ vi.mock('../BottomNav', () => ({
NavKey: {},
}));
const eventContext = {
events: [],
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
hasMultipleEvents: false,
hasEvents: true,
selectEvent: vi.fn(),
};
vi.mock('../../../context/EventContext', () => ({
useEventContext: () => ({
events: [],
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
hasMultipleEvents: false,
hasEvents: true,
selectEvent: vi.fn(),
}),
useEventContext: () => eventContext,
}));
vi.mock('../../../auth/context', () => ({
@@ -105,6 +107,7 @@ vi.mock('../../theme', () => ({
}));
import { MobileShell } from '../MobileShell';
import { ADMIN_EVENTS_PATH } from '../../../constants';
describe('MobileShell', () => {
beforeEach(() => {
@@ -113,6 +116,9 @@ describe('MobileShell', () => {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
eventContext.events = [];
eventContext.hasMultipleEvents = false;
eventContext.activeEvent = { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} };
});
it('renders quick QR as icon-only button', async () => {
@@ -149,4 +155,44 @@ describe('MobileShell', () => {
expect(screen.queryByText('Test Event')).not.toBeInTheDocument();
});
it('shows the event switcher when multiple events are available', async () => {
eventContext.hasMultipleEvents = true;
eventContext.events = [
{ slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
{ slug: 'event-2', name: 'Second Event', event_date: '2024-02-01', status: 'active', settings: {} },
];
await act(async () => {
render(
<MemoryRouter>
<MobileShell activeTab="home">
<div>Body</div>
</MobileShell>
</MemoryRouter>
);
});
expect(screen.getByLabelText('Switch event')).toBeInTheDocument();
});
it('hides the event switcher on the events list page', async () => {
eventContext.hasMultipleEvents = true;
eventContext.events = [
{ slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
{ slug: 'event-2', name: 'Second Event', event_date: '2024-02-01', status: 'active', settings: {} },
];
await act(async () => {
render(
<MemoryRouter initialEntries={[ADMIN_EVENTS_PATH]}>
<MobileShell activeTab="home">
<div>Body</div>
</MobileShell>
</MemoryRouter>
);
});
expect(screen.queryByLabelText('Switch event')).not.toBeInTheDocument();
});
});