Skills › Marketing Skills › conversion-tracking › cal-com.js
cal-com.js
132 lines · skills/conversion-tracking/assets/snippets/cal-com.js at 88c79f6
1/*! Cal.com conversion detection (MIT)
2 * Detects: meeting booked. Pushes dataLayer event: calcom_booking_successful
3 * From the conversion-tracking skill in Marketing Skills */
4(function () {
5 'use strict';
6 if (window.__ctDetectCalcom) return;
7 window.__ctDetectCalcom = true;
8 window.dataLayer = window.dataLayer || [];
10 // Cal.com's embed code (inline, popup and floating button) renders an iframe
11 // from https://app.cal.com; a booking page pasted in as a plain iframe loads
12 // from https://cal.com. Each posts from its own origin, so a frame is only
13 // trusted for the origin its address starts with. The CSS selectors do that
14 // prefix match so this listener never reads an iframe's address, because
15 // Cal.com prefill parameters can carry a name or email.
16 var CAL_FRAMES = [
17 { selector: 'iframe[src^="https://app.cal.com/"]', origin: 'https://app.cal.com' },
18 { selector: 'iframe[src^="https://cal.com/"]', origin: 'https://cal.com' }
19 ];
20 var UID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
21 var STATUS_PATTERN = /^[A-Z_]{1,32}$/;
22 var trackedFrames = [];
23 var seenUids = [];
25 function trackFrame(frame, origin) {
26 if (!frame || !frame.contentWindow) return;
27 for (var i = 0; i < trackedFrames.length; i++) {
28 if (trackedFrames[i].frame === frame) {
29 trackedFrames[i].origin = origin;
30 return;
31 }
32 }
33 trackedFrames.push({ frame: frame, origin: origin });
34 }
36 function scan(root) {
37 if (!root || typeof root.querySelectorAll !== 'function') return;
38 for (var k = 0; k < CAL_FRAMES.length; k++) {
39 var frames;
40 try { frames = root.querySelectorAll(CAL_FRAMES[k].selector); } catch (e) { continue; }
41 for (var i = 0; i < frames.length; i++) trackFrame(frames[i], CAL_FRAMES[k].origin);
42 }
43 }
45 // The message must come from a Cal.com frame that is on this page right now,
46 // and from the origin that frame was loaded from. Cal.com re-creates popup
47 // frames, so a frame seen earlier is re-checked live.
48 function sourceIsTracked(message) {
49 for (var i = 0; i < trackedFrames.length; i++) {
50 var item = trackedFrames[i];
51 try {
52 if (item.frame.isConnected === true && item.frame.contentWindow === message.source && item.origin === message.origin) return true;
53 } catch (e) { /* replaced or inaccessible frames fail closed */ }
54 }
55 return false;
56 }
58 function isCalOrigin(origin) {
59 for (var k = 0; k < CAL_FRAMES.length; k++) if (CAL_FRAMES[k].origin === origin) return true;
60 return false;
61 }
63 function isPlainObject(value) {
64 return !!value && typeof value === 'object' && Object.prototype.toString.call(value) === '[object Object]';
65 }
67 function uidSeen(uid) {
68 for (var i = 0; i < seenUids.length; i++) if (seenUids[i] === uid) return true;
69 return false;
70 }
72 function onMessage(message) {
73 if (!message || !message.source || message.source === window) return;
74 if (!isCalOrigin(message.origin)) return;
75 // A frame added since the last scan (a popup that just opened) is picked up here.
76 if (!sourceIsTracked(message)) {
77 scan(document);
78 if (!sourceIsTracked(message)) return;
79 }
80 var data = message.data;
81 if (typeof data === 'string') {
82 try { data = JSON.parse(data); } catch (e) { return; }
83 }
84 // Only a completed new booking. Reschedules (rescheduleBookingSuccessfulV2)
85 // and the deprecated V1 events, which carry the attendee's details, never match.
86 if (!isPlainObject(data) || data.originator !== 'CAL' || data.type !== 'bookingSuccessfulV2') return;
87 var booking = data.data;
88 if (!isPlainObject(booking) || typeof booking.uid !== 'string' || !UID_PATTERN.test(booking.uid)) return;
89 if (uidSeen(booking.uid)) return;
90 // Only these three values are copied. The booking title names the attendee,
91 // so it and every other field are left behind.
92 var push = { event: 'calcom_booking_successful', booking_uid: booking.uid };
93 if (typeof booking.eventTypeId === 'number' && isFinite(booking.eventTypeId)) push.event_type_id = String(booking.eventTypeId);
94 if (typeof booking.status === 'string' && STATUS_PATTERN.test(booking.status)) push.booking_status = booking.status;
95 // The ID is remembered only once the push went through, so a dataLayer
96 // that throws (a broken wrapper) never escapes into the page, and a
97 // repeat of the same message can still be counted.
98 try {
99 window.dataLayer = window.dataLayer || [];
100 window.dataLayer.push(push);
101 } catch (e) {
102 return;
103 }
104 seenUids.push(booking.uid);
105 }
107 function start() {
108 if (typeof document === 'undefined') return;
109 scan(document);
110 if (typeof MutationObserver === 'undefined' || !document.documentElement) return;
111 try {
112 var observer = new MutationObserver(function (mutations) {
113 for (var i = 0; i < mutations.length; i++) {
114 var added = mutations[i].addedNodes || [];
115 for (var j = 0; j < added.length; j++) {
116 var node = added[j];
117 if (node && node.tagName === 'IFRAME') scan(document);
118 else scan(node);
119 }
120 if (mutations[i].type === 'attributes' && mutations[i].attributeName === 'src') scan(document);
121 }
122 });
123 observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
124 } catch (e) {}
125 }
127 if (typeof document !== 'undefined') {
128 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, false);
129 else start();
130 }
131 window.addEventListener('message', onMessage, false);
132})();