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.
|
- **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 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 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
|
### Improved
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import TechSpecs from '@/components/dashboard/TechSpecs'
|
|||||||
import Chart from '@/components/dashboard/Chart'
|
import Chart from '@/components/dashboard/Chart'
|
||||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||||
import GoalStats from '@/components/dashboard/GoalStats'
|
import GoalStats from '@/components/dashboard/GoalStats'
|
||||||
|
import ScrollDepth from '@/components/dashboard/ScrollDepth'
|
||||||
import Campaigns from '@/components/dashboard/Campaigns'
|
import Campaigns from '@/components/dashboard/Campaigns'
|
||||||
import {
|
import {
|
||||||
useDashboardOverview,
|
useDashboardOverview,
|
||||||
@@ -380,8 +381,9 @@ export default function SiteDashboardPage() {
|
|||||||
<Campaigns siteId={siteId} dateRange={dateRange} />
|
<Campaigns siteId={siteId} dateRange={dateRange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||||
<GoalStats goalCounts={goalsData?.goal_counts ?? []} />
|
<GoalStats goalCounts={(goalsData?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))} />
|
||||||
|
<ScrollDepth goalCounts={goalsData?.goal_counts ?? []} totalPageviews={stats.pageviews} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DatePicker
|
<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) {
|
if (url !== lastUrl) {
|
||||||
lastUrl = url;
|
lastUrl = url;
|
||||||
trackPageview();
|
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 });
|
new MutationObserver(onUrlChange).observe(document, { subtree: true, childList: true });
|
||||||
@@ -308,7 +312,11 @@
|
|||||||
history.replaceState = function() { _replace.apply(this, arguments); onUrlChange(); };
|
history.replaceState = function() { _replace.apply(this, arguments); onUrlChange(); };
|
||||||
|
|
||||||
// * Track popstate (browser back/forward)
|
// * 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)
|
// * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars)
|
||||||
var EVENT_NAME_MAX = 64;
|
var EVENT_NAME_MAX = 64;
|
||||||
@@ -346,6 +354,62 @@
|
|||||||
window.pulse = window.pulse || {};
|
window.pulse = window.pulse || {};
|
||||||
window.pulse.track = trackCustomEvent;
|
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)
|
// * 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
|
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag
|
||||||
var trackOutbound = !script.hasAttribute('data-no-outbound');
|
var trackOutbound = !script.hasAttribute('data-no-outbound');
|
||||||
|
|||||||
Reference in New Issue
Block a user