Files
pulse/app/share/[id]/page.tsx
Usman Baig b305b5345b refactor: remove performance insights (Web Vitals) feature entirely
Remove Performance tab, PerformanceStats component, settings toggle,
Web Vitals observers from tracking script, and all related API types
and SWR hooks. Duration tracking is preserved.
2026-03-14 22:47:33 +01:00

462 lines
18 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import Image from 'next/image'
import { useParams, useSearchParams, useRouter } from 'next/navigation'
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, 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/icons'
// 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<DashboardData | null>(null)
const [password, setPassword] = useState(passwordParam || '')
const [isPasswordProtected, setIsPasswordProtected] = 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<Stats | undefined>(undefined)
const [prevDailyStats, setPrevDailyStats] = useState<DailyStat[] | undefined>(undefined)
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(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 auth = {
password,
captcha: {
captcha_id: captchaId,
captcha_solution: captchaSolution,
captcha_token: captchaToken
}
}
const realtimeData = await getPublicRealtime(siteId, auth)
if (data) {
setData({
...data,
realtime_visitors: realtimeData.visitors
})
}
} catch (error) {
// Silently fail for realtime updates
}
}, [siteId, password, captchaId, captchaSolution, captchaToken, data])
const loadDashboard = useCallback(async (silent = false) => {
try {
if (!silent) setLoading(true)
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
const auth = {
password,
captcha: {
captcha_id: captchaId,
captcha_solution: captchaSolution,
captcha_token: captchaToken
}
}
const [dashboardData, prevStatsData, prevDailyStatsData] = await Promise.all([
getPublicDashboard(
siteId,
dateRange.start,
dateRange.end,
10,
interval,
password,
auth.captcha
),
(async () => {
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
return getPublicStats(siteId, prevRange.start, prevRange.end, auth)
})(),
(async () => {
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
return getPublicDailyStats(siteId, prevRange.start, prevRange.end, interval, auth)
})()
])
setData(dashboardData)
setPrevStats(prevStatsData)
setPrevDailyStats(prevDailyStatsData)
setLastUpdatedAt(Date.now())
setIsPasswordProtected(false)
// Reset captcha
setCaptchaId('')
setCaptchaSolution('')
setCaptchaToken('')
} catch (error: unknown) {
const apiErr = error instanceof ApiError ? error : null
if (apiErr?.status === 401 && (apiErr.data as Record<string, unknown>)?.is_protected) {
setIsPasswordProtected(true)
if (password) {
toast.error('Invalid password or captcha')
// Reset captcha on failure
setCaptchaId('')
setCaptchaSolution('')
setCaptchaToken('')
}
} 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, password, captchaId, captchaSolution, captchaToken])
// * 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, password, loadDashboard, loadRealtime])
useEffect(() => {
loadDashboard()
}, [siteId, dateRange, todayInterval, multiDayInterval, loadDashboard])
const handlePasswordSubmit = (e: React.FormEvent) => {
e.preventDefault()
loadDashboard()
}
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
const fadeClass = useSkeletonFade(showSkeleton)
if (showSkeleton) {
return <DashboardSkeleton />
}
if (isPasswordProtected && !data) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 shadow-lg transition-shadow duration-300">
<div className="text-center mb-6">
<div className="w-12 h-12 bg-brand-orange/10 rounded-xl flex items-center justify-center mx-auto mb-4 text-brand-orange">
<ZapIcon className="w-6 h-6" />
</div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Protected Dashboard
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
This dashboard is password protected. Please enter the password to view stats.
</p>
</div>
<form onSubmit={handlePasswordSubmit}>
<div className="mb-4">
<input
type="password"
value={password}
onChange={(e) => 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
/>
</div>
<div className="mb-4">
<Captcha
onVerify={(id, solution, token) => {
setCaptchaId(id)
setCaptchaSolution(solution)
setCaptchaToken(token || '')
}}
apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL}
action="share-access"
/>
</div>
<Button
type="submit"
variant="primary"
className="w-full"
>
Access Dashboard
</Button>
</form>
</div>
</div>
)
}
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 (
<div className={`min-h-screen ${fadeClass}`}>
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-4 mb-2">
<div>
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 rounded-full bg-brand-orange animate-pulse" />
<span className="text-sm font-medium text-brand-orange uppercase tracking-wider">Public Dashboard</span>
</div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
<Image
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt={site.name}
width={32}
height={32}
className="w-8 h-8 rounded-lg"
onError={(e) => {
(e.target as HTMLImageElement).src = '/globe.svg'
}}
unoptimized
/>
{site.domain}
</h1>
</div>
{/* Realtime Indicator - Desktop */}
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 self-end mb-1">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span className="text-sm font-medium text-green-700 dark:text-green-400">
{realtime_visitors} current visitors
</span>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setIsExportModalOpen(true)}
className="hidden md:flex items-center gap-2 px-3 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
<DownloadIcon className="w-4 h-4" />
<span>Export</span>
</button>
<Select
value={
dateRange.start === new Date().toISOString().split('T')[0] && dateRange.end === new Date().toISOString().split('T')[0]
? 'today'
: dateRange.start === getDateRange(7).start
? '7'
: dateRange.start === getDateRange(30).start
? '30'
: 'custom'
}
onChange={(value) => {
if (value === '7') setDateRange(getDateRange(7))
else if (value === '30') setDateRange(getDateRange(30))
else if (value === 'today') {
const today = new Date().toISOString().split('T')[0]
setDateRange({ start: today, end: today })
}
else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'custom', label: 'Custom' },
]}
/>
{/* Powered by Ciphera Badge */}
<a
href="https://ciphera.net"
target="_blank"
rel="noopener noreferrer"
className="hidden md:flex items-center gap-2 px-3 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
<ZapIcon className="w-4 h-4" />
<span>Powered by Ciphera</span>
</a>
</div>
</div>
{/* Realtime Indicator - Mobile */}
<div className="md:hidden flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 w-fit mt-4">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span className="text-sm font-medium text-green-700 dark:text-green-400">
{realtime_visitors} current visitors
</span>
</div>
</div>
{/* Chart */}
<div className="mb-8">
<Chart
data={safeDailyStats}
prevData={prevDailyStats}
stats={safeStats}
prevStats={prevStats}
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
dateRange={dateRange}
todayInterval={todayInterval}
setTodayInterval={setTodayInterval}
multiDayInterval={multiDayInterval}
setMultiDayInterval={setMultiDayInterval}
lastUpdatedAt={lastUpdatedAt}
/>
</div>
{/* Details Grid */}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<TopPages
topPages={safeTopPages}
entryPages={safeEntryPages}
exitPages={safeExitPages}
domain={site.domain}
collectPagePaths={site.collect_page_paths ?? true}
siteId={siteId}
dateRange={dateRange}
/>
<TopReferrers
referrers={safeTopReferrers}
collectReferrers={site.collect_referrers ?? true}
siteId={siteId}
dateRange={dateRange}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<Locations
countries={safeCountries}
cities={safeCities}
regions={safeRegions}
geoDataLevel={site.collect_geo_data || 'full'}
siteId={siteId}
dateRange={dateRange}
/>
<TechSpecs
browsers={safeBrowsers}
os={safeOS}
devices={safeDevices}
screenResolutions={safeScreenResolutions}
collectDeviceInfo={site.collect_device_info ?? true}
collectScreenResolution={site.collect_screen_resolution ?? true}
siteId={siteId}
dateRange={dateRange}
/>
</div>
</div>
<DatePickerModal
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
initialRange={dateRange}
onApply={(range) => {
setDateRange(range)
setIsDatePickerOpen(false)
}}
/>
{data && (
<ExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
data={data.daily_stats || []}
stats={data.stats}
topPages={data.top_pages}
topReferrers={data.top_referrers}
/>
)}
</div>
)
}