feat: add automatic 404 detection, scroll depth tracking, and scroll depth dashboard card

- 404 detection: checks document.title for "404" or "not found", fires custom event, SPA-aware
- Scroll depth: passive scroll listener fires events at 25/50/75/100% thresholds
- ScrollDepth dashboard card: progress bar visualization showing % of visitors reaching each threshold
- Scroll events filtered out of GoalStats to avoid duplication
- Both features on by default, opt-out via data-no-404 / data-no-scroll
This commit is contained in:
Usman Baig
2026-03-06 20:00:22 +01:00
parent 53a0341925
commit 8b1d196812
4 changed files with 151 additions and 3 deletions

View File

@@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **AI traffic source identification.** Pulse now automatically recognizes visitors coming from AI tools — ChatGPT, Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI, You.com, and Phind. These sources appear in your Top Referrers with proper brand icons and display names instead of raw domain URLs. If someone clicks a link in an AI chat to visit your site, you'll see exactly which AI tool sent them.
- **Automatic outbound link tracking.** Pulse now tracks when visitors click links that take them to other websites. These show up as "outbound link" events in your Goals & Events panel — no setup needed. You can turn this off in your tracking snippet settings if you prefer.
- **Automatic file download tracking.** When a visitor clicks a link to a downloadable file — PDF, ZIP, Excel, Word, MP3, and 20+ other formats — Pulse records it as a "file download" event. Like outbound links, this works automatically with no setup required.
- **Automatic 404 error page detection.** Pulse now detects when a visitor lands on a page that doesn't exist and records it as a "404" event. You'll see these in your Goals & Events panel so you can find and fix broken links. Works automatically — no setup needed.
- **Automatic scroll depth tracking.** Pulse now tracks how far visitors scroll down each page — at 25%, 50%, 75%, and 100% milestones. These show up as scroll events in your Goals & Events panel, helping you understand which content keeps people reading. Works automatically with no configuration required.
### Improved

View File

@@ -19,6 +19,7 @@ import TechSpecs from '@/components/dashboard/TechSpecs'
import Chart from '@/components/dashboard/Chart'
import PerformanceStats from '@/components/dashboard/PerformanceStats'
import GoalStats from '@/components/dashboard/GoalStats'
import ScrollDepth from '@/components/dashboard/ScrollDepth'
import Campaigns from '@/components/dashboard/Campaigns'
import {
useDashboardOverview,
@@ -380,8 +381,9 @@ export default function SiteDashboardPage() {
<Campaigns siteId={siteId} dateRange={dateRange} />
</div>
<div className="mb-8">
<GoalStats goalCounts={goalsData?.goal_counts ?? []} />
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<GoalStats goalCounts={(goalsData?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))} />
<ScrollDepth goalCounts={goalsData?.goal_counts ?? []} totalPageviews={stats.pageviews} />
</div>
<DatePicker

View File

@@ -0,0 +1,80 @@
'use client'
import { formatNumber } from '@ciphera-net/ui'
import { BarChartIcon } from '@ciphera-net/ui'
import type { GoalCountStat } from '@/lib/api/stats'
interface ScrollDepthProps {
goalCounts: GoalCountStat[]
totalPageviews: number
}
const THRESHOLDS = [25, 50, 75, 100] as const
export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthProps) {
const scrollCounts = new Map<number, number>()
for (const row of goalCounts) {
const match = row.event_name.match(/^scroll_(\d+)$/)
if (match) {
scrollCounts.set(Number(match[1]), row.count)
}
}
const hasData = scrollCounts.size > 0 && totalPageviews > 0
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Scroll Depth
</h3>
</div>
{hasData ? (
<div className="space-y-3 flex-1 min-h-[200px]">
{THRESHOLDS.map((threshold) => {
const count = scrollCounts.get(threshold) ?? 0
const pct = totalPageviews > 0 ? Math.round((count / totalPageviews) * 100) : 0
const barWidth = Math.max(pct, 2)
return (
<div key={threshold} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-neutral-900 dark:text-white">
{threshold}%
</span>
<div className="flex items-center gap-2">
<span className="text-neutral-500 dark:text-neutral-400 tabular-nums">
{formatNumber(count)}
</span>
<span className="font-semibold text-brand-orange tabular-nums w-12 text-right">
{pct}%
</span>
</div>
</div>
<div className="h-2 rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full rounded-full bg-brand-orange transition-all duration-500"
style={{ width: `${barWidth}%` }}
/>
</div>
</div>
)
})}
</div>
) : (
<div className="flex-1 min-h-[200px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<BarChartIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No scroll data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
Scroll depth tracking is automatic data will appear here once visitors start scrolling on your pages.
</p>
</div>
)}
</div>
)
}

View File

@@ -299,6 +299,10 @@
if (url !== lastUrl) {
lastUrl = url;
trackPageview();
// * Check for 404 after SPA navigation (deferred so title updates first)
setTimeout(check404, 100);
// * Reset scroll depth tracking for the new page
if (trackScroll) scrollFired = {};
}
}
new MutationObserver(onUrlChange).observe(document, { subtree: true, childList: true });
@@ -308,7 +312,11 @@
history.replaceState = function() { _replace.apply(this, arguments); onUrlChange(); };
// * Track popstate (browser back/forward)
window.addEventListener('popstate', trackPageview);
window.addEventListener('popstate', function() {
trackPageview();
setTimeout(check404, 100);
if (trackScroll) scrollFired = {};
});
// * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars)
var EVENT_NAME_MAX = 64;
@@ -346,6 +354,62 @@
window.pulse = window.pulse || {};
window.pulse.track = trackCustomEvent;
// * Auto-track 404 error pages (on by default)
// * Detects pages where document.title contains "404" or "not found"
// * Opt-out: add data-no-404 to the script tag
var track404 = !script.hasAttribute('data-no-404');
var sent404ForUrl = '';
function check404() {
if (!track404) return;
// * Only fire once per URL
var currentUrl = location.href;
if (sent404ForUrl === currentUrl) return;
if (/404|not found/i.test(document.title)) {
sent404ForUrl = currentUrl;
trackCustomEvent('404');
}
}
// * Check on initial load (deferred so SPAs can set title)
setTimeout(check404, 0);
// * Auto-track scroll depth at 25%, 50%, 75%, and 100% (on by default)
// * Each threshold fires once per pageview; resets on SPA navigation
// * Opt-out: add data-no-scroll to the script tag
var trackScroll = !script.hasAttribute('data-no-scroll');
if (trackScroll) {
var scrollThresholds = [25, 50, 75, 100];
var scrollFired = {};
var scrollTicking = false;
function checkScroll() {
var docHeight = document.documentElement.scrollHeight;
var viewHeight = window.innerHeight;
// * Page fits in viewport — nothing to scroll
if (docHeight <= viewHeight) return;
var scrollTop = window.scrollY;
var scrollPercent = Math.round((scrollTop + viewHeight) / docHeight * 100);
for (var i = 0; i < scrollThresholds.length; i++) {
var t = scrollThresholds[i];
if (!scrollFired[t] && scrollPercent >= t) {
scrollFired[t] = true;
trackCustomEvent('scroll_' + t);
}
}
scrollTicking = false;
}
window.addEventListener('scroll', function() {
if (!scrollTicking) {
scrollTicking = true;
requestAnimationFrame(checkScroll);
}
}, { passive: true });
}
// * Auto-track outbound link clicks and file downloads (on by default)
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag
var trackOutbound = !script.hasAttribute('data-no-outbound');