Skills › Marketing Skills › conversion-tracking › formidable-forms.js
formidable-forms.js
201 lines · skills/conversion-tracking/assets/snippets/formidable-forms.js at 88c79f6
1/*! Formidable Forms conversion detection (MIT)
2 * Detects: form submission. Pushes dataLayer event: formidable_forms_submitted
3 * From the conversion-tracking skill in Marketing Skills */
4(function () {
5 'use strict';
7 // Shared with the recipe snippet so template + recipe installation is one listener.
8 if (window.__ctDetectFormidableForms) return;
9 window.__ctDetectFormidableForms = true;
10 if (!/^https?:$/.test(window.location.protocol)) return;
11 window.dataLayer = window.dataLayer || [];
13 var MARKER_KEY = '__ctDetectFormidableNativePendingV2';
14 var MARKER_TTL_MS = 60000;
15 var handledResponses = typeof WeakSet === 'function' ? new WeakSet() : [];
16 var activeAttempts = [];
17 var jqueryHooked = false;
19 function safeId(value) {
20 var id = String(value || '');
21 return /^[1-9][0-9]*$/.test(id) ? id : '';
22 }
24 // Formidable's hidden form_id is non-sensitive structural metadata. No other
25 // field value, response property, label, HTML, or rendered text is inspected.
26 function formId(form) {
27 if (!form || typeof form.querySelector !== 'function') return '';
28 try {
29 var input = form.querySelector('input[name="form_id"]');
30 return input && input.getAttribute ? safeId(input.getAttribute('value')) : '';
31 } catch (e) {
32 return '';
33 }
34 }
36 function wrapperFor(id) {
37 if (!id) return null;
38 try { return document.getElementById('frm_form_' + id + '_container'); } catch (e) { return null; }
39 }
41 function hasNode(scope, selector) {
42 if (!scope || typeof scope.querySelector !== 'function') return false;
43 try { return !!scope.querySelector(selector); } catch (e) { return false; }
44 }
46 function hasRejectionMarker(id) {
47 return hasNode(wrapperFor(id), '.frm_error, .frm_error_style, .frm_blank_field, [aria-invalid="true"]');
48 }
50 function hasSuccessMarker(id) {
51 return hasNode(wrapperFor(id), '.frm_message');
52 }
54 function responseWasHandled(response) {
55 if (!response || (typeof response !== 'object' && typeof response !== 'function')) return true;
56 if (typeof WeakSet === 'function' && handledResponses instanceof WeakSet) {
57 if (handledResponses.has(response)) return true;
58 handledResponses.add(response);
59 return false;
60 }
61 for (var i = 0; i < handledResponses.length; i++) {
62 if (handledResponses[i] === response) return true;
63 }
64 handledResponses.push(response);
65 if (handledResponses.length > 100) handledResponses.shift();
66 return false;
67 }
69 function push(id) {
70 if (!id) return;
71 window.dataLayer.push({ event: 'formidable_forms_submitted', form_id: id });
72 }
74 // Optional Method 2 adapter owns any enriched push. The detector passes only
75 // structural metadata and accepts only a literal true handled verdict; it
76 // never receives or inspects an adapter payload.
77 function emitAccepted(id, form, source) {
78 var handled = false;
79 try {
80 if (typeof window.__ctFormidableMethod2Emit === 'function') {
81 handled = window.__ctFormidableMethod2Emit(id, form, source) === true;
82 }
83 } catch (e) {}
84 if (!handled) push(id);
85 }
87 // In 6.32.1 these are jQuery events: formidable.js calls
88 // jQuery(document).trigger(name, [form, response]). addEventListener does not
89 // receive these success events. The response is used only by object identity
90 // so the redirect and completion callbacks for one response count once.
91 function onAjaxSuccess(event, form, response) {
92 var id = formId(form);
93 if (!id || !response || responseWasHandled(response)) return;
94 emitAccepted(id, form, 'ajax');
95 disarmAttempt(form);
96 }
98 function hookJquery() {
99 if (jqueryHooked) return true;
100 var jq = window.jQuery || window.$;
101 if (typeof jq !== 'function') return false;
102 jq(document).on(
103 'frmFormComplete.ctRecipe frmBeforeFormRedirect.ctRecipe',
104 onAjaxSuccess
105 );
106 jqueryHooked = true;
107 return true;
108 }
110 function readMarker() {
111 try {
112 var raw = window.sessionStorage.getItem(MARKER_KEY);
113 if (!raw) return null;
114 window.sessionStorage.removeItem(MARKER_KEY);
115 var marker = JSON.parse(raw);
116 if (!marker || typeof marker !== 'object') return null;
117 if (typeof marker.t !== 'number' || Date.now() < marker.t || Date.now() - marker.t > MARKER_TTL_MS) return null;
118 if (marker.hadSuccessAtSubmit === true) return null;
119 return { id: safeId(marker.id), origin: marker.origin, path: marker.path };
120 } catch (e) {
121 try { window.sessionStorage.removeItem(MARKER_KEY); } catch (e2) {}
122 return null;
123 }
124 }
126 // Native success either renders a form-specific message or redirects away
127 // from the submitted form. Rejection stays on the form page with errors.
128 // Only origin and pathname are retained, never query strings or form answers.
129 function processNativeMessage() {
130 var marker = readMarker();
131 if (!marker || !marker.id || hasRejectionMarker(marker.id)) return;
132 var redirected = marker.origin === window.location.origin &&
133 typeof marker.path === 'string' && marker.path !== window.location.pathname &&
134 !wrapperFor(marker.id) && !hasNode(document, '.frm_error, .frm_error_style');
135 if (hasSuccessMarker(marker.id) || redirected) {
136 var wrapper = wrapperFor(marker.id);
137 var form = null;
138 try { form = wrapper && wrapper.querySelector ? wrapper.querySelector('form') : null; } catch (e) {}
139 emitAccepted(marker.id, form, 'native');
140 }
141 }
143 function getAttempt(form) {
144 for (var i = 0; i < activeAttempts.length; i++) {
145 if (activeAttempts[i].form === form) return activeAttempts[i];
146 }
147 return null;
148 }
150 function disarmAttempt(form) {
151 var attempt = getAttempt(form);
152 if (!attempt) return;
153 try { window.removeEventListener('pagehide', attempt.onPageHide, true); } catch (e) {}
154 var index = activeAttempts.indexOf(attempt);
155 if (index !== -1) activeAttempts.splice(index, 1);
156 }
158 function armNativeMessageAttempt(event) {
159 var form = event && event.target;
160 if (!form || String(form.tagName || '').toLowerCase() !== 'form') return;
161 var isFormidable = false;
162 try { isFormidable = !!(form.closest && form.closest('.frm_forms')); } catch (e) {}
163 if (!isFormidable) return;
164 var id = formId(form);
165 if (!id) return;
166 var wrapper = wrapperFor(id);
167 var hadSuccessAtSubmit = hasNode(wrapper, '.frm_message');
168 var prior = getAttempt(form);
169 if (prior) disarmAttempt(form);
170 var attempt = { form: form, id: id, hadSuccessAtSubmit: hadSuccessAtSubmit, onPageHide: null };
171 attempt.onPageHide = function () {
172 disarmAttempt(form);
173 // Old server errors remain visible while a corrected native POST leaves.
174 // Decide acceptance from the response page, not the departing page.
175 if (attempt.hadSuccessAtSubmit) return;
176 try {
177 window.sessionStorage.setItem(MARKER_KEY, JSON.stringify({
178 t: Date.now(),
179 id: attempt.id,
180 hadSuccessAtSubmit: false,
181 origin: window.location.origin,
182 path: window.location.pathname
183 }));
184 } catch (e) {}
185 };
186 activeAttempts.push(attempt);
187 try { window.addEventListener('pagehide', attempt.onPageHide, true); } catch (e) {}
188 }
190 hookJquery();
191 if (!jqueryHooked) {
192 document.addEventListener('DOMContentLoaded', hookJquery, { once: true });
193 window.addEventListener('load', hookJquery, { once: true });
194 }
195 document.addEventListener('submit', armNativeMessageAttempt, true);
196 if (document.readyState === 'loading') {
197 document.addEventListener('DOMContentLoaded', processNativeMessage, { once: true });
198 } else {
199 processNativeMessage();
200 }
201})();