'use client' import { useCallback, useEffect, useRef, useState } from 'react' import Image from 'next/image' import { useParams, useSearchParams, useRouter } from 'next/navigation' import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, authenticatePublicDashboard, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { ApiError } from '@/lib/api/client' import { LoadingOverlay, Button } from '@ciphera-net/ui' import Chart from '@/components/dashboard/Chart' import TopPages from '@/components/dashboard/ContentStats' import TopReferrers from '@/components/dashboard/TopReferrers' import Locations from '@/components/dashboard/Locations' import TechSpecs from '@/components/dashboard/TechSpecs' import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui' import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import ExportModal from '@/components/dashboard/ExportModal' import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon' // 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) const [isAuthenticated, setIsAuthenticated] = useState(false) const [authLoading, setAuthLoading] = useState(false) // Captcha State const [captchaId, setCaptchaId] = useState('') const [captchaSolution, setCaptchaSolution] = useState('') const [captchaToken, setCaptchaToken] = useState('') // Date range state const [dateRange, setDateRange] = useState(getDateRange(30)) const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) const [isExportModalOpen, setIsExportModalOpen] = useState(false) const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour') const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day') // Previous period data const [prevStats, setPrevStats] = useState(undefined) const [prevDailyStats, setPrevDailyStats] = useState(undefined) const [lastUpdatedAt, setLastUpdatedAt] = useState(null) const [, setTick] = useState(0) const getPreviousDateRange = (start: string, end: string) => { const startDate = new Date(start) const endDate = new Date(end) const duration = endDate.getTime() - startDate.getTime() // * If duration is 0 (Today), set previous range to yesterday if (duration === 0) { const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000) const prevStart = prevEnd return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] } } const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000) const prevStart = new Date(prevEnd.getTime() - duration) return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] } } // * Tick every 1s so "Live · Xs ago" counts in real time useEffect(() => { const interval = setInterval(() => setTick((t) => t + 1), 1000) return () => clearInterval(interval) }, []) const loadRealtime = useCallback(async () => { try { const realtimeData = await getPublicRealtime(siteId) if (data) { setData({ ...data, realtime_visitors: realtimeData.visitors }) } } catch { // Silently fail for realtime updates } }, [siteId, data]) const loadDashboard = useCallback(async (silent = false) => { try { if (!silent) setLoading(true) const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval const [dashboardData, prevStatsData, prevDailyStatsData] = await Promise.all([ getPublicDashboard(siteId, dateRange.start, dateRange.end, 10, interval), (async () => { const prevRange = getPreviousDateRange(dateRange.start, dateRange.end) return getPublicStats(siteId, prevRange.start, prevRange.end) })(), (async () => { const prevRange = getPreviousDateRange(dateRange.start, dateRange.end) return getPublicDailyStats(siteId, prevRange.start, prevRange.end, interval) })() ]) setData(dashboardData) setPrevStats(prevStatsData) setPrevDailyStats(prevDailyStatsData) setLastUpdatedAt(Date.now()) setIsPasswordProtected(false) } catch (error: unknown) { const apiErr = error instanceof ApiError ? error : null if (apiErr?.status === 401 && (apiErr.data as Record)?.is_protected) { setIsPasswordProtected(true) } else if (apiErr?.status === 404) { toast.error('Site not found') } else if (!silent) { toast.error(getAuthErrorMessage(error) || 'Failed to load public dashboard') } } finally { if (!silent) setLoading(false) } }, [siteId, dateRange, todayInterval, multiDayInterval]) // * Auto-refresh interval: chart, KPIs, and realtime count update every 30 seconds useEffect(() => { if (data && !isPasswordProtected) { const interval = setInterval(() => { loadDashboard(true) loadRealtime() }, 30000) return () => clearInterval(interval) } }, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, loadDashboard, loadRealtime]) useEffect(() => { loadDashboard() }, [siteId, dateRange, todayInterval, multiDayInterval, loadDashboard]) const handlePasswordSubmit = async (e: React.FormEvent) => { e.preventDefault() setAuthLoading(true) try { await authenticatePublicDashboard(siteId, password, captchaToken, captchaId, captchaSolution) // Cookie is now set — load dashboard (cookie sent automatically) setIsAuthenticated(true) await loadDashboard() } catch (error: unknown) { const apiErr = error instanceof ApiError ? error : null if (apiErr?.status === 401) { const errData = apiErr.data as Record | undefined const errMsg = errData?.error as string | undefined toast.error(errMsg || 'Invalid password or captcha') } else { toast.error('Authentication failed') } // Reset captcha on failure setCaptchaId('') setCaptchaSolution('') setCaptchaToken('') } finally { setAuthLoading(false) } } const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected) const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { 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-white focus:ring-2 focus:ring-brand-orange focus:border-transparent" autoFocus />
{ setCaptchaId(id) setCaptchaSolution(solution) setCaptchaToken(token || '') }} apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL} action="share-access" />
) } 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, 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' }} unoptimized /> {site.domain}

{/* Realtime Indicator - Desktop */}
{realtime_visitors} current visitors