Skills › Marketing Skills › conversion-tracking › wpforms.js
wpforms.js
196 lines · skills/conversion-tracking/assets/snippets/wpforms.js at 88c79f6
1/*! WPForms conversion detection (MIT)
2 * Detects: form submission. Pushes dataLayer event: wpforms_form_submitted
3 * From the conversion-tracking skill in Marketing Skills */
4/* WPForms listener. Records message and redirect confirmations.
5 * Native redirects use a short-lived submission marker and changed pathname.
6 * Does not read form values, labels, rendered text/HTML, response bodies,
7 * redirect URLs, or URL query strings. */
8(function () {
9 'use strict';
10 if (window.__ctDetectWpforms) return;
11 window.__ctDetectWpforms = true;
12 if (!/^https?:$/.test(window.location.protocol)) return;
13 window.dataLayer = window.dataLayer || [];
15 var PENDING_KEY = '__ctWpformsNativePendingV1';
16 var PENDING_TTL_MS = 120000;
18 function formId(form) {
19 var match = /^wpforms-form-(\d+)$/.exec((form && form.id) || '');
20 return match ? match[1] : null;
21 }
22 function confirmationNode(id) {
23 if (!id || !/^\d+$/.test(id)) return null;
24 try { return document.querySelector('#wpforms-confirmation-' + id); } catch (e) { return null; }
25 }
26 // A delayed callback can run after another callback has already observed
27 // the same inserted node. Deduplicate by node identity, not by form ID, so
28 // a later accepted response with a distinct confirmation node still counts.
29 var supportsWeakSet = typeof WeakSet === 'function';
30 var handledConfirmationNodes = supportsWeakSet ? new WeakSet() : [];
31 function markConfirmationSeen(node) {
32 if (supportsWeakSet) {
33 if (handledConfirmationNodes.has(node)) return false;
34 handledConfirmationNodes.add(node);
35 return true;
36 }
37 if (handledConfirmationNodes.indexOf(node) !== -1) return false;
38 handledConfirmationNodes.push(node);
39 return true;
40 }
41 function push(id) {
42 if (!id || !/^\d+$/.test(id)) return;
43 window.dataLayer.push({ event: 'wpforms_form_submitted', form_id: id });
44 }
45 // Method 2 owns and pushes its enriched payload. The detector sees only a
46 // literal handled marker; arbitrary objects or PII are never read/forwarded.
47 function emitConfirmed(id, form, mode) {
48 var adapter = window.__ctWpformsMethod2Emit;
49 try { if (typeof adapter === 'function' && adapter(id, form || null, mode) === true) return; } catch (e) {}
50 push(id);
51 }
52 function discardAdapterSnapshot(id, form, mode) {
53 var discard = window.__ctWpformsMethod2Discard;
54 try { if (typeof discard === 'function') discard(id, form || null, mode); } catch (e) {}
55 }
56 function storePending(id) {
57 if (!id) return;
58 try {
59 window.sessionStorage.setItem(PENDING_KEY, JSON.stringify({ id: id, time: Date.now(), path: window.location.pathname }));
60 } catch (e) {}
61 }
62 function takePending() {
63 try {
64 var raw = window.sessionStorage.getItem(PENDING_KEY);
65 if (!raw) return null;
66 window.sessionStorage.removeItem(PENDING_KEY);
67 var value = JSON.parse(raw);
68 if (!value || !/^\d+$/.test(String(value.id || '')) ||
69 typeof value.time !== 'number' || Date.now() - value.time > PENDING_TTL_MS ||
70 Date.now() < value.time) return null;
71 return { id: String(value.id), path: typeof value.path === 'string' ? value.path : null };
72 } catch (e) { return null; }
73 }
74 function hasVisibleErrors(root) {
75 var errors = root.querySelectorAll('.wpforms-error, .wpforms-error-container, [aria-invalid="true"]');
76 for (var i = 0; i < errors.length; i++) {
77 if (errors[i].getClientRects().length) return true;
78 }
79 return false;
80 }
81 function processNativeVerdict() {
82 var marker = takePending();
83 if (!marker) return;
84 if (hasVisibleErrors(document)) discardAdapterSnapshot(marker.id, null, 'native-no-confirmation');
85 else if (confirmationNode(marker.id)) emitConfirmed(marker.id, null, 'native-message');
86 else if (marker.path && marker.path !== window.location.pathname) {
87 // A clean submit followed by a new page is the redirect confirmation.
88 // An optional Method 2 adapter may attach what it saved on the form page.
89 emitConfirmed(marker.id, null, 'native-redirect');
90 }
91 else discardAdapterSnapshot(marker.id, null, 'native-no-confirmation');
92 }
94 // Keep one short-lived navigation watcher per form. Client validation can
95 // cancel submission, so a submit attempt alone must not leave a marker.
96 var nativeWatchers = new WeakMap();
97 document.addEventListener('submit', function (event) {
98 var form = event && event.target;
99 if (!form || (form.tagName || '').toLowerCase() !== 'form') return;
100 var id = formId(form);
101 if (!id) return;
102 var classes = typeof form.className === 'string' ? form.className : '';
103 if (/(^|\s)wpforms-ajax-form(\s|$)/.test(classes)) return;
104 var previous = nativeWatchers.get(form);
105 if (previous) previous();
106 var timer;
107 function cleanup() {
108 window.removeEventListener('pagehide', onPageHide, true);
109 clearTimeout(timer);
110 nativeWatchers.delete(form);
111 }
112 function onPageHide() {
113 cleanup();
114 // A corrected retry can still display the previous server error box.
115 // Use the current client-validation result, without reading answers.
116 var jq = window.jQuery;
117 var validator = typeof jq === 'function' ? jq(form).data('validator') : null;
118 if (validator && validator.errorList) {
119 if (validator.errorList.length) return;
120 } else if (hasVisibleErrors(form)) return;
121 storePending(id);
122 }
123 nativeWatchers.set(form, cleanup);
124 window.addEventListener('pagehide', onPageHide, true);
125 timer = setTimeout(cleanup, 10000);
126 }, true);
128 // AJAX message confirmation: the callback alone is ambiguous. Wait until
129 // WPForms has inserted a new, form-attributed confirmation node.
130 var jqueryHooked = false;
131 var redirectEpochByForm = typeof WeakMap === 'function' ? new WeakMap() : null;
132 var seenRedirectEvents = typeof WeakSet === 'function' ? new WeakSet() : [];
133 var seenSuccessEvents = typeof WeakSet === 'function' ? new WeakSet() : [];
134 function markEventSeen(set, event) {
135 if (!event || (typeof event !== 'object' && typeof event !== 'function')) return false;
136 if (typeof WeakSet === 'function' && set instanceof WeakSet) {
137 if (set.has(event)) return false;
138 set.add(event);
139 return true;
140 }
141 if (set.indexOf(event) !== -1) return false;
142 set.push(event);
143 return true;
144 }
145 function markRedirectEvent(event) {
146 return markEventSeen(seenRedirectEvents, event);
147 }
148 function hookWpformsEvents() {
149 if (jqueryHooked) return;
150 var jq = window.jQuery || window.$;
151 if (typeof jq !== 'function') return;
152 jqueryHooked = true;
153 jq(document).on('wpformsAjaxSubmitSuccess.ctWpforms', function (event) {
154 if (!markEventSeen(seenSuccessEvents, event)) return;
155 var form = event && event.target;
156 var id = formId(form);
157 if (!id) return;
158 var redirectEpoch = redirectEpochByForm ? (redirectEpochByForm.get(form) || 0) : 0;
159 var before = confirmationNode(id);
160 setTimeout(function () {
161 var after = confirmationNode(id);
162 if (after && after !== before && markConfirmationSeen(after)) {
163 emitConfirmed(id, form, 'ajax-message');
164 } else if (!redirectEpochByForm || (redirectEpochByForm.get(form) || 0) === redirectEpoch) {
165 discardAdapterSnapshot(id, form, 'ajax-no-confirmation');
166 }
167 }, 0);
168 });
169 jq(document).on('wpformsAjaxSubmitFailed.ctWpforms wpformsAjaxSubmitError.ctWpforms wpformsAjaxSubmitActionRequired.ctWpforms', function (event) {
170 var form = event && event.target;
171 var id = formId(form);
172 if (id) discardAdapterSnapshot(id, form, 'ajax-rejected');
173 });
174 // WPForms 1.6.7 triggers this only after ajax_process_redirect has
175 // produced a nonempty redirect_url from accepted processing. The URL and
176 // response object are intentionally not inspected.
177 jq(document).on('wpformsAjaxSubmitBeforeRedirect.ctWpforms', function (event) {
178 if (!markRedirectEvent(event)) return;
179 var form = event && event.target;
180 var id = formId(form);
181 if (!id) return;
182 if (redirectEpochByForm) redirectEpochByForm.set(form, (redirectEpochByForm.get(form) || 0) + 1);
183 emitConfirmed(id, form, 'ajax-redirect');
184 });
185 }
186 function ready() {
187 processNativeVerdict();
188 hookWpformsEvents();
189 }
190 // Let sibling Method 2 tags in the same page task install their enrichment
191 // delegate before a native confirmation verdict is dispatched.
192 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready, { once: true });
193 else setTimeout(ready, 0);
194 document.addEventListener('DOMContentLoaded', hookWpformsEvents, { once: true });
195 window.addEventListener('load', hookWpformsEvents, { once: true });
196})();