Files
fotospiel-app/resources/js/admin/mobile/components/MobileShell.tsx
Codex Agent 911880f1a0
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Refactor: Update Tenant PWA headers and tabs to use Playfair Display and Tamagui components
2026-01-22 13:29:56 +01:00

406 lines
14 KiB
TypeScript

import React, { Suspense } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ChevronLeft, Bell, QrCode, ChevronsUpDown, Search } 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 { Image } from '@tamagui/image';
import { useTranslation } from 'react-i18next';
import { useEventContext } from '../../context/EventContext';
import { BottomNav, NavKey } from './BottomNav';
import { useMobileNav } from '../hooks/useMobileNav';
import { ADMIN_EVENTS_PATH, adminPath } from '../../constants';
import { MobileCard, CTAButton } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api';
import { withAlpha } from './colors';
import { setTabHistory } from '../lib/tabHistory';
import { loadPhotoQueue } from '../lib/photoModerationQueue';
import { countQueuedPhotoActions } from '../lib/queueStatus';
import { useAdminTheme } from '../theme';
import { useAuth } from '../../auth/context';
import { EventSwitcherSheet } from './EventSwitcherSheet';
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, selectEvent } = useEventContext();
const { user } = useAuth();
const { go } = useMobileNav(activeEvent?.slug, activeTab);
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
const theme = useAdminTheme();
const backgroundColor = theme.background;
const [isCompactHeader, setIsCompactHeader] = React.useState(false);
// --- DARK HEADER ---
const headerSurface = '#0F172A'; // Slate 900
const actionSurface = 'rgba(255, 255, 255, 0.1)';
const actionBorder = 'rgba(255, 255, 255, 0.05)';
const textColor = 'white';
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0);
const [switcherOpen, setSwitcherOpen] = React.useState(false);
const effectiveEvents = events.length ? events : fallbackEvents;
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 (typeof window === 'undefined' || !window.matchMedia) {
return;
}
const mediaQuery = window.matchMedia('(max-width: 320px)');
const handleChange = (event: MediaQueryListEvent | MediaQueryList) => {
setIsCompactHeader(event.matches);
};
handleChange(mediaQuery);
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
mediaQuery.addListener?.(handleChange);
return () => mediaQuery.removeListener?.(handleChange);
}, []);
React.useEffect(() => {
const path = `${location.pathname}${location.search}${location.hash}`;
if (!location.pathname.includes('/billing/shop') && !location.pathname.includes('/welcome')) {
setTabHistory(activeTab, path);
}
}, [activeTab, location.hash, location.pathname, location.search]);
const refreshQueuedActions = React.useCallback(() => {
const queue = loadPhotoQueue();
setQueuedPhotoCount(countQueuedPhotoActions(queue, effectiveActive?.slug ?? null));
}, [effectiveActive?.slug]);
React.useEffect(() => {
refreshQueuedActions();
}, [refreshQueuedActions, location.pathname]);
React.useEffect(() => {
const handleFocus = () => refreshQueuedActions();
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [refreshQueuedActions]);
const pageTitle = title ?? t('header.appName', 'Event Admin');
const isEventsIndex = location.pathname === ADMIN_EVENTS_PATH;
const canSwitchEvents = effectiveEvents.length > 1 && !isEventsIndex;
const isMember = user?.role === 'member';
const memberPermissions = Array.isArray(effectiveActive?.member_permissions) ? effectiveActive?.member_permissions ?? [] : [];
const allowPermission = (permission: string) => {
if (!isMember) return true;
if (memberPermissions.includes('*') || memberPermissions.includes(permission)) return true;
if (permission.includes(':')) {
const [prefix] = permission.split(':');
return memberPermissions.includes(`${prefix}:*`);
}
return false;
};
const showQr = Boolean(effectiveActive?.slug) && allowPermission('join-tokens:manage');
// --- CONTEXT PILL ---
const EventContextPill = () => {
if (!effectiveActive || isEventsIndex || isCompactHeader) {
return (
<Text fontSize="$md" fontWeight="700" fontFamily="$display" color="white">
{pageTitle}
</Text>
);
}
const displayName = resolveEventDisplayName(effectiveActive);
if (!canSwitchEvents) {
return (
<Text
fontSize="$lg"
fontWeight="700"
fontFamily="$display"
color="white"
numberOfLines={1}
ellipsizeMode="tail"
>
{displayName}
</Text>
);
}
return (
<Pressable onPress={() => setSwitcherOpen(true)} aria-label={t('header.eventSwitcher', 'Switch event')}>
<XStack
backgroundColor="rgba(255, 255, 255, 0.12)"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
alignItems="center"
space="$1.5"
maxWidth={220}
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
pressStyle={{ backgroundColor: 'rgba(255, 255, 255, 0.2)' }}
>
<Text
fontSize="$sm"
fontWeight="700"
color="white"
numberOfLines={1}
ellipsizeMode="tail"
flexShrink={1}
>
{displayName}
</Text>
<ChevronsUpDown size={14} color="rgba(255,255,255,0.6)" />
</XStack>
</Pressable>
);
};
const headerBackButton = onBack ? (
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={28} color="white" strokeWidth={2.5} />
</XStack>
</HeaderActionButton>
) : (
<HeaderActionButton onPress={() => {}} ariaLabel="Search">
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center">
<Search size={22} color="rgba(255,255,255,0.8)" strokeWidth={2.5} />
</XStack>
</HeaderActionButton>
);
const headerActionsRow = (
<XStack alignItems="center" space="$2.5">
{showQr ? (
<HeaderActionButton
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
ariaLabel={t('header.quickQr', 'Quick QR')}
>
<XStack
width={36}
height={36}
borderRadius={10}
backgroundColor="rgba(255, 255, 255, 0.15)"
alignItems="center"
justifyContent="center"
>
<QrCode size={18} color="white" />
</XStack>
</HeaderActionButton>
) : null}
<HeaderActionButton
onPress={() => navigate(adminPath('/mobile/notifications'))}
ariaLabel={t('mobile.notifications', 'Notifications')}
>
<XStack
width={36}
height={36}
borderRadius={10}
backgroundColor={actionSurface}
borderWidth={1}
borderColor={actionBorder}
alignItems="center"
justifyContent="center"
position="relative"
>
<Bell size={18} color="white" />
{notificationCount > 0 ? (
<YStack
position="absolute"
top={-4}
right={-4}
minWidth={18}
height={18}
paddingHorizontal={6}
borderRadius={999}
backgroundColor={theme.accent}
alignItems="center"
justifyContent="center"
borderWidth={2}
borderColor={headerSurface}
>
<Text fontSize={10} color="white" fontWeight="700">
{notificationCount > 9 ? '9+' : notificationCount}
</Text>
</YStack>
) : null}
</XStack>
</HeaderActionButton>
{/* User Avatar */}
<Pressable onPress={() => navigate(adminPath('/mobile/profile'))}>
<XStack
width={36} height={36} borderRadius={18}
backgroundColor={actionSurface}
overflow="hidden"
alignItems="center" justifyContent="center"
borderWidth={1} borderColor={actionBorder}
>
{user?.avatar_url ? (
<Image source={{ uri: user.avatar_url }} width={36} height={36} resizeMode="cover" />
) : (
<Text fontSize="$xs" fontWeight="700" color="white">
{user?.name?.charAt(0).toUpperCase() ?? 'U'}
</Text>
)}
</XStack>
</Pressable>
{headerActions ?? null}
</XStack>
);
return (
<YStack
backgroundColor={backgroundColor}
minHeight="100vh"
alignItems="center"
>
<YStack
backgroundColor={headerSurface}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
width="100%"
maxWidth={800}
position="sticky"
top={0}
zIndex={60}
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
}}
>
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$2">
{headerBackButton}
<XStack flex={1} justifyContent="center" alignItems="center">
<EventContextPill />
</XStack>
<XStack justifyContent="flex-end" minWidth={28}>
{headerActionsRow}
</XStack>
</XStack>
</YStack>
<YStack
flex={1}
padding="$4"
paddingBottom="$10"
space="$3"
width="100%"
maxWidth={800}
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
{!online ? (
<XStack alignItems="center" justifyContent="center" borderRadius={12} backgroundColor={theme.warningBg} paddingVertical="$2" paddingHorizontal="$3">
<Text fontSize="$xs" fontWeight="700" color={theme.warningText}>
{t('status.offline', 'Offline mode: changes will sync when you are back online.')}
</Text>
</XStack>
) : null}
{queuedPhotoCount > 0 ? (
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{t('status.queueTitle', 'Photo actions pending')}
</Text>
<Text fontSize="$xs" color={theme.muted}>
{online
? t('status.queueBodyOnline', '{{count}} actions ready to sync.', { count: queuedPhotoCount })
: t('status.queueBodyOffline', '{{count}} actions saved offline.', { count: queuedPhotoCount })}
</Text>
{effectiveActive?.slug ? (
<CTAButton
label={t('status.queueAction', 'Open moderation')}
tone="ghost"
fullWidth={false}
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive.slug}/control-room`))}
/>
) : null}
</MobileCard>
) : null}
{children}
</YStack>
<BottomNav active={activeTab} onNavigate={go} />
<EventSwitcherSheet
open={switcherOpen}
onClose={() => setSwitcherOpen(false)}
events={effectiveEvents}
activeSlug={effectiveActive?.slug ?? null}
/>
</YStack>
);
}
export function HeaderActionButton({
onPress,
children,
ariaLabel,
}: {
onPress: () => void;
children: React.ReactNode;
ariaLabel?: string;
}) {
const [pressed, setPressed] = React.useState(false);
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
aria-label={ariaLabel}
style={{
transform: pressed ? 'scale(0.96)' : 'scale(1)',
opacity: pressed ? 0.86 : 1,
transition: 'transform 120ms ease, opacity 120ms ease',
}}
>
{children}
</Pressable>
);
}