Refresh mobile dashboard and header
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user