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:
@@ -40,6 +40,7 @@
|
|||||||
{"id":"fotospiel-app-6zc","title":"Live Show: Admin app settings \u0026 effect presets","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:27.038815978+01:00","created_by":"soeren","updated_at":"2026-01-05T15:02:42.035082497+01:00","closed_at":"2026-01-05T15:02:42.035082497+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-6zc","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:50.048055484+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-6zc","title":"Live Show: Admin app settings \u0026 effect presets","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:27.038815978+01:00","created_by":"soeren","updated_at":"2026-01-05T15:02:42.035082497+01:00","closed_at":"2026-01-05T15:02:42.035082497+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-6zc","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:50.048055484+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-7bu","title":"Paddle migration: extend config/env handling for Paddle keys/webhook secrets","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:27.242854801+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:32.890355888+01:00","closed_at":"2026-01-01T15:57:32.890355888+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-7bu","title":"Paddle migration: extend config/env handling for Paddle keys/webhook secrets","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:27.242854801+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:32.890355888+01:00","closed_at":"2026-01-01T15:57:32.890355888+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-7u1","title":"Paddle catalog sync: PaddlePackagePull job","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:47.468892178+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:53.126602817+01:00","closed_at":"2026-01-01T16:00:53.126602817+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-7u1","title":"Paddle catalog sync: PaddlePackagePull job","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:47.468892178+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:53.126602817+01:00","closed_at":"2026-01-01T16:00:53.126602817+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
|
||||||
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-99o","title":"Fix German welcome phrasing with article-safe app_name","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T11:50:17.410390085+01:00","created_by":"soeren","updated_at":"2026-01-04T12:19:55.741616753+01:00","closed_at":"2026-01-04T12:19:55.741616753+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-99o","title":"Fix German welcome phrasing with article-safe app_name","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T11:50:17.410390085+01:00","created_by":"soeren","updated_at":"2026-01-04T12:19:55.741616753+01:00","closed_at":"2026-01-04T12:19:55.741616753+01:00","close_reason":"Closed"}
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
{"id":"fotospiel-app-xg5","title":"Live Show: Admin app moderation queue UI","acceptance_criteria":"- Dedicated Live Show moderation API endpoints exist for list + approve/reject/clear\\n- Admin mobile UI exposes Live Show queue with status filter and actions\\n- PhotoResource includes live_* fields for admin UI\\n- Feature tests cover list + approve/reject/clear workflows","notes":"Added dedicated Live Show moderation API (tenant admin): /events/{slug}/live-show/photos + approve/reject/clear actions. Added LiveShowPhotoController + FormRequests. PhotoResource now exposes live_* fields. Admin app: new Live Show queue page, route, and Event detail shortcut tile. Admin API updated with Live Show functions + types. Added translations (EN/DE) for Live Show queue UI. Tests: tests/Feature/LiveShowPhotoControllerTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T14:03:41.410176482+01:00","closed_at":"2026-01-05T14:03:41.410176482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-xg5","title":"Live Show: Admin app moderation queue UI","acceptance_criteria":"- Dedicated Live Show moderation API endpoints exist for list + approve/reject/clear\\n- Admin mobile UI exposes Live Show queue with status filter and actions\\n- PhotoResource includes live_* fields for admin UI\\n- Feature tests cover list + approve/reject/clear workflows","notes":"Added dedicated Live Show moderation API (tenant admin): /events/{slug}/live-show/photos + approve/reject/clear actions. Added LiveShowPhotoController + FormRequests. PhotoResource now exposes live_* fields. Admin app: new Live Show queue page, route, and Event detail shortcut tile. Admin API updated with Live Show functions + types. Added translations (EN/DE) for Live Show queue UI. Tests: tests/Feature/LiveShowPhotoControllerTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T14:03:41.410176482+01:00","closed_at":"2026-01-05T14:03:41.410176482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"}
|
||||||
|
{"id":"fotospiel-app-yii","title":"Implement 'Upgrade to Premium' flow for Analytics Upsell","description":"The Analytics page currently has an upsell screen for non-premium users. The 'Upgrade to Premium' button redirects to the billing page, but the actual upgrade/purchase flow needs to be fully implemented and verified to allow users to unlock the feature.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T16:13:55.446495378+01:00","created_by":"soeren","updated_at":"2026-01-06T16:13:55.446495378+01:00"}
|
||||||
{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"}
|
{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"}
|
||||||
{"id":"fotospiel-app-z5g","title":"Tenant admin onboarding: PWA/Capacitor/TWA packaging prep","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:46.126417696+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:46.126417696+01:00"}
|
{"id":"fotospiel-app-z5g","title":"Tenant admin onboarding: PWA/Capacitor/TWA packaging prep","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:46.126417696+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:46.126417696+01:00"}
|
||||||
{"id":"fotospiel-app-zli","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:03.625388684+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:09.286391766+01:00","closed_at":"2026-01-01T15:55:09.286391766+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-zli","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:03.625388684+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:09.286391766+01:00","closed_at":"2026-01-01T15:55:09.286391766+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fotospiel-app-2yn
|
fotospiel-app-83q
|
||||||
|
|||||||
46
app/Http/Controllers/Api/Tenant/EventAnalyticsController.php
Normal file
46
app/Http/Controllers/Api/Tenant/EventAnalyticsController.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Services\Analytics\EventAnalyticsService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class EventAnalyticsController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EventAnalyticsService $analyticsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(Request $request, Event $event): JsonResponse
|
||||||
|
{
|
||||||
|
// Check if package has advanced_analytics feature
|
||||||
|
$packageFeatures = $event->eventPackage?->package?->features ?? [];
|
||||||
|
// Handle array or JSON string features
|
||||||
|
if (is_string($packageFeatures)) {
|
||||||
|
$packageFeatures = json_decode($packageFeatures, true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasAccess = in_array('advanced_analytics', $packageFeatures, true);
|
||||||
|
|
||||||
|
if (!$hasAccess) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'This feature is only available in the Premium package.',
|
||||||
|
'code' => 'feature_locked'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeline = $this->analyticsService->getTimeline($event);
|
||||||
|
$contributors = $this->analyticsService->getTopContributors($event);
|
||||||
|
$tasks = $this->analyticsService->getTaskStats($event);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'timeline' => $timeline,
|
||||||
|
'contributors' => $contributors,
|
||||||
|
'tasks' => $tasks,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Services/Analytics/EventAnalyticsService.php
Normal file
73
app/Services/Analytics/EventAnalyticsService.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Analytics;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class EventAnalyticsService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the activity timeline (uploads per hour).
|
||||||
|
*/
|
||||||
|
public function getTimeline(Event $event): array
|
||||||
|
{
|
||||||
|
// Group by hour of created_at
|
||||||
|
// Adjust for timezone if necessary, but for now we'll use UTC or server time
|
||||||
|
// Ideally we should use the event's timezone if stored, or client's.
|
||||||
|
// We'll return data in ISO format buckets.
|
||||||
|
|
||||||
|
$stats = $event->photos()
|
||||||
|
->selectRaw('DATE_FORMAT(created_at, "%Y-%m-%d %H:00:00") as hour, count(*) as count')
|
||||||
|
->groupBy('hour')
|
||||||
|
->orderBy('hour')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $stats->map(fn ($item) => [
|
||||||
|
'timestamp' => $item->hour,
|
||||||
|
'count' => (int) $item->count,
|
||||||
|
])->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top contributors (users with most uploads).
|
||||||
|
*/
|
||||||
|
public function getTopContributors(Event $event, int $limit = 5): array
|
||||||
|
{
|
||||||
|
$stats = $event->photos()
|
||||||
|
->select('guest_name', DB::raw('count(*) as count'), DB::raw('sum(likes_count) as likes'))
|
||||||
|
->whereNotNull('guest_name')
|
||||||
|
->groupBy('guest_name')
|
||||||
|
->orderByDesc('count')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $stats->map(fn ($item) => [
|
||||||
|
'name' => $item->guest_name,
|
||||||
|
'count' => (int) $item->count,
|
||||||
|
'likes' => (int) $item->likes,
|
||||||
|
])->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get task completion stats.
|
||||||
|
*/
|
||||||
|
public function getTaskStats(Event $event, int $limit = 5): array
|
||||||
|
{
|
||||||
|
$stats = $event->photos()
|
||||||
|
->whereNotNull('task_id')
|
||||||
|
->select('task_id', DB::raw('count(*) as count'))
|
||||||
|
->groupBy('task_id')
|
||||||
|
->with('task:id,name,name_translations') // Eager load task name
|
||||||
|
->orderByDesc('count')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $stats->map(fn ($item) => [
|
||||||
|
'task_id' => $item->task_id,
|
||||||
|
'task_name' => $item->task ? $item->task->getNameForLocale() : 'Unknown Task', // Assuming getNameForLocale exists or similar
|
||||||
|
'count' => (int) $item->count,
|
||||||
|
])->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2856,6 +2856,35 @@ export async function removeEventMember(eventIdentifier: number | string, member
|
|||||||
throw new Error('Failed to remove 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> = {
|
type CacheEntry<T> = {
|
||||||
value?: T;
|
value?: T;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } 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, 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 { 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';
|
||||||
@@ -462,6 +462,7 @@ export default function MobileDashboardPage() {
|
|||||||
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
|
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
|
||||||
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
|
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
|
||||||
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))}
|
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))}
|
||||||
|
onAnalytics={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/analytics`))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
@@ -1112,12 +1113,14 @@ function SecondaryGrid({
|
|||||||
onPrint,
|
onPrint,
|
||||||
onInvites,
|
onInvites,
|
||||||
onSettings,
|
onSettings,
|
||||||
|
onAnalytics,
|
||||||
}: {
|
}: {
|
||||||
event: TenantEvent | null;
|
event: TenantEvent | null;
|
||||||
onGuests: () => void;
|
onGuests: () => void;
|
||||||
onPrint: () => void;
|
onPrint: () => void;
|
||||||
onInvites: () => void;
|
onInvites: () => void;
|
||||||
onSettings: () => void;
|
onSettings: () => void;
|
||||||
|
onAnalytics: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
||||||
@@ -1130,6 +1133,12 @@ function SecondaryGrid({
|
|||||||
color: ADMIN_ACTION_COLORS.guests,
|
color: ADMIN_ACTION_COLORS.guests,
|
||||||
action: onGuests,
|
action: onGuests,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: TrendingUp,
|
||||||
|
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
|
||||||
|
color: ADMIN_ACTION_COLORS.analytics,
|
||||||
|
action: onAnalytics,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: QrCode,
|
icon: QrCode,
|
||||||
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
|
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',
|
photobooth: '#FF8A8E',
|
||||||
recap: ADMIN_COLORS.warning,
|
recap: ADMIN_COLORS.warning,
|
||||||
packages: ADMIN_COLORS.primary,
|
packages: ADMIN_COLORS.primary,
|
||||||
|
analytics: '#8b5cf6',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ADMIN_GRADIENTS = {
|
export const ADMIN_GRADIENTS = {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const MobileEventLiveShowSettingsPage = React.lazy(() => import('./mobile/EventL
|
|||||||
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
|
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
|
||||||
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
|
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
|
||||||
const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'));
|
const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'));
|
||||||
|
const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage'));
|
||||||
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
|
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
|
||||||
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
||||||
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
|
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', element: <RequireAdminAccess><MobileEventLiveShowQueuePage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/live-show/settings', element: <RequireAdminAccess><MobileEventLiveShowSettingsPage /></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/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/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
||||||
{ path: 'mobile/events/:slug/photobooth', element: <RequireAdminAccess><MobileEventPhotoboothPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/photobooth', element: <RequireAdminAccess><MobileEventPhotoboothPage /></RequireAdminAccess> },
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::prefix('events/{event:slug}')->scopeBindings()->group(function () {
|
Route::prefix('events/{event:slug}')->scopeBindings()->group(function () {
|
||||||
Route::middleware('tenant.admin')->group(function () {
|
Route::middleware('tenant.admin')->group(function () {
|
||||||
Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats');
|
Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats');
|
||||||
|
Route::get('analytics', [\App\Http\Controllers\Api\Tenant\EventAnalyticsController::class, 'show'])->name('tenant.events.analytics');
|
||||||
Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle');
|
Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle');
|
||||||
Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites');
|
Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites');
|
||||||
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit');
|
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit');
|
||||||
|
|||||||
Reference in New Issue
Block a user