6acc3f1d04
Splits the analytics panel into Traffic / Engagement / System tabs so episode plays, engagement metrics, and contact/study data are no longer buried under the visitor log. Fixes a bug where topCountries, topStates, topCities, device breakdown, and referrers were computed from only the 100 most-recent visitor rows instead of all rows in the selected range — causing older geographic data (e.g. visits from India, Texas, Georgia) to silently disappear from the aggregate totals. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
894 lines
40 KiB
TypeScript
894 lines
40 KiB
TypeScript
import { useState } from 'react'
|
|
import { Line, Bar, Pie, Doughnut } from 'react-chartjs-2'
|
|
import GeoMaps from './GeoMaps'
|
|
import {
|
|
Chart as ChartJS,
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
BarElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
ArcElement,
|
|
} from 'chart.js'
|
|
import type { AdminStats } from '../AdminPage'
|
|
|
|
ChartJS.register(
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
BarElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
ArcElement,
|
|
)
|
|
|
|
interface Props {
|
|
stats: AdminStats | null
|
|
statsStatus: 'loading' | 'error' | 'ready'
|
|
statsRange?: '7d' | '30d' | '90d'
|
|
onRangeChange?: (range: '7d' | '30d' | '90d') => 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
|
|
maintenanceMsg: string | null
|
|
selectedBackup: string
|
|
backupFiles: Array<{ filename: string; sizeBytes: number; createdAt: string | null; reason: string; adminUpdatedAt: string | null; totalHits: number; totalVisits: number }>
|
|
selectedBackupPreview: { filename: string; sizeBytes: number; createdAt: string | null; reason: string; adminUpdatedAt: string | null; totalHits: number; totalVisits: number } | null
|
|
onBackupSelect: (filename: string) => void
|
|
onRestore: () => void
|
|
onExport: () => void
|
|
onBackupNow: () => void
|
|
onDownloadFullBackup: () => void
|
|
onImportFullBackup: (file: File) => void
|
|
fullBackupBusy: boolean
|
|
onPrune: () => void
|
|
onClear: () => void
|
|
onPurgeCache: () => void
|
|
onDeployHook: () => void
|
|
onRefreshStatus: () => void
|
|
}
|
|
|
|
export function AnalyticsPanel({
|
|
stats,
|
|
statsStatus,
|
|
statsRange = '30d',
|
|
onRangeChange,
|
|
opsStatus,
|
|
formatDate,
|
|
maskIp,
|
|
maintenanceMsg,
|
|
selectedBackup,
|
|
backupFiles,
|
|
selectedBackupPreview,
|
|
onBackupSelect,
|
|
onRestore,
|
|
onExport,
|
|
onBackupNow,
|
|
onDownloadFullBackup,
|
|
onImportFullBackup,
|
|
fullBackupBusy,
|
|
onPrune,
|
|
onClear,
|
|
onPurgeCache,
|
|
onDeployHook,
|
|
onRefreshStatus,
|
|
}: Props) {
|
|
const [panelTab, setPanelTab] = useState<'traffic' | 'engagement' | 'system'>('traffic')
|
|
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic' | 'referrers'>('overview')
|
|
const [expandedVisitor, setExpandedVisitor] = useState<string | null>(null)
|
|
const [fullBackupFile, setFullBackupFile] = useState<File | null>(null)
|
|
|
|
if (statsStatus === 'loading') return <p className="admin-stats-note">Loading analytics…</p>
|
|
if (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
|
|
if (!stats) return null
|
|
|
|
// 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
|
|
const last30DaysBotTotal = stats.last30DaysBotTotal ?? 0
|
|
const topPathsReal = stats.topPathsReal ?? stats.topPaths
|
|
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),
|
|
datasets: [
|
|
{
|
|
label: 'Real Visitors',
|
|
data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
|
|
borderColor: '#c9a84c',
|
|
backgroundColor: 'rgba(201, 168, 76, 0.1)',
|
|
tension: 0.4,
|
|
fill: true,
|
|
},
|
|
{
|
|
label: 'Bot Traffic',
|
|
data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
|
|
borderColor: '#666',
|
|
backgroundColor: 'rgba(102, 102, 102, 0.1)',
|
|
tension: 0.4,
|
|
fill: true,
|
|
},
|
|
],
|
|
}
|
|
|
|
// 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'],
|
|
datasets: [{
|
|
data: [realHits, botHits],
|
|
backgroundColor: ['#c9a84c', '#999'],
|
|
borderColor: '#1a1a15',
|
|
borderWidth: 2,
|
|
}],
|
|
}
|
|
|
|
// Top paths comparison
|
|
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}`
|
|
}).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',
|
|
},
|
|
],
|
|
}
|
|
|
|
// 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 = {
|
|
labels: ['Desktop', 'Mobile', 'Tablet', 'Unknown'],
|
|
datasets: [{
|
|
data: [deviceBreakdown.desktop, deviceBreakdown.mobile, deviceBreakdown.tablet, deviceBreakdown.unknown],
|
|
backgroundColor: ['#c9a84c', '#a0853d', '#6d5a2e', '#444'],
|
|
borderColor: '#1a1a15',
|
|
borderWidth: 2,
|
|
}],
|
|
}
|
|
|
|
const chartOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
plugins: {
|
|
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' },
|
|
},
|
|
},
|
|
}
|
|
|
|
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits
|
|
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange
|
|
|
|
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>
|
|
</div>
|
|
|
|
{/* Top-level panel tabs */}
|
|
<div className="admin-tabs admin-tabs--charts" style={{ marginBottom: '1.5rem' }}>
|
|
<button type="button" className={`admin-tab${panelTab === 'traffic' ? ' admin-tab--active' : ''}`} onClick={() => setPanelTab('traffic')}>Traffic</button>
|
|
<button type="button" className={`admin-tab${panelTab === 'engagement' ? ' admin-tab--active' : ''}`} onClick={() => setPanelTab('engagement')}>Engagement</button>
|
|
<button type="button" className={`admin-tab${panelTab === 'system' ? ' admin-tab--active' : ''}`} onClick={() => setPanelTab('system')}>System</button>
|
|
</div>
|
|
|
|
{panelTab === 'traffic' && (<>
|
|
{/* Time-range tabs */}
|
|
{onRangeChange && (
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
|
{(['7d', '30d', '90d'] as const).map(r => (
|
|
<button
|
|
key={r}
|
|
type="button"
|
|
onClick={() => onRangeChange(r)}
|
|
style={{
|
|
padding: '0.35rem 1rem',
|
|
borderRadius: '20px',
|
|
border: `1px solid ${statsRange === r ? '#c9a84c' : '#3a3320'}`,
|
|
background: statsRange === r ? 'rgba(201,168,76,0.15)' : 'transparent',
|
|
color: statsRange === r ? '#c9a84c' : '#7a7060',
|
|
cursor: 'pointer',
|
|
fontSize: '0.85rem',
|
|
fontWeight: statsRange === r ? 600 : 400,
|
|
transition: 'all 0.15s',
|
|
}}
|
|
>
|
|
{r === '7d' ? 'Last 7 days' : r === '30d' ? 'Last 30 days' : 'Last 90 days'}
|
|
</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>
|
|
<p>{realHits.toLocaleString()}</p>
|
|
<small>{rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
|
</article>
|
|
<article>
|
|
<h3>Bot Traffic</h3>
|
|
<p>{botHits.toLocaleString()}</p>
|
|
<small>{rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
|
</article>
|
|
<article>
|
|
<h3>Last 30 Days</h3>
|
|
<p>{last30DaysRealTotal.toLocaleString()}</p>
|
|
<small>Real hits • {last30DaysBotTotal.toLocaleString()} bots</small>
|
|
</article>
|
|
<article>
|
|
<h3>Unique Visitors</h3>
|
|
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
|
|
<small>Tracked with consent</small>
|
|
</article>
|
|
<article>
|
|
<h3>Returning Rate</h3>
|
|
<p>{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}</p>
|
|
<small>{stats.visitors.returningVisits.toLocaleString()} return visits</small>
|
|
</article>
|
|
</div>
|
|
|
|
{/* Chart Tabs */}
|
|
<div className="admin-tabs admin-tabs--charts">
|
|
<button type="button" className={`admin-tab${chartTab === 'overview' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('overview')}>Overview</button>
|
|
<button type="button" className={`admin-tab${chartTab === 'breakdown' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('breakdown')}>Bot & Devices</button>
|
|
<button type="button" className={`admin-tab${chartTab === 'geographic' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('geographic')}>Geographic</button>
|
|
<button type="button" className={`admin-tab${chartTab === 'referrers' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('referrers')}>Referrers</button>
|
|
</div>
|
|
|
|
{/* Overview Charts */}
|
|
{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>
|
|
</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>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Bot & Device Charts */}
|
|
{chartTab === 'breakdown' && (
|
|
<>
|
|
<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}}} />
|
|
</div>
|
|
<div className="admin-analytics-card">
|
|
<h3 className="admin-analytics-card-title">Device Counts</h3>
|
|
<ul className="admin-bot-reason-list">
|
|
<li><span>Desktop</span><strong>{deviceBreakdown.desktop.toLocaleString()}</strong></li>
|
|
<li><span>Mobile</span><strong>{deviceBreakdown.mobile.toLocaleString()}</strong></li>
|
|
<li><span>Tablet</span><strong>{deviceBreakdown.tablet.toLocaleString()}</strong></li>
|
|
{deviceBreakdown.unknown > 0 && <li><span>Unknown</span><strong>{deviceBreakdown.unknown.toLocaleString()}</strong></li>}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
|
<div className="admin-analytics-card">
|
|
<h3 className="admin-analytics-card-title">Bot Sources</h3>
|
|
{botReasons.length === 0 ? (
|
|
<p className="admin-stats-note">No bot traffic detected.</p>
|
|
) : (
|
|
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
|
)}
|
|
</div>
|
|
<div className="admin-analytics-card">
|
|
<h3 className="admin-analytics-card-title">Bot Detection Details</h3>
|
|
{botReasons.length === 0 ? (
|
|
<p className="admin-stats-note">No bots detected yet.</p>
|
|
) : (
|
|
<ul className="admin-bot-reason-list">
|
|
{botReasons.map((reason: { reason: string; count: number }) => (
|
|
<li key={reason.reason} className="admin-bot-reason-item">
|
|
<span>{reason.reason.replace(/-/g, ' ')}</span>
|
|
<strong>{reason.count.toLocaleString()}</strong>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
{/* Referrers Tab */}
|
|
{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>
|
|
{(stats.visitors.topReferrers ?? []).length === 0 ? (
|
|
<p className="admin-stats-note">No referrer data yet. Referrers are captured on SPA navigations after consent.</p>
|
|
) : (
|
|
<ul className="admin-bot-reason-list">
|
|
{(stats.visitors.topReferrers ?? []).map((item: { referrer: string; count: number }) => (
|
|
<li key={item.referrer}>
|
|
<span>{item.referrer}</span>
|
|
<strong>{item.count.toLocaleString()}</strong>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Visitors Table */}
|
|
<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>
|
|
</div>
|
|
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</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>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article>
|
|
<article><h3>First Visit</h3><p>{formatDate(stats.visitors.firstVisitAt)}</p></article>
|
|
</div>
|
|
|
|
<div className="admin-stats-lists">
|
|
<div>
|
|
<h3>Top Countries</h3>
|
|
<ul>
|
|
{stats.visitors.topCountries.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 States</h3>
|
|
<ul>
|
|
{stats.visitors.topStates.map((item: { name: string; hits: number }) => (
|
|
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-visits-table-wrap">
|
|
<h3>Recent Visitor Log</h3>
|
|
{stats.visitors.recentVisits.length === 0 ? (
|
|
<p className="admin-stats-note">No visitor records yet.</p>
|
|
) : (
|
|
<div className="admin-visitor-cards">
|
|
{stats.visitors.recentVisits.map(row => {
|
|
const rowKey = `${row.visitorId}-${row.at}`
|
|
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'
|
|
return (
|
|
<div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}>
|
|
<button
|
|
className="admin-visitor-card__main"
|
|
onClick={() => setExpandedVisitor(isExpanded ? null : rowKey)}
|
|
aria-expanded={isExpanded}
|
|
>
|
|
<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>}
|
|
</span>
|
|
<span className="admin-visitor-card__path">{row.path}</span>
|
|
</div>
|
|
<div className="admin-visitor-card__right">
|
|
<span className="admin-visitor-card__meta">{location}</span>
|
|
<span className="admin-visitor-card__meta">{row.device || 'unknown'} · visit #{row.visitCount}</span>
|
|
<div className="admin-visitor-card__tags">
|
|
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
|
|
{row.returningVisitor ? 'Returning' : 'New'}
|
|
</span>
|
|
{row.studyUser && <span className="admin-visitor-tag admin-visitor-tag--user">Logged in</span>}
|
|
<span className="admin-visitor-card__time">{formatDate(row.at)}</span>
|
|
</div>
|
|
</div>
|
|
<span className="admin-visitor-card__chevron">{isExpanded ? '▲' : '▼'}</span>
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<div className="admin-visitor-card__detail">
|
|
<div className="admin-visitor-card__detail-meta">
|
|
<span><strong>IP</strong> {maskIp(row.ip)}</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>
|
|
</div>
|
|
{history.length > 0 ? (
|
|
<>
|
|
<p className="admin-visitor-history-label">Page history ({history.length} pages)</p>
|
|
<ol className="admin-visitor-history-list">
|
|
{[...history].reverse().map((entry, i) => (
|
|
<li key={i} className="admin-visitor-history-entry">
|
|
<span className="admin-visitor-history-time">{formatDate(entry.at)}</span>
|
|
<span className="admin-visitor-history-path">{entry.path}</span>
|
|
{entry.referrer && <span className="admin-visitor-history-ref">from {entry.referrer}</span>}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</>
|
|
) : (
|
|
<p style={{ color: '#8a7f5a', fontSize: '0.82rem', margin: 0 }}>No page history yet — recorded after consent is given.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</>)}
|
|
|
|
{panelTab === 'engagement' && (<>
|
|
{/* Episode Plays */}
|
|
{stats.episodePlays && stats.episodePlays.length > 0 && (
|
|
<>
|
|
<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>
|
|
</div>
|
|
<div className="admin-stats-chart-wrap" style={{ marginBottom: '1.5rem' }}>
|
|
<Bar
|
|
data={{
|
|
labels: stats.episodePlays.slice(0, 12).map((ep: { title: string; total: number }) => ep.title.length > 40 ? ep.title.slice(0, 40) + '…' : ep.title),
|
|
datasets: [{
|
|
label: 'Total Plays',
|
|
data: stats.episodePlays.slice(0, 12).map((ep: { title: string; total: number }) => ep.total),
|
|
backgroundColor: '#c9a84c',
|
|
borderColor: '#8a6e28',
|
|
borderWidth: 1,
|
|
borderRadius: 4,
|
|
}],
|
|
}}
|
|
options={{
|
|
indexAxis: 'y' as const,
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: { callbacks: { label: (ctx) => ` ${ctx.parsed.x} plays` } },
|
|
},
|
|
scales: {
|
|
x: { ticks: { color: '#b0a48c', font: { size: 11 } }, grid: { color: 'rgba(42,37,24,0.6)' } },
|
|
y: { ticks: { color: '#f0ead8', font: { size: 11 } }, grid: { display: false } },
|
|
},
|
|
}}
|
|
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>
|
|
</>
|
|
)}
|
|
|
|
{/* Engagement */}
|
|
{stats.engagement && (
|
|
<>
|
|
<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>
|
|
</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>
|
|
<div className="admin-stats-table-wrap">
|
|
<table className="admin-stats-table">
|
|
<thead><tr><th>Page</th><th>Avg Time</th><th>Sessions</th></tr></thead>
|
|
<tbody>
|
|
{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>{row.count.toLocaleString()}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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>
|
|
<div className="admin-stats-table-wrap">
|
|
<table className="admin-stats-table">
|
|
<thead><tr><th>Page</th><th>25%</th><th>50%</th><th>75%</th><th>90%</th></tr></thead>
|
|
<tbody>
|
|
{stats.engagement.scrollDepth.slice(0, 15).map((row: { path: string; 25?: number; 50?: number; 75?: number; 90?: number }) => (
|
|
<tr key={row.path}>
|
|
<td>{row.path}</td>
|
|
<td>{(row[25] ?? 0).toLocaleString()}</td>
|
|
<td>{(row[50] ?? 0).toLocaleString()}</td>
|
|
<td>{(row[75] ?? 0).toLocaleString()}</td>
|
|
<td>{(row[90] ?? 0).toLocaleString()}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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>
|
|
<div className="admin-stats-table-wrap">
|
|
<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>
|
|
)
|
|
})}
|
|
</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>
|
|
<div className="admin-stats-table-wrap">
|
|
<table className="admin-stats-table">
|
|
<thead><tr><th>Query</th><th>Searches</th></tr></thead>
|
|
<tbody>
|
|
{stats.engagement.topSearchQueries.map((row: { query: string; count: number }) => (
|
|
<tr key={row.query}><td>{row.query}</td><td>{row.count.toLocaleString()}</td></tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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>
|
|
<div className="admin-stats-table-wrap">
|
|
<table className="admin-stats-table">
|
|
<thead><tr><th>Destination</th><th>Type</th><th>Clicks</th></tr></thead>
|
|
<tbody>
|
|
{stats.engagement.topLinkClicks.map((row: { url: string; count: number; internal: boolean }) => (
|
|
<tr key={row.url}>
|
|
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{row.internal
|
|
? <span style={{ color: '#c9a84c' }}>{row.url}</span>
|
|
: <a href={row.url} target="_blank" rel="noreferrer" style={{ color: '#c9a84c' }}>{row.url}</a>}
|
|
</td>
|
|
<td style={{ color: row.internal ? '#6fcf97' : '#9b9b9b', fontSize: '0.8rem' }}>
|
|
{row.internal ? 'internal' : 'external'}
|
|
</td>
|
|
<td>{row.count.toLocaleString()}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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>
|
|
<div className="admin-stats-table-wrap">
|
|
<table className="admin-stats-table">
|
|
<thead><tr><th>URL</th><th>Clicks</th></tr></thead>
|
|
<tbody>
|
|
{stats.engagement.topOutboundClicks.map((row: { url: string; count: number }) => (
|
|
<tr key={row.url}>
|
|
<td style={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
<a href={row.url} target="_blank" rel="noreferrer" style={{ color: '#c9a84c' }}>{row.url}</a>
|
|
</td>
|
|
<td>{row.count.toLocaleString()}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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>
|
|
<div className="admin-stats-table-wrap">
|
|
<table className="admin-stats-table">
|
|
<thead><tr><th>Source</th><th>Visits</th></tr></thead>
|
|
<tbody>
|
|
{stats.engagement.topUTMSources.map((row: { source: string; count: number }) => (
|
|
<tr key={row.source}><td>{row.source}</td><td>{row.count.toLocaleString()}</td></tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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>
|
|
<div className="admin-stats-table-wrap">
|
|
<table className="admin-stats-table">
|
|
<thead><tr><th>Path</th><th>Hits</th></tr></thead>
|
|
<tbody>
|
|
{stats.engagement.top404s.map((row: { path: string; count: number }) => (
|
|
<tr key={row.path}><td>{row.path}</td><td>{row.count.toLocaleString()}</td></tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Contact Summary */}
|
|
<div className="admin-stats-head admin-stats-head--visitors">
|
|
<h2>Contact Summary</h2>
|
|
<p>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>
|
|
</div>
|
|
|
|
{/* Study Enrollment Funnel */}
|
|
{stats.studyEnrollment?.funnel && (
|
|
<>
|
|
<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>
|
|
</div>
|
|
<div className="admin-funnel">
|
|
{(() => {
|
|
const { signups, firstVisit, firstCompletion } = stats.studyEnrollment.funnel!
|
|
const steps = [
|
|
{ label: 'Signed Up', count: signups, pct: 100 },
|
|
{ label: 'Visited a Study', count: firstVisit, pct: signups > 0 ? Math.round((firstVisit / signups) * 100) : 0 },
|
|
{ label: 'Completed a Section', count: firstCompletion, pct: signups > 0 ? Math.round((firstCompletion / signups) * 100) : 0 },
|
|
]
|
|
return steps.map((step, i) => (
|
|
<div key={i} className="admin-funnel-step">
|
|
<div className="admin-funnel-bar-wrap">
|
|
<div className="admin-funnel-bar" style={{ width: `${step.pct}%` }} />
|
|
</div>
|
|
<div className="admin-funnel-label">
|
|
<span className="admin-funnel-step-name">{step.label}</span>
|
|
<span className="admin-funnel-count">{step.count.toLocaleString()}</span>
|
|
<span className="admin-funnel-pct">{step.pct}%</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
})()}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
</>)}
|
|
|
|
{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>
|
|
</div>
|
|
<div className="admin-stats-grid">
|
|
<article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article>
|
|
<article><h3>Build Number</h3><p>{opsStatus?.buildNumber ?? 'Not available'}</p></article>
|
|
<article><h3>Deployed At</h3><p>{formatDate(opsStatus?.deployedAt ?? null)}</p></article>
|
|
<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>
|
|
</div>
|
|
<div className="admin-stats-grid">
|
|
<article><h3>Hit Stats Write</h3><p>{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.hitStats.at)}</p></article>
|
|
<article><h3>Visitor Stats Write</h3><p>{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.visitorStats.at)}</p></article>
|
|
<article><h3>Backup Status</h3><p>{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.backups.at)}</p></article>
|
|
</div>
|
|
<div className="admin-actions admin-actions--maintenance">
|
|
<button type="button" className="btn-admin-reset" onClick={onExport}>Export JSON</button>
|
|
<button type="button" className="btn-admin-reset" onClick={onBackupNow}>Backup Now</button>
|
|
<button type="button" className="btn-admin-reset" onClick={onPrune}>Prune Old Data</button>
|
|
<button type="button" className="btn-admin-remove" onClick={onClear}>Clear Analytics</button>
|
|
</div>
|
|
<div className="admin-actions admin-actions--maintenance">
|
|
<button type="button" className="btn-admin-reset" onClick={onPurgeCache}>Purge Cache</button>
|
|
<button type="button" className="btn-admin-reset" onClick={onDeployHook}>Trigger Deploy</button>
|
|
<button type="button" className="btn-admin-reset" onClick={onRefreshStatus}>Refresh Status</button>
|
|
</div>
|
|
<div className="admin-restore-row">
|
|
<label htmlFor="restore-backup">Restore Backup</label>
|
|
<select id="restore-backup" value={selectedBackup} onChange={e => onBackupSelect(e.target.value)} disabled={backupFiles.length === 0}>
|
|
{backupFiles.length === 0 && <option value="">No backups found</option>}
|
|
{backupFiles.map(file => <option key={file.filename} value={file.filename}>{file.filename}</option>)}
|
|
</select>
|
|
<button type="button" className="btn-admin-reset" onClick={onRestore} disabled={!selectedBackup}>Restore Selected Backup</button>
|
|
</div>
|
|
{selectedBackupPreview && (
|
|
<div className="admin-restore-preview">
|
|
<h3>Restore Preview</h3>
|
|
<p><strong>Backup:</strong> {selectedBackupPreview.filename}</p>
|
|
<p><strong>Created:</strong> {formatDate(selectedBackupPreview.createdAt)}</p>
|
|
<p><strong>Size:</strong> {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB</p>
|
|
<p><strong>Total Hits:</strong> {selectedBackupPreview.totalHits.toLocaleString()}</p>
|
|
<p><strong>Total Visits:</strong> {selectedBackupPreview.totalVisits.toLocaleString()}</p>
|
|
</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>
|
|
</div>
|
|
<div className="admin-actions admin-actions--maintenance">
|
|
<button type="button" className="btn-admin-reset" onClick={onDownloadFullBackup} disabled={fullBackupBusy}>
|
|
{fullBackupBusy ? 'Working…' : 'Download Full Backup (.tar.gz)'}
|
|
</button>
|
|
</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"
|
|
onChange={e => setFullBackupFile(e.target.files?.[0] ?? null)}
|
|
disabled={fullBackupBusy}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn-admin-remove"
|
|
onClick={() => { if (fullBackupFile) onImportFullBackup(fullBackupFile) }}
|
|
disabled={!fullBackupFile || fullBackupBusy}
|
|
>
|
|
{fullBackupBusy ? 'Working…' : 'Restore Full Backup'}
|
|
</button>
|
|
</div>
|
|
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
|
|
</>)}
|
|
</section>
|
|
)
|
|
}
|