Skill Tracer
Skills › Marketing Skills › conversion-tracking › squarespace-forms.js

squarespace-forms.js

426 lines · skills/conversion-tracking/assets/snippets/squarespace-forms.js at 88c79f6

1/*! Squarespace Forms conversion detection (MIT)
2 * Detects: form submission. Pushes dataLayer event: squarespace_form_submitted
3 * From the conversion-tracking skill in Marketing Skills */
4(function () {
5 'use strict';
6 // Install once, even if GTM injects this tag more than once on the page.
7 if (window.__ctDetectSquarespaceForms) return;
8 window.__ctDetectSquarespaceForms = true;
9 window.dataLayer = window.dataLayer || [];
11 // Ported from a production module (cdn/v1/modules/
12 // squarespace-forms.js, measured against a live Squarespace 7.1 site in
13 // October 2026). Event only: it never reads what the visitor typed.
14 //
15 // What Squarespace does:
16 // - The Form block renders a React form in the page (no iframe):
17 // .sqs-block-form ... form.react-form-contents[novalidate]. It mounts
18 // after DOMContentLoaded, so submits are caught on document.
19 // - There is no check in the browser. Every submit posts
20 // /api/form/FormSubmissionKey then /api/form/SaveFormSubmission, and the
21 // server decides: 204 on success, 400 for a field error, 401 for a failed
22 // reCAPTCHA. A submit is never a conversion on its own.
23 // - On success it marks the form (react-form-contents--submitted), shows a
24 // .form-submission-text message and gives the button the class
25 // "success". A form set to redirect leaves for its page about 1.3
26 // seconds after the 204, before that message renders in Chromium.
27 // - The Newsletter block posts to the same SaveFormSubmission address, and
28 // that request carries no form identity, so a save only counts for a
29 // form that provably owns it.
30 //
31 // It fires once per form, on the first confirmed success for an attempt:
32 // 1. a success marker that was not there when the attempt started, or
33 // 2. the SaveFormSubmission request answering 2xx (read from the
34 // browser's resource timing, where it exposes responseStatus), when
35 // no other form's submit could own that save, or
36 // 3. as the page leaves (a redirect form), either of the above.
37 // It never fires on a 400 or 401, on a filled anti-spam field, or when it
38 // cannot tell which form a save belongs to.
40 var EVENT_NAME = 'squarespace_form_submitted';
41 var BLOCK_SELECTOR = '.sqs-block-form, [data-sqsp-block="form"], [data-definition-name="website.components.form"]';
42 var SAVE_PATH = '/api/form/SaveFormSubmission';
43 // Long enough for a visitor to solve a reCAPTCHA picture challenge.
44 var WATCH_MS = 180000;
46 function now() {
47 try {
48 if (window.performance && typeof window.performance.now === 'function') return window.performance.now();
49 } catch (e) {}
50 return 0;
51 }
53 function hasToken(el, token) {
54 if (!el || !el.getAttribute) return false;
55 var cls = el.getAttribute('class') || '';
56 return (' ' + cls + ' ').indexOf(' ' + token + ' ') !== -1;
57 }
59 function isConnected(el) {
60 try {
61 if (typeof el.isConnected === 'boolean') return el.isConnected;
62 return !!(el.ownerDocument && el.ownerDocument.documentElement.contains(el));
63 } catch (e) {
64 return false;
65 }
66 }
68 // ---- Which forms are Squarespace forms ----
70 function blockFor(formEl) {
71 try {
72 return (formEl && formEl.closest) ? formEl.closest(BLOCK_SELECTOR) : null;
73 } catch (e) {
74 return null;
75 }
76 }
78 function isSquarespaceForm(formEl) {
79 if (!formEl || (formEl.tagName || '').toLowerCase() !== 'form') return false;
80 return !!blockFor(formEl);
81 }
83 // The block's own form definition (the site owner's settings, not the
84 // visitor's answers): { formId, formName, ... }.
85 function formIdentity(block) {
86 var id = { formId: '', formName: '' };
87 try {
88 var script = block && block.querySelector('script.sqs-form-block-context');
89 if (!script) return id;
90 var parsed = JSON.parse(script.textContent || '{}');
91 if (parsed && typeof parsed.formId === 'string') id.formId = parsed.formId;
92 if (parsed && typeof parsed.formName === 'string') id.formName = parsed.formName;
93 } catch (e) {}
94 return id;
95 }
97 // The honeypot is a text input visitors cannot see or reach (measured id
98 // "message-field", inside an aria-hidden wrapper). Squarespace answers a
99 // filled one with a 204 and its thank-you message, so a filled honeypot
100 // means a bot. Only whether it is empty is checked.
101 function honeypotFilled(formEl) {
102 try {
103 var hp = formEl.querySelector('#message-field');
104 if (!hp) return false;
105 var wrap = hp.closest && hp.closest('[aria-hidden="true"]');
106 if (!wrap || !formEl.contains(wrap)) return false;
107 return hp.value !== '';
108 } catch (e) {
109 return false;
110 }
111 }
113 function submitButtonOf(formEl) {
114 try { return formEl.querySelector('button[type="submit"]'); } catch (e) { return null; }
115 }
117 // ---- Which form a save request belongs to ----
119 var seenSaves = []; // SaveFormSubmission resource entries seen so far
120 var claimed = []; // entries already credited to a form
121 var submitLog = []; // every form submit on the page, with its time
123 function pageOrigin() {
124 try {
125 var l = window.location;
126 return l.origin || (l.protocol + '//' + l.host);
127 } catch (e) {
128 return '';
129 }
130 }
132 // The save request's address is checked for its origin and path only.
133 function isSaveEntry(e) {
134 var n = (e && e.name) || '';
135 var origin = pageOrigin();
136 if (!origin || n.indexOf(origin + '/') !== 0) return false;
137 return n.slice(origin.length).split(/[?#]/)[0] === SAVE_PATH;
138 }
140 function prune() {
141 // An attempt is watched for WATCH_MS, and submits up to WATCH_MS before
142 // it still count when deciding who owns a save.
143 var cutoff = now() - 2 * WATCH_MS;
144 var i;
145 for (i = submitLog.length - 1; i >= 0; i--) if (submitLog[i].at < cutoff) submitLog.splice(i, 1);
146 for (i = seenSaves.length - 1; i >= 0; i--) if (seenSaves[i].startTime < cutoff) seenSaves.splice(i, 1);
147 if (seenSaves.length > 100) seenSaves.splice(0, seenSaves.length - 100);
148 if (claimed.length > 100) claimed.splice(0, claimed.length - 100);
149 }
151 function allSaveEntries() {
152 var all = seenSaves.slice();
153 try {
154 if (window.performance && typeof window.performance.getEntriesByType === 'function') {
155 var buffered = window.performance.getEntriesByType('resource');
156 for (var b = 0; b < buffered.length; b++) if (all.indexOf(buffered[b]) === -1) all.push(buffered[b]);
157 }
158 } catch (err) {}
159 var out = [];
160 for (var i = 0; i < all.length; i++) if (isSaveEntry(all[i])) out.push(all[i]);
161 return out;
162 }
164 function saveEntriesSince(since) {
165 var all = allSaveEntries();
166 var out = [];
167 for (var i = 0; i < all.length; i++) {
168 if (all[i].startTime >= since && claimed.indexOf(all[i]) === -1) out.push(all[i]);
169 }
170 out.sort(function (x, y) { return x.startTime - y.startTime; });
171 return out;
172 }
174 // A save's owner is ambiguous when another form (a Newsletter block
175 // included) was submitted during this attempt. It is also ambiguous when
176 // another form was submitted earlier in the watch window and may still be
177 // waiting for its save, because a slow reCAPTCHA can hold that save back
178 // until after this attempt starts. Each submit gets at most one save, so
179 // fewer saves than submits before this attempt means one is outstanding.
180 function saveOwnershipAmbiguous(formEl, since, until) {
181 var other = false;
182 var first = null;
183 var submits = 0;
184 for (var i = 0; i < submitLog.length; i++) {
185 var s = submitLog[i];
186 if (s.at > until || s.at < since - WATCH_MS) continue;
187 if (s.at >= since) {
188 if (s.form !== formEl) return true;
189 continue;
190 }
191 if (s.form !== formEl) other = true;
192 submits++;
193 if (first === null || s.at < first) first = s.at;
194 }
195 if (!other) return false;
196 var saves = allSaveEntries();
197 var answered = 0;
198 for (var j = 0; j < saves.length; j++) {
199 if (saves[j].startTime >= first && saves[j].startTime < since) answered++;
200 }
201 return answered < submits;
202 }
204 function statusOf(entry) {
205 var s = entry && entry.responseStatus;
206 return (typeof s === 'number' && s > 0) ? s : null;
207 }
209 // ---- One watcher per form ----
211 var watchers = [];
212 var firedForms = [];
214 function findWatcher(formEl) {
215 for (var i = 0; i < watchers.length; i++) if (watchers[i].form === formEl) return watchers[i];
216 return null;
217 }
219 function removeWatcher(w) {
220 for (var i = 0; i < watchers.length; i++) if (watchers[i] === w) { watchers.splice(i, 1); return; }
221 }
223 function dropStaleWatchers(formEl, block) {
224 for (var i = watchers.length - 1; i >= 0; i--) {
225 var w = watchers[i];
226 if (w.form === formEl) continue;
227 if (!isConnected(w.form) || (block && w.block === block)) w.cleanup();
228 }
229 }
231 function createWatcher(formEl, block) {
232 var scope = (formEl.closest && formEl.closest('.form-wrapper')) || block;
233 function markers() {
234 var text = false;
235 try { text = !!(scope && scope.querySelector('.form-submission-text')); } catch (e) {}
236 return {
237 submitted: hasToken(formEl, 'react-form-contents--submitted'),
238 button: hasToken(submitButtonOf(formEl), 'success'),
239 text: text
240 };
241 }
242 var w = {
243 form: formEl,
244 block: block,
245 identity: formIdentity(block),
246 armed: false,
247 submitAt: 0,
248 answered: true,
249 done: false,
250 timer: null,
251 observer: null,
252 pagehideArmed: false,
253 staleError: false,
254 had: markers()
255 };
257 function redirectConfigured() {
258 try { return !!(formEl.getAttribute && formEl.getAttribute('data-success-redirect')); } catch (e) { return false; }
259 }
261 function cleanup() {
262 w.done = true;
263 if (w.timer) { try { clearTimeout(w.timer); } catch (e) {} w.timer = null; }
264 if (w.observer) { try { w.observer.disconnect(); } catch (e) {} w.observer = null; }
265 if (w.pagehideArmed) {
266 try { window.removeEventListener('pagehide', onPageHide, true); } catch (e) {}
267 w.pagehideArmed = false;
268 }
269 removeWatcher(w);
270 }
272 // The push comes first. The form is marked done only once the push went
273 // through, so a dataLayer that throws (a broken wrapper) loses nothing,
274 // and the next confirmation tries again.
275 function fire() {
276 if (w.done) return false;
277 var payload = { event: EVENT_NAME, form_id: w.identity.formId };
278 if (w.identity.formName) payload.form_name = w.identity.formName;
279 try {
280 window.dataLayer = window.dataLayer || [];
281 window.dataLayer.push(payload);
282 } catch (e) {
283 return false;
284 }
285 cleanup();
286 firedForms.push(formEl);
287 return true;
288 }
290 function domConfirmsSuccess() {
291 var m = markers();
292 return (m.submitted && !w.had.submitted) || (m.button && !w.had.button) || (m.text && !w.had.text);
293 }
295 function check() {
296 if (w.done || !w.armed) return;
297 if (!isConnected(formEl)) { cleanup(); return; }
298 if (domConfirmsSuccess()) { fire(); return; }
299 if (w.answered) return;
300 // A fresh error state on the button is Squarespace refusing this
301 // attempt; one left over from an earlier attempt is not.
302 var errorShowing = hasToken(submitButtonOf(formEl), 'error');
303 if (!errorShowing) w.staleError = false;
304 else if (!w.staleError) w.answered = true;
305 }
307 // A failure in any callback stays inside this listener and never
308 // reaches Squarespace's page.
309 function onPageHide() {
310 try { pageHideCheck(); } catch (e) {}
311 }
313 function pageHideCheck() {
314 if (w.done || !w.armed) return;
315 if (!isConnected(formEl)) { cleanup(); return; }
316 if (domConfirmsSuccess()) { fire(); return; }
317 if (saveOwnershipAmbiguous(formEl, w.submitAt, now())) { cleanup(); return; }
318 var entries = saveEntriesSince(w.submitAt);
319 var last = entries[entries.length - 1];
320 var status = statusOf(last);
321 if (status !== null && status >= 200 && status < 300) {
322 if (fire()) claimed.push(last);
323 else cleanup();
324 return;
325 }
326 cleanup();
327 }
329 w.refresh = function (submitAt) {
330 // A new attempt replaces the old one once the old one was answered
331 // (or its save has been seen); a double submit keeps the first.
332 if (!w.armed || w.answered || saveEntriesSince(w.submitAt).length > 0) {
333 w.submitAt = submitAt;
334 w.answered = false;
335 w.staleError = hasToken(submitButtonOf(formEl), 'error');
336 }
337 w.armed = true;
338 if (w.timer) { try { clearTimeout(w.timer); } catch (e) {} }
339 try { w.timer = setTimeout(cleanup, WATCH_MS); } catch (e) {}
340 if (!w.pagehideArmed && redirectConfigured()) {
341 try {
342 window.addEventListener('pagehide', onPageHide, true);
343 w.pagehideArmed = true;
344 } catch (e) {}
345 }
346 };
347 w.check = check;
348 w.fire = fire;
349 w.cleanup = cleanup;
350 w.alive = function () { return isConnected(formEl); };
351 w.markAnswered = function () { w.answered = true; };
353 try {
354 if (typeof MutationObserver !== 'undefined' && block) {
355 w.observer = new MutationObserver(function () {
356 try { check(); } catch (e) {}
357 });
358 w.observer.observe(block, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
359 }
360 } catch (e) {}
361 watchers.push(w);
362 return w;
363 }
365 // ---- The save request's verdict, from resource timing ----
367 function onResourceEntries(list) {
368 var entries;
369 try { entries = list.getEntries(); } catch (e) { return; }
370 for (var i = 0; i < entries.length; i++) {
371 var e = entries[i];
372 if (!e || !isSaveEntry(e)) continue;
373 if (seenSaves.indexOf(e) === -1) { seenSaves.push(e); prune(); }
374 var status = statusOf(e);
375 if (status === null || claimed.indexOf(e) !== -1) continue;
376 var candidates = [];
377 for (var j = 0; j < watchers.length; j++) {
378 var w = watchers[j];
379 if (!w.done && w.armed && !w.answered && w.alive() && e.startTime >= w.submitAt) candidates.push(w);
380 }
381 if (candidates.length !== 1) continue;
382 var c = candidates[0];
383 if (saveOwnershipAmbiguous(c.form, c.submitAt, e.startTime)) continue;
384 if (status >= 200 && status < 300) {
385 if (c.fire()) claimed.push(e);
386 } else {
387 c.markAnswered();
388 }
389 }
390 }
392 // ---- Submits ----
394 function handleSubmit(event) {
395 try {
396 var formEl = event.target;
397 if (!formEl || (formEl.tagName || '').toLowerCase() !== 'form') return;
398 submitLog.push({ form: formEl, at: now() });
399 prune();
400 if (!isSquarespaceForm(formEl)) return;
401 var block = blockFor(formEl);
402 dropStaleWatchers(formEl, block);
403 // Squarespace allows one success per page view.
404 if (firedForms.indexOf(formEl) !== -1) return;
405 if (hasToken(formEl, 'react-form-contents--submitted')) return;
406 if (honeypotFilled(formEl)) {
407 var old = findWatcher(formEl);
408 if (old) old.cleanup();
409 return;
410 }
411 var w = findWatcher(formEl) || createWatcher(formEl, block);
412 w.refresh(now());
413 } catch (e) {
414 // Never throw back into Squarespace's own form handling.
415 }
416 }
418 document.addEventListener('submit', handleSubmit, true);
419 try {
420 if (typeof PerformanceObserver !== 'undefined') {
421 new PerformanceObserver(function (list) {
422 try { onResourceEntries(list); } catch (e) {}
423 }).observe({ type: 'resource' });
424 }
425 } catch (e) {}
426})();