diff --git a/CHANGELOG.md b/CHANGELOG.md index feae0b6..d5a123c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Removed + +- **Realtime visitors detail page.** The page that showed individual active visitors and their page-by-page session journey has been removed. The live visitor count on your dashboard still works — it just no longer links to a separate page. + ### Added - **Rage click detection.** Pulse now detects when visitors rapidly click the same element 3 or more times — a strong signal of UI frustration. Rage clicks are tracked automatically (no setup required) and surfaced in the new Behavior tab with the element, page, click count, and number of affected sessions. diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 51a8a89..50d3072 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -451,9 +451,8 @@ export default function SiteDashboardPage() { {/* Realtime Indicator */} - +
diff --git a/app/sites/[id]/realtime/error.tsx b/app/sites/[id]/realtime/error.tsx deleted file mode 100644 index 77bb93a..0000000 --- a/app/sites/[id]/realtime/error.tsx +++ /dev/null @@ -1,13 +0,0 @@ -'use client' - -import ErrorDisplay from '@/components/ErrorDisplay' - -export default function RealtimeError({ reset }: { error: Error; reset: () => void }) { - return ( - - ) -} diff --git a/app/sites/[id]/realtime/layout.tsx b/app/sites/[id]/realtime/layout.tsx deleted file mode 100644 index 64b256b..0000000 --- a/app/sites/[id]/realtime/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Metadata } from 'next' - -export const metadata: Metadata = { - title: 'Realtime | Pulse', - description: 'See who is on your site right now.', - robots: { index: false, follow: false }, -} - -export default function RealtimeLayout({ - children, -}: { - children: React.ReactNode -}) { - return children -} diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx deleted file mode 100644 index 0ef2c04..0000000 --- a/app/sites/[id]/realtime/page.tsx +++ /dev/null @@ -1,234 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { useParams, useRouter } from 'next/navigation' -import { getSite, type Site } from '@/lib/api/sites' -import { getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime' -import { useRealtimeSSE } from '@/lib/hooks/useRealtimeSSE' -import { toast } from '@ciphera-net/ui' -import { getAuthErrorMessage } from '@ciphera-net/ui' -import { UserIcon } from '@ciphera-net/ui' -import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons' -import { motion, AnimatePresence } from 'framer-motion' - -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 } = useRealtimeSSE(siteId) - const [selectedVisitor, setSelectedVisitor] = useState(null) - const [sessionEvents, setSessionEvents] = useState([]) - const [loading, setLoading] = useState(true) - const [loadingEvents, setLoadingEvents] = useState(false) - - // Load site info - useEffect(() => { - const init = async () => { - try { - const siteData = await getSite(siteId) - setSite(siteData) - } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to load site') - } finally { - setLoading(false) - } - } - init() - }, [siteId]) - - // Auto-select the first visitor when the list populates and nothing is selected - useEffect(() => { - if (visitors.length > 0 && !selectedVisitor) { - handleSelectVisitor(visitors[0]) - } - }, [visitors]) // eslint-disable-line react-hooks/exhaustive-deps - - const handleSelectVisitor = async (visitor: Visitor) => { - setSelectedVisitor(visitor) - setLoadingEvents(true) - try { - const events = await getSessionDetails(siteId, visitor.session_id) - setSessionEvents(events || []) - } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to load session events') - } finally { - setLoadingEvents(false) - } - } - - useEffect(() => { - if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse` - }, [site?.domain]) - - const showSkeleton = useMinimumLoading(loading) - - if (showSkeleton) 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 -

-

- New visitors will appear here in real-time -

