Refresh mobile dashboard and header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

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

View File

@@ -334,7 +334,7 @@
"qr": "QR-Code-Layouts",
"images": "Bildverwaltung",
"liveShow": "Live-Show-Warteschlange",
"liveShowSettings": "Live-Show Einstellungen",
"liveShowSettings": "Live-Show-Einstellungen",
"guests": "Gästeverwaltung",
"guestMessages": "Gästebenachrichtigungen",
"branding": "Branding & Design",
@@ -1873,7 +1873,7 @@
"qr": "QR-Code-Layouts",
"images": "Bildverwaltung",
"liveShow": "Live-Show-Warteschlange",
"liveShowSettings": "Live-Show Einstellungen",
"liveShowSettings": "Live-Show-Einstellungen",
"guests": "Gästeverwaltung",
"branding": "Branding & Design",
"moderation": "Foto-Moderation",
@@ -2265,10 +2265,24 @@
"shortcutSettings": "Event-Einstellungen",
"shortcutBranding": "Branding & Moderation",
"shortcutAnalytics": "Statistiken",
"quickActionsTitle": "Schnellzugriff",
"kpiTitle": "Wichtigste Kennzahlen",
"kpiTasks": "Offene Tasks",
"kpiPhotos": "Fotos",
"kpiGuests": "Gäste",
"kpiUnlimited": "{{label}} unbegrenzt",
"kpiRemaining": "{{remaining}} übrig",
"kpiTasksNote": "Aktiv",
"kpiTasksDisabled": "Deaktiviert",
"todayTitle": "Heute im Blick",
"todayUploads": "Uploads (24h)",
"todayPending": "Ausstehend",
"todayLikes": "Likes",
"todayTasks": "Tasks",
"recentActivityTitle": "Letzte Aktivitaeten",
"recentUploads": "Uploads in den letzten 24h",
"recentLikes": "Likes gesamt",
"recentPending": "Ausstehende Moderation",
"alertsTitle": "Hinweise",
"alertPending": "{{count}} neue Uploads warten auf Freigabe",
"alertTasks": "{{count}} Tasks offen oder fällig"
@@ -2377,7 +2391,7 @@
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange."
},
"liveShowSettings": {
"title": "Live-Show Einstellungen",
"title": "Live-Show-Einstellungen",
"subtitle": "Tempo, Layout und Effekte für die Leinwand feinjustieren.",
"link": {
"title": "Live-Show-Link",

View File

@@ -2269,10 +2269,24 @@
"shortcutSettings": "Event settings",
"shortcutBranding": "Branding & moderation",
"shortcutAnalytics": "Analytics",
"quickActionsTitle": "Quick actions",
"kpiTitle": "Key performance indicators",
"kpiTasks": "Open tasks",
"kpiPhotos": "Photos",
"kpiGuests": "Guests",
"kpiUnlimited": "{{label}} unlimited",
"kpiRemaining": "{{remaining}} remaining",
"kpiTasksNote": "Active",
"kpiTasksDisabled": "Disabled",
"todayTitle": "Today at a glance",
"todayUploads": "Uploads (24h)",
"todayPending": "Pending",
"todayLikes": "Likes",
"todayTasks": "Tasks",
"recentActivityTitle": "Recent activity",
"recentUploads": "Uploads in the last 24h",
"recentLikes": "Total likes",
"recentPending": "Pending moderation",
"alertsTitle": "Alerts",
"alertPending": "{{count}} new uploads awaiting moderation",
"alertTasks": "{{count}} tasks due or open"

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
import { Activity, Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
import { Card } from '@tamagui/card';
import { Progress } from '@tamagui/progress';
import { XGroup, YGroup } from '@tamagui/group';
@@ -26,6 +26,8 @@ import { useAuth } from '../auth/context';
import toast from 'react-hot-toast';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
import { isPastEvent } from './eventDate';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { withAlpha } from './components/colors';
type DeviceSetupProps = {
installPrompt: ReturnType<typeof useInstallPrompt>;
@@ -557,12 +559,6 @@ export default function MobileDashboardPage() {
activeTab="home"
title={t('mobileDashboard.title', 'Dashboard')}
>
{showPackageSummaryBanner && activePackage ? (
<PackageSummaryBanner
activePackage={activePackage}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<EventHeaderCard
event={activeEvent}
locale={locale}
@@ -582,6 +578,13 @@ export default function MobileDashboardPage() {
permissions={memberPermissions}
onNavigate={(path) => navigate(path)}
/>
{showPackageSummaryBanner && activePackage ? (
<PackageSummaryBanner
activePackage={activePackage}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<TodayStrip event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
<KpiStrip
event={activeEvent}
stats={stats}
@@ -591,6 +594,7 @@ export default function MobileDashboardPage() {
/>
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
<RecentActivity event={activeEvent} stats={stats} />
<DeviceSetupCard
installPrompt={installPrompt}
pushState={pushState}
@@ -1369,6 +1373,36 @@ function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: s
return t('events.detail.locationPlaceholder', 'Location');
}
function resolveHeroGradient(
event: TenantEvent,
colors: { primary: string; accent: string }
): string {
const descriptor = `${event.event_type?.name ?? ''} ${event.event_type?.slug ?? ''} ${resolveEventDisplayName(event)}`.toLowerCase();
const palette = [
{
keys: ['wedding', 'hochzeit', 'wedding day', 'marriage'],
stops: ['#FCE4EC', '#F8D7DA', '#EDE7F6'],
},
{
keys: ['birthday', 'geburtstag'],
stops: ['#FFE8D6', '#FFD6E8', '#E0F2FE'],
},
{
keys: ['corporate', 'business', 'company', 'konferenz', 'conference'],
stops: ['#E0E7FF', '#DBEAFE', '#E2E8F0'],
},
{
keys: ['festival', 'party', 'fest', 'celebration'],
stops: ['#FFE4B5', '#FFEDD5', '#EDE9FE'],
},
];
const match = palette.find((entry) => entry.keys.some((key) => descriptor.includes(key)));
const stops = match?.stops ?? [colors.primary, colors.accent];
return `linear-gradient(135deg, ${withAlpha(stops[0], 0.75)}, ${withAlpha(stops[1], 0.65)}, ${withAlpha(stops[2], 0.55)})`;
}
function EventHeaderCard({
event,
locale,
@@ -1385,7 +1419,7 @@ function EventHeaderCard({
onEdit?: () => void;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary, shadow } = useAdminTheme();
const { textStrong, muted, border, surface, accentSoft, primary, accent, shadow } = useAdminTheme();
if (!event) {
return null;
@@ -1393,6 +1427,16 @@ function EventHeaderCard({
const dateLabel = formatEventDate(event.event_date, locale) ?? t('events.detail.dateTbd', 'Date tbd');
const locationLabel = resolveLocation(event, t);
const eventTypeLabel = event.event_type?.name ?? t('events.detail.typeDefault', 'Event');
const accentColor = accent || primary;
const heroGradient = resolveHeroGradient(event, { primary, accent: accentColor });
const statusLabel =
event.status === 'published'
? t('events.status.published', 'Live')
: event.status === 'archived'
? t('events.status.archived', 'Archived')
: t('events.status.draft', 'Draft');
const statusTone = event.status === 'published' ? 'success' : event.status === 'archived' ? 'muted' : 'warning';
return (
<Card
@@ -1406,40 +1450,55 @@ function EventHeaderCard({
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
overflow="hidden"
>
<YStack space="$2.5">
<YStack
position="absolute"
inset={0}
backgroundColor="transparent"
opacity={0.92}
style={{ background: heroGradient }}
/>
<YStack position="absolute" top={-30} right={-40} opacity={0.22}>
<Sparkles size={120} color={primary} />
</YStack>
<YStack space="$2.5" position="relative">
<XStack alignItems="center" justifyContent="space-between" space="$2">
{canSwitch ? (
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
<XStack alignItems="center" space="$2">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
<Text fontSize="$lg" fontWeight="900" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Pressable>
) : (
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
<Text fontSize="$lg" fontWeight="900" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
)}
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published'
? t('events.status.published', 'Live')
: t('events.status.draft', 'Draft')}
<PillBadge tone={statusTone}>
{statusLabel}
</PillBadge>
</XStack>
<XStack alignItems="center" space="$2">
<Text fontSize="$xs" color={muted} fontWeight="700">
{eventTypeLabel}
</Text>
<XStack alignItems="center" space="$2" flexWrap="wrap">
<XStack alignItems="center" space="$1.5">
<CalendarDays size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{dateLabel}
</Text>
</XStack>
<XStack alignItems="center" space="$1.5">
<MapPin size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{locationLabel}
</Text>
</XStack>
</XStack>
</YStack>
{canEdit && onEdit ? (
@@ -1643,7 +1702,7 @@ function EventManagementGrid({
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={textStrong}>
{t('events.detail.managementTitle', 'Event management')}
{t('mobileDashboard.quickActionsTitle', 'Quick actions')}
</Text>
</XStack>
</XStack>
@@ -1693,17 +1752,31 @@ function KpiStrip({
const { textStrong, muted, border, surface, surfaceMuted, shadow } = useAdminTheme();
const text = textStrong;
if (!event) return null;
const photoLimit = event.limits?.photos ?? null;
const guestLimit = event.limits?.guests ?? null;
const resolveLimitNote = (limit: typeof photoLimit, label: string) => {
if (!limit) return null;
if (limit.state === 'unlimited') {
return t('mobileDashboard.kpiUnlimited', '{{label}} unlimited', { label });
}
if (typeof limit.remaining === 'number') {
return t('mobileDashboard.kpiRemaining', '{{remaining}} remaining', { remaining: limit.remaining });
}
return null;
};
const kpis = [
{
label: t('mobileDashboard.kpiPhotos', 'Photos'),
value: stats?.uploads_total ?? event.photo_count ?? '—',
icon: ImageIcon,
note: resolveLimitNote(photoLimit, t('mobileDashboard.kpiPhotos', 'Photos')),
},
{
label: t('mobileDashboard.kpiGuests', 'Guests'),
value: event.active_invites_count ?? event.total_invites_count ?? '—',
icon: Users,
note: resolveLimitNote(guestLimit, t('mobileDashboard.kpiGuests', 'Guests')),
},
];
@@ -1712,6 +1785,7 @@ function KpiStrip({
label: t('mobileDashboard.kpiTasks', 'Open tasks'),
value: event.tasks_count ?? '—',
icon: ListTodo,
note: tasksEnabled ? t('mobileDashboard.kpiTasksNote', 'Active') : t('mobileDashboard.kpiTasksDisabled', 'Disabled'),
});
}
@@ -1755,7 +1829,7 @@ function KpiStrip({
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value ?? '—'} />
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value ?? '—'} note={kpi.note ?? undefined} />
))}
</XStack>
)}
@@ -1764,19 +1838,48 @@ function KpiStrip({
);
}
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string) {
const defaults: Record<string, string> = {
photosBlocked: 'Upload limit reached. Buy more photos to continue.',
photosWarning: '{{remaining}} of {{limit}} photos remaining.',
guestsBlocked: 'Guest limit reached.',
guestsWarning: '{{remaining}} of {{limit}} guests remaining.',
galleryExpired: 'Gallery expired. Extend to keep it online.',
galleryWarningHour: 'Gallery expires in {{hours}} hour.',
galleryWarningHours: 'Gallery expires in {{hours}} hours.',
galleryWarningDay: 'Gallery expires in {{days}} day.',
galleryWarningDays: 'Gallery expires in {{days}} days.',
};
return (key: string, options?: Record<string, unknown>) => t(`limits.${key}`, defaults[key] ?? key, options);
}
function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | null; stats: EventStats | null | undefined; tasksEnabled: boolean }) {
const { t } = useTranslation('management');
const { textStrong, warningBg, warningBorder, warningText, border, surface, surfaceMuted, shadow } = useAdminTheme();
const { textStrong, warningBg, warningBorder, warningText, dangerBg, dangerText, border, surface, surfaceMuted, shadow } = useAdminTheme();
const text = textStrong;
if (!event) return null;
const alerts: string[] = [];
if (stats?.pending_photos) {
alerts.push(t('mobileDashboard.alertPending', '{{count}} new uploads awaiting moderation', { count: stats.pending_photos }));
const alerts: Array<{ message: string; tone: 'warning' | 'danger' }> = [];
const pendingCount = stats?.pending_photos ?? event.pending_photo_count ?? 0;
if (pendingCount > 0) {
alerts.push({
message: t('mobileDashboard.alertPending', '{{count}} new uploads awaiting moderation', { count: pendingCount }),
tone: 'warning',
});
}
if (tasksEnabled && event.tasks_count) {
alerts.push(t('mobileDashboard.alertTasks', '{{count}} tasks due or open', { count: event.tasks_count }));
alerts.push({
message: t('mobileDashboard.alertTasks', '{{count}} tasks due or open', { count: event.tasks_count }),
tone: 'warning',
});
}
const limitWarnings = buildLimitWarnings(event.limits ?? null, translateLimits(t as any));
limitWarnings.forEach((warning) => {
alerts.push({
message: warning.message,
tone: warning.tone === 'danger' ? 'danger' : 'warning',
});
});
if (alerts.length === 0) {
return null;
@@ -1813,15 +1916,197 @@ function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | n
<YStack space="$2">
{alerts.map((alert) => (
<XStack
key={alert}
key={alert.message}
padding="$2.5"
borderRadius={16}
borderWidth={1}
borderColor={warningBorder}
backgroundColor={warningBg}
borderColor={alert.tone === 'danger' ? dangerText : warningBorder}
backgroundColor={alert.tone === 'danger' ? dangerBg : warningBg}
>
<Text fontSize="$sm" color={warningText}>
{alert}
<Text fontSize="$sm" color={alert.tone === 'danger' ? dangerText : warningText}>
{alert.message}
</Text>
</XStack>
))}
</YStack>
</YStack>
</Card>
);
}
function TodayStrip({
event,
stats,
tasksEnabled,
}: {
event: TenantEvent | null;
stats: EventStats | null | undefined;
tasksEnabled: boolean;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, surfaceMuted, shadow, accentSoft, primary } = useAdminTheme();
if (!event) return null;
const items = [
{
key: 'uploads',
label: t('mobileDashboard.todayUploads', 'Uploads (24h)'),
value: stats?.uploads_24h ?? 0,
icon: ImageIcon,
},
{
key: 'pending',
label: t('mobileDashboard.todayPending', 'Pending'),
value: stats?.pending_photos ?? event.pending_photo_count ?? 0,
icon: Bell,
},
{
key: 'likes',
label: t('mobileDashboard.todayLikes', 'Likes'),
value: stats?.likes_total ?? event.like_count ?? 0,
icon: Sparkles,
},
];
if (tasksEnabled) {
items.unshift({
key: 'tasks',
label: t('mobileDashboard.todayTasks', 'Tasks'),
value: event.tasks_count ?? 0,
icon: ListTodo,
});
}
return (
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.12}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={textStrong}>
{t('mobileDashboard.todayTitle', 'Today at a glance')}
</Text>
</XStack>
<Activity size={18} color={muted} />
</XStack>
<XStack space="$2" flexWrap="wrap">
{items.slice(0, 3).map((item) => (
<XStack
key={item.key}
alignItems="center"
space="$2"
padding="$2"
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
flex={1}
minWidth={120}
>
<XStack width={32} height={32} borderRadius={12} backgroundColor={accentSoft} alignItems="center" justifyContent="center">
<item.icon size={16} color={primary} />
</XStack>
<YStack space="$0.5" flex={1}>
<Text fontSize="$xs" color={muted}>
{item.label}
</Text>
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{item.value}
</Text>
</YStack>
</XStack>
))}
</XStack>
</YStack>
</Card>
);
}
function RecentActivity({ event, stats }: { event: TenantEvent | null; stats: EventStats | null | undefined }) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, surfaceMuted, shadow } = useAdminTheme();
if (!event) return null;
const activityItems = [
{
key: 'uploads',
label: t('mobileDashboard.recentUploads', 'Uploads in the last 24h'),
value: stats?.uploads_24h ?? 0,
},
{
key: 'likes',
label: t('mobileDashboard.recentLikes', 'Total likes'),
value: stats?.likes_total ?? event.like_count ?? 0,
},
{
key: 'pending',
label: t('mobileDashboard.recentPending', 'Pending moderation'),
value: stats?.pending_photos ?? event.pending_photo_count ?? 0,
},
];
return (
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.12}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={textStrong}>
{t('mobileDashboard.recentActivityTitle', 'Recent activity')}
</Text>
</XStack>
</XStack>
<YStack space="$2">
{activityItems.map((item) => (
<XStack
key={item.key}
alignItems="center"
justifyContent="space-between"
padding="$2.5"
borderRadius={16}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$sm" color={textStrong}>
{item.label}
</Text>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{item.value}
</Text>
</XStack>
))}

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,8 +170,7 @@ 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">
const headerTitleRight = (
<YStack alignItems="flex-end" maxWidth="100%">
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
{pageTitle}
@@ -176,10 +181,44 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</Text>
) : null}
</YStack>
</XStack>
);
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">
<XStack
alignItems="center"
justifyContent="space-between"
minHeight={48}
space="$3"
flexWrap="wrap"
>
{headerBackButton}
<XStack flex={1} minWidth={0} justifyContent="flex-end">
{headerTitle}
<XStack flex={1} minWidth={120} justifyContent="center">
{headerTitleCenter}
</XStack>
</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: {},
}));
vi.mock('../../../context/EventContext', () => ({
useEventContext: () => ({
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: () => 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();
});
});