import React, { Suspense } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { 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 { 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'; 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 { go } = useMobileNav(activeEvent?.slug, activeTab); const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation('mobile'); const { count: notificationCount } = useNotificationsBadge(); const online = useOnlineStatus(); const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme(); const backgroundColor = background; const surfaceColor = surface; const borderColor = border; const textColor = text; const mutedText = muted; const headerSurface = withAlpha(surfaceColor, 0.94); const [fallbackEvents, setFallbackEvents] = React.useState([]); const [loadingEvents, setLoadingEvents] = React.useState(false); const [attemptedFetch, setAttemptedFetch] = React.useState(false); const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0); const [isCompactHeader, setIsCompactHeader] = 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(() => { const path = `${location.pathname}${location.search}${location.hash}`; // Blacklist transient paths from being saved in tab history const isBlacklisted = location.pathname.includes('/billing/shop') || location.pathname.includes('/welcome'); if (!isBlacklisted) { 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]); React.useEffect(() => { if (typeof window === 'undefined' || !window.matchMedia) { return; } const query = window.matchMedia('(max-width: 520px)'); const handleChange = (event: MediaQueryListEvent) => { setIsCompactHeader(event.matches); }; setIsCompactHeader(query.matches); query.addEventListener?.('change', handleChange); return () => { query.removeEventListener?.('change', handleChange); }; }, []); const pageTitle = title ?? t('header.appName', 'Event Admin'); const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null; const subtitleText = subtitle ?? eventContext ?? ''; const showQr = Boolean(effectiveActive?.slug); const headerBackButton = onBack ? ( ) : ( ); const headerTitle = ( {pageTitle} {subtitleText ? ( {subtitleText} ) : null} ); const headerActionsRow = ( navigate(adminPath('/mobile/notifications'))} ariaLabel={t('mobile.notifications', 'Notifications')} > {notificationCount > 0 ? ( {notificationCount > 9 ? '9+' : notificationCount} ) : null} {showQr ? ( navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))} ariaLabel={t('header.quickQr', 'Quick QR')} > ) : null} {headerActions ?? null} ); return ( {isCompactHeader ? ( {headerBackButton} {headerTitle} {headerActionsRow} ) : ( {headerBackButton} {headerTitle} {headerActionsRow} )} {!online ? ( {t('status.offline', 'Offline mode: changes will sync when you are back online.')} ) : null} {queuedPhotoCount > 0 ? ( {t('status.queueTitle', 'Photo actions pending')} {online ? t('status.queueBodyOnline', '{{count}} actions ready to sync.', { count: queuedPhotoCount }) : t('status.queueBodyOffline', '{{count}} actions saved offline.', { count: queuedPhotoCount })} {effectiveActive?.slug ? ( navigate(adminPath(`/mobile/events/${effectiveActive.slug}/photos`))} /> ) : null} ) : null} {children} ); } export function HeaderActionButton({ onPress, children, ariaLabel, }: { onPress: () => void; children: React.ReactNode; ariaLabel?: string; }) { const [pressed, setPressed] = React.useState(false); return ( 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} ); } export function renderEventLocation(event?: TenantEvent | null): string { if (!event) return 'Location'; const settings = (event.settings ?? {}) as Record; 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'; }