Skills › Marketing Skills › conversion-tracking › calendly.js
calendly.js
111 lines · skills/conversion-tracking/assets/snippets/calendly.js at 88c79f6
1/*! Calendly conversion detection (MIT)
2 * Detects: meeting booked. Pushes dataLayer event: calendly_event_scheduled
3 * From the conversion-tracking skill in Marketing Skills */
4(function () {
5 'use strict';
6 if (window.__ctDetectCalendly) return;
7 window.__ctDetectCalendly = true;
8 window.dataLayer = window.dataLayer || [];
10 // The CSS selector matches only Calendly's documented exact embed host.
11 // Do not read iframe.src or its `src` attribute: prefill parameters can
12 // contain invitee PII. The selector engine performs the prefix match without
13 // exposing the URL value to this listener.
14 var CALENDLY_FRAME_SELECTOR = 'iframe[src^=\"https://calendly.com/\"]';
15 var CALENDLY_ORIGIN = 'https://calendly.com';
16 var API_HOSTS = { 'api.calendly.com': true, 'calendly.com': true };
17 var trackedFrames = [];
18 var seenPairs = [];
20 function trackFrame(frame) {
21 if (!frame || !frame.contentWindow) return;
22 for (var i = 0; i < trackedFrames.length; i++) {
23 if (trackedFrames[i].frame === frame) {
24 trackedFrames[i].source = frame.contentWindow;
25 trackedFrames[i].origin = CALENDLY_ORIGIN;
26 return;
27 }
28 }
29 trackedFrames.push({ frame: frame, source: frame.contentWindow, origin: CALENDLY_ORIGIN });
30 }
32 function scan(root) {
33 if (!root || typeof root.querySelectorAll !== 'function') return;
34 var frames;
35 try { frames = root.querySelectorAll(CALENDLY_FRAME_SELECTOR); } catch (e) { return; }
36 for (var i = 0; i < frames.length; i++) trackFrame(frames[i]);
37 }
39 function sourceIsTracked(event) {
40 for (var i = 0; i < trackedFrames.length; i++) {
41 var item = trackedFrames[i];
42 try {
43 if (item.frame.isConnected === true && item.frame.contentWindow === event.source && item.source === event.source && item.origin === event.origin) return true;
44 } catch (e) { /* replaced or inaccessible frames fail closed */ }
45 }
46 return false;
47 }
49 function resourceIdentity(value, kind) {
50 if (typeof value !== 'string' || /[?#]/.test(value)) return null;
51 try {
52 var url = new URL(value);
53 if (url.protocol !== 'https:' || url.port || !API_HOSTS[url.hostname]) return null;
54 var eventMatch = /(?:^|\/)scheduled_events\/([A-Za-z0-9_-]{1,128})\/?$/.exec(url.pathname);
55 var inviteeMatch = /(?:^|\/)scheduled_events\/([A-Za-z0-9_-]{1,128})\/invitees\/([A-Za-z0-9_-]{1,128})\/?$/.exec(url.pathname);
56 if (kind === 'event' && eventMatch) return { eventId: eventMatch[1].toLowerCase() };
57 if (kind === 'invitee' && inviteeMatch) return { eventId: inviteeMatch[1].toLowerCase(), inviteeId: inviteeMatch[2].toLowerCase() };
58 } catch (e) { return null; }
59 return null;
60 }
62 function pairSeen(eventId, inviteeId) {
63 var key = eventId + ':' + inviteeId;
64 for (var i = 0; i < seenPairs.length; i++) if (seenPairs[i] === key) return true;
65 seenPairs.push(key);
66 return false;
67 }
69 function onMessage(message) {
70 if (!message || !message.source || message.source === window || !sourceIsTracked(message)) return;
71 var data = message.data;
72 if (typeof data === 'string') {
73 try { data = JSON.parse(data); } catch (e) { return; }
74 }
75 if (!data || typeof data !== 'object' || data.event !== 'calendly.event_scheduled') return;
76 var payload = data.payload || {};
77 var eventUri = payload.event && payload.event.uri;
78 var inviteeUri = payload.invitee && payload.invitee.uri;
79 var eventIdentity = resourceIdentity(eventUri, 'event');
80 var inviteeIdentity = resourceIdentity(inviteeUri, 'invitee');
81 if (!eventIdentity || !inviteeIdentity || eventIdentity.eventId !== inviteeIdentity.eventId) return;
82 if (pairSeen(eventIdentity.eventId, inviteeIdentity.inviteeId)) return;
83 window.dataLayer.push({ event: 'calendly_event_scheduled', event_uri: eventUri, invitee_uri: inviteeUri });
84 }
86 function start() {
87 if (typeof document === 'undefined') return;
88 scan(document);
89 if (typeof MutationObserver === 'undefined' || !document.documentElement) return;
90 try {
91 var observer = new MutationObserver(function (mutations) {
92 for (var i = 0; i < mutations.length; i++) {
93 var added = mutations[i].addedNodes || [];
94 for (var j = 0; j < added.length; j++) {
95 var node = added[j];
96 if (node && node.tagName === 'IFRAME') scan(document);
97 else scan(node);
98 }
99 if (mutations[i].type === 'attributes' && mutations[i].attributeName === 'src') scan(document);
100 }
101 });
102 observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
103 } catch (e) {}
104 }
106 if (typeof document !== 'undefined') {
107 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, false);
108 else start();
109 }
110 window.addEventListener('message', onMessage, false);
111})();