diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 0bf773d..2937749 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -163,7 +163,7 @@ export default function SiteDashboardPage() {
- +
diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx new file mode 100644 index 0000000..dcdd2e6 --- /dev/null +++ b/app/sites/[id]/realtime/page.tsx @@ -0,0 +1,235 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { getSite, type Site } from '@/lib/api/sites' +import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime' +import { toast } from 'sonner' +import LoadingOverlay from '@/components/LoadingOverlay' + +function formatTimeAgo(dateString: string) { + const date = new Date(dateString) + const now = new Date() + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) + + if (diffInSeconds < 60) return 'just now' + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago` + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago` + return `${Math.floor(diffInSeconds / 86400)}d ago` +} + +export default function RealtimePage() { + const params = useParams() + const router = useRouter() + const siteId = params.id as string + + const [site, setSite] = useState(null) + const [visitors, setVisitors] = useState([]) + const [selectedVisitor, setSelectedVisitor] = useState(null) + const [sessionEvents, setSessionEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [loadingEvents, setLoadingEvents] = useState(false) + + // Load site info and initial visitors + useEffect(() => { + const init = async () => { + try { + const [siteData, visitorsData] = await Promise.all([ + getSite(siteId), + getRealtimeVisitors(siteId) + ]) + setSite(siteData) + setVisitors(visitorsData || []) + // Select first visitor if available + if (visitorsData && visitorsData.length > 0) { + handleSelectVisitor(visitorsData[0]) + } + } catch (error: any) { + toast.error('Failed to load data') + } finally { + setLoading(false) + } + } + init() + }, [siteId]) + + // Poll for updates + useEffect(() => { + const interval = setInterval(async () => { + try { + const data = await getRealtimeVisitors(siteId) + setVisitors(data || []) + + // Update selected visitor reference if they are still in the list + if (selectedVisitor) { + const updatedVisitor = data?.find(v => v.session_id === selectedVisitor.session_id) + if (updatedVisitor) { + // Don't overwrite the selectedVisitor state directly to avoid flickering details + // But we could update "last seen" indicators if we wanted + } + } + } catch (e) { + // Silent fail + } + }, 5000) + return () => clearInterval(interval) + }, [siteId, selectedVisitor]) + + const handleSelectVisitor = async (visitor: Visitor) => { + setSelectedVisitor(visitor) + setLoadingEvents(true) + try { + const events = await getSessionDetails(siteId, visitor.session_id) + setSessionEvents(events || []) + } catch (error) { + toast.error('Failed to load session details') + } finally { + setLoadingEvents(false) + } + } + + if (loading) return + if (!site) return
Site not found
+ + return ( +
+
+
+
+ +
+

+ Realtime Visitors + + + + + + {visitors.length} active now + +

+
+
+ +
+ {/* Visitors List */} +
+
+

Active Sessions

+
+
+ {visitors.length === 0 ? ( +
+ No active visitors right now. +
+ ) : ( +
+ {visitors.map((visitor) => ( + + ))} +
+ )} +
+
+ + {/* Session Details */} +
+
+

+ {selectedVisitor ? 'Session Journey' : 'Select a visitor'} +

+ {selectedVisitor && ( + + ID: {selectedVisitor.session_id.substring(0, 8)}... + + )} +
+ +
+ {!selectedVisitor ? ( +
+ Select a visitor on the left to see their activity. +
+ ) : loadingEvents ? ( +
+
+
+ ) : ( +
+ {sessionEvents.map((event, idx) => ( +
+ + +
+
+ + Visited {event.path} + + + {new Date(event.timestamp).toLocaleTimeString()} + +
+ + {event.referrer && ( +
+ Referrer: {event.referrer} +
+ )} +
+
+ ))} +
+ +
+ Session started {formatTimeAgo(sessionEvents[sessionEvents.length - 1]?.timestamp || new Date().toISOString())} +
+
+
+ )} +
+
+
+
+ ) +} + +function getFlagEmoji(countryCode: string) { + if (!countryCode || countryCode.length !== 2) return '🌍' + const codePoints = countryCode + .toUpperCase() + .split('') + .map(char => 127397 + char.charCodeAt(0)) + return String.fromCodePoint(...codePoints) +} diff --git a/components/dashboard/RealtimeVisitors.tsx b/components/dashboard/RealtimeVisitors.tsx index f16c757..18dc588 100644 --- a/components/dashboard/RealtimeVisitors.tsx +++ b/components/dashboard/RealtimeVisitors.tsx @@ -1,12 +1,20 @@ 'use client' +import { useRouter } from 'next/navigation' + interface RealtimeVisitorsProps { count: number + siteId?: string } -export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) { +export default function RealtimeVisitors({ count, siteId }: RealtimeVisitorsProps) { + const router = useRouter() + return ( -
+
siteId && router.push(`/sites/${siteId}/realtime`)} + className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 ${siteId ? 'cursor-pointer hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors' : ''}`} + >
Real-time Visitors diff --git a/lib/api/realtime.ts b/lib/api/realtime.ts new file mode 100644 index 0000000..3bbc89d --- /dev/null +++ b/lib/api/realtime.ts @@ -0,0 +1,42 @@ +import apiRequest from './client' + +export interface Visitor { + session_id: string + first_seen: string + last_seen: string + pageviews: number + current_path: string + browser: string + os: string + device_type: string + country: string + city: string +} + +export interface SessionEvent { + id: string + site_id: string + session_id: string + path: string + referrer: string | null + user_agent: string + country: string | null + city: string | null + region: string | null + device_type: string + screen_resolution: string | null + browser: string | null + os: string | null + timestamp: string + created_at: string +} + +export async function getRealtimeVisitors(siteId: string): Promise { + const data = await apiRequest<{ visitors: Visitor[] }>(`/sites/${siteId}/realtime/visitors`) + return data.visitors +} + +export async function getSessionDetails(siteId: string, sessionId: string): Promise { + const data = await apiRequest<{ events: SessionEvent[] }>(`/sites/${siteId}/sessions/${sessionId}`) + return data.events +}