Analytics overhaul: privacy fixes, All Time range, readability improvements; v2.0.29
Publish Container / docker (push) Successful in 14m17s
Publish Container / docker (push) Successful in 14m17s
- Remove raw IP storage; store only SHA-256 hash prefix in visitor records - Non-blocking geo lookup: recordVisitor now synchronous, geo resolved in background - UTM capture race condition fixed via sessionStorage pre-consent capture - Add consent + bot checks to play endpoint (was missing) - Add rate limiter to pageview endpoint (60 req/min per IP) - Add 'All time' range option alongside 7d/30d/90d with trendData aggregation - AnalyticsPanel: tabbed layout (Traffic/Engagement/System), dynamic chart titles, range buttons with active state, visitor log uses ipHash instead of raw IP, trendData drives main chart with weekly bucketing for all-time view - Re-add prop-types dependency (required by react-simple-maps bundler) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
da5c1c5078
commit
4002f1ee93
6 files changed
+333
-231
No files matched your search
+4
-4
@@ -546,7 +546,7 @@ export interface AdminStats {
|
||||
recentVisits: Array<{
|
||||
at: string
|
||||
visitorId: string
|
||||
ip: string
|
||||
ipHash?: string
|
||||
path: string
|
||||
referrer?: string
|
||||
device?: string
|
||||
@@ -1531,7 +1531,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [navOpenGroups, setNavOpenGroups] = useState<Set<string>>(new Set())
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d'>('30d')
|
||||
const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d')
|
||||
const [maintenanceMsg, setMaintenanceMsg] = useState('')
|
||||
const [opsMsg, setOpsMsg] = useState('')
|
||||
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
|
||||
@@ -1883,7 +1883,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
})
|
||||
}, [selectedBackup])
|
||||
|
||||
async function reloadStats(range?: '7d' | '30d' | '90d') {
|
||||
async function reloadStats(range?: '7d' | '30d' | '90d' | 'all') {
|
||||
const r = await fetch(`/api/admin-stats?range=${range ?? statsRange}`)
|
||||
if (!r.ok) throw new Error('Could not refresh stats')
|
||||
const data = await r.json()
|
||||
@@ -5592,7 +5592,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
stats={stats}
|
||||
statsStatus={statsStatus}
|
||||
statsRange={statsRange}
|
||||
onRangeChange={async (range) => {
|
||||
onRangeChange={async (range: '7d' | '30d' | '90d' | 'all') => {
|
||||
setStatsRange(range)
|
||||
setStatsStatus('loading')
|
||||
try { await reloadStats(range) } catch { setStatsStatus('error') }
|
||||
|
||||
+30
-14
@@ -2,11 +2,28 @@ import { useEffect, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
const CONSENT_KEY = 'vbn_analytics_consent_choice'
|
||||
const UTM_PENDING_KEY = 'vbn_pending_utm'
|
||||
|
||||
function hasConsent(): boolean {
|
||||
return localStorage.getItem(CONSENT_KEY) === 'accepted'
|
||||
}
|
||||
|
||||
// Capture UTM params immediately on module load, before consent is given.
|
||||
// They're held in sessionStorage so they survive a consent-banner accept.
|
||||
;(function captureUtmEarly() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const utm: Record<string, string> = {}
|
||||
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
|
||||
const val = params.get(key)
|
||||
if (val) utm[key] = val
|
||||
}
|
||||
if (Object.keys(utm).length > 0) {
|
||||
sessionStorage.setItem(UTM_PENDING_KEY, JSON.stringify({ path: window.location.pathname, ...utm }))
|
||||
}
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
})()
|
||||
|
||||
export function sendEvent(type: string, data: Record<string, unknown>): void {
|
||||
if (!hasConsent()) return
|
||||
fetch('/api/analytics/event', {
|
||||
@@ -69,22 +86,21 @@ export function useTimeOnPage() {
|
||||
}, [location.pathname])
|
||||
}
|
||||
|
||||
// --- UTM capture (runs once per page load) ---
|
||||
// --- UTM capture ---
|
||||
// UTM params are captured into sessionStorage before consent (see top of file).
|
||||
// This hook drains sessionStorage and sends the event once consent is present.
|
||||
// Running without a dep array so it retries after the consent banner triggers a re-render.
|
||||
export function useUTMCapture() {
|
||||
const captured = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (captured.current || !hasConsent()) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const utm: Record<string, string> = {}
|
||||
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
|
||||
const val = params.get(key)
|
||||
if (val) utm[key] = val
|
||||
}
|
||||
if (Object.keys(utm).length === 0) return
|
||||
captured.current = true
|
||||
sendEvent('utm', { path: window.location.pathname, ...utm })
|
||||
}, [])
|
||||
if (!hasConsent()) return
|
||||
try {
|
||||
const raw = sessionStorage.getItem(UTM_PENDING_KEY)
|
||||
if (!raw) return
|
||||
const utm = JSON.parse(raw) as Record<string, string>
|
||||
sessionStorage.removeItem(UTM_PENDING_KEY)
|
||||
sendEvent('utm', utm)
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
}
|
||||
|
||||
// --- All link clicks (internal + external) ---
|
||||
|
||||
+168
-167
@@ -27,11 +27,13 @@ ChartJS.register(
|
||||
ArcElement,
|
||||
)
|
||||
|
||||
type StatsRange = '7d' | '30d' | '90d' | 'all'
|
||||
|
||||
interface Props {
|
||||
stats: AdminStats | null
|
||||
statsStatus: 'loading' | 'error' | 'ready'
|
||||
statsRange?: '7d' | '30d' | '90d'
|
||||
onRangeChange?: (range: '7d' | '30d' | '90d') => void
|
||||
statsRange?: StatsRange
|
||||
onRangeChange?: (range: StatsRange) => void
|
||||
opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null
|
||||
formatDate: (date: string | null) => string
|
||||
maskIp: (ip: string) => string
|
||||
@@ -53,6 +55,37 @@ interface Props {
|
||||
onRefreshStatus: () => void
|
||||
}
|
||||
|
||||
const RANGE_LABELS: Record<StatsRange, string> = {
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
'90d': 'Last 90 days',
|
||||
'all': 'All time',
|
||||
}
|
||||
|
||||
function fmtSec(s: number) {
|
||||
if (s >= 3600) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
||||
if (s >= 60) return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
// Aggregate daily data into weekly buckets when there are many points (all-time)
|
||||
function aggregateTrend(data: Array<{ day: string; real: number; bot: number }>) {
|
||||
if (data.length <= 60) return data
|
||||
// Group by ISO week (YYYY-Www) — bucket by ~7 days
|
||||
const buckets: Record<string, { day: string; real: number; bot: number }> = {}
|
||||
for (const d of data) {
|
||||
const date = new Date(d.day)
|
||||
// Bucket key: year + week number
|
||||
const startOfYear = new Date(date.getFullYear(), 0, 1)
|
||||
const week = Math.ceil(((date.getTime() - startOfYear.getTime()) / 86400000 + startOfYear.getDay() + 1) / 7)
|
||||
const key = `${date.getFullYear()}-W${String(week).padStart(2, '0')}`
|
||||
if (!buckets[key]) buckets[key] = { day: d.day, real: 0, bot: 0 }
|
||||
buckets[key].real += d.real
|
||||
buckets[key].bot += d.bot
|
||||
}
|
||||
return Object.values(buckets)
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({
|
||||
stats,
|
||||
statsStatus,
|
||||
@@ -60,7 +93,6 @@ export function AnalyticsPanel({
|
||||
onRangeChange,
|
||||
opsStatus,
|
||||
formatDate,
|
||||
maskIp,
|
||||
maintenanceMsg,
|
||||
selectedBackup,
|
||||
backupFiles,
|
||||
@@ -90,7 +122,6 @@ export function AnalyticsPanel({
|
||||
// Normalize fields that may be missing from older data
|
||||
const realHits = stats.realHits ?? 0
|
||||
const botHits = stats.botHits ?? 0
|
||||
const totalHits = stats.totalHits ?? 0
|
||||
const last7DaysReal = stats.last7DaysReal ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
|
||||
const last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
|
||||
const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0
|
||||
@@ -99,51 +130,46 @@ export function AnalyticsPanel({
|
||||
const topPathsBot = stats.topPathsBot ?? []
|
||||
const botReasons = stats.botReasons ?? []
|
||||
|
||||
// Chart data - Daily trend
|
||||
const dailyChartData = {
|
||||
labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day),
|
||||
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? stats.totalHits
|
||||
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? RANGE_LABELS[statsRange]
|
||||
const rawTrendData = (stats as AdminStats & { trendData?: Array<{ day: string; real: number; bot: number }> }).trendData
|
||||
// Fall back to last7Days format if trendData not present (old API)
|
||||
const trendPoints = rawTrendData
|
||||
? aggregateTrend(rawTrendData)
|
||||
: last7DaysReal.map((d: { day: string; hits: number }, i: number) => ({
|
||||
day: d.day,
|
||||
real: d.hits,
|
||||
bot: last7DaysBot[i]?.hits ?? 0,
|
||||
}))
|
||||
|
||||
const trendTitle = `Traffic Trend — ${rangeLabel}`
|
||||
const trendLabels = trendPoints.map(d => d.day.length === 10 ? d.day.slice(5) : d.day)
|
||||
|
||||
// Trend chart data
|
||||
const trendChartData = {
|
||||
labels: trendLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Real Visitors',
|
||||
data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
|
||||
data: trendPoints.map(d => d.real),
|
||||
borderColor: '#c9a84c',
|
||||
backgroundColor: 'rgba(201, 168, 76, 0.1)',
|
||||
tension: 0.4,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: trendPoints.length > 90 ? 0 : 2,
|
||||
},
|
||||
{
|
||||
label: 'Bot Traffic',
|
||||
data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
|
||||
data: trendPoints.map(d => d.bot),
|
||||
borderColor: '#666',
|
||||
backgroundColor: 'rgba(102, 102, 102, 0.1)',
|
||||
tension: 0.4,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: trendPoints.length > 90 ? 0 : 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Chart data - Bot breakdown (pie)
|
||||
const botPieData = {
|
||||
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
|
||||
datasets: [{
|
||||
data: botReasons.map((b: { reason: string; count: number }) => b.count),
|
||||
backgroundColor: [
|
||||
'#c9a84c',
|
||||
'#a0853d',
|
||||
'#6d5a2e',
|
||||
'#8b7635',
|
||||
'#9e8843',
|
||||
'#bbb477',
|
||||
'#7a6930',
|
||||
'#5a4820',
|
||||
'#c0a060',
|
||||
'#746c36',
|
||||
],
|
||||
borderColor: '#1a1a15',
|
||||
borderWidth: 2,
|
||||
}],
|
||||
}
|
||||
|
||||
// Traffic ratio (doughnut)
|
||||
const trafficRatioData = {
|
||||
labels: ['Real Visitors', 'Bot Traffic'],
|
||||
@@ -155,44 +181,30 @@ export function AnalyticsPanel({
|
||||
}],
|
||||
}
|
||||
|
||||
// Top paths comparison
|
||||
// Bot breakdown (pie)
|
||||
const botPieData = {
|
||||
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
|
||||
datasets: [{
|
||||
data: botReasons.map((b: { reason: string; count: number }) => b.count),
|
||||
backgroundColor: ['#c9a84c','#a0853d','#6d5a2e','#8b7635','#9e8843','#bbb477','#7a6930','#5a4820','#c0a060','#746c36'],
|
||||
borderColor: '#1a1a15',
|
||||
borderWidth: 2,
|
||||
}],
|
||||
}
|
||||
|
||||
// Top paths
|
||||
const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => {
|
||||
const realPath = topPathsReal[i]?.path || ''
|
||||
const botPath = topPathsBot[i]?.path || ''
|
||||
return realPath || botPath || `Path ${i + 1}`
|
||||
return topPathsReal[i]?.path || topPathsBot[i]?.path || `Path ${i + 1}`
|
||||
}).slice(0, 8)
|
||||
|
||||
const topPathsChartData = {
|
||||
labels: topPathsLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Real Hits',
|
||||
data: topPathsLabels.map((_label, i) => topPathsReal[i]?.hits || 0),
|
||||
backgroundColor: '#c9a84c',
|
||||
},
|
||||
{
|
||||
label: 'Bot Hits',
|
||||
data: topPathsLabels.map((_label, i) => topPathsBot[i]?.hits || 0),
|
||||
backgroundColor: '#999',
|
||||
},
|
||||
{ label: 'Real Hits', data: topPathsLabels.map((_l, i) => topPathsReal[i]?.hits || 0), backgroundColor: '#c9a84c' },
|
||||
{ label: 'Bot Hits', data: topPathsLabels.map((_l, i) => topPathsBot[i]?.hits || 0), backgroundColor: '#999' },
|
||||
],
|
||||
}
|
||||
|
||||
// 30-day trend chart
|
||||
const last30DaysReal = stats.visitors.last30DaysReal ?? []
|
||||
const thirtyDayChartData = {
|
||||
labels: last30DaysReal.map((d: { day: string; hits: number }) => d.day.slice(5)),
|
||||
datasets: [{
|
||||
label: 'Real Visitors',
|
||||
data: last30DaysReal.map((d: { day: string; hits: number }) => d.hits),
|
||||
borderColor: '#c9a84c',
|
||||
backgroundColor: 'rgba(201, 168, 76, 0.08)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: 2,
|
||||
}],
|
||||
}
|
||||
|
||||
// Device breakdown (doughnut)
|
||||
const deviceBreakdown = stats.visitors.deviceBreakdown ?? { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
|
||||
const deviceChartData = {
|
||||
@@ -209,41 +221,22 @@ export function AnalyticsPanel({
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#f0ead8',
|
||||
font: { size: 12 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#2a2518',
|
||||
titleColor: '#f0ead8',
|
||||
bodyColor: '#f0ead8',
|
||||
borderColor: '#c9a84c',
|
||||
borderWidth: 1,
|
||||
},
|
||||
legend: { labels: { color: '#f0ead8', font: { size: 12 } } },
|
||||
tooltip: { backgroundColor: '#2a2518', titleColor: '#f0ead8', bodyColor: '#f0ead8', borderColor: '#c9a84c', borderWidth: 1 },
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: '#7a7060' },
|
||||
grid: { color: '#2a2518' },
|
||||
},
|
||||
x: {
|
||||
ticks: { color: '#7a7060' },
|
||||
grid: { color: '#2a2518' },
|
||||
},
|
||||
y: { beginAtZero: true, ticks: { color: '#7a7060' }, grid: { color: '#2a2518' } },
|
||||
x: { ticks: { color: '#7a7060', maxTicksLimit: 12 }, grid: { color: '#2a2518' } },
|
||||
},
|
||||
}
|
||||
|
||||
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits
|
||||
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange
|
||||
const rangeRealVisitors = stats.visitors.recentVisits.length
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Analytics">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Analytics Dashboard</h2>
|
||||
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
|
||||
<p>Site traffic, bot detection, visitor behavior, and data management.</p>
|
||||
</div>
|
||||
|
||||
{/* Top-level panel tabs */}
|
||||
@@ -254,10 +247,10 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
|
||||
{panelTab === 'traffic' && (<>
|
||||
{/* Time-range tabs */}
|
||||
{/* Time-range selector */}
|
||||
{onRangeChange && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{(['7d', '30d', '90d'] as const).map(r => (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{(['7d', '30d', '90d', 'all'] as StatsRange[]).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
@@ -274,36 +267,33 @@ export function AnalyticsPanel({
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{r === '7d' ? 'Last 7 days' : r === '30d' ? 'Last 30 days' : 'Last 90 days'}
|
||||
{RANGE_LABELS[r]}
|
||||
</button>
|
||||
))}
|
||||
<span style={{ color: '#5a5440', fontSize: '0.8rem', alignSelf: 'center', marginLeft: '0.25rem' }}>
|
||||
Showing: {rangeLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Grid */}
|
||||
<div className="admin-stats-grid">
|
||||
<article>
|
||||
<h3>Total Hits</h3>
|
||||
<p>{rangeTotalHits.toLocaleString()}</p>
|
||||
<small>{rangeLabel} all requests</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Real Traffic</h3>
|
||||
<h3>Real Hits</h3>
|
||||
<p>{realHits.toLocaleString()}</p>
|
||||
<small>{rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
<small>{rangeLabel}</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Bot Traffic</h3>
|
||||
<p>{botHits.toLocaleString()}</p>
|
||||
<small>{rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
<small>{rangeTotalHits > 0 ? `${((botHits / rangeTotalHits) * 100).toFixed(1)}% of total` : '0% of total'}</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>All-Time Real Hits</h3>
|
||||
<p>{((stats as AdminStats & { allTimeRealHits?: number }).allTimeRealHits ?? stats.totalHits).toLocaleString()}</p>
|
||||
<small>Since {formatDate(stats.firstHitAt)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Last 30 Days</h3>
|
||||
<p>{last30DaysRealTotal.toLocaleString()}</p>
|
||||
<small>Real hits • {last30DaysBotTotal.toLocaleString()} bots</small>
|
||||
<small>Real hits · {last30DaysBotTotal.toLocaleString()} bots</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Unique Visitors</h3>
|
||||
@@ -329,19 +319,23 @@ export function AnalyticsPanel({
|
||||
{chartTab === 'overview' && (
|
||||
<>
|
||||
<div className="admin-analytics-grid">
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3>
|
||||
<Line data={dailyChartData} options={chartOptions} />
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Traffic Composition</h3>
|
||||
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">{trendTitle}</h3>
|
||||
<Line data={trendChartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">30-Day Real Visitor Trend</h3>
|
||||
<Line data={thirtyDayChartData} options={chartOptions} />
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Traffic Composition ({rangeLabel})</h3>
|
||||
<Doughnut data={trafficRatioData} options={chartOptions} />
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Top Pages ({rangeLabel})</h3>
|
||||
<ul className="admin-bot-reason-list">
|
||||
{topPathsReal.slice(0, 8).map((item: { path: string; hits: number }) => (
|
||||
<li key={item.path}><span>{item.path}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -353,7 +347,7 @@ export function AnalyticsPanel({
|
||||
<div className="admin-analytics-grid">
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Device Breakdown</h3>
|
||||
<Doughnut data={deviceChartData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
<Doughnut data={deviceChartData} options={chartOptions} />
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Device Counts</h3>
|
||||
@@ -371,7 +365,7 @@ export function AnalyticsPanel({
|
||||
{botReasons.length === 0 ? (
|
||||
<p className="admin-stats-note">No bot traffic detected.</p>
|
||||
) : (
|
||||
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
<Pie data={botPieData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
@@ -390,20 +384,40 @@ export function AnalyticsPanel({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3>
|
||||
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Geographic Charts */}
|
||||
{chartTab === 'geographic' && (
|
||||
<div className="admin-analytics-geo">
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">Top Pages</h3>
|
||||
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
|
||||
</div>
|
||||
<GeoMaps
|
||||
topCountries={stats.visitors.topCountries}
|
||||
topStates={stats.visitors.topStates}
|
||||
/>
|
||||
<div className="admin-stats-lists" style={{ marginTop: '1rem' }}>
|
||||
<div>
|
||||
<h3>Top Cities</h3>
|
||||
<ul>
|
||||
{(stats.visitors.topCities ?? []).slice(0, 10).map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Top Countries</h3>
|
||||
<ul>
|
||||
{stats.visitors.topCountries.slice(0, 10).map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -411,7 +425,7 @@ export function AnalyticsPanel({
|
||||
{chartTab === 'referrers' && (
|
||||
<div className="admin-analytics-grid">
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">Top Traffic Sources</h3>
|
||||
<h3 className="admin-analytics-card-title">Top Traffic Sources ({rangeLabel})</h3>
|
||||
{(stats.visitors.topReferrers ?? []).length === 0 ? (
|
||||
<p className="admin-stats-note">No referrer data yet. Referrers are captured on SPA navigations after consent.</p>
|
||||
) : (
|
||||
@@ -428,16 +442,16 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Visitors Table */}
|
||||
{/* Recent Visitors */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Visitor Details</h2>
|
||||
<p>Real-time tracking of visitor activity, geography, and returning behavior.</p>
|
||||
<h2>Visitor Log</h2>
|
||||
<p>Activity for {rangeLabel.toLowerCase()} · {rangeRealVisitors} records shown.</p>
|
||||
</div>
|
||||
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p>
|
||||
<p className="admin-privacy-note">Analytics only run after cookie consent. Visitor IDs are pseudonymous hash prefixes.</p>
|
||||
|
||||
<div className="admin-stats-grid admin-stats-grid--visitor">
|
||||
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></article>
|
||||
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p></article>
|
||||
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p><small>All time</small></article>
|
||||
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p><small>All time</small></article>
|
||||
<article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article>
|
||||
<article><h3>First Visit</h3><p>{formatDate(stats.visitors.firstVisitAt)}</p></article>
|
||||
</div>
|
||||
@@ -472,6 +486,9 @@ export function AnalyticsPanel({
|
||||
const isExpanded = expandedVisitor === rowKey
|
||||
const history = row.pageHistory ?? []
|
||||
const location = [row.city !== 'Unknown' && row.city, row.state !== 'Unknown' && row.state, row.country !== 'Unknown' && row.country].filter(Boolean).join(', ') || 'Unknown location'
|
||||
const visitorLabel = row.studyUser
|
||||
? (row.studyUser.displayName || row.studyUser.username)
|
||||
: `Visitor ${(row.ipHash ?? row.visitorId).slice(0, 8)}`
|
||||
return (
|
||||
<div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}>
|
||||
<button
|
||||
@@ -481,7 +498,9 @@ export function AnalyticsPanel({
|
||||
>
|
||||
<div className="admin-visitor-card__left">
|
||||
<span className="admin-visitor-card__name">
|
||||
{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : <code className="admin-visitor-ip">{maskIp(row.ip)}</code>}
|
||||
{row.studyUser
|
||||
? visitorLabel
|
||||
: <code className="admin-visitor-ip">{visitorLabel}</code>}
|
||||
</span>
|
||||
<span className="admin-visitor-card__path">{row.path}</span>
|
||||
</div>
|
||||
@@ -502,7 +521,7 @@ export function AnalyticsPanel({
|
||||
{isExpanded && (
|
||||
<div className="admin-visitor-card__detail">
|
||||
<div className="admin-visitor-card__detail-meta">
|
||||
<span><strong>IP</strong> {maskIp(row.ip)}</span>
|
||||
<span><strong>ID</strong> {(row.ipHash ?? row.visitorId).slice(0, 12)}…</span>
|
||||
{row.studyUser && <span><strong>Username</strong> {row.studyUser.username}</span>}
|
||||
{row.referrer && <span><strong>Referrer</strong> {row.referrer}</span>}
|
||||
<span><strong>Location</strong> {[row.city, row.county, row.state, row.country].filter(v => v && v !== 'Unknown').join(', ') || '—'}</span>
|
||||
@@ -540,7 +559,12 @@ export function AnalyticsPanel({
|
||||
<>
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Episode Plays</h2>
|
||||
<p>Tracked each time a visitor hits play on the audio player. One count per player mount.</p>
|
||||
<p>Counted each time a visitor presses play. One count per player mount.</p>
|
||||
</div>
|
||||
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
|
||||
<article><h3>Total Plays</h3><p>{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}</p></article>
|
||||
<article><h3>Episodes Played</h3><p>{stats.episodePlays.length.toLocaleString()}</p></article>
|
||||
<article><h3>Most Played</h3><p style={{ fontSize: '0.8rem' }}>{stats.episodePlays[0]?.title ?? '—'}</p><small>{stats.episodePlays[0]?.total.toLocaleString()} plays</small></article>
|
||||
</div>
|
||||
<div className="admin-stats-chart-wrap" style={{ marginBottom: '1.5rem' }}>
|
||||
<Bar
|
||||
@@ -571,11 +595,6 @@ export function AnalyticsPanel({
|
||||
style={{ height: `${Math.max(180, stats.episodePlays.slice(0, 12).length * 36)}px` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
|
||||
<article><h3>Total Episode Plays</h3><p>{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}</p></article>
|
||||
<article><h3>Episodes Played</h3><p>{stats.episodePlays.length.toLocaleString()}</p></article>
|
||||
<article><h3>Most Played</h3><p style={{ fontSize: '0.8rem' }}>{stats.episodePlays[0]?.title ?? '—'}</p></article>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -584,10 +603,9 @@ export function AnalyticsPanel({
|
||||
<>
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Engagement</h2>
|
||||
<p>Scroll depth, time on page, audio, and interaction data from consenting visitors.</p>
|
||||
<p>Scroll depth, time on page, audio, and interaction data. Consent-gated.</p>
|
||||
</div>
|
||||
|
||||
{/* Time on page */}
|
||||
{stats.engagement.timeOnPage?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Avg. Time on Page</h3>
|
||||
@@ -598,7 +616,7 @@ export function AnalyticsPanel({
|
||||
{stats.engagement.timeOnPage.slice(0, 15).map((row: { path: string; avgSeconds: number; count: number }) => (
|
||||
<tr key={row.path}>
|
||||
<td>{row.path}</td>
|
||||
<td>{row.avgSeconds >= 60 ? `${Math.floor(row.avgSeconds / 60)}m ${row.avgSeconds % 60}s` : `${row.avgSeconds}s`}</td>
|
||||
<td>{fmtSec(row.avgSeconds)}</td>
|
||||
<td>{row.count.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -608,7 +626,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll depth */}
|
||||
{stats.engagement.scrollDepth?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Scroll Depth</h3>
|
||||
@@ -631,7 +648,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio events */}
|
||||
{stats.engagement.audioEvents?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Audio Engagement</h3>
|
||||
@@ -639,26 +655,20 @@ export function AnalyticsPanel({
|
||||
<table className="admin-stats-table">
|
||||
<thead><tr><th>Episode</th><th>Completions</th><th>Pauses</th><th>Total Listen</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => {
|
||||
const hrs = Math.floor(row.totalListenSeconds / 3600)
|
||||
const mins = Math.floor((row.totalListenSeconds % 3600) / 60)
|
||||
const listenStr = hrs > 0 ? `${hrs}h ${mins}m` : `${mins}m`
|
||||
return (
|
||||
<tr key={row.title}>
|
||||
<td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.title}</td>
|
||||
<td>{row.completions.toLocaleString()}</td>
|
||||
<td>{row.pauses.toLocaleString()}</td>
|
||||
<td>{listenStr}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => (
|
||||
<tr key={row.title}>
|
||||
<td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.title}</td>
|
||||
<td>{row.completions.toLocaleString()}</td>
|
||||
<td>{row.pauses.toLocaleString()}</td>
|
||||
<td>{fmtSec(row.totalListenSeconds)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search queries */}
|
||||
{stats.engagement.topSearchQueries?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Top Search Queries</h3>
|
||||
@@ -675,7 +685,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All link clicks */}
|
||||
{stats.engagement.topLinkClicks?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Link Clicks</h3>
|
||||
@@ -702,7 +711,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outbound clicks */}
|
||||
{stats.engagement.topOutboundClicks?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Outbound Link Clicks</h3>
|
||||
@@ -724,7 +732,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UTM sources */}
|
||||
{stats.engagement.topUTMSources?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>UTM Sources</h3>
|
||||
@@ -741,7 +748,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 404s */}
|
||||
{stats.engagement.top404s?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>404 Not Found</h3>
|
||||
@@ -763,11 +769,11 @@ export function AnalyticsPanel({
|
||||
{/* Contact Summary */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Contact Summary</h2>
|
||||
<p>Submission totals from the contact form.</p>
|
||||
<p>All-time submission totals from the contact form.</p>
|
||||
</div>
|
||||
<div className="admin-stats-grid">
|
||||
<article><h3>Total Contact Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
|
||||
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
|
||||
<article><h3>Total Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
|
||||
<article><h3>Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
|
||||
</div>
|
||||
|
||||
{/* Study Enrollment Funnel */}
|
||||
@@ -775,7 +781,7 @@ export function AnalyticsPanel({
|
||||
<>
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Study Enrollment Funnel</h2>
|
||||
<p>How many users progress from signup → first visit → first section completed.</p>
|
||||
<p>Signup → first study visit → first section completed.</p>
|
||||
</div>
|
||||
<div className="admin-funnel">
|
||||
{(() => {
|
||||
@@ -805,10 +811,9 @@ export function AnalyticsPanel({
|
||||
</>)}
|
||||
|
||||
{panelTab === 'system' && (<>
|
||||
{/* Deployment & Cache Status */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Deployment & Cache Status</h2>
|
||||
<p>Current deployment metadata and cache purge health for the live app.</p>
|
||||
<p>Current deployment metadata and cache purge health.</p>
|
||||
</div>
|
||||
<div className="admin-stats-grid">
|
||||
<article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article>
|
||||
@@ -817,7 +822,6 @@ export function AnalyticsPanel({
|
||||
<article><h3>Cache Purge</h3><p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p><p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p></article>
|
||||
</div>
|
||||
|
||||
{/* Data Management */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Data Management</h2>
|
||||
<p>Export, backup, or retain only recent analytics data.</p>
|
||||
@@ -857,7 +861,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Data Backup (entire /data folder — server migration) */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Full Data Backup</h2>
|
||||
<p>Download every persisted file — site content, uploads, study accounts, notes, progress, questions, analytics — plus a snapshot of server settings (env-snapshot.env) as one archive, or restore that archive on this or a new server. The archive contains secrets like the admin password and API keys, so keep it private. Restored settings are not applied automatically — copy them into the new server's environment.</p>
|
||||
@@ -869,8 +872,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
<div className="admin-restore-row">
|
||||
<label htmlFor="full-backup-file">Restore from File</label>
|
||||
{/* No accept filter: iPadOS greys out .tar.gz with one — the server
|
||||
validates the archive contents before restoring anyway. */}
|
||||
<input
|
||||
id="full-backup-file"
|
||||
type="file"
|
||||
|
||||
Reference in new issue
Block a user