refactor: extract frustration tracking into separate add-on script
Move rage click and dead click detection (35% of script.js) into script.frustration.js as an optional add-on. Core script drops from 8.1KB to 5.7KB gzipped. Add-on auto-discovers core via window.pulse polling and supports opt-out via data-no-rage/data-no-dead attributes. - Expose cleanPath on window.pulse for add-on consumption - Add script.frustration.js to middleware PUBLIC_ROUTES - Update integration guides, ScriptSetupBlock, and FrustrationTable empty state to reference the add-on script
This commit is contained in:
@@ -67,6 +67,18 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
{children}
|
||||
|
||||
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
|
||||
<h3>Optional: Frustration Tracking</h3>
|
||||
<p>
|
||||
Detect rage clicks and dead clicks by adding the frustration tracking
|
||||
add-on after the core script:
|
||||
</p>
|
||||
<pre><code>{`<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>`}</code></pre>
|
||||
<p>
|
||||
No extra configuration needed. Add <code>data-no-rage</code> or{' '}
|
||||
<code>data-no-dead</code> to disable individual signals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* * --- Related Integrations --- */}
|
||||
|
||||
@@ -184,8 +184,11 @@ export default function FrustrationTable({
|
||||
No {title.toLowerCase()} detected
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
|
||||
{description}. Data will appear here once frustration signals are detected on your site.
|
||||
Frustration tracking requires the add-on script. Add it after your core Pulse script:
|
||||
</p>
|
||||
<code className="text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 px-3 py-2 rounded-lg font-mono break-all">
|
||||
{'<script defer src="https://pulse.ciphera.net/script.frustration.js"></script>'}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -92,6 +92,9 @@ export default function ScriptSetupBlock({
|
||||
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Default: cross-tab (localStorage). Optional: <code className="rounded px-1 bg-neutral-200 dark:bg-neutral-700">data-storage="session"</code> to opt out (per-tab, ephemeral). Optional: <code className="rounded px-1 bg-neutral-200 dark:bg-neutral-700">data-storage-ttl="48"</code> to set expiry in hours (default: 24).
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Optional: add <code className="rounded px-1 bg-neutral-200 dark:bg-neutral-700">{`<script defer src="${APP_URL}/script.frustration.js"></script>`}</code> for rage click and dead click detection.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyScript}
|
||||
|
||||
@@ -13,6 +13,7 @@ const PUBLIC_ROUTES = new Set([
|
||||
'/changelog',
|
||||
'/installation',
|
||||
'/script.js', // * Tracking script – must load without auth for embedded sites (Shopify, etc.)
|
||||
'/script.frustration.js', // * Frustration tracking add-on (rage clicks, dead clicks)
|
||||
])
|
||||
|
||||
const PUBLIC_PREFIXES = [
|
||||
|
||||
313
public/script.frustration.js
Normal file
313
public/script.frustration.js
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Pulse Frustration Tracking Add-on
|
||||
* Detects rage clicks and dead clicks. Requires the core Pulse script.
|
||||
* Opt-out: add data-no-rage or data-no-dead to either script tag.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// * Capture own script tag for opt-out attributes (must be at parse time)
|
||||
// * Fallback to querySelector for dynamic insertion (e.g. tag managers) where currentScript is null
|
||||
var addonScript = document.currentScript || document.querySelector('script[src*="script.frustration"]');
|
||||
|
||||
var MAX_WAIT = 5000;
|
||||
var POLL_INTERVAL = 50;
|
||||
var waited = 0;
|
||||
|
||||
function init() {
|
||||
var pulse = window.pulse;
|
||||
if (!pulse || typeof pulse.track !== 'function' || typeof pulse.cleanPath !== 'function') {
|
||||
waited += POLL_INTERVAL;
|
||||
if (waited < MAX_WAIT) {
|
||||
setTimeout(init, POLL_INTERVAL);
|
||||
} else {
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
console.warn('Pulse frustration add-on: core script not detected. Frustration tracking disabled.');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var trackCustomEvent = pulse.track;
|
||||
var cleanPath = pulse.cleanPath;
|
||||
|
||||
// * Check opt-out attributes on both the add-on script tag and the core script tag
|
||||
var coreScript = document.querySelector('script[data-domain]');
|
||||
|
||||
function hasOptOut(attr) {
|
||||
return (addonScript && addonScript.hasAttribute(attr)) ||
|
||||
(coreScript && coreScript.hasAttribute(attr));
|
||||
}
|
||||
|
||||
// * Strip HTML tags from a string (used for sanitizing attribute values)
|
||||
function stripHtml(str) {
|
||||
if (typeof str !== 'string') return '';
|
||||
return str.replace(/<[^>]*>/g, '').trim();
|
||||
}
|
||||
|
||||
// * Build a compact element identifier string for frustration tracking
|
||||
// * Format: tag#id.class1.class2[href="/path"]
|
||||
function getElementIdentifier(el) {
|
||||
if (!el || !el.tagName) return '';
|
||||
var result = el.tagName.toLowerCase();
|
||||
|
||||
// * Add #id if present
|
||||
if (el.id) {
|
||||
result += '#' + stripHtml(el.id);
|
||||
}
|
||||
|
||||
// * Add classes (handle SVG elements where className is SVGAnimatedString)
|
||||
var rawClassName = el.className;
|
||||
if (rawClassName && typeof rawClassName !== 'string' && rawClassName.baseVal !== undefined) {
|
||||
rawClassName = rawClassName.baseVal;
|
||||
}
|
||||
if (typeof rawClassName === 'string' && rawClassName.trim()) {
|
||||
var classes = rawClassName.trim().split(/\s+/);
|
||||
var filtered = [];
|
||||
for (var ci = 0; ci < classes.length && filtered.length < 5; ci++) {
|
||||
var cls = classes[ci];
|
||||
if (cls.length > 50) continue;
|
||||
if (/^(ng-|js-|is-|has-|animate)/.test(cls)) continue;
|
||||
filtered.push(cls);
|
||||
}
|
||||
if (filtered.length > 0) {
|
||||
result += '.' + filtered.join('.');
|
||||
}
|
||||
}
|
||||
|
||||
// * Add key attributes
|
||||
var attrs = ['href', 'role', 'type', 'name', 'data-action'];
|
||||
for (var ai = 0; ai < attrs.length; ai++) {
|
||||
var attrName = attrs[ai];
|
||||
var attrVal = el.getAttribute(attrName);
|
||||
if (attrVal !== null && attrVal !== '') {
|
||||
var sanitized = stripHtml(attrVal);
|
||||
if (sanitized.length > 50) sanitized = sanitized.substring(0, 50);
|
||||
result += '[' + attrName + '="' + sanitized + '"]';
|
||||
}
|
||||
}
|
||||
|
||||
// * Truncate to max 200 chars
|
||||
if (result.length > 200) {
|
||||
result = result.substring(0, 200);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// * Auto-track rage clicks (rapid repeated clicks on the same element)
|
||||
// * Fires rage_click when same element is clicked 3+ times within 800ms
|
||||
if (!hasOptOut('data-no-rage')) {
|
||||
var rageClickHistory = {}; // * selector -> { times: [timestamps], lastFired: 0 }
|
||||
var RAGE_CLICK_THRESHOLD = 3;
|
||||
var RAGE_CLICK_WINDOW = 800;
|
||||
var RAGE_CLICK_DEBOUNCE = 5000;
|
||||
var RAGE_CLEANUP_INTERVAL = 10000;
|
||||
|
||||
// * Cleanup stale rage click entries every 10 seconds
|
||||
setInterval(function() {
|
||||
var now = Date.now();
|
||||
for (var key in rageClickHistory) {
|
||||
if (!rageClickHistory.hasOwnProperty(key)) continue;
|
||||
var entry = rageClickHistory[key];
|
||||
// * Remove if last click was more than 10 seconds ago
|
||||
if (entry.times.length === 0 || now - entry.times[entry.times.length - 1] > RAGE_CLEANUP_INTERVAL) {
|
||||
delete rageClickHistory[key];
|
||||
}
|
||||
}
|
||||
}, RAGE_CLEANUP_INTERVAL);
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var el = e.target;
|
||||
if (!el || !el.tagName) return;
|
||||
|
||||
var selector = getElementIdentifier(el);
|
||||
if (!selector) return;
|
||||
|
||||
var now = Date.now();
|
||||
var currentPath = cleanPath();
|
||||
|
||||
if (!rageClickHistory[selector]) {
|
||||
rageClickHistory[selector] = { times: [], lastFired: 0 };
|
||||
}
|
||||
|
||||
var entry = rageClickHistory[selector];
|
||||
|
||||
// * Add current click timestamp
|
||||
entry.times.push(now);
|
||||
|
||||
// * Remove clicks outside the time window
|
||||
while (entry.times.length > 0 && now - entry.times[0] > RAGE_CLICK_WINDOW) {
|
||||
entry.times.shift();
|
||||
}
|
||||
|
||||
// * Check if rage click threshold is met
|
||||
if (entry.times.length >= RAGE_CLICK_THRESHOLD) {
|
||||
// * Skip if user is selecting text (triple-click to select paragraph)
|
||||
try {
|
||||
var sel = window.getSelection();
|
||||
if (sel && sel.toString().trim().length > 0) {
|
||||
entry.times = [];
|
||||
return;
|
||||
}
|
||||
} catch (ex) {}
|
||||
|
||||
// * Debounce: max one rage_click per element per 5 seconds
|
||||
if (now - entry.lastFired >= RAGE_CLICK_DEBOUNCE) {
|
||||
var clickCount = entry.times.length;
|
||||
trackCustomEvent('rage_click', {
|
||||
selector: selector,
|
||||
click_count: String(clickCount),
|
||||
page_path: currentPath,
|
||||
x: String(Math.round(e.clientX)),
|
||||
y: String(Math.round(e.clientY))
|
||||
});
|
||||
entry.lastFired = now;
|
||||
}
|
||||
// * Reset tracker after firing or debounce skip
|
||||
entry.times = [];
|
||||
}
|
||||
}, true); // * Capture phase
|
||||
}
|
||||
|
||||
// * Auto-track dead clicks (clicks on interactive elements that produce no effect)
|
||||
// * Fires dead_click when an interactive element is clicked but no DOM change, navigation,
|
||||
// * or network request occurs within 1 second
|
||||
if (!hasOptOut('data-no-dead')) {
|
||||
var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]:not([tabindex="-1"])';
|
||||
var DEAD_CLICK_DEBOUNCE = 10000;
|
||||
var DEAD_CLEANUP_INTERVAL = 30000;
|
||||
var deadClickDebounce = {}; // * selector -> lastFiredTimestamp
|
||||
|
||||
// * Cleanup stale dead click debounce entries every 30 seconds
|
||||
setInterval(function() {
|
||||
var now = Date.now();
|
||||
for (var key in deadClickDebounce) {
|
||||
if (!deadClickDebounce.hasOwnProperty(key)) continue;
|
||||
if (now - deadClickDebounce[key] > DEAD_CLEANUP_INTERVAL) {
|
||||
delete deadClickDebounce[key];
|
||||
}
|
||||
}
|
||||
}, DEAD_CLEANUP_INTERVAL);
|
||||
|
||||
// * Polyfill check for Element.matches
|
||||
var matchesFn = (function() {
|
||||
var ep = Element.prototype;
|
||||
return ep.matches || ep.msMatchesSelector || ep.webkitMatchesSelector || null;
|
||||
})();
|
||||
|
||||
// * Find the nearest interactive element by walking up max 3 levels
|
||||
function findInteractiveElement(el) {
|
||||
if (!matchesFn) return null;
|
||||
var depth = 0;
|
||||
var current = el;
|
||||
while (current && depth <= 3) {
|
||||
if (current.nodeType === 1 && matchesFn.call(current, INTERACTIVE_SELECTOR)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var target = findInteractiveElement(e.target);
|
||||
if (!target) return;
|
||||
|
||||
// * Skip form inputs — clicking to focus/interact is expected, not a dead click
|
||||
var tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
|
||||
var selector = getElementIdentifier(target);
|
||||
if (!selector) return;
|
||||
|
||||
var now = Date.now();
|
||||
|
||||
// * Debounce: max one dead_click per element per 10 seconds
|
||||
if (deadClickDebounce[selector] && now - deadClickDebounce[selector] < DEAD_CLICK_DEBOUNCE) {
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPath = cleanPath();
|
||||
var clickX = String(Math.round(e.clientX));
|
||||
var clickY = String(Math.round(e.clientY));
|
||||
var effectDetected = false;
|
||||
var hrefBefore = location.href;
|
||||
var mutationObs = null;
|
||||
var perfObs = null;
|
||||
var cleanupTimer = null;
|
||||
var popstateHandler = null;
|
||||
var hashchangeHandler = null;
|
||||
|
||||
function cleanup() {
|
||||
if (mutationObs) { try { mutationObs.disconnect(); } catch (ex) {} mutationObs = null; }
|
||||
if (perfObs) { try { perfObs.disconnect(); } catch (ex) {} perfObs = null; }
|
||||
if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; }
|
||||
if (popstateHandler) { window.removeEventListener('popstate', popstateHandler); popstateHandler = null; }
|
||||
if (hashchangeHandler) { window.removeEventListener('hashchange', hashchangeHandler); hashchangeHandler = null; }
|
||||
}
|
||||
|
||||
function onEffect() {
|
||||
effectDetected = true;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
// * Set up MutationObserver to detect DOM changes on the element, its parent, and body
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
try {
|
||||
mutationObs = new MutationObserver(function() {
|
||||
onEffect();
|
||||
});
|
||||
var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true };
|
||||
mutationObs.observe(target, mutOpts);
|
||||
var parent = target.parentElement;
|
||||
if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') {
|
||||
mutationObs.observe(parent, { childList: true });
|
||||
}
|
||||
// * Also observe body for top-level DOM changes (modals, drawers, overlays, toasts)
|
||||
mutationObs.observe(document.body, { childList: true, attributes: true });
|
||||
} catch (ex) {
|
||||
mutationObs = null;
|
||||
}
|
||||
}
|
||||
|
||||
// * Set up PerformanceObserver to detect network requests
|
||||
if (typeof PerformanceObserver !== 'undefined') {
|
||||
try {
|
||||
perfObs = new PerformanceObserver(function() {
|
||||
onEffect();
|
||||
});
|
||||
perfObs.observe({ type: 'resource' });
|
||||
} catch (ex) {
|
||||
perfObs = null;
|
||||
}
|
||||
}
|
||||
|
||||
// * Listen for SPA navigation events (popstate, hashchange)
|
||||
popstateHandler = function() { onEffect(); };
|
||||
hashchangeHandler = function() { onEffect(); };
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
window.addEventListener('hashchange', hashchangeHandler);
|
||||
|
||||
// * After 1 second, check if any effect was detected
|
||||
cleanupTimer = setTimeout(function() {
|
||||
cleanup();
|
||||
// * Also check if navigation occurred
|
||||
if (effectDetected || location.href !== hrefBefore) return;
|
||||
|
||||
deadClickDebounce[selector] = Date.now();
|
||||
trackCustomEvent('dead_click', {
|
||||
selector: selector,
|
||||
page_path: currentPath,
|
||||
x: clickX,
|
||||
y: clickY
|
||||
});
|
||||
}, 1000);
|
||||
}, true); // * Capture phase
|
||||
}
|
||||
}
|
||||
|
||||
// * Start immediately — if core is already loaded, init succeeds on the first call
|
||||
init();
|
||||
})();
|
||||
271
public/script.js
271
public/script.js
@@ -417,6 +417,7 @@
|
||||
// * Expose pulse.track() for custom events (e.g. pulse.track('signup_click'))
|
||||
window.pulse = window.pulse || {};
|
||||
window.pulse.track = trackCustomEvent;
|
||||
window.pulse.cleanPath = cleanPath;
|
||||
|
||||
// * Auto-track 404 error pages (on by default)
|
||||
// * Detects pages where document.title contains "404" or "not found"
|
||||
@@ -474,276 +475,6 @@
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// * Strip HTML tags from a string (used for sanitizing attribute values)
|
||||
function stripHtml(str) {
|
||||
if (typeof str !== 'string') return '';
|
||||
return str.replace(/<[^>]*>/g, '').trim();
|
||||
}
|
||||
|
||||
// * Build a compact element identifier string for frustration tracking
|
||||
// * Format: tag#id.class1.class2[href="/path"]
|
||||
function getElementIdentifier(el) {
|
||||
if (!el || !el.tagName) return '';
|
||||
var result = el.tagName.toLowerCase();
|
||||
|
||||
// * Add #id if present
|
||||
if (el.id) {
|
||||
result += '#' + stripHtml(el.id);
|
||||
}
|
||||
|
||||
// * Add classes (handle SVG elements where className is SVGAnimatedString)
|
||||
var rawClassName = el.className;
|
||||
if (rawClassName && typeof rawClassName !== 'string' && rawClassName.baseVal !== undefined) {
|
||||
rawClassName = rawClassName.baseVal;
|
||||
}
|
||||
if (typeof rawClassName === 'string' && rawClassName.trim()) {
|
||||
var classes = rawClassName.trim().split(/\s+/);
|
||||
var filtered = [];
|
||||
for (var ci = 0; ci < classes.length && filtered.length < 5; ci++) {
|
||||
var cls = classes[ci];
|
||||
if (cls.length > 50) continue;
|
||||
if (/^(ng-|js-|is-|has-|animate)/.test(cls)) continue;
|
||||
filtered.push(cls);
|
||||
}
|
||||
if (filtered.length > 0) {
|
||||
result += '.' + filtered.join('.');
|
||||
}
|
||||
}
|
||||
|
||||
// * Add key attributes
|
||||
var attrs = ['href', 'role', 'type', 'name', 'data-action'];
|
||||
for (var ai = 0; ai < attrs.length; ai++) {
|
||||
var attrName = attrs[ai];
|
||||
var attrVal = el.getAttribute(attrName);
|
||||
if (attrVal !== null && attrVal !== '') {
|
||||
var sanitized = stripHtml(attrVal);
|
||||
if (sanitized.length > 50) sanitized = sanitized.substring(0, 50);
|
||||
result += '[' + attrName + '="' + sanitized + '"]';
|
||||
}
|
||||
}
|
||||
|
||||
// * Truncate to max 200 chars
|
||||
if (result.length > 200) {
|
||||
result = result.substring(0, 200);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// * Auto-track rage clicks (rapid repeated clicks on the same element)
|
||||
// * Fires rage_click when same element is clicked 3+ times within 800ms
|
||||
// * Opt-out: add data-no-rage to the script tag
|
||||
if (!script.hasAttribute('data-no-rage')) {
|
||||
var rageClickHistory = {}; // * selector -> { times: [timestamps], lastFired: 0 }
|
||||
var RAGE_CLICK_THRESHOLD = 3;
|
||||
var RAGE_CLICK_WINDOW = 800;
|
||||
var RAGE_CLICK_DEBOUNCE = 5000;
|
||||
var RAGE_CLEANUP_INTERVAL = 10000;
|
||||
|
||||
// * Cleanup stale rage click entries every 10 seconds
|
||||
setInterval(function() {
|
||||
var now = Date.now();
|
||||
for (var key in rageClickHistory) {
|
||||
if (!rageClickHistory.hasOwnProperty(key)) continue;
|
||||
var entry = rageClickHistory[key];
|
||||
// * Remove if last click was more than 10 seconds ago
|
||||
if (entry.times.length === 0 || now - entry.times[entry.times.length - 1] > RAGE_CLEANUP_INTERVAL) {
|
||||
delete rageClickHistory[key];
|
||||
}
|
||||
}
|
||||
}, RAGE_CLEANUP_INTERVAL);
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var el = e.target;
|
||||
if (!el || !el.tagName) return;
|
||||
|
||||
var selector = getElementIdentifier(el);
|
||||
if (!selector) return;
|
||||
|
||||
var now = Date.now();
|
||||
var currentPath = cleanPath();
|
||||
|
||||
if (!rageClickHistory[selector]) {
|
||||
rageClickHistory[selector] = { times: [], lastFired: 0 };
|
||||
}
|
||||
|
||||
var entry = rageClickHistory[selector];
|
||||
|
||||
// * Add current click timestamp
|
||||
entry.times.push(now);
|
||||
|
||||
// * Remove clicks outside the time window
|
||||
while (entry.times.length > 0 && now - entry.times[0] > RAGE_CLICK_WINDOW) {
|
||||
entry.times.shift();
|
||||
}
|
||||
|
||||
// * Check if rage click threshold is met
|
||||
if (entry.times.length >= RAGE_CLICK_THRESHOLD) {
|
||||
// * Skip if user is selecting text (triple-click to select paragraph)
|
||||
try {
|
||||
var sel = window.getSelection();
|
||||
if (sel && sel.toString().trim().length > 0) {
|
||||
entry.times = [];
|
||||
return;
|
||||
}
|
||||
} catch (ex) {}
|
||||
|
||||
// * Debounce: max one rage_click per element per 5 seconds
|
||||
if (now - entry.lastFired >= RAGE_CLICK_DEBOUNCE) {
|
||||
var clickCount = entry.times.length;
|
||||
trackCustomEvent('rage_click', {
|
||||
selector: selector,
|
||||
click_count: String(clickCount),
|
||||
page_path: currentPath,
|
||||
x: String(Math.round(e.clientX)),
|
||||
y: String(Math.round(e.clientY))
|
||||
});
|
||||
entry.lastFired = now;
|
||||
}
|
||||
// * Reset tracker after firing or debounce skip
|
||||
entry.times = [];
|
||||
}
|
||||
}, true); // * Capture phase
|
||||
}
|
||||
|
||||
// * Auto-track dead clicks (clicks on interactive elements that produce no effect)
|
||||
// * Fires dead_click when an interactive element is clicked but no DOM change, navigation,
|
||||
// * or network request occurs within 1 second
|
||||
// * Opt-out: add data-no-dead to the script tag
|
||||
if (!script.hasAttribute('data-no-dead')) {
|
||||
var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]:not([tabindex="-1"])';
|
||||
var DEAD_CLICK_DEBOUNCE = 10000;
|
||||
var DEAD_CLEANUP_INTERVAL = 30000;
|
||||
var deadClickDebounce = {}; // * selector -> lastFiredTimestamp
|
||||
|
||||
// * Cleanup stale dead click debounce entries every 30 seconds
|
||||
setInterval(function() {
|
||||
var now = Date.now();
|
||||
for (var key in deadClickDebounce) {
|
||||
if (!deadClickDebounce.hasOwnProperty(key)) continue;
|
||||
if (now - deadClickDebounce[key] > DEAD_CLEANUP_INTERVAL) {
|
||||
delete deadClickDebounce[key];
|
||||
}
|
||||
}
|
||||
}, DEAD_CLEANUP_INTERVAL);
|
||||
|
||||
// * Polyfill check for Element.matches
|
||||
var matchesFn = (function() {
|
||||
var ep = Element.prototype;
|
||||
return ep.matches || ep.msMatchesSelector || ep.webkitMatchesSelector || null;
|
||||
})();
|
||||
|
||||
// * Find the nearest interactive element by walking up max 3 levels
|
||||
function findInteractiveElement(el) {
|
||||
if (!matchesFn) return null;
|
||||
var depth = 0;
|
||||
var current = el;
|
||||
while (current && depth <= 3) {
|
||||
if (current.nodeType === 1 && matchesFn.call(current, INTERACTIVE_SELECTOR)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var target = findInteractiveElement(e.target);
|
||||
if (!target) return;
|
||||
|
||||
// * Skip form inputs — clicking to focus/interact is expected, not a dead click
|
||||
var tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
|
||||
var selector = getElementIdentifier(target);
|
||||
if (!selector) return;
|
||||
|
||||
var now = Date.now();
|
||||
|
||||
// * Debounce: max one dead_click per element per 10 seconds
|
||||
if (deadClickDebounce[selector] && now - deadClickDebounce[selector] < DEAD_CLICK_DEBOUNCE) {
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPath = cleanPath();
|
||||
var clickX = String(Math.round(e.clientX));
|
||||
var clickY = String(Math.round(e.clientY));
|
||||
var effectDetected = false;
|
||||
var hrefBefore = location.href;
|
||||
var mutationObs = null;
|
||||
var perfObs = null;
|
||||
var cleanupTimer = null;
|
||||
var popstateHandler = null;
|
||||
var hashchangeHandler = null;
|
||||
|
||||
function cleanup() {
|
||||
if (mutationObs) { try { mutationObs.disconnect(); } catch (ex) {} mutationObs = null; }
|
||||
if (perfObs) { try { perfObs.disconnect(); } catch (ex) {} perfObs = null; }
|
||||
if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; }
|
||||
if (popstateHandler) { window.removeEventListener('popstate', popstateHandler); popstateHandler = null; }
|
||||
if (hashchangeHandler) { window.removeEventListener('hashchange', hashchangeHandler); hashchangeHandler = null; }
|
||||
}
|
||||
|
||||
function onEffect() {
|
||||
effectDetected = true;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
// * Set up MutationObserver to detect DOM changes on the element, its parent, and body
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
try {
|
||||
mutationObs = new MutationObserver(function() {
|
||||
onEffect();
|
||||
});
|
||||
var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true };
|
||||
mutationObs.observe(target, mutOpts);
|
||||
var parent = target.parentElement;
|
||||
if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') {
|
||||
mutationObs.observe(parent, { childList: true });
|
||||
}
|
||||
// * Also observe body for top-level DOM changes (modals, drawers, overlays, toasts)
|
||||
mutationObs.observe(document.body, { childList: true, attributes: true });
|
||||
} catch (ex) {
|
||||
mutationObs = null;
|
||||
}
|
||||
}
|
||||
|
||||
// * Set up PerformanceObserver to detect network requests
|
||||
if (typeof PerformanceObserver !== 'undefined') {
|
||||
try {
|
||||
perfObs = new PerformanceObserver(function() {
|
||||
onEffect();
|
||||
});
|
||||
perfObs.observe({ type: 'resource' });
|
||||
} catch (ex) {
|
||||
perfObs = null;
|
||||
}
|
||||
}
|
||||
|
||||
// * Listen for SPA navigation events (popstate, hashchange)
|
||||
popstateHandler = function() { onEffect(); };
|
||||
hashchangeHandler = function() { onEffect(); };
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
window.addEventListener('hashchange', hashchangeHandler);
|
||||
|
||||
// * After 1 second, check if any effect was detected
|
||||
cleanupTimer = setTimeout(function() {
|
||||
cleanup();
|
||||
// * Also check if navigation occurred
|
||||
if (effectDetected || location.href !== hrefBefore) return;
|
||||
|
||||
deadClickDebounce[selector] = Date.now();
|
||||
trackCustomEvent('dead_click', {
|
||||
selector: selector,
|
||||
page_path: currentPath,
|
||||
x: clickX,
|
||||
y: clickY
|
||||
});
|
||||
}, 1000);
|
||||
}, true); // * Capture phase
|
||||
}
|
||||
|
||||
// * 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