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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
80
components/dashboard/ScrollDepth.tsx
Normal file
80
components/dashboard/ScrollDepth.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user