From 8fe2500d19781e624be010f44f71e34815d978bf Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 18 Jan 2026 18:39:26 +0100 Subject: [PATCH] feat(analytics): add site settings and public dashboard --- app/share/[id]/page.tsx | 321 +++++++++++++++++++++++++++++++ app/sites/[id]/settings/page.tsx | 274 ++++++++++++++++++++++---- lib/api/sites.ts | 13 ++ lib/api/stats.ts | 10 + 4 files changed, 583 insertions(+), 35 deletions(-) create mode 100644 app/share/[id]/page.tsx diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx new file mode 100644 index 0000000..1eeb46e --- /dev/null +++ b/app/share/[id]/page.tsx @@ -0,0 +1,321 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useSearchParams, useRouter } from 'next/navigation' +import { getPublicDashboard, type DashboardData } from '@/lib/api/stats' +import { toast } from 'sonner' +import LoadingOverlay from '@/components/LoadingOverlay' +import StatsCard from '@/components/dashboard/StatsCard' +import Chart from '@/components/dashboard/Chart' +import TopPages from '@/components/dashboard/TopPages' +import TopReferrers from '@/components/dashboard/TopReferrers' +import Locations from '@/components/dashboard/Locations' +import TechSpecs from '@/components/dashboard/TechSpecs' +import PerformanceStats from '@/components/dashboard/PerformanceStats' +import Select from '@/components/Select' +import { CalendarIcon } from '@heroicons/react/outline' +import { LightningBoltIcon } from '@heroicons/react/solid' +import DatePickerModal from '@/components/dashboard/DatePickerModal' + +// Helper to get date ranges +const getDateRange = (days: number) => { + const end = new Date() + const start = new Date() + start.setDate(end.getDate() - (days - 1)) // -1 because today counts as 1 day + return { + start: start.toISOString().split('T')[0], + end: end.toISOString().split('T')[0] + } +} + +export default function PublicDashboardPage() { + const params = useParams() + const searchParams = useSearchParams() + const router = useRouter() + const siteId = params.id as string + const passwordParam = searchParams.get('password') || undefined + + const [loading, setLoading] = useState(true) + const [data, setData] = useState(null) + const [password, setPassword] = useState(passwordParam || '') + const [isPasswordProtected, setIsPasswordProtected] = useState(false) + + // Date range state + const [dateRange, setDateRange] = useState(getDateRange(30)) + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) + + // Auto-refresh interval (for realtime) + useEffect(() => { + const interval = setInterval(() => { + // Only refresh realtime count if we have data + if (data && !isPasswordProtected) { + loadDashboard(true) + } + }, 10000) // 10 seconds + + return () => clearInterval(interval) + }, [data, isPasswordProtected, dateRange, password]) + + useEffect(() => { + loadDashboard() + }, [siteId, dateRange]) + + const loadDashboard = async (silent = false) => { + try { + if (!silent) setLoading(true) + + const dashboardData = await getPublicDashboard( + siteId, + dateRange.start, + dateRange.end, + 10, + dateRange.start === dateRange.end ? 'hour' : 'day', + password + ) + + setData(dashboardData) + setIsPasswordProtected(false) + } catch (error: any) { + if (error.response?.status === 401 && error.response?.data?.is_protected) { + setIsPasswordProtected(true) + } else if (error.response?.status === 404) { + toast.error('Site not found') + } else if (!silent) { + toast.error('Failed to load dashboard: ' + (error.message || 'Unknown error')) + } + } finally { + if (!silent) setLoading(false) + } + } + + const handlePasswordSubmit = (e: React.FormEvent) => { + e.preventDefault() + loadDashboard() + } + + if (loading && !data && !isPasswordProtected) { + return + } + + if (isPasswordProtected && !data) { + return ( +
+
+
+
+ +
+

+ Protected Dashboard +

+

+ This dashboard is password protected. Please enter the password to view stats. +

+
+ +
+
+ setPassword(e.target.value)} + placeholder="Enter password" + className="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange focus:border-transparent" + autoFocus + /> +
+ +
+
+
+ ) + } + + if (!data) return null + + const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, performance, realtime_visitors } = data + + // Provide defaults for potentially undefined data + const safeDailyStats = daily_stats || [] + const safeStats = stats || { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 } + const safeTopPages = top_pages || [] + const safeEntryPages = entry_pages || [] + const safeExitPages = exit_pages || [] + const safeTopReferrers = top_referrers || [] + const safeCountries = countries || [] + const safeCities = cities || [] + const safeRegions = regions || [] + const safeBrowsers = browsers || [] + const safeOS = os || [] + const safeDevices = devices || [] + const safeScreenResolutions = screen_resolutions || [] + + return ( +
+
+ {/* Header */} +
+
+
+
+ Public Dashboard +
+

+ {site.name} { + (e.target as HTMLImageElement).src = '/globe.svg' + }} + /> + {site.domain} +

+
+ +
+ setFormData({ ...formData, name: e.target.value })} - className="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange focus:border-transparent" - /> +
+
+ + setFormData({ ...formData, name: e.target.value })} + className="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange focus:border-transparent" + /> +
+ +
+ + +
+ +
+ + +

+ Domain cannot be changed after creation +

+
+
-
- - -

- Domain cannot be changed after creation -

+ {/* Data Filters */} +
+

+ Data Filters +

+ +
+ +

+ Enter paths to exclude from tracking (one per line). Supports simple matching (e.g., /admin/*). +

+