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:
@@ -22,6 +22,7 @@ import {
|
|||||||
loadQrCodesFromDisk,
|
loadQrCodesFromDisk,
|
||||||
loadEpisodePlaysFromDisk,
|
loadEpisodePlaysFromDisk,
|
||||||
loadPodcastChecklistFromDisk,
|
loadPodcastChecklistFromDisk,
|
||||||
|
loadAnalyticsEventsFromDisk,
|
||||||
createBackupSnapshot,
|
createBackupSnapshot,
|
||||||
refreshContentCaches,
|
refreshContentCaches,
|
||||||
queueHitStatsWrite,
|
queueHitStatsWrite,
|
||||||
@@ -115,6 +116,7 @@ Promise.all([
|
|||||||
loadQrCodesFromDisk(),
|
loadQrCodesFromDisk(),
|
||||||
loadEpisodePlaysFromDisk(),
|
loadEpisodePlaysFromDisk(),
|
||||||
loadPodcastChecklistFromDisk(),
|
loadPodcastChecklistFromDisk(),
|
||||||
|
loadAnalyticsEventsFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
|||||||
@@ -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 EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
|
||||||
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.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 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 MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||||
|
|
||||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
UPLOADS_META_FILE,
|
UPLOADS_META_FILE,
|
||||||
DOWNLOAD_COUNTS_FILE,
|
DOWNLOAD_COUNTS_FILE,
|
||||||
EPISODE_PLAYS_FILE,
|
EPISODE_PLAYS_FILE,
|
||||||
|
ANALYTICS_EVENTS_FILE,
|
||||||
EMPTY_HIT_STATS,
|
EMPTY_HIT_STATS,
|
||||||
EMPTY_VISITOR_STATS,
|
EMPTY_VISITOR_STATS,
|
||||||
DEFAULT_REPLY_TEMPLATES,
|
DEFAULT_REPLY_TEMPLATES,
|
||||||
@@ -710,6 +711,96 @@ export function recordEpisodePlay(title) {
|
|||||||
queueEpisodePlaysWrite()
|
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 ────────────────────────────────────────────────────────────────
|
// ── Uploads ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function readUploadsMetadata() {
|
export async function readUploadsMetadata() {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto'
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
|
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
|
||||||
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
|
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
|
||||||
|
import { getStudyUserFromRequest } from '../study-helpers.js'
|
||||||
import {
|
import {
|
||||||
VISITOR_COOKIE,
|
VISITOR_COOKIE,
|
||||||
MAX_RECENT_VISITS,
|
MAX_RECENT_VISITS,
|
||||||
} from '../config.js'
|
} from '../config.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay } from '../data.js'
|
import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay, recordAnalyticsEvent } from '../data.js'
|
||||||
import {
|
import {
|
||||||
detectBot,
|
detectBot,
|
||||||
sanitizeUserAgent,
|
sanitizeUserAgent,
|
||||||
@@ -88,6 +89,10 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
|||||||
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 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 ipHash = createHash('sha256').update(ip).digest('hex')
|
||||||
const geo = await resolveGeo(ip)
|
const geo = await resolveGeo(ip)
|
||||||
@@ -113,6 +118,11 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
|||||||
const prevHistory = existingVisitor?.pageHistory ?? []
|
const prevHistory = existingVisitor?.pageHistory ?? []
|
||||||
const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100)
|
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] = {
|
state.visitorStats.visitors[visitorId] = {
|
||||||
visitorId, ip, ipHash,
|
visitorId, ip, ipHash,
|
||||||
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
|
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
|
||||||
@@ -124,6 +134,7 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
|||||||
userAgents,
|
userAgents,
|
||||||
device,
|
device,
|
||||||
pageHistory,
|
pageHistory,
|
||||||
|
...(knownIdentities.length > 0 ? { knownIdentities } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
state.visitorStats.totalVisits += 1
|
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,
|
at: nowIso, visitorId, ip, path: pathKey, referrer, device,
|
||||||
country: geo.country, state: geo.state, county: geo.county, city: geo.city,
|
country: geo.country, state: geo.state, county: geo.county, city: geo.city,
|
||||||
returningVisitor: isReturning, visitCount: nextVisitCount,
|
returningVisitor: isReturning, visitCount: nextVisitCount,
|
||||||
|
...(studyIdentity ? { studyUser: studyIdentity } : {}),
|
||||||
})
|
})
|
||||||
state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
|
state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
|
||||||
|
|
||||||
@@ -154,6 +166,19 @@ export function register(app) {
|
|||||||
res.json({ ok: true })
|
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) => {
|
app.post('/api/analytics/pageview', async (req, res) => {
|
||||||
if (isValidAdminSession(req)) {
|
if (isValidAdminSession(req)) {
|
||||||
res.json({ ok: false, reason: 'admin' }); return
|
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 })),
|
last30Days: buildLastNDaysStats(30).map(item => ({ day: item.day, plays: data.byDay?.[item.day] ?? 0 })),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.total - a.total),
|
.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),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,27 @@ export const state = {
|
|||||||
episodePlays: {},
|
episodePlays: {},
|
||||||
episodePlaysWritePromise: Promise.resolve(),
|
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 }>
|
// qrCodes: Array<{ id, slug, label, destination, createdAt }>
|
||||||
// qrScans: Array<{ id, qrId, slug, scannedAt, ip, userAgent }>
|
// qrScans: Array<{ id, qrId, slug, scannedAt, ip, userAgent }>
|
||||||
qrCodes: [],
|
qrCodes: [],
|
||||||
|
|||||||
@@ -540,6 +540,7 @@ export interface AdminStats {
|
|||||||
returningVisitor: boolean
|
returningVisitor: boolean
|
||||||
visitCount: number
|
visitCount: number
|
||||||
pageHistory?: Array<{ at: string; path: string; referrer?: string }>
|
pageHistory?: Array<{ at: string; path: string; referrer?: string }>
|
||||||
|
studyUser?: { userId: string; username: string; displayName: string }
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
writeStatus: {
|
writeStatus: {
|
||||||
@@ -570,6 +571,15 @@ export interface AdminStats {
|
|||||||
byDay: Record<string, number>
|
byDay: Record<string, number>
|
||||||
last30Days: Array<{ day: string; plays: 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 {
|
interface AdminAsset {
|
||||||
|
|||||||
+27
-1
@@ -13,11 +13,17 @@ import { useGlobalSearch } from './hooks/useGlobalSearch'
|
|||||||
import { GlobalSearch } from './components/GlobalSearch'
|
import { GlobalSearch } from './components/GlobalSearch'
|
||||||
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useOutboundLinkTracking } from './analytics'
|
||||||
|
|
||||||
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 ScrollToTop() {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
useEffect(() => { window.scrollTo(0, 0) }, [pathname])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function usePageTracking() {
|
function usePageTracking() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2308,6 +2314,10 @@ export default function App() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
usePageTracking()
|
usePageTracking()
|
||||||
|
useScrollDepthTracking()
|
||||||
|
useTimeOnPage()
|
||||||
|
useUTMCapture()
|
||||||
|
useOutboundLinkTracking()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin-content')
|
fetch('/api/admin-content')
|
||||||
@@ -2375,6 +2385,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ScrollToTop />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage content={content} />} />
|
<Route path="/" element={<LandingPage content={content} />} />
|
||||||
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
||||||
@@ -2418,12 +2429,27 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<BackToTopButton />
|
<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() {
|
function BackToTopButton() {
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -456,6 +456,7 @@ export function AnalyticsPanel({
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
|
<th>User</th>
|
||||||
<th>Country</th>
|
<th>Country</th>
|
||||||
<th>Path</th>
|
<th>Path</th>
|
||||||
<th>Referrer</th>
|
<th>Referrer</th>
|
||||||
@@ -479,6 +480,7 @@ export function AnalyticsPanel({
|
|||||||
>
|
>
|
||||||
<td>{formatDate(row.at)}</td>
|
<td>{formatDate(row.at)}</td>
|
||||||
<td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></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>{row.country || '—'}</td>
|
||||||
<td className="admin-visitor-path" title={row.path}>{row.path}</td>
|
<td className="admin-visitor-path" title={row.path}>{row.path}</td>
|
||||||
<td className="admin-visitor-path" title={row.referrer || ''}>{row.referrer || '—'}</td>
|
<td className="admin-visitor-path" title={row.referrer || ''}>{row.referrer || '—'}</td>
|
||||||
@@ -492,7 +494,7 @@ export function AnalyticsPanel({
|
|||||||
</tr>
|
</tr>
|
||||||
{isExpanded && (row.pageHistory ?? []).length > 0 && (
|
{isExpanded && (row.pageHistory ?? []).length > 0 && (
|
||||||
<tr key={`${rowKey}-history`} className="admin-visitor-history-row">
|
<tr key={`${rowKey}-history`} className="admin-visitor-history-row">
|
||||||
<td colSpan={8}>
|
<td colSpan={9}>
|
||||||
<div className="admin-visitor-history">
|
<div className="admin-visitor-history">
|
||||||
<p className="admin-visitor-history-label">Full page history for this visitor ({(row.pageHistory ?? []).length} pages):</p>
|
<p className="admin-visitor-history-label">Full page history for this visitor ({(row.pageHistory ?? []).length} pages):</p>
|
||||||
<ol className="admin-visitor-history-list">
|
<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 */}
|
{/* Contact Summary */}
|
||||||
<div className="admin-stats-head admin-stats-head--visitors">
|
<div className="admin-stats-head admin-stats-head--visitors">
|
||||||
<h2>Contact Summary</h2>
|
<h2>Contact Summary</h2>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { sendEvent } from '../analytics'
|
||||||
|
|
||||||
interface EpisodeAudioPlayerProps {
|
interface EpisodeAudioPlayerProps {
|
||||||
src: string
|
src: string
|
||||||
@@ -36,13 +37,30 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
|
|||||||
const [currentTime, setCurrentTime] = useState(0)
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
const [duration, setDuration] = useState(0)
|
const [duration, setDuration] = useState(0)
|
||||||
const playTrackedRef = useRef(false)
|
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(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
const onTimeUpdate = () => setCurrentTime(audio.currentTime)
|
const onTimeUpdate = () => setCurrentTime(audio.currentTime)
|
||||||
const onDurationChange = () => setDuration(audio.duration)
|
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('timeupdate', onTimeUpdate)
|
||||||
audio.addEventListener('durationchange', onDurationChange)
|
audio.addEventListener('durationchange', onDurationChange)
|
||||||
audio.addEventListener('loadedmetadata', onDurationChange)
|
audio.addEventListener('loadedmetadata', onDurationChange)
|
||||||
@@ -52,7 +70,9 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
|
|||||||
audio.removeEventListener('durationchange', onDurationChange)
|
audio.removeEventListener('durationchange', onDurationChange)
|
||||||
audio.removeEventListener('loadedmetadata', onDurationChange)
|
audio.removeEventListener('loadedmetadata', onDurationChange)
|
||||||
audio.removeEventListener('ended', onEnded)
|
audio.removeEventListener('ended', onEnded)
|
||||||
|
flushListenTime()
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [src])
|
}, [src])
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
@@ -61,9 +81,12 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
|
|||||||
if (playing) {
|
if (playing) {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
setPlaying(false)
|
setPlaying(false)
|
||||||
|
flushListenTime()
|
||||||
|
if (title) sendEvent('audio_pause', { title })
|
||||||
} else {
|
} else {
|
||||||
audio.play().then(() => {
|
audio.play().then(() => {
|
||||||
setPlaying(true)
|
setPlaying(true)
|
||||||
|
listenStartRef.current = Date.now()
|
||||||
if (!playTrackedRef.current && title) {
|
if (!playTrackedRef.current && title) {
|
||||||
playTrackedRef.current = true
|
playTrackedRef.current = true
|
||||||
fetch('/api/analytics/play', {
|
fetch('/api/analytics/play', {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useEffect, useState } from 'react'
|
import { useRef, useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import type { SearchResult, SearchResultType } from '../hooks/useGlobalSearch'
|
import type { SearchResult, SearchResultType } from '../hooks/useGlobalSearch'
|
||||||
|
import { sendEvent } from '../analytics'
|
||||||
|
|
||||||
const TYPE_LABEL: Record<SearchResultType, string> = {
|
const TYPE_LABEL: Record<SearchResultType, string> = {
|
||||||
episode: 'Episode',
|
episode: 'Episode',
|
||||||
@@ -51,6 +52,8 @@ export function GlobalSearch({ query, setQuery, results }: Props) {
|
|||||||
|
|
||||||
function handleSelect(result: SearchResult) {
|
function handleSelect(result: SearchResult) {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
const q = query.trim()
|
||||||
|
if (q) sendEvent('search_query', { query: q })
|
||||||
setQuery('')
|
setQuery('')
|
||||||
if (result.href.startsWith('http')) {
|
if (result.href.startsWith('http')) {
|
||||||
window.open(result.href, '_blank', 'noreferrer')
|
window.open(result.href, '_blank', 'noreferrer')
|
||||||
|
|||||||
Reference in New Issue
Block a user