feat: implement advanced analytics for mobile admin dashboard
This commit includes: - Backend EventAnalyticsService and Controller - API endpoint for event analytics - Frontend EventAnalyticsPage with custom bar charts and top contributor lists - Analytics shortcut on the dashboard - Feature-lock upsell UI for non-premium users
This commit is contained in:
@@ -2856,6 +2856,35 @@ export async function removeEventMember(eventIdentifier: number | string, member
|
||||
throw new Error('Failed to remove member');
|
||||
}
|
||||
}
|
||||
export type AnalyticsTimelinePoint = {
|
||||
timestamp: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type AnalyticsContributor = {
|
||||
name: string;
|
||||
count: number;
|
||||
likes: number;
|
||||
};
|
||||
|
||||
export type AnalyticsTaskStat = {
|
||||
task_id: number;
|
||||
task_name: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type EventAnalytics = {
|
||||
timeline: AnalyticsTimelinePoint[];
|
||||
contributors: AnalyticsContributor[];
|
||||
tasks: AnalyticsTaskStat[];
|
||||
};
|
||||
|
||||
export async function getEventAnalytics(slug: string): Promise<EventAnalytics> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/analytics`);
|
||||
const data = await jsonOrThrow<EventAnalytics>(response, 'Failed to load analytics');
|
||||
return data;
|
||||
}
|
||||
|
||||
type CacheEntry<T> = {
|
||||
value?: T;
|
||||
expiresAt: number;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Bell, CheckCircle2, Download, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, ShieldCheck, Smartphone, Users, Sparkles } from 'lucide-react';
|
||||
import { Bell, CheckCircle2, Download, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, ShieldCheck, Smartphone, Users, Sparkles, TrendingUp } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
@@ -462,6 +462,7 @@ export default function MobileDashboardPage() {
|
||||
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
|
||||
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
|
||||
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))}
|
||||
onAnalytics={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/analytics`))}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
@@ -1112,12 +1113,14 @@ function SecondaryGrid({
|
||||
onPrint,
|
||||
onInvites,
|
||||
onSettings,
|
||||
onAnalytics,
|
||||
}: {
|
||||
event: TenantEvent | null;
|
||||
onGuests: () => void;
|
||||
onPrint: () => void;
|
||||
onInvites: () => void;
|
||||
onSettings: () => void;
|
||||
onAnalytics: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
||||
@@ -1130,6 +1133,12 @@ function SecondaryGrid({
|
||||
color: ADMIN_ACTION_COLORS.guests,
|
||||
action: onGuests,
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
|
||||
color: ADMIN_ACTION_COLORS.analytics,
|
||||
action: onAnalytics,
|
||||
},
|
||||
{
|
||||
icon: QrCode,
|
||||
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
|
||||
|
||||
260
resources/js/admin/mobile/EventAnalyticsPage.tsx
Normal file
260
resources/js/admin/mobile/EventAnalyticsPage.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BarChart2, TrendingUp, Users, ListTodo, Lock, Trophy, Calendar } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de, enGB } from 'date-fns/locale';
|
||||
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||
import { getEventAnalytics, EventAnalytics } from '../api';
|
||||
import { ApiError } from '../lib/apiError';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { adminPath } from '../constants';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
|
||||
export default function MobileEventAnalyticsPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const { activeEvent } = useEventContext();
|
||||
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
|
||||
|
||||
const dateLocale = i18n.language.startsWith('de') ? de : enGB;
|
||||
|
||||
const { data, isLoading, error } = useQuery<EventAnalytics, ApiError>({
|
||||
queryKey: ['event-analytics', slug],
|
||||
queryFn: () => getEventAnalytics(slug!),
|
||||
enabled: Boolean(slug),
|
||||
retry: false, // Don't retry if 403
|
||||
});
|
||||
|
||||
const isFeatureLocked = error?.status === 403 || error?.code === 'feature_locked';
|
||||
|
||||
if (isFeatureLocked) {
|
||||
return (
|
||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
||||
<MobileCard
|
||||
space="$4"
|
||||
padding="$6"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<YStack
|
||||
width={64}
|
||||
height={64}
|
||||
borderRadius={32}
|
||||
backgroundColor={accentSoft}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
marginBottom="$2"
|
||||
>
|
||||
<Lock size={32} color={primary} />
|
||||
</YStack>
|
||||
<YStack space="$2" alignItems="center">
|
||||
<Text fontSize="$xl" fontWeight="900" color={textStrong} textAlign="center">
|
||||
{t('analytics.lockedTitle', 'Unlock Analytics')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted} textAlign="center">
|
||||
{t('analytics.lockedBody', 'Get deep insights into your event engagement with the Premium package.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<CTAButton
|
||||
label={t('analytics.upgradeAction', 'Upgrade to Premium')}
|
||||
onPress={() => navigate(adminPath('/mobile/billing'))}
|
||||
/>
|
||||
</MobileCard>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
||||
<YStack space="$3">
|
||||
<SkeletonCard height={200} />
|
||||
<SkeletonCard height={150} />
|
||||
<SkeletonCard height={150} />
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
||||
<MobileCard borderColor={border} padding="$4">
|
||||
<Text color={muted}>{t('common.error', 'Something went wrong')}</Text>
|
||||
</MobileCard>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
const { timeline, contributors, tasks } = data;
|
||||
const hasTimeline = timeline.length > 0;
|
||||
const hasContributors = contributors.length > 0;
|
||||
const hasTasks = tasks.length > 0;
|
||||
|
||||
// Prepare chart data
|
||||
const maxCount = Math.max(...timeline.map((p) => p.count), 1);
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
title={t('analytics.title', 'Analytics')}
|
||||
subtitle={activeEvent?.name as string}
|
||||
activeTab="events"
|
||||
showBack
|
||||
>
|
||||
<YStack space="$4">
|
||||
{/* Activity Timeline */}
|
||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<TrendingUp size={18} color={primary} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('analytics.activityTitle', 'Activity Timeline')}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{hasTimeline ? (
|
||||
<YStack height={180} justifyContent="flex-end" space="$2">
|
||||
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
|
||||
{timeline.map((point, index) => {
|
||||
const heightPercent = (point.count / maxCount) * 100;
|
||||
const date = parseISO(point.timestamp);
|
||||
// Show label every 3rd point or if few points
|
||||
const showLabel = timeline.length < 8 || index % 3 === 0;
|
||||
|
||||
return (
|
||||
<YStack key={point.timestamp} flex={1} alignItems="center" space="$1">
|
||||
<YStack
|
||||
width="100%"
|
||||
height={`${Math.max(heightPercent, 4)}%`}
|
||||
backgroundColor={primary}
|
||||
opacity={0.8}
|
||||
borderTopLeftRadius={4}
|
||||
borderTopRightRadius={4}
|
||||
/>
|
||||
{showLabel && (
|
||||
<Text fontSize={10} color={muted} numberOfLines={1}>
|
||||
{format(date, 'HH:mm')}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color={muted} textAlign="center">
|
||||
{t('analytics.uploadsPerHour', 'Uploads per hour')}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noActivity', 'No uploads yet')} />
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
{/* Top Contributors */}
|
||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Trophy size={18} color={primary} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('analytics.contributorsTitle', 'Top Contributors')}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{hasContributors ? (
|
||||
<YStack space="$3">
|
||||
{contributors.map((contributor, idx) => (
|
||||
<XStack key={idx} alignItems="center" justifyContent="space-between" paddingVertical="$1">
|
||||
<XStack alignItems="center" space="$3">
|
||||
<YStack
|
||||
width={28}
|
||||
height={28}
|
||||
borderRadius={14}
|
||||
backgroundColor={idx < 3 ? accentSoft : '$gray5'}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={idx < 3 ? primary : muted}>
|
||||
{idx + 1}
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
|
||||
{contributor.name || t('common.anonymous', 'Anonymous')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('analytics.likesCount', { count: contributor.likes, defaultValue: '{{count}} likes' })}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color={primary}>
|
||||
{contributor.count}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noContributors', 'No contributors yet')} />
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
{/* Task Stats */}
|
||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<ListTodo size={18} color={primary} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('analytics.tasksTitle', 'Popular Tasks')}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{hasTasks ? (
|
||||
<YStack space="$3">
|
||||
{tasks.map((task) => {
|
||||
const maxTaskCount = Math.max(...tasks.map(t => t.count), 1);
|
||||
const percent = (task.count / maxTaskCount) * 100;
|
||||
return (
|
||||
<YStack key={task.task_id} space="$1">
|
||||
<XStack justifyContent="space-between">
|
||||
<Text fontSize="$sm" color={textStrong} numberOfLines={1} flex={1}>
|
||||
{task.task_name}
|
||||
</Text>
|
||||
<Text fontSize="$xs" fontWeight="700" color={textStrong}>
|
||||
{task.count}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack height={6} backgroundColor="$gray4" borderRadius={3} overflow="hidden">
|
||||
<YStack
|
||||
height="100%"
|
||||
width={`${percent}%`}
|
||||
backgroundColor={primary}
|
||||
borderRadius={3}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noTasks', 'No task activity yet')} />
|
||||
)}
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
const { muted } = useAdminTheme();
|
||||
return (
|
||||
<YStack padding="$4" alignItems="center" justifyContent="center">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{message}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const ADMIN_ACTION_COLORS = {
|
||||
photobooth: '#FF8A8E',
|
||||
recap: ADMIN_COLORS.warning,
|
||||
packages: ADMIN_COLORS.primary,
|
||||
analytics: '#8b5cf6',
|
||||
};
|
||||
|
||||
export const ADMIN_GRADIENTS = {
|
||||
|
||||
@@ -33,6 +33,7 @@ const MobileEventLiveShowSettingsPage = React.lazy(() => import('./mobile/EventL
|
||||
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
|
||||
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
|
||||
const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'));
|
||||
const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage'));
|
||||
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
|
||||
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
||||
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
|
||||
@@ -203,6 +204,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'mobile/events/:slug/live-show', element: <RequireAdminAccess><MobileEventLiveShowQueuePage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/live-show/settings', element: <RequireAdminAccess><MobileEventLiveShowSettingsPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/analytics', element: <RequireAdminAccess><MobileEventAnalyticsPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
||||
{ path: 'mobile/events/:slug/photobooth', element: <RequireAdminAccess><MobileEventPhotoboothPage /></RequireAdminAccess> },
|
||||
|
||||
Reference in New Issue
Block a user