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:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.32",
|
"version": "1.1.33",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -850,6 +850,7 @@ const EMPTY_ANALYTICS_EVENTS = {
|
|||||||
scrollDepth: {},
|
scrollDepth: {},
|
||||||
timeOnPage: {},
|
timeOnPage: {},
|
||||||
outboundClicks: {},
|
outboundClicks: {},
|
||||||
|
linkClicks: {},
|
||||||
utmSources: {},
|
utmSources: {},
|
||||||
searchQueries: {},
|
searchQueries: {},
|
||||||
notFound: {},
|
notFound: {},
|
||||||
@@ -875,6 +876,7 @@ export function loadAnalyticsEventsFromDisk() {
|
|||||||
scrollDepth: parsed?.scrollDepth ?? {},
|
scrollDepth: parsed?.scrollDepth ?? {},
|
||||||
timeOnPage: parsed?.timeOnPage ?? {},
|
timeOnPage: parsed?.timeOnPage ?? {},
|
||||||
outboundClicks: parsed?.outboundClicks ?? {},
|
outboundClicks: parsed?.outboundClicks ?? {},
|
||||||
|
linkClicks: parsed?.linkClicks ?? {},
|
||||||
utmSources: parsed?.utmSources ?? {},
|
utmSources: parsed?.utmSources ?? {},
|
||||||
searchQueries: parsed?.searchQueries ?? {},
|
searchQueries: parsed?.searchQueries ?? {},
|
||||||
notFound: parsed?.notFound ?? {},
|
notFound: parsed?.notFound ?? {},
|
||||||
@@ -902,6 +904,12 @@ export function recordAnalyticsEvent(type, data) {
|
|||||||
} else if (type === 'outbound_click') {
|
} else if (type === 'outbound_click') {
|
||||||
const url = typeof data.url === 'string' ? data.url.slice(0, 500) : ''
|
const url = typeof data.url === 'string' ? data.url.slice(0, 500) : ''
|
||||||
if (url) ev.outboundClicks[url] = (ev.outboundClicks[url] ?? 0) + 1
|
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') {
|
} else if (type === 'utm') {
|
||||||
const source = typeof data.utm_source === 'string' ? data.utm_source.slice(0, 100) : 'unknown'
|
const source = typeof data.utm_source === 'string' ? data.utm_source.slice(0, 100) : 'unknown'
|
||||||
ev.utmSources[source] = (ev.utmSources[source] ?? 0) + 1
|
ev.utmSources[source] = (ev.utmSources[source] ?? 0) + 1
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export function register(app) {
|
|||||||
const { isBot } = detectBot(ua)
|
const { isBot } = detectBot(ua)
|
||||||
if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
|
if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
|
||||||
const type = typeof req.body?.type === 'string' ? req.body.type : ''
|
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 }
|
if (!ALLOWED_TYPES.includes(type)) { res.status(400).json({ ok: false, reason: 'invalid-type' }); return }
|
||||||
recordAnalyticsEvent(type, req.body)
|
recordAnalyticsEvent(type, req.body)
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
@@ -378,6 +378,10 @@ export function register(app) {
|
|||||||
.map(([url, count]) => ({ url, count }))
|
.map(([url, count]) => ({ url, count }))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 20),
|
.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 ?? {})
|
topUTMSources: Object.entries(state.analyticsEvents.utmSources ?? {})
|
||||||
.map(([source, count]) => ({ source, count }))
|
.map(([source, count]) => ({ source, count }))
|
||||||
.sort((a, b) => b.count - a.count),
|
.sort((a, b) => b.count - a.count),
|
||||||
|
|||||||
@@ -601,6 +601,7 @@ export interface AdminStats {
|
|||||||
scrollDepth: Array<{ path: string; 25?: number; 50?: number; 75?: number; 90?: number }>
|
scrollDepth: Array<{ path: string; 25?: number; 50?: number; 75?: number; 90?: number }>
|
||||||
timeOnPage: Array<{ path: string; avgSeconds: number; count: number }>
|
timeOnPage: Array<{ path: string; avgSeconds: number; count: number }>
|
||||||
topOutboundClicks: Array<{ url: string; count: number }>
|
topOutboundClicks: Array<{ url: string; count: number }>
|
||||||
|
topLinkClicks: Array<{ url: string; count: number; internal: boolean }>
|
||||||
topUTMSources: Array<{ source: string; count: number }>
|
topUTMSources: Array<{ source: string; count: number }>
|
||||||
topSearchQueries: Array<{ query: string; count: number }>
|
topSearchQueries: Array<{ query: string; count: number }>
|
||||||
top404s: Array<{ path: string; count: number }>
|
top404s: Array<{ path: string; count: number }>
|
||||||
|
|||||||
+2
-2
@@ -16,7 +16,7 @@ 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'
|
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking } 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'
|
||||||
@@ -2387,7 +2387,7 @@ export default function App() {
|
|||||||
useScrollDepthTracking()
|
useScrollDepthTracking()
|
||||||
useTimeOnPage()
|
useTimeOnPage()
|
||||||
useUTMCapture()
|
useUTMCapture()
|
||||||
useOutboundLinkTracking()
|
useLinkClickTracking()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const TARGET = 'salvation'
|
const TARGET = 'salvation'
|
||||||
|
|||||||
+29
-8
@@ -87,22 +87,43 @@ export function useUTMCapture() {
|
|||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Outbound link clicks ---
|
// --- All link clicks (internal + external) ---
|
||||||
export function useOutboundLinkTracking() {
|
export function useLinkClickTracking() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onClick(e: MouseEvent) {
|
function onClick(e: MouseEvent) {
|
||||||
if (!hasConsent()) return
|
if (!hasConsent()) return
|
||||||
const target = (e.target as HTMLElement).closest('a')
|
const target = (e.target as HTMLElement).closest('a')
|
||||||
if (!target) return
|
if (!target) return
|
||||||
const href = target.getAttribute('href') ?? ''
|
const href = target.getAttribute('href') ?? ''
|
||||||
if (!href.startsWith('http') && !href.startsWith('//')) return
|
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return
|
||||||
try {
|
|
||||||
const url = new URL(href)
|
let url: string
|
||||||
if (url.hostname === window.location.hostname) return
|
let internal: boolean
|
||||||
sendEvent('outbound_click', { url: href, text: target.textContent?.trim().slice(0, 100) ?? '' })
|
|
||||||
} catch { /* ignore malformed */ }
|
if (href.startsWith('http') || href.startsWith('//')) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(href)
|
||||||
|
if (parsed.hostname === window.location.hostname) {
|
||||||
|
url = parsed.pathname
|
||||||
|
internal = true
|
||||||
|
} else {
|
||||||
|
url = href
|
||||||
|
internal = false
|
||||||
|
}
|
||||||
|
} catch { return }
|
||||||
|
} else if (href.startsWith('/')) {
|
||||||
|
url = href.split('?')[0]
|
||||||
|
internal = true
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent('link_click', { url: url.slice(0, 500), internal })
|
||||||
}
|
}
|
||||||
document.addEventListener('click', onClick, { capture: true })
|
document.addEventListener('click', onClick, { capture: true })
|
||||||
return () => document.removeEventListener('click', onClick, { capture: true })
|
return () => document.removeEventListener('click', onClick, { capture: true })
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use useLinkClickTracking instead */
|
||||||
|
export function useOutboundLinkTracking() { useLinkClickTracking() }
|
||||||
|
|||||||
@@ -663,6 +663,33 @@ export function AnalyticsPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* All link clicks */}
|
||||||
|
{stats.engagement.topLinkClicks?.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Link Clicks</h3>
|
||||||
|
<div className="admin-stats-table-wrap">
|
||||||
|
<table className="admin-stats-table">
|
||||||
|
<thead><tr><th>Destination</th><th>Type</th><th>Clicks</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.engagement.topLinkClicks.map((row: { url: string; count: number; internal: boolean }) => (
|
||||||
|
<tr key={row.url}>
|
||||||
|
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{row.internal
|
||||||
|
? <span style={{ color: '#c9a84c' }}>{row.url}</span>
|
||||||
|
: <a href={row.url} target="_blank" rel="noreferrer" style={{ color: '#c9a84c' }}>{row.url}</a>}
|
||||||
|
</td>
|
||||||
|
<td style={{ color: row.internal ? '#6fcf97' : '#9b9b9b', fontSize: '0.8rem' }}>
|
||||||
|
{row.internal ? 'internal' : 'external'}
|
||||||
|
</td>
|
||||||
|
<td>{row.count.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Outbound clicks */}
|
{/* Outbound clicks */}
|
||||||
{stats.engagement.topOutboundClicks?.length > 0 && (
|
{stats.engagement.topOutboundClicks?.length > 0 && (
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user