Files
fotospiel-app/resources/js/admin/mobile/EventAnalyticsPage.tsx
Codex Agent 9b1c5bf978
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
bd sync: 2026-01-12 16:57:37
2026-01-12 16:57:37 +01:00

258 lines
9.7 KiB
TypeScript

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';
export default function MobileEventAnalyticsPage() {
const { slug } = useParams<{ slug: string }>();
const { t, i18n } = useTranslation('management');
const navigate = useNavigate();
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="home">
<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/shop?feature=advanced_analytics'))}
/>
</MobileCard>
</MobileShell>
);
}
if (isLoading) {
return (
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
<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="home">
<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')}
activeTab="home"
onBack={() => navigate(-1)}
>
<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>
);
}