Add comprehensive visitor engagement tracking and scroll-to-top on navigation

Tracks scroll depth (25/50/75/90%), time on page, UTM parameters, outbound
link clicks, search queries, audio pause/completion/listen time, and 404s.
Logged-in study users are now tied to their visitor record and surfaced in
the admin recent visits table. Scroll position resets on every route change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-17 14:06:21 -04:00
parent a6f0c39c35
commit 31426fccba
11 changed files with 498 additions and 4 deletions
+10
View File
@@ -540,6 +540,7 @@ export interface AdminStats {
returningVisitor: boolean
visitCount: number
pageHistory?: Array<{ at: string; path: string; referrer?: string }>
studyUser?: { userId: string; username: string; displayName: string }
}>
}
writeStatus: {
@@ -570,6 +571,15 @@ export interface AdminStats {
byDay: Record<string, number>
last30Days: Array<{ day: string; plays: number }>
}>
engagement?: {
scrollDepth: Array<{ path: string; 25?: number; 50?: number; 75?: number; 90?: number }>
timeOnPage: Array<{ path: string; avgSeconds: number; count: number }>
topOutboundClicks: Array<{ url: string; count: number }>
topUTMSources: Array<{ source: string; count: number }>
topSearchQueries: Array<{ query: string; count: number }>
top404s: Array<{ path: string; count: number }>
audioEvents: Array<{ title: string; pauses: number; completions: number; totalListenSeconds: number }>
}
}
interface AdminAsset {
+27 -1
View File
@@ -13,11 +13,17 @@ import { useGlobalSearch } from './hooks/useGlobalSearch'
import { GlobalSearch } from './components/GlobalSearch'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import './App.css'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useOutboundLinkTracking } from './analytics'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => { window.scrollTo(0, 0) }, [pathname])
return null
}
function usePageTracking() {
const location = useLocation()
useEffect(() => {
@@ -2308,6 +2314,10 @@ export default function App() {
const navigate = useNavigate()
const location = useLocation()
usePageTracking()
useScrollDepthTracking()
useTimeOnPage()
useUTMCapture()
useOutboundLinkTracking()
useEffect(() => {
fetch('/api/admin-content')
@@ -2375,6 +2385,7 @@ export default function App() {
return (
<>
<ScrollToTop />
<Routes>
<Route path="/" element={<LandingPage content={content} />} />
<Route path="/start-here" element={<StartHerePage content={content} />} />
@@ -2418,12 +2429,27 @@ export default function App() {
/>
)}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
<BackToTopButton />
</>
)
}
function NotFoundPage() {
const location = useLocation()
useEffect(() => {
sendEvent('not_found', { path: location.pathname })
}, [location.pathname])
return (
<main style={{ padding: '4rem 2rem', textAlign: 'center' }}>
<h1>Page not found</h1>
<p>The page <code>{location.pathname}</code> doesn't exist.</p>
<Link to="/" style={{ marginTop: '1rem', display: 'inline-block' }}>← Back to home</Link>
</main>
)
}
function BackToTopButton() {
const [visible, setVisible] = useState(false)
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
function hasConsent(): boolean {
return localStorage.getItem(CONSENT_KEY) === 'accepted'
}
export function sendEvent(type: string, data: Record<string, unknown>): void {
if (!hasConsent()) return
fetch('/api/analytics/event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, ...data }),
keepalive: true,
}).catch(() => {})
}
// --- Scroll depth ---
export function useScrollDepthTracking() {
const location = useLocation()
const milestones = useRef(new Set<number>())
useEffect(() => {
milestones.current = new Set()
function onScroll() {
if (!hasConsent()) return
const el = document.documentElement
const pct = Math.round((el.scrollTop / (el.scrollHeight - el.clientHeight)) * 100)
for (const mark of [25, 50, 75, 90]) {
if (pct >= mark && !milestones.current.has(mark)) {
milestones.current.add(mark)
sendEvent('scroll_depth', { path: location.pathname, depth: mark })
}
}
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [location.pathname])
}
// --- Time on page ---
export function useTimeOnPage() {
const location = useLocation()
const enteredAt = useRef(Date.now())
const path = useRef(location.pathname)
useEffect(() => {
enteredAt.current = Date.now()
path.current = location.pathname
function send() {
if (!hasConsent()) return
const seconds = Math.round((Date.now() - enteredAt.current) / 1000)
if (seconds < 3) return
sendEvent('time_on_page', { path: path.current, seconds })
}
const onVisibilityChange = () => { if (document.hidden) send() }
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
send()
}
}, [location.pathname])
}
// --- UTM capture (runs once per page load) ---
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 })
}, [])
}
// --- Outbound link clicks ---
export function useOutboundLinkTracking() {
useEffect(() => {
function onClick(e: MouseEvent) {
if (!hasConsent()) return
const target = (e.target as HTMLElement).closest('a')
if (!target) return
const href = target.getAttribute('href') ?? ''
if (!href.startsWith('http') && !href.startsWith('//')) return
try {
const url = new URL(href)
if (url.hostname === window.location.hostname) return
sendEvent('outbound_click', { url: href, text: target.textContent?.trim().slice(0, 100) ?? '' })
} catch { /* ignore malformed */ }
}
document.addEventListener('click', onClick, { capture: true })
return () => document.removeEventListener('click', onClick, { capture: true })
}, [])
}
+157 -1
View File
@@ -456,6 +456,7 @@ export function AnalyticsPanel({
<tr>
<th>Time</th>
<th>IP</th>
<th>User</th>
<th>Country</th>
<th>Path</th>
<th>Referrer</th>
@@ -479,6 +480,7 @@ export function AnalyticsPanel({
>
<td>{formatDate(row.at)}</td>
<td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td>
<td>{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : '—'}</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>
@@ -492,7 +494,7 @@ export function AnalyticsPanel({
</tr>
{isExpanded && (row.pageHistory ?? []).length > 0 && (
<tr key={`${rowKey}-history`} className="admin-visitor-history-row">
<td colSpan={8}>
<td colSpan={9}>
<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">
@@ -566,6 +568,160 @@ export function AnalyticsPanel({
</>
)}
{/* 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>
)}
{/* 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>
+24 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { sendEvent } from '../analytics'
interface EpisodeAudioPlayerProps {
src: string
@@ -36,13 +37,30 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const playTrackedRef = useRef(false)
const listenStartRef = useRef<number | null>(null)
const totalListenSecondsRef = useRef(0)
function flushListenTime() {
if (listenStartRef.current !== null && title) {
const seconds = Math.round((Date.now() - listenStartRef.current) / 1000)
if (seconds > 1) {
totalListenSecondsRef.current += seconds
sendEvent('audio_listen_time', { title, seconds })
}
listenStartRef.current = null
}
}
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const onTimeUpdate = () => setCurrentTime(audio.currentTime)
const onDurationChange = () => setDuration(audio.duration)
const onEnded = () => setPlaying(false)
const onEnded = () => {
setPlaying(false)
flushListenTime()
if (title) sendEvent('audio_completion', { title })
}
audio.addEventListener('timeupdate', onTimeUpdate)
audio.addEventListener('durationchange', onDurationChange)
audio.addEventListener('loadedmetadata', onDurationChange)
@@ -52,7 +70,9 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
audio.removeEventListener('durationchange', onDurationChange)
audio.removeEventListener('loadedmetadata', onDurationChange)
audio.removeEventListener('ended', onEnded)
flushListenTime()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [src])
function togglePlay() {
@@ -61,9 +81,12 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
if (playing) {
audio.pause()
setPlaying(false)
flushListenTime()
if (title) sendEvent('audio_pause', { title })
} else {
audio.play().then(() => {
setPlaying(true)
listenStartRef.current = Date.now()
if (!playTrackedRef.current && title) {
playTrackedRef.current = true
fetch('/api/analytics/play', {
+3
View File
@@ -1,6 +1,7 @@
import { useRef, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import type { SearchResult, SearchResultType } from '../hooks/useGlobalSearch'
import { sendEvent } from '../analytics'
const TYPE_LABEL: Record<SearchResultType, string> = {
episode: 'Episode',
@@ -51,6 +52,8 @@ export function GlobalSearch({ query, setQuery, results }: Props) {
function handleSelect(result: SearchResult) {
setOpen(false)
const q = query.trim()
if (q) sendEvent('search_query', { query: q })
setQuery('')
if (result.href.startsWith('http')) {
window.open(result.href, '_blank', 'noreferrer')