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
+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', {