Refresh mobile dashboard and header
This commit is contained in:
@@ -334,7 +334,7 @@
|
|||||||
"qr": "QR-Code-Layouts",
|
"qr": "QR-Code-Layouts",
|
||||||
"images": "Bildverwaltung",
|
"images": "Bildverwaltung",
|
||||||
"liveShow": "Live-Show-Warteschlange",
|
"liveShow": "Live-Show-Warteschlange",
|
||||||
"liveShowSettings": "Live-Show Einstellungen",
|
"liveShowSettings": "Live-Show-Einstellungen",
|
||||||
"guests": "Gästeverwaltung",
|
"guests": "Gästeverwaltung",
|
||||||
"guestMessages": "Gästebenachrichtigungen",
|
"guestMessages": "Gästebenachrichtigungen",
|
||||||
"branding": "Branding & Design",
|
"branding": "Branding & Design",
|
||||||
@@ -1873,7 +1873,7 @@
|
|||||||
"qr": "QR-Code-Layouts",
|
"qr": "QR-Code-Layouts",
|
||||||
"images": "Bildverwaltung",
|
"images": "Bildverwaltung",
|
||||||
"liveShow": "Live-Show-Warteschlange",
|
"liveShow": "Live-Show-Warteschlange",
|
||||||
"liveShowSettings": "Live-Show Einstellungen",
|
"liveShowSettings": "Live-Show-Einstellungen",
|
||||||
"guests": "Gästeverwaltung",
|
"guests": "Gästeverwaltung",
|
||||||
"branding": "Branding & Design",
|
"branding": "Branding & Design",
|
||||||
"moderation": "Foto-Moderation",
|
"moderation": "Foto-Moderation",
|
||||||
@@ -2265,10 +2265,24 @@
|
|||||||
"shortcutSettings": "Event-Einstellungen",
|
"shortcutSettings": "Event-Einstellungen",
|
||||||
"shortcutBranding": "Branding & Moderation",
|
"shortcutBranding": "Branding & Moderation",
|
||||||
"shortcutAnalytics": "Statistiken",
|
"shortcutAnalytics": "Statistiken",
|
||||||
|
"quickActionsTitle": "Schnellzugriff",
|
||||||
"kpiTitle": "Wichtigste Kennzahlen",
|
"kpiTitle": "Wichtigste Kennzahlen",
|
||||||
"kpiTasks": "Offene Tasks",
|
"kpiTasks": "Offene Tasks",
|
||||||
"kpiPhotos": "Fotos",
|
"kpiPhotos": "Fotos",
|
||||||
"kpiGuests": "Gäste",
|
"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",
|
"alertsTitle": "Hinweise",
|
||||||
"alertPending": "{{count}} neue Uploads warten auf Freigabe",
|
"alertPending": "{{count}} neue Uploads warten auf Freigabe",
|
||||||
"alertTasks": "{{count}} Tasks offen oder fällig"
|
"alertTasks": "{{count}} Tasks offen oder fällig"
|
||||||
@@ -2377,7 +2391,7 @@
|
|||||||
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange."
|
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange."
|
||||||
},
|
},
|
||||||
"liveShowSettings": {
|
"liveShowSettings": {
|
||||||
"title": "Live-Show Einstellungen",
|
"title": "Live-Show-Einstellungen",
|
||||||
"subtitle": "Tempo, Layout und Effekte für die Leinwand feinjustieren.",
|
"subtitle": "Tempo, Layout und Effekte für die Leinwand feinjustieren.",
|
||||||
"link": {
|
"link": {
|
||||||
"title": "Live-Show-Link",
|
"title": "Live-Show-Link",
|
||||||
|
|||||||
@@ -2269,10 +2269,24 @@
|
|||||||
"shortcutSettings": "Event settings",
|
"shortcutSettings": "Event settings",
|
||||||
"shortcutBranding": "Branding & moderation",
|
"shortcutBranding": "Branding & moderation",
|
||||||
"shortcutAnalytics": "Analytics",
|
"shortcutAnalytics": "Analytics",
|
||||||
|
"quickActionsTitle": "Quick actions",
|
||||||
"kpiTitle": "Key performance indicators",
|
"kpiTitle": "Key performance indicators",
|
||||||
"kpiTasks": "Open tasks",
|
"kpiTasks": "Open tasks",
|
||||||
"kpiPhotos": "Photos",
|
"kpiPhotos": "Photos",
|
||||||
"kpiGuests": "Guests",
|
"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",
|
"alertsTitle": "Alerts",
|
||||||
"alertPending": "{{count}} new uploads awaiting moderation",
|
"alertPending": "{{count}} new uploads awaiting moderation",
|
||||||
"alertTasks": "{{count}} tasks due or open"
|
"alertTasks": "{{count}} tasks due or open"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { Card } from '@tamagui/card';
|
||||||
import { Progress } from '@tamagui/progress';
|
import { Progress } from '@tamagui/progress';
|
||||||
import { XGroup, YGroup } from '@tamagui/group';
|
import { XGroup, YGroup } from '@tamagui/group';
|
||||||
@@ -26,6 +26,8 @@ import { useAuth } from '../auth/context';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
||||||
import { isPastEvent } from './eventDate';
|
import { isPastEvent } from './eventDate';
|
||||||
|
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||||
|
import { withAlpha } from './components/colors';
|
||||||
|
|
||||||
type DeviceSetupProps = {
|
type DeviceSetupProps = {
|
||||||
installPrompt: ReturnType<typeof useInstallPrompt>;
|
installPrompt: ReturnType<typeof useInstallPrompt>;
|
||||||
@@ -552,29 +554,23 @@ export default function MobileDashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
title={t('mobileDashboard.title', 'Dashboard')}
|
title={t('mobileDashboard.title', 'Dashboard')}
|
||||||
>
|
>
|
||||||
{showPackageSummaryBanner && activePackage ? (
|
<EventHeaderCard
|
||||||
<PackageSummaryBanner
|
event={activeEvent}
|
||||||
activePackage={activePackage}
|
locale={locale}
|
||||||
onOpen={() => setSummaryOpen(true)}
|
canSwitch={effectiveMultiple}
|
||||||
|
onSwitch={() => setEventSwitcherOpen(true)}
|
||||||
|
canEdit={canManageEvents}
|
||||||
|
onEdit={
|
||||||
|
canManageEvents
|
||||||
|
? () => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
<EventHeaderCard
|
|
||||||
event={activeEvent}
|
|
||||||
locale={locale}
|
|
||||||
canSwitch={effectiveMultiple}
|
|
||||||
onSwitch={() => setEventSwitcherOpen(true)}
|
|
||||||
canEdit={canManageEvents}
|
|
||||||
onEdit={
|
|
||||||
canManageEvents
|
|
||||||
? () => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<EventManagementGrid
|
<EventManagementGrid
|
||||||
event={activeEvent}
|
event={activeEvent}
|
||||||
tasksEnabled={tasksEnabled}
|
tasksEnabled={tasksEnabled}
|
||||||
@@ -582,6 +578,13 @@ export default function MobileDashboardPage() {
|
|||||||
permissions={memberPermissions}
|
permissions={memberPermissions}
|
||||||
onNavigate={(path) => navigate(path)}
|
onNavigate={(path) => navigate(path)}
|
||||||
/>
|
/>
|
||||||
|
{showPackageSummaryBanner && activePackage ? (
|
||||||
|
<PackageSummaryBanner
|
||||||
|
activePackage={activePackage}
|
||||||
|
onOpen={() => setSummaryOpen(true)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TodayStrip event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
event={activeEvent}
|
event={activeEvent}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
@@ -591,24 +594,25 @@ export default function MobileDashboardPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
|
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
|
||||||
|
<RecentActivity event={activeEvent} stats={stats} />
|
||||||
<DeviceSetupCard
|
<DeviceSetupCard
|
||||||
installPrompt={installPrompt}
|
installPrompt={installPrompt}
|
||||||
pushState={pushState}
|
pushState={pushState}
|
||||||
devicePermissions={devicePermissions}
|
devicePermissions={devicePermissions}
|
||||||
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
||||||
/>
|
/>
|
||||||
{tourSheet}
|
{tourSheet}
|
||||||
{packageSummarySheet}
|
{packageSummarySheet}
|
||||||
{taskDecisionSheet}
|
{taskDecisionSheet}
|
||||||
<EventSwitcherSheet
|
<EventSwitcherSheet
|
||||||
open={eventSwitcherOpen}
|
open={eventSwitcherOpen}
|
||||||
onClose={() => setEventSwitcherOpen(false)}
|
onClose={() => setEventSwitcherOpen(false)}
|
||||||
events={effectiveEvents}
|
events={effectiveEvents}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PackageSummarySheet({
|
function PackageSummarySheet({
|
||||||
open,
|
open,
|
||||||
@@ -1369,6 +1373,36 @@ function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: s
|
|||||||
return t('events.detail.locationPlaceholder', 'Location');
|
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({
|
function EventHeaderCard({
|
||||||
event,
|
event,
|
||||||
locale,
|
locale,
|
||||||
@@ -1385,7 +1419,7 @@ function EventHeaderCard({
|
|||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
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) {
|
if (!event) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1393,6 +1427,16 @@ function EventHeaderCard({
|
|||||||
|
|
||||||
const dateLabel = formatEventDate(event.event_date, locale) ?? t('events.detail.dateTbd', 'Date tbd');
|
const dateLabel = formatEventDate(event.event_date, locale) ?? t('events.detail.dateTbd', 'Date tbd');
|
||||||
const locationLabel = resolveLocation(event, t);
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -1406,39 +1450,54 @@ function EventHeaderCard({
|
|||||||
shadowOpacity={0.16}
|
shadowOpacity={0.16}
|
||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
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">
|
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
||||||
{canSwitch ? (
|
{canSwitch ? (
|
||||||
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
|
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
<Text fontSize="$lg" fontWeight="900" color={textStrong}>
|
||||||
{resolveEventDisplayName(event)}
|
{resolveEventDisplayName(event)}
|
||||||
</Text>
|
</Text>
|
||||||
<ChevronDown size={16} color={muted} />
|
<ChevronDown size={16} color={muted} />
|
||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
<Text fontSize="$lg" fontWeight="900" color={textStrong}>
|
||||||
{resolveEventDisplayName(event)}
|
{resolveEventDisplayName(event)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
|
<PillBadge tone={statusTone}>
|
||||||
{event.status === 'published'
|
{statusLabel}
|
||||||
? t('events.status.published', 'Live')
|
|
||||||
: t('events.status.draft', 'Draft')}
|
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
<Text fontSize="$xs" color={muted} fontWeight="700">
|
||||||
<XStack alignItems="center" space="$2">
|
{eventTypeLabel}
|
||||||
<CalendarDays size={16} color={muted} />
|
</Text>
|
||||||
<Text fontSize="$sm" color={muted}>
|
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
||||||
{dateLabel}
|
<XStack alignItems="center" space="$1.5">
|
||||||
</Text>
|
<CalendarDays size={16} color={muted} />
|
||||||
<MapPin size={16} color={muted} />
|
<Text fontSize="$sm" color={muted}>
|
||||||
<Text fontSize="$sm" color={muted}>
|
{dateLabel}
|
||||||
{locationLabel}
|
</Text>
|
||||||
</Text>
|
</XStack>
|
||||||
|
<XStack alignItems="center" space="$1.5">
|
||||||
|
<MapPin size={16} color={muted} />
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{locationLabel}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
@@ -1643,7 +1702,7 @@ function EventManagementGrid({
|
|||||||
backgroundColor={surfaceMuted}
|
backgroundColor={surfaceMuted}
|
||||||
>
|
>
|
||||||
<Text fontSize="$xs" fontWeight="800" color={textStrong}>
|
<Text fontSize="$xs" fontWeight="800" color={textStrong}>
|
||||||
{t('events.detail.managementTitle', 'Event management')}
|
{t('mobileDashboard.quickActionsTitle', 'Quick actions')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -1693,17 +1752,31 @@ function KpiStrip({
|
|||||||
const { textStrong, muted, border, surface, surfaceMuted, shadow } = useAdminTheme();
|
const { textStrong, muted, border, surface, surfaceMuted, shadow } = useAdminTheme();
|
||||||
const text = textStrong;
|
const text = textStrong;
|
||||||
if (!event) return null;
|
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 = [
|
const kpis = [
|
||||||
{
|
{
|
||||||
label: t('mobileDashboard.kpiPhotos', 'Photos'),
|
label: t('mobileDashboard.kpiPhotos', 'Photos'),
|
||||||
value: stats?.uploads_total ?? event.photo_count ?? '—',
|
value: stats?.uploads_total ?? event.photo_count ?? '—',
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
|
note: resolveLimitNote(photoLimit, t('mobileDashboard.kpiPhotos', 'Photos')),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('mobileDashboard.kpiGuests', 'Guests'),
|
label: t('mobileDashboard.kpiGuests', 'Guests'),
|
||||||
value: event.active_invites_count ?? event.total_invites_count ?? '—',
|
value: event.active_invites_count ?? event.total_invites_count ?? '—',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
|
note: resolveLimitNote(guestLimit, t('mobileDashboard.kpiGuests', 'Guests')),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1712,6 +1785,7 @@ function KpiStrip({
|
|||||||
label: t('mobileDashboard.kpiTasks', 'Open tasks'),
|
label: t('mobileDashboard.kpiTasks', 'Open tasks'),
|
||||||
value: event.tasks_count ?? '—',
|
value: event.tasks_count ?? '—',
|
||||||
icon: ListTodo,
|
icon: ListTodo,
|
||||||
|
note: tasksEnabled ? t('mobileDashboard.kpiTasksNote', 'Active') : t('mobileDashboard.kpiTasksDisabled', 'Disabled'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1755,7 +1829,7 @@ function KpiStrip({
|
|||||||
) : (
|
) : (
|
||||||
<XStack space="$2" flexWrap="wrap">
|
<XStack space="$2" flexWrap="wrap">
|
||||||
{kpis.map((kpi) => (
|
{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>
|
</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 }) {
|
function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | null; stats: EventStats | null | undefined; tasksEnabled: boolean }) {
|
||||||
const { t } = useTranslation('management');
|
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;
|
const text = textStrong;
|
||||||
if (!event) return null;
|
if (!event) return null;
|
||||||
|
|
||||||
const alerts: string[] = [];
|
const alerts: Array<{ message: string; tone: 'warning' | 'danger' }> = [];
|
||||||
if (stats?.pending_photos) {
|
const pendingCount = stats?.pending_photos ?? event.pending_photo_count ?? 0;
|
||||||
alerts.push(t('mobileDashboard.alertPending', '{{count}} new uploads awaiting moderation', { count: stats.pending_photos }));
|
if (pendingCount > 0) {
|
||||||
|
alerts.push({
|
||||||
|
message: t('mobileDashboard.alertPending', '{{count}} new uploads awaiting moderation', { count: pendingCount }),
|
||||||
|
tone: 'warning',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (tasksEnabled && event.tasks_count) {
|
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) {
|
if (alerts.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1813,15 +1916,197 @@ function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | n
|
|||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<XStack
|
<XStack
|
||||||
key={alert}
|
key={alert.message}
|
||||||
padding="$2.5"
|
padding="$2.5"
|
||||||
borderRadius={16}
|
borderRadius={16}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={warningBorder}
|
borderColor={alert.tone === 'danger' ? dangerText : warningBorder}
|
||||||
backgroundColor={warningBg}
|
backgroundColor={alert.tone === 'danger' ? dangerBg : warningBg}
|
||||||
>
|
>
|
||||||
<Text fontSize="$sm" color={warningText}>
|
<Text fontSize="$sm" color={alert.tone === 'danger' ? dangerText : warningText}>
|
||||||
{alert}
|
{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>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { Suspense } from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
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 { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useEventContext } from '../../context/EventContext';
|
import { useEventContext } from '../../context/EventContext';
|
||||||
import { BottomNav, NavKey } from './BottomNav';
|
import { BottomNav, NavKey } from './BottomNav';
|
||||||
import { useMobileNav } from '../hooks/useMobileNav';
|
import { useMobileNav } from '../hooks/useMobileNav';
|
||||||
import { adminPath } from '../../constants';
|
import { ADMIN_EVENTS_PATH, adminPath } from '../../constants';
|
||||||
import { MobileCard, CTAButton } from './Primitives';
|
import { MobileCard, CTAButton } from './Primitives';
|
||||||
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
|
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
|
||||||
import { useOnlineStatus } from '../hooks/useOnlineStatus';
|
import { useOnlineStatus } from '../hooks/useOnlineStatus';
|
||||||
@@ -31,7 +31,7 @@ type MobileShellProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: 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 { user } = useAuth();
|
||||||
const { go } = useMobileNav(activeEvent?.slug, activeTab);
|
const { go } = useMobileNav(activeEvent?.slug, activeTab);
|
||||||
const navigate = useNavigate();
|
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 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 subtitleText = subtitle ?? eventContext ?? '';
|
||||||
const isMember = user?.role === 'member';
|
const isMember = user?.role === 'member';
|
||||||
const memberPermissions = Array.isArray(effectiveActive?.member_permissions) ? effectiveActive?.member_permissions ?? [] : [];
|
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} />
|
<XStack width={28} />
|
||||||
);
|
);
|
||||||
const headerTitle = (
|
const headerTitleRight = (
|
||||||
<XStack alignItems="center" space="$1" flex={1} minWidth={0} justifyContent="flex-end">
|
<YStack alignItems="flex-end" maxWidth="100%">
|
||||||
<YStack alignItems="flex-end" maxWidth="100%">
|
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
|
||||||
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
|
{pageTitle}
|
||||||
{pageTitle}
|
</Text>
|
||||||
|
{subtitleText ? (
|
||||||
|
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
|
||||||
|
{subtitleText}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitleText ? (
|
) : null}
|
||||||
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
|
</YStack>
|
||||||
{subtitleText}
|
|
||||||
</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 = (
|
const headerActionsRow = (
|
||||||
<XStack alignItems="center" space="$2">
|
<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
|
<HeaderActionButton
|
||||||
onPress={() => navigate(adminPath('/mobile/notifications'))}
|
onPress={() => navigate(adminPath('/mobile/notifications'))}
|
||||||
ariaLabel={t('mobile.notifications', 'Notifications')}
|
ariaLabel={t('mobile.notifications', 'Notifications')}
|
||||||
@@ -273,22 +312,28 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isCompactHeader ? (
|
{isCompactHeader ? (
|
||||||
<YStack space="$2">
|
<XStack
|
||||||
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
alignItems="center"
|
||||||
{headerBackButton}
|
justifyContent="space-between"
|
||||||
<XStack flex={1} minWidth={0} justifyContent="flex-end">
|
minHeight={48}
|
||||||
{headerTitle}
|
space="$3"
|
||||||
</XStack>
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
{headerBackButton}
|
||||||
|
<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}
|
{headerActionsRow}
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</XStack>
|
||||||
) : (
|
) : (
|
||||||
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
||||||
{headerBackButton}
|
{headerBackButton}
|
||||||
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end" minWidth={0}>
|
<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}
|
{headerActionsRow}
|
||||||
</XStack>
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|||||||
@@ -152,10 +152,12 @@ export function KpiTile({
|
|||||||
icon: IconCmp,
|
icon: IconCmp,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
note,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ComponentType<{ size?: number; color?: string }>;
|
icon: React.ComponentType<{ size?: number; color?: string }>;
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
|
note?: string;
|
||||||
}) {
|
}) {
|
||||||
const { accentSoft, primary, text } = useAdminTheme();
|
const { accentSoft, primary, text } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
@@ -178,6 +180,11 @@ export function KpiTile({
|
|||||||
<Text fontSize="$xl" fontWeight="800" color={text}>
|
<Text fontSize="$xl" fontWeight="800" color={text}>
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
|
{note ? (
|
||||||
|
<Text fontSize="$xs" color={text} opacity={0.7}>
|
||||||
|
{note}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,14 +42,16 @@ vi.mock('../BottomNav', () => ({
|
|||||||
NavKey: {},
|
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', () => ({
|
vi.mock('../../../context/EventContext', () => ({
|
||||||
useEventContext: () => ({
|
useEventContext: () => 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('../../../auth/context', () => ({
|
vi.mock('../../../auth/context', () => ({
|
||||||
@@ -105,6 +107,7 @@ vi.mock('../../theme', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { MobileShell } from '../MobileShell';
|
import { MobileShell } from '../MobileShell';
|
||||||
|
import { ADMIN_EVENTS_PATH } from '../../../constants';
|
||||||
|
|
||||||
describe('MobileShell', () => {
|
describe('MobileShell', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -113,6 +116,9 @@ describe('MobileShell', () => {
|
|||||||
addEventListener: vi.fn(),
|
addEventListener: vi.fn(),
|
||||||
removeEventListener: 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 () => {
|
it('renders quick QR as icon-only button', async () => {
|
||||||
@@ -149,4 +155,44 @@ describe('MobileShell', () => {
|
|||||||
|
|
||||||
expect(screen.queryByText('Test Event')).not.toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user