From 31426fccba2d57b4383b91f9385766cbdeedf2e2 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 17 Jun 2026 14:06:21 -0400 Subject: [PATCH] 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 --- server.js | 2 + server/config.js | 1 + server/data.js | 91 +++++++++++++++ server/routes/analytics.js | 55 ++++++++- server/state.js | 21 ++++ src/AdminPage.tsx | 10 ++ src/App.tsx | 28 ++++- src/analytics.ts | 108 ++++++++++++++++++ src/components/AnalyticsPanel.tsx | 158 +++++++++++++++++++++++++- src/components/EpisodeAudioPlayer.tsx | 25 +++- src/components/GlobalSearch.tsx | 3 + 11 files changed, 498 insertions(+), 4 deletions(-) create mode 100644 src/analytics.ts diff --git a/server.js b/server.js index 1bf78f3..24e3c94 100644 --- a/server.js +++ b/server.js @@ -22,6 +22,7 @@ import { loadQrCodesFromDisk, loadEpisodePlaysFromDisk, loadPodcastChecklistFromDisk, + loadAnalyticsEventsFromDisk, createBackupSnapshot, refreshContentCaches, queueHitStatsWrite, @@ -115,6 +116,7 @@ Promise.all([ loadQrCodesFromDisk(), loadEpisodePlaysFromDisk(), loadPodcastChecklistFromDisk(), + loadAnalyticsEventsFromDisk(), refreshContentCaches(), ]) .catch(err => { diff --git a/server/config.js b/server/config.js index 0499a5c..55ba9d0 100644 --- a/server/config.js +++ b/server/config.js @@ -38,6 +38,7 @@ export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.j export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json') export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json') export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json') +export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json') export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon export const DIST_DIR = path.join(ROOT_DIR, 'dist') diff --git a/server/data.js b/server/data.js index 8345294..9ef97b9 100644 --- a/server/data.js +++ b/server/data.js @@ -29,6 +29,7 @@ import { UPLOADS_META_FILE, DOWNLOAD_COUNTS_FILE, EPISODE_PLAYS_FILE, + ANALYTICS_EVENTS_FILE, EMPTY_HIT_STATS, EMPTY_VISITOR_STATS, DEFAULT_REPLY_TEMPLATES, @@ -710,6 +711,96 @@ export function recordEpisodePlay(title) { queueEpisodePlaysWrite() } +// ── Analytics events ─────────────────────────────────────────────────────── + +const EMPTY_ANALYTICS_EVENTS = { + scrollDepth: {}, + timeOnPage: {}, + outboundClicks: {}, + utmSources: {}, + searchQueries: {}, + notFound: {}, + audioEvents: {}, +} + +export function queueAnalyticsEventsWrite() { + state.analyticsEventsWritePromise = state.analyticsEventsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(ANALYTICS_EVENTS_FILE, JSON.stringify(state.analyticsEvents, null, 2), 'utf8') + }) + .catch(err => { + console.error('[analytics-events] failed to write:', err) + }) +} + +export function loadAnalyticsEventsFromDisk() { + return readFile(ANALYTICS_EVENTS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + state.analyticsEvents = { + scrollDepth: parsed?.scrollDepth ?? {}, + timeOnPage: parsed?.timeOnPage ?? {}, + outboundClicks: parsed?.outboundClicks ?? {}, + utmSources: parsed?.utmSources ?? {}, + searchQueries: parsed?.searchQueries ?? {}, + notFound: parsed?.notFound ?? {}, + audioEvents: parsed?.audioEvents ?? {}, + } + }) + .catch(() => { + state.analyticsEvents = { ...EMPTY_ANALYTICS_EVENTS } + }) +} + +export function recordAnalyticsEvent(type, data) { + const ev = state.analyticsEvents + if (type === 'scroll_depth') { + const path = data.path ?? '/' + if (!ev.scrollDepth[path]) ev.scrollDepth[path] = { 25: 0, 50: 0, 75: 0, 90: 0 } + const mark = String(data.depth) + ev.scrollDepth[path][mark] = (ev.scrollDepth[path][mark] ?? 0) + 1 + } else if (type === 'time_on_page') { + const path = data.path ?? '/' + const seconds = Number(data.seconds) || 0 + if (!ev.timeOnPage[path]) ev.timeOnPage[path] = { totalSeconds: 0, count: 0 } + ev.timeOnPage[path].totalSeconds += seconds + ev.timeOnPage[path].count += 1 + } else if (type === 'outbound_click') { + const url = typeof data.url === 'string' ? data.url.slice(0, 500) : '' + if (url) ev.outboundClicks[url] = (ev.outboundClicks[url] ?? 0) + 1 + } else if (type === 'utm') { + const source = typeof data.utm_source === 'string' ? data.utm_source.slice(0, 100) : 'unknown' + ev.utmSources[source] = (ev.utmSources[source] ?? 0) + 1 + } else if (type === 'search_query') { + const q = typeof data.query === 'string' ? data.query.trim().slice(0, 200) : '' + if (q) ev.searchQueries[q] = (ev.searchQueries[q] ?? 0) + 1 + } else if (type === 'not_found') { + const path = typeof data.path === 'string' ? data.path.slice(0, 300) : '/' + ev.notFound[path] = (ev.notFound[path] ?? 0) + 1 + } else if (type === 'audio_pause') { + const title = typeof data.title === 'string' ? data.title.trim().slice(0, 200) : '' + if (title) { + if (!ev.audioEvents[title]) ev.audioEvents[title] = { pauses: 0, completions: 0, totalListenSeconds: 0 } + ev.audioEvents[title].pauses += 1 + } + } else if (type === 'audio_completion') { + const title = typeof data.title === 'string' ? data.title.trim().slice(0, 200) : '' + if (title) { + if (!ev.audioEvents[title]) ev.audioEvents[title] = { pauses: 0, completions: 0, totalListenSeconds: 0 } + ev.audioEvents[title].completions += 1 + } + } else if (type === 'audio_listen_time') { + const title = typeof data.title === 'string' ? data.title.trim().slice(0, 200) : '' + const seconds = Number(data.seconds) || 0 + if (title && seconds > 0) { + if (!ev.audioEvents[title]) ev.audioEvents[title] = { pauses: 0, completions: 0, totalListenSeconds: 0 } + ev.audioEvents[title].totalListenSeconds += seconds + } + } + queueAnalyticsEventsWrite() +} + // ── Uploads ──────────────────────────────────────────────────────────────── export async function readUploadsMetadata() { diff --git a/server/routes/analytics.js b/server/routes/analytics.js index f6333f8..b2d3894 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -1,12 +1,13 @@ import { createHash, randomUUID } from 'node:crypto' import { requireAdminAuth, isValidAdminSession } from '../auth.js' import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js' +import { getStudyUserFromRequest } from '../study-helpers.js' import { VISITOR_COOKIE, MAX_RECENT_VISITS, } from '../config.js' import { state } from '../state.js' -import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay } from '../data.js' +import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay, recordAnalyticsEvent } from '../data.js' import { detectBot, sanitizeUserAgent, @@ -88,6 +89,10 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer const ip = getClientIp(req) const ua = sanitizeUserAgent(req.get('user-agent')) const device = detectDevice(ua) + const studyUser = getStudyUserFromRequest(req) + const studyIdentity = studyUser + ? { userId: studyUser.id, username: studyUser.username, displayName: studyUser.displayName ?? studyUser.username } + : null const ipHash = createHash('sha256').update(ip).digest('hex') const geo = await resolveGeo(ip) @@ -113,6 +118,11 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer const prevHistory = existingVisitor?.pageHistory ?? [] const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100) + const knownIdentities = existingVisitor?.knownIdentities ?? [] + if (studyIdentity && !knownIdentities.some(i => i.userId === studyIdentity.userId)) { + knownIdentities.push(studyIdentity) + } + state.visitorStats.visitors[visitorId] = { visitorId, ip, ipHash, firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso, @@ -124,6 +134,7 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer userAgents, device, pageHistory, + ...(knownIdentities.length > 0 ? { knownIdentities } : {}), } state.visitorStats.totalVisits += 1 @@ -133,6 +144,7 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer at: nowIso, visitorId, ip, path: pathKey, referrer, device, country: geo.country, state: geo.state, county: geo.county, city: geo.city, returningVisitor: isReturning, visitCount: nextVisitCount, + ...(studyIdentity ? { studyUser: studyIdentity } : {}), }) state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS) @@ -154,6 +166,19 @@ export function register(app) { res.json({ ok: true }) }) + app.post('/api/analytics/event', (req, res) => { + if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return } + 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 type = typeof req.body?.type === 'string' ? req.body.type : '' + const ALLOWED_TYPES = ['scroll_depth', 'time_on_page', 'outbound_click', 'utm', 'search_query', 'not_found', 'audio_pause', 'audio_completion', 'audio_listen_time'] + if (!ALLOWED_TYPES.includes(type)) { res.status(400).json({ ok: false, reason: 'invalid-type' }); return } + recordAnalyticsEvent(type, req.body) + res.json({ ok: true }) + }) + app.post('/api/analytics/pageview', async (req, res) => { if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return @@ -340,6 +365,34 @@ export function register(app) { last30Days: buildLastNDaysStats(30).map(item => ({ day: item.day, plays: data.byDay?.[item.day] ?? 0 })), })) .sort((a, b) => b.total - a.total), + engagement: { + scrollDepth: Object.entries(state.analyticsEvents.scrollDepth ?? {}) + .map(([path, marks]) => ({ path, ...marks })) + .sort((a, b) => (b[90] ?? 0) - (a[90] ?? 0)) + .slice(0, 20), + timeOnPage: Object.entries(state.analyticsEvents.timeOnPage ?? {}) + .map(([path, { totalSeconds, count }]) => ({ path, avgSeconds: count > 0 ? Math.round(totalSeconds / count) : 0, count })) + .sort((a, b) => b.avgSeconds - a.avgSeconds) + .slice(0, 20), + topOutboundClicks: Object.entries(state.analyticsEvents.outboundClicks ?? {}) + .map(([url, count]) => ({ url, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 20), + topUTMSources: Object.entries(state.analyticsEvents.utmSources ?? {}) + .map(([source, count]) => ({ source, count })) + .sort((a, b) => b.count - a.count), + topSearchQueries: Object.entries(state.analyticsEvents.searchQueries ?? {}) + .map(([query, count]) => ({ query, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 30), + top404s: Object.entries(state.analyticsEvents.notFound ?? {}) + .map(([path, count]) => ({ path, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 20), + audioEvents: Object.entries(state.analyticsEvents.audioEvents ?? {}) + .map(([title, data]) => ({ title, ...data })) + .sort((a, b) => b.completions - a.completions), + }, }) }) } diff --git a/server/state.js b/server/state.js index a99c3f7..2c43adf 100644 --- a/server/state.js +++ b/server/state.js @@ -63,6 +63,27 @@ export const state = { episodePlays: {}, episodePlaysWritePromise: Promise.resolve(), + // analyticsEvents: aggregated engagement data from frontend events + // { + // scrollDepth: { [path]: { 25: n, 50: n, 75: n, 90: n } } + // timeOnPage: { [path]: { totalSeconds: n, count: n } } + // outboundClicks: { [url]: n } + // utmSources: { [utm_source]: n } + // searchQueries: { [query]: n } + // notFound: { [path]: n } + // audioEvents: { [title]: { pauses: n, completions: n, totalListenSeconds: n } } + // } + analyticsEvents: { + scrollDepth: {}, + timeOnPage: {}, + outboundClicks: {}, + utmSources: {}, + searchQueries: {}, + notFound: {}, + audioEvents: {}, + }, + analyticsEventsWritePromise: Promise.resolve(), + // qrCodes: Array<{ id, slug, label, destination, createdAt }> // qrScans: Array<{ id, qrId, slug, scannedAt, ip, userAgent }> qrCodes: [], diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index e0d0c2b..76219f9 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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 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 { diff --git a/src/App.tsx b/src/App.tsx index a8419e5..ec56685 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( <> + } /> } /> @@ -2418,12 +2429,27 @@ export default function App() { /> )} /> + } /> ) } +function NotFoundPage() { + const location = useLocation() + useEffect(() => { + sendEvent('not_found', { path: location.pathname }) + }, [location.pathname]) + return ( +
+

Page not found

+

The page {location.pathname} doesn't exist.

+ ← Back to home +
+ ) +} + function BackToTopButton() { const [visible, setVisible] = useState(false) diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..7ebb7d9 --- /dev/null +++ b/src/analytics.ts @@ -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): 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()) + + 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 = {} + 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 }) + }, []) +} diff --git a/src/components/AnalyticsPanel.tsx b/src/components/AnalyticsPanel.tsx index 3a7f26c..4bb027e 100644 --- a/src/components/AnalyticsPanel.tsx +++ b/src/components/AnalyticsPanel.tsx @@ -456,6 +456,7 @@ export function AnalyticsPanel({ Time IP + User Country Path Referrer @@ -479,6 +480,7 @@ export function AnalyticsPanel({ > {formatDate(row.at)} {maskIp(row.ip)} + {row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : '—'} {row.country || '—'} {row.path} {row.referrer || '—'} @@ -492,7 +494,7 @@ export function AnalyticsPanel({ {isExpanded && (row.pageHistory ?? []).length > 0 && ( - +

Full page history for this visitor ({(row.pageHistory ?? []).length} pages):

    @@ -566,6 +568,160 @@ export function AnalyticsPanel({ )} + {/* Engagement */} + {stats.engagement && ( + <> +
    +

    Engagement

    +

    Scroll depth, time on page, audio, and interaction data from consenting visitors.

    +
    + + {/* Time on page */} + {stats.engagement.timeOnPage?.length > 0 && ( +
    +

    Avg. Time on Page

    +
    + + + + {stats.engagement.timeOnPage.slice(0, 15).map((row: { path: string; avgSeconds: number; count: number }) => ( + + + + + + ))} + +
    PageAvg TimeSessions
    {row.path}{row.avgSeconds >= 60 ? `${Math.floor(row.avgSeconds / 60)}m ${row.avgSeconds % 60}s` : `${row.avgSeconds}s`}{row.count.toLocaleString()}
    +
    +
    + )} + + {/* Scroll depth */} + {stats.engagement.scrollDepth?.length > 0 && ( +
    +

    Scroll Depth

    +
    + + + + {stats.engagement.scrollDepth.slice(0, 15).map((row: { path: string; 25?: number; 50?: number; 75?: number; 90?: number }) => ( + + + + + + + + ))} + +
    Page25%50%75%90%
    {row.path}{(row[25] ?? 0).toLocaleString()}{(row[50] ?? 0).toLocaleString()}{(row[75] ?? 0).toLocaleString()}{(row[90] ?? 0).toLocaleString()}
    +
    +
    + )} + + {/* Audio events */} + {stats.engagement.audioEvents?.length > 0 && ( +
    +

    Audio Engagement

    +
    + + + + {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 ( + + + + + + + ) + })} + +
    EpisodeCompletionsPausesTotal Listen
    {row.title}{row.completions.toLocaleString()}{row.pauses.toLocaleString()}{listenStr}
    +
    +
    + )} + + {/* Search queries */} + {stats.engagement.topSearchQueries?.length > 0 && ( +
    +

    Top Search Queries

    +
    + + + + {stats.engagement.topSearchQueries.map((row: { query: string; count: number }) => ( + + ))} + +
    QuerySearches
    {row.query}{row.count.toLocaleString()}
    +
    +
    + )} + + {/* Outbound clicks */} + {stats.engagement.topOutboundClicks?.length > 0 && ( +
    +

    Outbound Link Clicks

    +
    + + + + {stats.engagement.topOutboundClicks.map((row: { url: string; count: number }) => ( + + + + + ))} + +
    URLClicks
    + {row.url} + {row.count.toLocaleString()}
    +
    +
    + )} + + {/* UTM sources */} + {stats.engagement.topUTMSources?.length > 0 && ( +
    +

    UTM Sources

    +
    + + + + {stats.engagement.topUTMSources.map((row: { source: string; count: number }) => ( + + ))} + +
    SourceVisits
    {row.source}{row.count.toLocaleString()}
    +
    +
    + )} + + {/* 404s */} + {stats.engagement.top404s?.length > 0 && ( +
    +

    404 Not Found

    +
    + + + + {stats.engagement.top404s.map((row: { path: string; count: number }) => ( + + ))} + +
    PathHits
    {row.path}{row.count.toLocaleString()}
    +
    +
    + )} + + )} + {/* Contact Summary */}

    Contact Summary

    diff --git a/src/components/EpisodeAudioPlayer.tsx b/src/components/EpisodeAudioPlayer.tsx index 6560a39..49eaf66 100644 --- a/src/components/EpisodeAudioPlayer.tsx +++ b/src/components/EpisodeAudioPlayer.tsx @@ -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(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', { diff --git a/src/components/GlobalSearch.tsx b/src/components/GlobalSearch.tsx index 7deef6b..c800eae 100644 --- a/src/components/GlobalSearch.tsx +++ b/src/components/GlobalSearch.tsx @@ -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 = { 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')