Skills › Marketing Skills › conversion-tracking › generic-ajax-form.js
generic-ajax-form.js
163 lines · skills/conversion-tracking/assets/snippets/generic-ajax-form.js at 88c79f6
1/*! Generic AJAX form conversion detection (MIT)
2 * Detects: inline success after an AJAX form submit. Pushes dataLayer event: form_submitted
3 * From the conversion-tracking skill in Marketing Skills */
4(function () {
5 if (window.__ctDetectGenericAjaxForm) return;
6 window.__ctDetectGenericAjaxForm = true;
7 window.dataLayer = window.dataLayer || [];
9 /* Last-resort fallback for form tools this repo has no specific snippet
10 for. It infers success from the page changing after a submit, which is
11 weaker than a tool's own success event. Always prefer a named snippet
12 from snippets/ when one exists for the tool.
14 The design deliberately refuses to fire on submit alone. A submit is an
15 attempt; only a success signal appearing afterwards, with no visible
16 validation error, is treated as a conversion. */
18 var SUCCESS_WINDOW_MS = 7000; // how long to wait for a success signal
19 var watchers = [];
21 function fire(formEl) {
22 window.dataLayer.push({
23 event: 'form_submitted',
24 form_id: (formEl && formEl.id) || '',
25 form_name: (formEl && formEl.getAttribute && formEl.getAttribute('name')) || '',
26 page_path: window.location.pathname,
27 detection: 'generic-ajax'
28 });
29 }
31 function isHidden(el) {
32 try {
33 if (el.hidden) return true;
34 if (el.getAttribute && el.getAttribute('aria-hidden') === 'true') return true;
35 var view = el.ownerDocument && el.ownerDocument.defaultView;
36 if (view && view.getComputedStyle) {
37 var cs = view.getComputedStyle(el);
38 if (cs && (cs.display === 'none' || cs.visibility === 'hidden')) return true;
39 }
40 } catch (e) { /* fall through */ }
41 return false;
42 }
44 // A visible validation error means the submit was rejected. This is the
45 // guard that stops a failed submission being counted.
46 function hasVisibleError(root) {
47 try {
48 var nodes = root.querySelectorAll(
49 '[aria-invalid="true"], .error, .is-invalid, [class*="error"], [role="alert"]'
50 );
51 for (var i = 0; i < nodes.length; i++) {
52 if (isHidden(nodes[i])) continue;
53 if (nodes[i].getAttribute &&
54 nodes[i].getAttribute('aria-invalid') === 'true') return true;
55 if ((nodes[i].textContent || '').trim()) return true;
56 }
57 } catch (e) { /* fall through */ }
58 return false;
59 }
61 function looksLikeSuccess(node) {
62 if (!node || node.nodeType !== 1) return false;
63 try {
64 var cls = (typeof node.className === 'string' ? node.className : '') || '';
65 var hook = (node.getAttribute && node.getAttribute('data-hook')) || '';
66 if (/error|invalid/i.test(cls + ' ' + hook)) return false;
68 // Structural marker the tool itself stamped. Strongest signal here.
69 if (/success|confirmation|thank/i.test(cls + ' ' + hook)) return true;
71 var text = (node.textContent || '').trim();
72 if (!text || text.length > 300) return false;
73 if (/error|required|invalid|try again|please enter|please fill/i.test(text)) return false;
74 // Still in flight, not done.
75 if (/^(submitting|sending|loading|processing|please wait|one moment)\b/i.test(text)) return false;
76 return /thank you|thanks|success|received|we'?ll be in touch|message sent|submitted/i.test(text);
77 } catch (e) {
78 return false;
79 }
80 }
82 function watch(formEl) {
83 for (var i = 0; i < watchers.length; i++) {
84 if (watchers[i].form === formEl) return; // already watching this form
85 }
87 var entry = { form: formEl };
88 watchers.push(entry);
90 var done = false;
91 var observer = null;
92 var timer = null;
94 function stop() {
95 done = true;
96 if (observer) { try { observer.disconnect(); } catch (e) {} }
97 if (timer) { clearTimeout(timer); }
98 for (var j = 0; j < watchers.length; j++) {
99 if (watchers[j] === entry) { watchers.splice(j, 1); break; }
100 }
101 }
103 function evaluate(nodes) {
104 if (done) return;
105 // A visible error anywhere in the form means rejection. Give up.
106 if (hasVisibleError(formEl)) { stop(); return; }
107 for (var k = 0; k < nodes.length; k++) {
108 if (looksLikeSuccess(nodes[k])) {
109 fire(formEl);
110 stop();
111 return;
112 }
113 }
114 }
116 try {
117 observer = new MutationObserver(function (mutations) {
118 var added = [];
119 for (var a = 0; a < mutations.length; a++) {
120 var m = mutations[a];
121 for (var b = 0; b < m.addedNodes.length; b++) added.push(m.addedNodes[b]);
122 if (m.type === 'attributes' && m.target) added.push(m.target);
123 }
124 if (added.length) evaluate(added);
125 });
126 // Watch the form's container, so a success panel that REPLACES the form
127 // is still seen. Falls back to the form itself.
128 var scope = formEl.parentNode || formEl;
129 observer.observe(scope, {
130 childList: true, subtree: true,
131 attributes: true, attributeFilter: ['class', 'style', 'hidden', 'aria-hidden']
132 });
133 } catch (e) {
134 stop();
135 return;
136 }
138 timer = setTimeout(stop, SUCCESS_WINDOW_MS);
139 }
141 function handleSubmit(event) {
142 try {
143 var formEl = event.target;
144 if (!formEl || (formEl.tagName || '').toLowerCase() !== 'form') return;
146 // Another handler already cancelled this submission.
147 if (event.defaultPrevented) return;
149 // Constraint validation gate: an invalid form cannot have submitted.
150 try {
151 if (typeof formEl.checkValidity === 'function' &&
152 formEl.checkValidity() === false) return;
153 } catch (e) { /* validation unavailable — fall through */ }
155 watch(formEl);
156 } catch (e) {
157 // Never throw back into the page's own form handling.
158 }
159 }
161 // Bubble phase so the tool's own handlers run first.
162 document.addEventListener('submit', handleSubmit, false);
163})();