Skill Tracer
Skills › Marketing Skills › conversion-tracking › tally.js

tally.js

165 lines · skills/conversion-tracking/assets/snippets/tally.js at 88c79f6

1/*! Tally conversion detection (MIT)
2 * Detects: form submission. Pushes dataLayer event: tally_form_submitted
3 * From the conversion-tracking skill in Marketing Skills */
4/* Tally Submission Listener. Reads only the envelope of Tally's
5 * own submission message; it does not decode or read answer values and never
6 * pushes answers. */
7(function () {
8 'use strict';
9 if (window.__ctDetectTally || window.__ctTallyMethod2Installed) return;
10 window.__ctDetectTally = true;
11 // Keep the historical Method 2 marker as a one-way compatibility alias.
12 // The separate Method 2 adapter has its own marker and can still register
13 // after this core is already installed.
14 window.__ctTallyMethod2Installed = true;
15 window.dataLayer = window.dataLayer || [];
17 function isWs(c) { return c === ' ' || c === '\n' || c === '\r' || c === '\t'; }
18 function skipWs(s, p) { while (p.i < s.length && isWs(s.charAt(p.i))) p.i++; }
19 function stringEnd(s, p, decode) {
20 if (s.charAt(p.i) !== '"') throw new Error('string');
21 var start = p.i++;
22 while (p.i < s.length) {
23 var c = s.charAt(p.i++);
24 if (c === '"') {
25 if (!decode) return null;
26 // Decode only object keys and allowlisted envelope metadata strings.
27 return JSON.parse(s.slice(start, p.i));
28 }
29 if (c.charCodeAt(0) < 0x20) throw new Error('control');
30 if (c === '\\') {
31 if (p.i >= s.length) throw new Error('escape end');
32 var esc = s.charAt(p.i++);
33 if ('"\\/bfnrt'.indexOf(esc) !== -1) continue;
34 if (esc !== 'u' || !/^[0-9a-fA-F]{4}$/.test(s.slice(p.i, p.i + 4))) throw new Error('escape');
35 p.i += 4;
36 }
37 }
38 throw new Error('unterminated');
39 }
40 function skipValue(s, p, depth) {
41 if (depth > 32) throw new Error('depth');
42 skipWs(s, p);
43 var c = s.charAt(p.i);
44 if (c === '"') { stringEnd(s, p, false); return; }
45 if (c === '{') {
46 p.i++; skipWs(s, p); if (s.charAt(p.i) === '}') { p.i++; return; }
47 while (p.i < s.length) {
48 skipWs(s, p); stringEnd(s, p, false); skipWs(s, p);
49 if (s.charAt(p.i++) !== ':') throw new Error('colon');
50 skipValue(s, p, depth + 1); skipWs(s, p);
51 var sep = s.charAt(p.i++); if (sep === '}') return; if (sep !== ',') throw new Error('object');
52 }
53 throw new Error('object end');
54 }
55 if (c === '[') {
56 p.i++; skipWs(s, p); if (s.charAt(p.i) === ']') { p.i++; return; }
57 while (p.i < s.length) {
58 skipValue(s, p, depth + 1); skipWs(s, p);
59 var a = s.charAt(p.i++); if (a === ']') return; if (a !== ',') throw new Error('array');
60 }
61 throw new Error('array end');
62 }
63 var begin = p.i;
64 while (p.i < s.length && !isWs(s.charAt(p.i)) && ',}]'.indexOf(s.charAt(p.i)) === -1) p.i++;
65 if (p.i === begin) throw new Error('value');
66 var token = s.slice(begin, p.i);
67 if (token !== 'true' && token !== 'false' && token !== 'null' &&
68 !/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/.test(token)) throw new Error('primitive');
69 }
70 function readStringValue(s, p) { skipWs(s, p); return stringEnd(s, p, true); }
71 function readMetadataObject(s, p, wanted, out) {
72 skipWs(s, p); if (s.charAt(p.i++) !== '{') throw new Error('metadata object');
73 skipWs(s, p); if (s.charAt(p.i) === '}') { p.i++; return; }
74 while (p.i < s.length) {
75 skipWs(s, p); var key = stringEnd(s, p, true); skipWs(s, p);
76 if (s.charAt(p.i++) !== ':') throw new Error('metadata colon');
77 if (wanted[key]) { out[key] = readStringValue(s, p); }
78 else if (key === 'fields') { skipWs(s, p); if (s.charAt(p.i) !== '[') throw new Error('fields shape'); skipValue(s, p, 0); out.fieldsArray = true; }
79 else skipValue(s, p, 0);
80 skipWs(s, p); var sep = s.charAt(p.i++); if (sep === '}') return; if (sep !== ',') throw new Error('metadata separator');
81 }
82 throw new Error('metadata end');
83 }
84 function readEnvelope(s) {
85 if (typeof s !== 'string' || s.length > 1048576) return null;
86 var p = { i: 0 }, out = {}, payload = {};
87 try {
88 skipWs(s, p); if (s.charAt(p.i++) !== '{') return null;
89 skipWs(s, p); if (s.charAt(p.i) === '}') return null;
90 while (p.i < s.length) {
91 skipWs(s, p); var key = stringEnd(s, p, true); skipWs(s, p);
92 if (s.charAt(p.i++) !== ':') return null;
93 if (key === 'event') out.event = readStringValue(s, p);
94 else if (key === 'payload') readMetadataObject(s, p, { id: true, formId: true }, payload);
95 else skipValue(s, p, 0);
96 skipWs(s, p); var sep = s.charAt(p.i++); if (sep === '}') break; if (sep !== ',') return null;
97 }
98 skipWs(s, p); if (p.i !== s.length) return null;
99 if (out.event !== 'Tally.FormSubmitted') return null;
100 if (!payload.id || !payload.formId || !payload.fieldsArray || !/^[-A-Za-z0-9_]{1,128}$/.test(payload.id) || !/^[-A-Za-z0-9_]{1,128}$/.test(payload.formId)) return null;
101 return { id: payload.id, formId: payload.formId };
102 } catch (e) { return null; }
103 }
104 function isTallyOrigin(origin) {
105 return typeof origin === 'string' && /^https:\/\/tally\.so(?::443)?$/i.test(origin);
106 }
107 // Bind only fixed-host iframe paths using CSS attribute selectors. Do not
108 // retrieve src or data-tally-src: either may contain prefilled respondent
109 // data in its query. Only a materialized iframe src is supported here; a
110 // deferred attribute without a loaded src fails closed.
111 function frameFormId(frame, expectedFormId) {
112 if (!/^[-A-Za-z0-9_]{1,128}$/.test(expectedFormId || '') ||
113 !frame || typeof frame.matches !== 'function') return '';
114 var found = false;
115 var modes = ['embed', 'r', 'popup'];
116 for (var i = 0; i < modes.length; i++) {
117 var base = 'https://tally.so/' + modes[i] + '/' + expectedFormId;
118 var selectors = [
119 'iframe[src="' + base + '"]',
120 'iframe[src^="' + base + '?"]',
121 'iframe[src^="' + base + '#"]'
122 ];
123 for (var j = 0; j < selectors.length; j++) {
124 try { if (frame.matches(selectors[j])) { found = true; break; } }
125 catch (e) { return ''; }
126 }
127 if (found) return expectedFormId;
128 }
129 return '';
130 }
131 function renderedFrameFormId(source, expectedFormId) {
132 if (!source || source === window) return '';
133 var frames;
134 try { frames = document.getElementsByTagName('iframe'); } catch (e) { return ''; }
135 for (var i = 0; frames && i < frames.length; i++) {
136 try {
137 if (frames[i].contentWindow === source) {
138 // The event carries only a bounded public form ID. The selector tests
139 // the fixed-host path and exact delimiter without exposing the URL.
140 return frameFormId(frames[i], expectedFormId);
141 }
142 } catch (e) {}
143 }
144 return '';
145 }
146 var seen = Object.create(null);
147 window.addEventListener('message', function (event) {
148 try {
149 if (!event || !isTallyOrigin(event.origin)) return;
150 var verdict = readEnvelope(event.data);
151 if (!verdict) return;
152 var configuredFormId = renderedFrameFormId(event.source, verdict.formId);
153 if (!configuredFormId || verdict.formId !== configuredFormId) return;
154 var key = verdict.formId + ':' + verdict.id;
155 if (seen[key]) return;
156 seen[key] = true;
157 var handled = false;
158 var adapter = window.__ctTallyMethod2Handler;
159 if (typeof adapter === 'function') {
160 try { handled = adapter(event, verdict) === true; } catch (e) { handled = false; }
161 }
162 if (!handled) window.dataLayer.push({ event: 'tally_form_submitted', form_id: verdict.formId, submission_id: verdict.id });
163 } catch (e) {}
164 }, false);
165})();