-
- ) : ( -
- - {visitors.map((visitor) => ( - handleSelectVisitor(visitor)} - className={`w-full text-left p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-inset ${ - selectedVisitor?.session_id === visitor.session_id ? 'bg-neutral-50 dark:bg-neutral-800/50 ring-1 ring-inset ring-neutral-200 dark:ring-neutral-700' : '' - }`} - > -
-
- {visitor.country ? `${getFlagEmoji(visitor.country)} ${visitor.city || 'Unknown City'}` : 'Unknown Location'} -
- - {formatTimeAgo(visitor.last_seen)} - -
-
- {visitor.current_path} -
-
- {visitor.device_type} - - {visitor.browser} - - {visitor.os} - - {visitor.pageviews} views - -
-
- ))} -
-
- )} -
-
- - {/* 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 7fda2a2..852e636 100644 --- a/components/dashboard/RealtimeVisitors.tsx +++ b/components/dashboard/RealtimeVisitors.tsx @@ -1,19 +1,11 @@ -'use client' - -import { useRouter } from 'next/navigation' - interface RealtimeVisitorsProps { count: number - siteId?: string } -export default function RealtimeVisitors({ count, siteId }: RealtimeVisitorsProps) { - const router = useRouter() - +export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) { return ( -
siteId && router.push(`/sites/${siteId}/realtime`)} - className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 ${siteId ? 'cursor-pointer hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors' : ''}`} +
diff --git a/components/dashboard/SiteNav.tsx b/components/dashboard/SiteNav.tsx index 39fcf4d..f767184 100644 --- a/components/dashboard/SiteNav.tsx +++ b/components/dashboard/SiteNav.tsx @@ -26,7 +26,7 @@ export default function SiteNav({ siteId }: SiteNavProps) { const isActive = (href: string) => { if (href === `/sites/${siteId}`) { - return pathname === href || pathname === `${href}/realtime` + return pathname === href } return pathname.startsWith(href) } diff --git a/components/skeletons.tsx b/components/skeletons.tsx index 89ee488..d92a0e756 100644 --- a/components/skeletons.tsx +++ b/components/skeletons.tsx @@ -166,78 +166,6 @@ export function DashboardSkeleton() { ) } -// ─── Realtime page skeleton ────────────────────────────────── - -export function RealtimeSkeleton() { - return ( -
-
- - -
-
- {/* Visitors list */} -
-
- -
-
- {Array.from({ length: 6 }).map((_, i) => ( -
-
- - -
- -
- - - -
-
- ))} -
-
- {/* Session details */} -
-
- -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
- - -
-
- ))} -
-
-
-
- ) -} - -// ─── Session events skeleton (for loading events panel) ────── - -export function SessionEventsSkeleton() { - return ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
- - -
-
- ))} -
- ) -} - // ─── Uptime page skeleton ──────────────────────────────────── export function UptimeSkeleton() { diff --git a/lib/api/realtime.ts b/lib/api/realtime.ts deleted file mode 100644 index 3bbc89d..0000000 --- a/lib/api/realtime.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 -} diff --git a/lib/hooks/useRealtimeSSE.ts b/lib/hooks/useRealtimeSSE.ts deleted file mode 100644 index 63e3b63..0000000 --- a/lib/hooks/useRealtimeSSE.ts +++ /dev/null @@ -1,53 +0,0 @@ -// * SSE hook for real-time visitor streaming. -// * Replaces 5-second polling with a persistent EventSource connection. -// * The backend broadcasts one DB query per site to all connected clients, -// * so 1,000 users on the same site share a single query instead of each -// * triggering their own. - -import { useEffect, useRef, useState, useCallback } from 'react' -import { API_URL } from '@/lib/api/client' -import type { Visitor } from '@/lib/api/realtime' - -interface UseRealtimeSSEReturn { - visitors: Visitor[] - connected: boolean -} - -export function useRealtimeSSE(siteId: string): UseRealtimeSSEReturn { - const [visitors, setVisitors] = useState([]) - const [connected, setConnected] = useState(false) - const esRef = useRef(null) - - // Stable callback so we don't recreate EventSource on every render - const handleMessage = useCallback((event: MessageEvent) => { - try { - const data = JSON.parse(event.data) - setVisitors(data.visitors || []) - } catch { - // Ignore malformed messages - } - }, []) - - useEffect(() => { - if (!siteId) return - - const url = `${API_URL}/api/v1/sites/${siteId}/realtime/stream` - const es = new EventSource(url, { withCredentials: true }) - esRef.current = es - - es.onopen = () => setConnected(true) - es.onmessage = handleMessage - es.onerror = () => { - setConnected(false) - // EventSource auto-reconnects with exponential backoff - } - - return () => { - es.close() - esRef.current = null - setConnected(false) - } - }, [siteId, handleMessage]) - - return { visitors, connected } -}