analytics fixs
This commit is contained in:
@@ -612,6 +612,7 @@ const EMPTY_VISITOR_STATS = {
|
|||||||
firstVisitAt: null,
|
firstVisitAt: null,
|
||||||
lastVisitAt: null,
|
lastVisitAt: null,
|
||||||
visitors: {},
|
visitors: {},
|
||||||
|
ipHashIndex: {}, // ipHash → visitorId — prevents same IP counting as multiple unique visitors
|
||||||
recentVisits: [],
|
recentVisits: [],
|
||||||
geoCacheByIp: {},
|
geoCacheByIp: {},
|
||||||
}
|
}
|
||||||
@@ -1343,6 +1344,24 @@ function sanitizeUserAgent(userAgent) {
|
|||||||
return userAgent.trim().slice(0, 300) || 'unknown'
|
return userAgent.trim().slice(0, 300) || 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectDevice(userAgent) {
|
||||||
|
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
|
||||||
|
const ua = userAgent.toLowerCase()
|
||||||
|
if (/tablet|ipad|playbook|silk|(android(?!.*mobile))/.test(ua)) return 'tablet'
|
||||||
|
if (/mobile|iphone|ipod|android|blackberry|opera mini|opera mobi|iemobile|windows phone|palm|smartphone/.test(ua)) return 'mobile'
|
||||||
|
return 'desktop'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeReferrer(referrer) {
|
||||||
|
if (!referrer || typeof referrer !== 'string') return ''
|
||||||
|
try {
|
||||||
|
const parsed = new URL(referrer.trim())
|
||||||
|
return `${parsed.hostname}${parsed.pathname}`.slice(0, 200)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function detectBot(userAgent, pathInfo = {}) {
|
function detectBot(userAgent, pathInfo = {}) {
|
||||||
if (!userAgent || typeof userAgent !== 'string') {
|
if (!userAgent || typeof userAgent !== 'string') {
|
||||||
return { isBot: true, reason: 'missing-user-agent' }
|
return { isBot: true, reason: 'missing-user-agent' }
|
||||||
@@ -1472,7 +1491,7 @@ async function resolveGeo(ip) {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordVisitor(req, res) {
|
async function recordVisitor(req, res, overridePath = null, overrideReferrer = null) {
|
||||||
const cookies = parseCookies(req.headers.cookie)
|
const cookies = parseCookies(req.headers.cookie)
|
||||||
let visitorId = cookies[VISITOR_COOKIE]
|
let visitorId = cookies[VISITOR_COOKIE]
|
||||||
if (!visitorId) {
|
if (!visitorId) {
|
||||||
@@ -1481,24 +1500,42 @@ async function recordVisitor(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nowIso = new Date().toISOString()
|
const nowIso = new Date().toISOString()
|
||||||
const pathKey = normalizeHitPath(req.path)
|
const pathKey = overridePath ? normalizeHitPath(overridePath) : normalizeHitPath(req.path)
|
||||||
|
const referrer = overrideReferrer !== null ? sanitizeReferrer(overrideReferrer) : sanitizeReferrer(req.get('referer') || req.get('referrer') || '')
|
||||||
const ip = getClientIp(req)
|
const ip = getClientIp(req)
|
||||||
const ua = sanitizeUserAgent(req.get('user-agent'))
|
const ua = sanitizeUserAgent(req.get('user-agent'))
|
||||||
|
const device = detectDevice(ua)
|
||||||
|
|
||||||
|
const ipHash = createHash('sha256').update(ip).digest('hex')
|
||||||
|
const geo = await resolveGeo(ip)
|
||||||
|
|
||||||
|
// Resolve canonical visitorId by IP hash — if this IP was seen before under a
|
||||||
|
// different cookie (e.g. cleared cookies), reuse the existing record so the
|
||||||
|
// same person is never counted as a second unique visitor.
|
||||||
|
const existingIdByIp = visitorStats.ipHashIndex[ipHash]
|
||||||
|
if (existingIdByIp && existingIdByIp !== visitorId) {
|
||||||
|
// Reuse the existing record for this IP; overwrite cookie with canonical ID
|
||||||
|
visitorId = existingIdByIp
|
||||||
|
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
|
||||||
|
}
|
||||||
|
|
||||||
const existingVisitor = visitorStats.visitors[visitorId]
|
const existingVisitor = visitorStats.visitors[visitorId]
|
||||||
const isReturning = Boolean(existingVisitor)
|
const isReturning = Boolean(existingVisitor)
|
||||||
const geo = await resolveGeo(ip)
|
|
||||||
|
|
||||||
if (!existingVisitor) {
|
if (!existingVisitor) {
|
||||||
visitorStats.uniqueVisitors += 1
|
visitorStats.uniqueVisitors += 1
|
||||||
|
visitorStats.ipHashIndex[ipHash] = visitorId
|
||||||
} else {
|
} else {
|
||||||
visitorStats.returningVisits += 1
|
visitorStats.returningVisits += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const ipHash = createHash('sha256').update(ip).digest('hex')
|
|
||||||
const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1
|
const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1
|
||||||
const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5)
|
const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5)
|
||||||
|
|
||||||
|
// Append to page history, keeping last 100 entries per visitor
|
||||||
|
const prevHistory = existingVisitor?.pageHistory ?? []
|
||||||
|
const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100)
|
||||||
|
|
||||||
visitorStats.visitors[visitorId] = {
|
visitorStats.visitors[visitorId] = {
|
||||||
visitorId,
|
visitorId,
|
||||||
ip,
|
ip,
|
||||||
@@ -1510,6 +1547,8 @@ async function recordVisitor(req, res) {
|
|||||||
returningVisitor: isReturning,
|
returningVisitor: isReturning,
|
||||||
location: geo,
|
location: geo,
|
||||||
userAgents,
|
userAgents,
|
||||||
|
device,
|
||||||
|
pageHistory,
|
||||||
}
|
}
|
||||||
|
|
||||||
visitorStats.totalVisits += 1
|
visitorStats.totalVisits += 1
|
||||||
@@ -1520,6 +1559,8 @@ async function recordVisitor(req, res) {
|
|||||||
visitorId,
|
visitorId,
|
||||||
ip,
|
ip,
|
||||||
path: pathKey,
|
path: pathKey,
|
||||||
|
referrer,
|
||||||
|
device,
|
||||||
country: geo.country,
|
country: geo.country,
|
||||||
state: geo.state,
|
state: geo.state,
|
||||||
county: geo.county,
|
county: geo.county,
|
||||||
@@ -1536,13 +1577,26 @@ function loadVisitorStatsFromDisk() {
|
|||||||
return readFile(VISITOR_STATS_FILE, 'utf8')
|
return readFile(VISITOR_STATS_FILE, 'utf8')
|
||||||
.then(raw => {
|
.then(raw => {
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
|
const loadedVisitors = parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {}
|
||||||
|
|
||||||
|
// Rebuild ipHashIndex from saved visitors if not persisted (handles upgrades from old data)
|
||||||
|
let ipHashIndex = parsed?.ipHashIndex && typeof parsed.ipHashIndex === 'object' ? parsed.ipHashIndex : {}
|
||||||
|
if (Object.keys(ipHashIndex).length === 0 && Object.keys(loadedVisitors).length > 0) {
|
||||||
|
for (const [vid, visitor] of Object.entries(loadedVisitors)) {
|
||||||
|
if (visitor?.ipHash && typeof visitor.ipHash === 'string') {
|
||||||
|
ipHashIndex[visitor.ipHash] = vid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
visitorStats = {
|
visitorStats = {
|
||||||
totalVisits: Number(parsed?.totalVisits) || 0,
|
totalVisits: Number(parsed?.totalVisits) || 0,
|
||||||
uniqueVisitors: Number(parsed?.uniqueVisitors) || 0,
|
uniqueVisitors: Number(parsed?.uniqueVisitors) || 0,
|
||||||
returningVisits: Number(parsed?.returningVisits) || 0,
|
returningVisits: Number(parsed?.returningVisits) || 0,
|
||||||
firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null,
|
firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null,
|
||||||
lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null,
|
lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null,
|
||||||
visitors: parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {},
|
visitors: loadedVisitors,
|
||||||
|
ipHashIndex,
|
||||||
recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
|
recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
|
||||||
geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {},
|
geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {},
|
||||||
}
|
}
|
||||||
@@ -4023,6 +4077,25 @@ app.post('/api/analytics-consent', (req, res) => {
|
|||||||
res.json({ ok: true, consent })
|
res.json({ ok: true, consent })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Client-side SPA pageview tracking (fires on every React Router navigation)
|
||||||
|
app.post('/api/analytics/pageview', async (req, res) => {
|
||||||
|
if (!hasVisitorConsent(req)) {
|
||||||
|
res.json({ ok: false, reason: 'no-consent' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ua = req.get('user-agent') ?? ''
|
||||||
|
const { isBot } = detectBot(ua)
|
||||||
|
if (isBot) {
|
||||||
|
res.json({ ok: false, reason: 'bot' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
|
||||||
|
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
|
||||||
|
recordHit(rawPath, false)
|
||||||
|
await recordVisitor(req, res, rawPath, rawReferrer)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
||||||
const topPaths = Object.entries(hitStats.byPath)
|
const topPaths = Object.entries(hitStats.byPath)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
@@ -4059,7 +4132,10 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(([reason, count]) => ({ reason, count }))
|
.map(([reason, count]) => ({ reason, count }))
|
||||||
|
|
||||||
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
|
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100).map(row => {
|
||||||
|
const fullVisitor = visitorStats.visitors[row.visitorId]
|
||||||
|
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
|
||||||
|
})
|
||||||
const enrollmentCountsBySlug = {}
|
const enrollmentCountsBySlug = {}
|
||||||
for (const user of studyUsers) {
|
for (const user of studyUsers) {
|
||||||
const userEnrollments = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
const userEnrollments = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||||
@@ -4126,6 +4202,26 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
|||||||
topStates: buildTopLocations(recentVisitorRows, 'state'),
|
topStates: buildTopLocations(recentVisitorRows, 'state'),
|
||||||
topCounties: buildTopLocations(recentVisitorRows, 'county'),
|
topCounties: buildTopLocations(recentVisitorRows, 'county'),
|
||||||
topCities: buildTopLocations(recentVisitorRows, 'city'),
|
topCities: buildTopLocations(recentVisitorRows, 'city'),
|
||||||
|
deviceBreakdown: (() => {
|
||||||
|
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
|
||||||
|
for (const row of recentVisitorRows) {
|
||||||
|
const d = row.device ?? 'unknown'
|
||||||
|
counts[d] = (counts[d] ?? 0) + 1
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
})(),
|
||||||
|
topReferrers: (() => {
|
||||||
|
const counts = {}
|
||||||
|
for (const row of recentVisitorRows) {
|
||||||
|
if (!row.referrer) continue
|
||||||
|
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
|
||||||
|
}
|
||||||
|
return Object.entries(counts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([referrer, count]) => ({ referrer, count }))
|
||||||
|
})(),
|
||||||
|
last30DaysReal: buildLastNDaysStats(30).map(item => ({ day: item.day, hits: hitStats.byDayReal?.[item.day] ?? 0 })),
|
||||||
recentVisits: recentVisitorRows,
|
recentVisits: recentVisitorRows,
|
||||||
},
|
},
|
||||||
writeStatus: {
|
writeStatus: {
|
||||||
|
|||||||
@@ -162,17 +162,23 @@ export interface AdminStats {
|
|||||||
topStates: Array<{ name: string; hits: number }>
|
topStates: Array<{ name: string; hits: number }>
|
||||||
topCounties: Array<{ name: string; hits: number }>
|
topCounties: Array<{ name: string; hits: number }>
|
||||||
topCities: Array<{ name: string; hits: number }>
|
topCities: Array<{ name: string; hits: number }>
|
||||||
|
deviceBreakdown: { mobile: number; desktop: number; tablet: number; unknown: number }
|
||||||
|
topReferrers: Array<{ referrer: string; count: number }>
|
||||||
|
last30DaysReal: Array<{ day: string; hits: number }>
|
||||||
recentVisits: Array<{
|
recentVisits: Array<{
|
||||||
at: string
|
at: string
|
||||||
visitorId: string
|
visitorId: string
|
||||||
ip: string
|
ip: string
|
||||||
path: string
|
path: string
|
||||||
|
referrer?: string
|
||||||
|
device?: string
|
||||||
country: string
|
country: string
|
||||||
state: string
|
state: string
|
||||||
county: string
|
county: string
|
||||||
city: string
|
city: string
|
||||||
returningVisitor: boolean
|
returningVisitor: boolean
|
||||||
visitCount: number
|
visitCount: number
|
||||||
|
pageHistory?: Array<{ at: string; path: string; referrer?: string }>
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
writeStatus: {
|
writeStatus: {
|
||||||
|
|||||||
+55
@@ -3300,6 +3300,61 @@
|
|||||||
background: rgba(201, 168, 76, 0.04);
|
background: rgba(201, 168, 76, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-visitor-row--expanded {
|
||||||
|
background: rgba(201, 168, 76, 0.07) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-visitor-history-row td {
|
||||||
|
padding: 0 !important;
|
||||||
|
background: #0d0d0a;
|
||||||
|
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-visitor-history {
|
||||||
|
padding: 0.75rem 1.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-visitor-history-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #8a7f5a;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-visitor-history-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-visitor-history-entry {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-visitor-history-time {
|
||||||
|
color: #5a5440;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-visitor-history-path {
|
||||||
|
color: #c9a84c;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-visitor-history-ref {
|
||||||
|
color: #5a5440;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-visits-table th {
|
.admin-visits-table th {
|
||||||
color: var(--brand-gold);
|
color: var(--brand-gold);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
|||||||
+15
@@ -15,6 +15,20 @@ const SPOTIFY_EMBED_URL =
|
|||||||
const CONSENT_KEY = 'vbn_analytics_consent_choice'
|
const CONSENT_KEY = 'vbn_analytics_consent_choice'
|
||||||
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
|
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
|
||||||
|
|
||||||
|
function usePageTracking() {
|
||||||
|
const location = useLocation()
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem(CONSENT_KEY) !== 'accepted') return
|
||||||
|
const referrer = document.referrer || ''
|
||||||
|
fetch('/api/analytics/pageview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: location.pathname, referrer }),
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [location.pathname])
|
||||||
|
}
|
||||||
|
|
||||||
function toSpotifyEpisodeEmbedUrl(url: string | undefined): string {
|
function toSpotifyEpisodeEmbedUrl(url: string | undefined): string {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
|
|
||||||
@@ -2120,6 +2134,7 @@ export default function App() {
|
|||||||
const [content, setContent] = useState<SiteContent>(DEFAULTS)
|
const [content, setContent] = useState<SiteContent>(DEFAULTS)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
usePageTracking()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin-content')
|
fetch('/api/admin-content')
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ export function AnalyticsPanel({
|
|||||||
onDeployHook,
|
onDeployHook,
|
||||||
onRefreshStatus,
|
onRefreshStatus,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic'>('overview')
|
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic' | 'referrers'>('overview')
|
||||||
|
const [expandedVisitor, setExpandedVisitor] = useState<string | null>(null)
|
||||||
|
|
||||||
if (statsStatus === 'loading') return <p className="admin-stats-note">Loading analytics…</p>
|
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 (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
|
||||||
@@ -165,6 +166,33 @@ export function AnalyticsPanel({
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 = {
|
const chartOptions = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: true,
|
maintainAspectRatio: true,
|
||||||
@@ -240,51 +268,77 @@ export function AnalyticsPanel({
|
|||||||
{/* Chart Tabs */}
|
{/* Chart Tabs */}
|
||||||
<div className="admin-tabs admin-tabs--charts">
|
<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 === 'overview' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('overview')}>Overview</button>
|
||||||
<button type="button" className={`admin-tab${chartTab === 'breakdown' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('breakdown')}>Bot Breakdown</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 === '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>
|
</div>
|
||||||
|
|
||||||
{/* Overview Charts */}
|
{/* Overview Charts */}
|
||||||
{chartTab === 'overview' && (
|
{chartTab === 'overview' && (
|
||||||
<div className="admin-analytics-grid">
|
<>
|
||||||
<div className="admin-analytics-card">
|
<div className="admin-analytics-grid">
|
||||||
<h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3>
|
<div className="admin-analytics-card">
|
||||||
<Line data={dailyChartData} options={chartOptions} />
|
<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>
|
||||||
<div className="admin-analytics-card">
|
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
||||||
<h3 className="admin-analytics-card-title">Traffic Composition</h3>
|
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||||
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
<h3 className="admin-analytics-card-title">30-Day Real Visitor Trend</h3>
|
||||||
|
<Line data={thirtyDayChartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bot Breakdown Charts */}
|
{/* Bot & Device Charts */}
|
||||||
{chartTab === 'breakdown' && (
|
{chartTab === 'breakdown' && (
|
||||||
<div className="admin-analytics-grid">
|
<>
|
||||||
<div className="admin-analytics-card">
|
<div className="admin-analytics-grid">
|
||||||
<h3 className="admin-analytics-card-title">Bot Sources</h3>
|
<div className="admin-analytics-card">
|
||||||
{botReasons.length === 0 ? (
|
<h3 className="admin-analytics-card-title">Device Breakdown</h3>
|
||||||
<p className="admin-stats-note">No bot traffic detected.</p>
|
<Doughnut data={deviceChartData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||||
) : (
|
</div>
|
||||||
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
<div className="admin-analytics-card">
|
||||||
)}
|
<h3 className="admin-analytics-card-title">Device Counts</h3>
|
||||||
</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">
|
<ul className="admin-bot-reason-list">
|
||||||
{botReasons.map((reason: { reason: string; count: number }) => (
|
<li><span>Desktop</span><strong>{deviceBreakdown.desktop.toLocaleString()}</strong></li>
|
||||||
<li key={reason.reason} className="admin-bot-reason-item">
|
<li><span>Mobile</span><strong>{deviceBreakdown.mobile.toLocaleString()}</strong></li>
|
||||||
<span>{reason.reason.replace(/-/g, ' ')}</span>
|
<li><span>Tablet</span><strong>{deviceBreakdown.tablet.toLocaleString()}</strong></li>
|
||||||
<strong>{reason.count.toLocaleString()}</strong>
|
{deviceBreakdown.unknown > 0 && <li><span>Unknown</span><strong>{deviceBreakdown.unknown.toLocaleString()}</strong></li>}
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</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 */}
|
{/* Geographic Charts */}
|
||||||
@@ -301,6 +355,27 @@ export function AnalyticsPanel({
|
|||||||
</div>
|
</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 */}
|
{/* Recent Visitors Table */}
|
||||||
<div className="admin-stats-head admin-stats-head--visitors">
|
<div className="admin-stats-head admin-stats-head--visitors">
|
||||||
<h2>Visitor Details</h2>
|
<h2>Visitor Details</h2>
|
||||||
@@ -347,25 +422,64 @@ export function AnalyticsPanel({
|
|||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
<th>Country</th>
|
<th>Country</th>
|
||||||
<th>Path</th>
|
<th>Path</th>
|
||||||
|
<th>Referrer</th>
|
||||||
|
<th>Device</th>
|
||||||
<th>Returning</th>
|
<th>Returning</th>
|
||||||
<th>Visit #</th>
|
<th>Visit #</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{stats.visitors.recentVisits.map(row => (
|
{stats.visitors.recentVisits.map(row => {
|
||||||
<tr key={`${row.visitorId}-${row.at}`}>
|
const rowKey = `${row.visitorId}-${row.at}`
|
||||||
<td>{formatDate(row.at)}</td>
|
const isExpanded = expandedVisitor === rowKey
|
||||||
<td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td>
|
return (
|
||||||
<td>{row.country || '—'}</td>
|
<>
|
||||||
<td className="admin-visitor-path" title={row.path}>{row.path}</td>
|
<tr
|
||||||
<td>
|
key={rowKey}
|
||||||
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
|
className={`admin-visitor-row${isExpanded ? ' admin-visitor-row--expanded' : ''}`}
|
||||||
{row.returningVisitor ? 'Returning' : 'New'}
|
style={{ cursor: 'pointer' }}
|
||||||
</span>
|
onClick={() => setExpandedVisitor(isExpanded ? null : rowKey)}
|
||||||
</td>
|
title="Click to view page history"
|
||||||
<td className="admin-visitor-count">{row.visitCount}</td>
|
>
|
||||||
</tr>
|
<td>{formatDate(row.at)}</td>
|
||||||
))}
|
<td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td>
|
||||||
|
<td>{row.country || '—'}</td>
|
||||||
|
<td className="admin-visitor-path" title={row.path}>{row.path}</td>
|
||||||
|
<td className="admin-visitor-path" title={row.referrer || ''}>{row.referrer || '—'}</td>
|
||||||
|
<td>{row.device || '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
|
||||||
|
{row.returningVisitor ? 'Returning' : 'New'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="admin-visitor-count">{row.visitCount} {isExpanded ? '▲' : '▼'}</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (row.pageHistory ?? []).length > 0 && (
|
||||||
|
<tr key={`${rowKey}-history`} className="admin-visitor-history-row">
|
||||||
|
<td colSpan={8}>
|
||||||
|
<div className="admin-visitor-history">
|
||||||
|
<p className="admin-visitor-history-label">Full page history for this visitor ({(row.pageHistory ?? []).length} pages):</p>
|
||||||
|
<ol className="admin-visitor-history-list">
|
||||||
|
{[...(row.pageHistory ?? [])].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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{isExpanded && (row.pageHistory ?? []).length === 0 && (
|
||||||
|
<tr key={`${rowKey}-history-empty`} className="admin-visitor-history-row">
|
||||||
|
<td colSpan={8}><p style={{ padding: '0.5rem 1rem', color: '#8a7f5a', margin: 0, fontSize: '0.85rem' }}>No page history yet — history is built from SPA navigations after consent.</p></td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user