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

typeform.js

145 lines · skills/conversion-tracking/assets/snippets/typeform.js at 88c79f6

1/*! Typeform conversion detection (MIT)
2 * Detects: form submission. Pushes dataLayer event: typeform_form_submitted
3 * From the conversion-tracking skill in Marketing Skills */
4(function () {
5 'use strict';
6
7 // Shared recipe/template install guard. Keep this exact name so both
8 // installation paths attach one message listener.
9 if (window.__ctDetectTypeform) return;
10 window.__ctDetectTypeform = true;
11 window.dataLayer = window.dataLayer || [];
13 var ORIGIN_PATTERN = /(^|\.)typeform\.com$/;
14 var TYPEFORM_IFRAME_SELECTOR = 'iframe[src^="https://"][src*=".typeform.com/to/"],iframe[src^="https://typeform.com/to/"]';
15 var accepted = Object.create(null);
16 var trackedFrames = [];
18 function isSafeId(value) {
19 return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
20 }
22 // Match a public form ID against the URL path using CSS selectors. Do not
23 // fetch, parse, retain, or serialize the iframe src; its query can contain
24 // prefilled respondent data.
25 function hasFormPath(iframe, formId, host) {
26 if (!isSafeId(formId) || !host || !iframe || typeof iframe.matches !== 'function') return false;
27 var prefix = 'iframe[src^="https://' + host + '/to/' + formId;
28 var selectors = [
29 'iframe[src="https://' + host + '/to/' + formId + '"]',
30 prefix + '?"]',
31 prefix + '#"]',
32 prefix + '/"]'
33 ];
34 for (var i = 0; i < selectors.length; i++) {
35 try {
36 if (iframe.matches(selectors[i])) return true;
37 } catch (e) {
38 return false;
39 }
40 }
41 return false;
42 }
44 function isTypeformFrame(iframe) {
45 if (!iframe || typeof iframe.matches !== 'function') return false;
46 try { return iframe.matches(TYPEFORM_IFRAME_SELECTOR); } catch (e) { return false; }
47 }
49 function track(iframe) {
50 if (!isTypeformFrame(iframe)) return;
51 for (var i = 0; i < trackedFrames.length; i++) {
52 if (trackedFrames[i].iframe === iframe) return;
53 }
54 try {
55 trackedFrames.push({ iframe: iframe, source: iframe.contentWindow });
56 } catch (e) {
57 // If a browser denies the WindowProxy reference, fail closed for this frame.
58 }
59 }
61 function scan(root) {
62 if (!root || typeof root.querySelectorAll !== 'function') return;
63 var frames;
64 try { frames = root.querySelectorAll(TYPEFORM_IFRAME_SELECTOR); } catch (e) { return; }
65 for (var i = 0; i < frames.length; i++) track(frames[i]);
66 }
68 function trackedSourceForForm(source, formId, host) {
69 scan(document);
70 for (var i = 0; i < trackedFrames.length; i++) {
71 var tracked = trackedFrames[i];
72 try {
73 if (tracked.source === source && hasFormPath(tracked.iframe, formId, host)) return true;
74 } catch (e) {
75 // A browser may deny access to a detached frame; ignore that frame.
76 }
77 }
78 return false;
79 }
81 function ownDataValue(object, key) {
82 if (!object || typeof object !== 'object') return undefined;
83 var descriptor;
84 try { descriptor = Object.getOwnPropertyDescriptor(object, key); } catch (e) { return undefined; }
85 return descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')
86 ? descriptor.value
87 : undefined;
88 }
90 function onMessage(event) {
91 if (!event || !event.source || event.source === window) return;
92 var origin = event.origin;
93 if (typeof origin !== 'string') return;
94 var match = /^https:\/\/([^/:?#]+)$/.exec(origin);
95 if (!match || !ORIGIN_PATTERN.test(match[1])) return;
97 var data = event.data;
98 if (!data || typeof data !== 'object') return;
100 // Only the historically captured canonical success message is accepted.
101 // Public Typeform docs document onSubmit, not raw postMessage aliases.
102 if (ownDataValue(data, 'type') !== 'form-submit') return;
103 var formId = ownDataValue(data, 'formId');
104 var responseId = ownDataValue(data, 'responseId');
105 if (!isSafeId(formId) || !isSafeId(responseId)) return;
106 if (!trackedSourceForForm(event.source, formId, match[1])) return;
108 var key = formId + '\u0000' + responseId;
109 if (accepted[key]) return;
110 accepted[key] = true;
111 window.dataLayer.push({
112 event: 'typeform_form_submitted',
113 form_id: formId,
114 response_id: responseId
115 });
116 }
118 scan(document);
119 if (typeof MutationObserver !== 'undefined' && document.documentElement) {
120 var observer = new MutationObserver(function (mutations) {
121 for (var i = 0; i < mutations.length; i++) {
122 var mutation = mutations[i];
123 if (mutation.type === 'attributes') {
124 track(mutation.target);
125 continue;
126 }
127 var nodes = mutation.addedNodes || [];
128 for (var j = 0; j < nodes.length; j++) {
129 var node = nodes[j];
130 if (!node) continue;
131 if (node.tagName === 'IFRAME') track(node);
132 else scan(node);
133 }
134 }
135 });
136 observer.observe(document.documentElement, {
137 childList: true,
138 subtree: true,
139 attributes: true,
140 attributeFilter: ['src']
141 });
142 }
144 window.addEventListener('message', onMessage, false);
145})();