Track all link clicks (internal + external) in analytics; v1.1.33

Replaces outbound-only tracking with a useLinkClickTracking hook that
captures every <a> click on the site. Stores as link_click events keyed
by destination URL, with an internal flag. Admin analytics page gains a
Link Clicks table sorted by count, showing type (internal/external).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-08-10 09:11:53 -04:00
parent abb4c87c41
commit bf3154378b
7 changed files with 73 additions and 12 deletions
+8
View File
@@ -850,6 +850,7 @@ const EMPTY_ANALYTICS_EVENTS = {
scrollDepth: {},
timeOnPage: {},
outboundClicks: {},
linkClicks: {},
utmSources: {},
searchQueries: {},
notFound: {},
@@ -875,6 +876,7 @@ export function loadAnalyticsEventsFromDisk() {
scrollDepth: parsed?.scrollDepth ?? {},
timeOnPage: parsed?.timeOnPage ?? {},
outboundClicks: parsed?.outboundClicks ?? {},
linkClicks: parsed?.linkClicks ?? {},
utmSources: parsed?.utmSources ?? {},
searchQueries: parsed?.searchQueries ?? {},
notFound: parsed?.notFound ?? {},
@@ -902,6 +904,12 @@ export function recordAnalyticsEvent(type, data) {
} 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 === 'link_click') {
const url = typeof data.url === 'string' ? data.url.slice(0, 500) : ''
if (url) {
if (!ev.linkClicks[url]) ev.linkClicks[url] = { count: 0, internal: data.internal === true }
ev.linkClicks[url].count += 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
+5 -1
View File
@@ -173,7 +173,7 @@ export function register(app) {
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']
const ALLOWED_TYPES = ['scroll_depth', 'time_on_page', 'outbound_click', 'link_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 })
@@ -378,6 +378,10 @@ export function register(app) {
.map(([url, count]) => ({ url, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20),
topLinkClicks: Object.entries(state.analyticsEvents.linkClicks ?? {})
.map(([url, { count, internal }]) => ({ url, count, internal }))
.sort((a, b) => b.count - a.count)
.slice(0, 50),
topUTMSources: Object.entries(state.analyticsEvents.utmSources ?? {})
.map(([source, count]) => ({ source, count }))
.sort((a, b) => b.count - a.count),