@oneuptime/common 12.0.19 → 12.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Server/API/EnterpriseLicenseAPI.ts +94 -19
- package/Server/EnvironmentConfig.ts +17 -0
- package/Server/Services/ProjectService.ts +104 -0
- package/Server/Utils/Response.ts +2 -2
- package/Server/Utils/StartServer.ts +2 -2
- package/Server/Views/Partials/AnalyticsConsent.ejs +533 -0
- package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +302 -0
- package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
- package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
- package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
- package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
- package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
- package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
- package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
- package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
- package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +155 -0
- package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
- package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
- package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
- package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
- package/Types/Analytics/RevenueEvent.ts +1 -0
- package/Types/Marketing/MarketingEvent.ts +49 -6
- package/UI/Components/Charts/Area/AreaChart.tsx +8 -1
- package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +19 -4
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +20 -2
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +19 -4
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +105 -0
- package/UI/Components/Charts/Line/LineChart.tsx +8 -1
- package/UI/Components/Detail/Detail.tsx +18 -1
- package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
- package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
- package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +15 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +88 -0
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +2 -2
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +2 -2
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +40 -1
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +9 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +22 -13
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +27 -15
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +27 -18
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +71 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
- package/build/dist/UI/Components/Charts/Line/LineChart.js +9 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Detail/Detail.js +17 -1
- package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Consent Mode v2 and attribution capture, shared by every OneUptime-owned
|
|
3
|
+
origin that loads the Google Tag Manager container.
|
|
4
|
+
|
|
5
|
+
This lived only in Home/Views/head-basic.ejs, which meant the marketing site
|
|
6
|
+
denied storage by default and asked the visitor, while /accounts and
|
|
7
|
+
/dashboard loaded the same container with no consent signal at all and
|
|
8
|
+
captured nothing. Two consequences, both bad: somebody who pressed "Reject
|
|
9
|
+
all" on the marketing site was tracked anyway the moment they reached the
|
|
10
|
+
signup page — which is exactly where the conversion fires — and an ad
|
|
11
|
+
pointed straight at /accounts/register?gclid=... stored no click id, so
|
|
12
|
+
that signup was permanently unattributable.
|
|
13
|
+
|
|
14
|
+
It is one file rather than a copy per origin because the storage contract
|
|
15
|
+
has to stay byte-identical: the same 'cookiesAccepted' values, the same
|
|
16
|
+
'clickIds' and 'firstTouch' JSON shapes. Three copies is how those stop
|
|
17
|
+
agreeing.
|
|
18
|
+
|
|
19
|
+
Include this BEFORE the Google Tag Manager snippet. Consent Mode defaults
|
|
20
|
+
are only meaningful if they are on the dataLayer before the container reads
|
|
21
|
+
it.
|
|
22
|
+
|
|
23
|
+
Home resolves this as ../../Common/Server/Views/Partials/AnalyticsConsent
|
|
24
|
+
and the App FeatureSets as ../../../../Common/Server/Views/Partials/...;
|
|
25
|
+
/usr/src/Common is present in both images.
|
|
26
|
+
|
|
27
|
+
Deliberately NOT here: the PostHog loader (its project key is Home's) and
|
|
28
|
+
the home/-prefixed page view and CTA listeners. Those are Home's alone and
|
|
29
|
+
stay in head-basic.ejs. Reporting on a captured touch is handed back to
|
|
30
|
+
the page through window.oneUptimeOnAttributionCaptured for the same
|
|
31
|
+
reason. The Cal booking helper does live here: it is a pure reader over
|
|
32
|
+
the shared attribution store, and splitting it from the store it reads
|
|
33
|
+
would be worse than sharing an unused function definition.
|
|
34
|
+
-%>
|
|
35
|
+
<!--
|
|
36
|
+
Consent.
|
|
37
|
+
|
|
38
|
+
This block runs BEFORE Google Tag Manager and before PostHog, because both
|
|
39
|
+
of the things it does are only meaningful ahead of them: Consent Mode's
|
|
40
|
+
defaults have to be on the dataLayer before the container reads it, and
|
|
41
|
+
PostHog must not be loaded at all until there is consent to load it.
|
|
42
|
+
|
|
43
|
+
Three states, not two. `granted` and `denied` are the visitor's explicit
|
|
44
|
+
choices; `unset` is the state before they have answered, and it is treated
|
|
45
|
+
as denied for storage purposes rather than as permission-by-silence.
|
|
46
|
+
|
|
47
|
+
What that costs, stated plainly: a visitor who never touches the banner is
|
|
48
|
+
not measured, and their attribution is not stored. What stops that from
|
|
49
|
+
losing an ad click outright is the pending-attribution buffer further
|
|
50
|
+
down — attribution seen on this page is held in memory and written the
|
|
51
|
+
moment consent is granted, so accepting the banner on the landing page
|
|
52
|
+
keeps everything the click carried.
|
|
53
|
+
-->
|
|
54
|
+
<% if(typeof enableGoogleTagManager !== 'undefined' ? enableGoogleTagManager : false){ %>
|
|
55
|
+
<!--
|
|
56
|
+
The dataLayer and the gtag shim, defined before both the consent signals
|
|
57
|
+
below and the container itself.
|
|
58
|
+
|
|
59
|
+
Deliberately inside the enableGoogleTagManager gate. A self-hosted install
|
|
60
|
+
loads no Google tag at all, and every OneUptime event guards on
|
|
61
|
+
`typeof dataLayer !== 'undefined'` — so creating the array there would turn
|
|
62
|
+
those no-ops into an array that grows for the life of the page and that
|
|
63
|
+
nothing will ever read.
|
|
64
|
+
-->
|
|
65
|
+
<script>
|
|
66
|
+
window.dataLayer = window.dataLayer || [];
|
|
67
|
+
function gtag() { dataLayer.push(arguments); }
|
|
68
|
+
</script>
|
|
69
|
+
<% } %>
|
|
70
|
+
|
|
71
|
+
<script>
|
|
72
|
+
window.oneUptimeConsent = (function () {
|
|
73
|
+
var STORAGE_KEY = 'cookiesAccepted';
|
|
74
|
+
var listeners = [];
|
|
75
|
+
|
|
76
|
+
function readRaw() {
|
|
77
|
+
try {
|
|
78
|
+
return window.localStorage.getItem(STORAGE_KEY);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
/*
|
|
81
|
+
* Private mode, disabled storage, or a cross-origin sandbox.
|
|
82
|
+
* Unreadable storage is not consent.
|
|
83
|
+
*/
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function state() {
|
|
89
|
+
var raw = readRaw();
|
|
90
|
+
if (raw === 'true') { return 'granted'; }
|
|
91
|
+
if (raw === 'false') { return 'denied'; }
|
|
92
|
+
return 'unset';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isGranted() {
|
|
96
|
+
return state() === 'granted';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* Consent Mode v2. `security_storage` is granted because it covers
|
|
101
|
+
* things like fraud prevention that are not analytics; everything that
|
|
102
|
+
* feeds advertising or measurement starts denied.
|
|
103
|
+
*
|
|
104
|
+
* wait_for_update gives the container half a second to see an update
|
|
105
|
+
* before it decides how to behave, which is what makes a visitor who
|
|
106
|
+
* accepts immediately still count.
|
|
107
|
+
*/
|
|
108
|
+
function pushConsentSignal(mode, granted) {
|
|
109
|
+
/*
|
|
110
|
+
* No tag, nothing to tell. On a self-hosted install gtag is never
|
|
111
|
+
* defined, and consent is still tracked — it gates the attribution
|
|
112
|
+
* capture and PostHog either way.
|
|
113
|
+
*/
|
|
114
|
+
if (typeof window.gtag !== 'function') {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
window.gtag('consent', mode, {
|
|
119
|
+
ad_storage: granted ? 'granted' : 'denied',
|
|
120
|
+
ad_user_data: granted ? 'granted' : 'denied',
|
|
121
|
+
ad_personalization: granted ? 'granted' : 'denied',
|
|
122
|
+
analytics_storage: granted ? 'granted' : 'denied',
|
|
123
|
+
functionality_storage: granted ? 'granted' : 'denied',
|
|
124
|
+
personalization_storage: granted ? 'granted' : 'denied',
|
|
125
|
+
security_storage: 'granted',
|
|
126
|
+
wait_for_update: 500
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
pushConsentSignal('default', false);
|
|
131
|
+
|
|
132
|
+
// An earlier visit already answered: tell Google before the tag loads.
|
|
133
|
+
if (isGranted()) {
|
|
134
|
+
pushConsentSignal('update', true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function set(granted) {
|
|
138
|
+
try {
|
|
139
|
+
window.localStorage.setItem(STORAGE_KEY, granted ? 'true' : 'false');
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// Nothing to persist to. The in-page decision still applies.
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pushConsentSignal('update', granted);
|
|
145
|
+
|
|
146
|
+
for (var i = 0; i < listeners.length; i++) {
|
|
147
|
+
try {
|
|
148
|
+
listeners[i](granted);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
// One bad listener must not stop the others.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
state: state,
|
|
157
|
+
isGranted: isGranted,
|
|
158
|
+
grant: function () { set(true); },
|
|
159
|
+
deny: function () { set(false); },
|
|
160
|
+
onChange: function (listener) {
|
|
161
|
+
if (typeof listener === 'function') {
|
|
162
|
+
listeners.push(listener);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
})();
|
|
167
|
+
</script>
|
|
168
|
+
|
|
169
|
+
<!--
|
|
170
|
+
Attribution capture (UTM parameters + ad platform click IDs), and the
|
|
171
|
+
readers that hand it on to the two places it has to reach: the signup form
|
|
172
|
+
(through localStorage, same origin) and a Cal.com booking (through the
|
|
173
|
+
embed's metadata).
|
|
174
|
+
|
|
175
|
+
STORAGE MODEL
|
|
176
|
+
|
|
177
|
+
- Last-touch UTM values live in the individual utm* localStorage keys, read
|
|
178
|
+
by the signup page and stored on the User record.
|
|
179
|
+
- Last-touch click IDs live as a JSON object under 'clickIds'.
|
|
180
|
+
- The visitor's first attributed visit is preserved as JSON under
|
|
181
|
+
'firstTouch' and never overwritten.
|
|
182
|
+
|
|
183
|
+
A visit counts as a new "touch" if it carries ANY utm parameter or ad click
|
|
184
|
+
ID — Google Ads auto-tagging sends only gclid, no UTMs.
|
|
185
|
+
|
|
186
|
+
CONSENT
|
|
187
|
+
|
|
188
|
+
Nothing is written without consent. Attribution seen before the visitor has
|
|
189
|
+
answered the banner is held in memory and flushed if they accept, so the
|
|
190
|
+
ordinary case — land on an ad, accept, sign up — keeps everything. An
|
|
191
|
+
explicit refusal clears anything a previous visit stored.
|
|
192
|
+
-->
|
|
193
|
+
<script>
|
|
194
|
+
(function () {
|
|
195
|
+
var UTM_KEY_MAP = {
|
|
196
|
+
'utm_source': 'utmSource',
|
|
197
|
+
'utm_medium': 'utmMedium',
|
|
198
|
+
'utm_campaign': 'utmCampaign',
|
|
199
|
+
'utm_term': 'utmTerm',
|
|
200
|
+
'utm_content': 'utmContent'
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/*
|
|
204
|
+
* Must stay in step with AdClickIdKeys in
|
|
205
|
+
* Common/Types/Marketing/Attribution.ts — the server whitelists on that
|
|
206
|
+
* list, so a key sent from here that is missing there is silently
|
|
207
|
+
* dropped on arrival.
|
|
208
|
+
*/
|
|
209
|
+
var CLICK_ID_PARAMS = ['gclid', 'wbraid', 'gbraid', 'fbclid', 'msclkid', 'li_fat_id', 'twclid', 'rdt_cid'];
|
|
210
|
+
|
|
211
|
+
// Mirrors MaxAttributionValueLength on the server.
|
|
212
|
+
var MAX_VALUE_LENGTH = 500;
|
|
213
|
+
|
|
214
|
+
// Mirrors MAX_FIRST_TOUCH_LENGTH in App/API/CalWebhook.ts.
|
|
215
|
+
var MAX_FIRST_TOUCH_LENGTH = 4000;
|
|
216
|
+
|
|
217
|
+
// Attribution seen on this page but not yet allowed to be stored.
|
|
218
|
+
var pending = null;
|
|
219
|
+
|
|
220
|
+
function canStore() {
|
|
221
|
+
return Boolean(window.oneUptimeConsent && window.oneUptimeConsent.isGranted());
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function setItem(key, value) {
|
|
225
|
+
try {
|
|
226
|
+
window.localStorage.setItem(key, value);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
// Storage unavailable; attribution is best-effort.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function removeItem(key) {
|
|
233
|
+
try {
|
|
234
|
+
window.localStorage.removeItem(key);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
// Nothing to remove from.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getItem(key) {
|
|
241
|
+
try {
|
|
242
|
+
return window.localStorage.getItem(key);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function bound(value) {
|
|
249
|
+
return String(value).slice(0, MAX_VALUE_LENGTH);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readTouchFromUrl() {
|
|
253
|
+
var urlParams = new URLSearchParams(window.location.search);
|
|
254
|
+
|
|
255
|
+
var utmValues = {};
|
|
256
|
+
var hasUtm = false;
|
|
257
|
+
Object.keys(UTM_KEY_MAP).forEach(function (param) {
|
|
258
|
+
var value = urlParams.get(param);
|
|
259
|
+
if (value) {
|
|
260
|
+
utmValues[UTM_KEY_MAP[param]] = bound(value);
|
|
261
|
+
hasUtm = true;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
var clickIds = {};
|
|
266
|
+
var hasClickId = false;
|
|
267
|
+
CLICK_ID_PARAMS.forEach(function (param) {
|
|
268
|
+
var value = urlParams.get(param);
|
|
269
|
+
if (value) {
|
|
270
|
+
clickIds[param] = bound(value);
|
|
271
|
+
hasClickId = true;
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (!hasUtm && !hasClickId) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
utmValues: utmValues,
|
|
281
|
+
clickIds: clickIds,
|
|
282
|
+
hasClickId: hasClickId,
|
|
283
|
+
url: window.location.href,
|
|
284
|
+
referrer: document.referrer || '',
|
|
285
|
+
timestamp: new Date().toISOString()
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function storeTouch(touch) {
|
|
290
|
+
// New touch: clear previous last-touch values, then store this visit's.
|
|
291
|
+
Object.keys(UTM_KEY_MAP).forEach(function (param) {
|
|
292
|
+
removeItem(UTM_KEY_MAP[param]);
|
|
293
|
+
});
|
|
294
|
+
Object.keys(touch.utmValues).forEach(function (key) {
|
|
295
|
+
setItem(key, touch.utmValues[key]);
|
|
296
|
+
});
|
|
297
|
+
setItem('utmUrl', touch.url);
|
|
298
|
+
|
|
299
|
+
if (touch.hasClickId) {
|
|
300
|
+
setItem('clickIds', JSON.stringify(touch.clickIds));
|
|
301
|
+
} else {
|
|
302
|
+
removeItem('clickIds');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Preserve the first attributed touch forever.
|
|
306
|
+
if (!getItem('firstTouch')) {
|
|
307
|
+
var firstTouch = {};
|
|
308
|
+
Object.keys(touch.utmValues).forEach(function (key) {
|
|
309
|
+
firstTouch[key] = touch.utmValues[key];
|
|
310
|
+
});
|
|
311
|
+
if (touch.hasClickId) {
|
|
312
|
+
firstTouch.clickIds = touch.clickIds;
|
|
313
|
+
}
|
|
314
|
+
firstTouch.landingUrl = touch.url;
|
|
315
|
+
firstTouch.referrer = touch.referrer;
|
|
316
|
+
firstTouch.timestamp = touch.timestamp;
|
|
317
|
+
setItem('firstTouch', JSON.stringify(firstTouch));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function clearStoredAttribution() {
|
|
322
|
+
Object.keys(UTM_KEY_MAP).forEach(function (param) {
|
|
323
|
+
removeItem(UTM_KEY_MAP[param]);
|
|
324
|
+
});
|
|
325
|
+
removeItem('utmUrl');
|
|
326
|
+
removeItem('clickIds');
|
|
327
|
+
removeItem('firstTouch');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function parseStoredJson(key) {
|
|
331
|
+
var raw = getItem(key);
|
|
332
|
+
if (!raw) { return null; }
|
|
333
|
+
try {
|
|
334
|
+
var parsed = JSON.parse(raw);
|
|
335
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
336
|
+
} catch (e) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/*
|
|
342
|
+
* Everything known about where this visitor came from, whether it has
|
|
343
|
+
* been written to storage yet or is still only in memory.
|
|
344
|
+
*
|
|
345
|
+
* The in-memory touch wins: it is this page load, and the stored copy
|
|
346
|
+
* is at best the same thing and at worst an older visit.
|
|
347
|
+
*/
|
|
348
|
+
window.oneUptimeGetAttribution = function () {
|
|
349
|
+
var utm = {};
|
|
350
|
+
Object.keys(UTM_KEY_MAP).forEach(function (param) {
|
|
351
|
+
var key = UTM_KEY_MAP[param];
|
|
352
|
+
var value = (pending && pending.utmValues[key]) || getItem(key);
|
|
353
|
+
if (value && value !== 'null' && value !== 'undefined') {
|
|
354
|
+
utm[key] = value;
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
var utmUrl = (pending && pending.url) || getItem('utmUrl');
|
|
359
|
+
if (utmUrl) {
|
|
360
|
+
utm.utmUrl = utmUrl;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
var clickIds = (pending && pending.hasClickId ? pending.clickIds : null) || parseStoredJson('clickIds') || {};
|
|
364
|
+
|
|
365
|
+
var firstTouch = parseStoredJson('firstTouch');
|
|
366
|
+
|
|
367
|
+
if (!firstTouch && pending) {
|
|
368
|
+
firstTouch = {};
|
|
369
|
+
Object.keys(pending.utmValues).forEach(function (key) {
|
|
370
|
+
firstTouch[key] = pending.utmValues[key];
|
|
371
|
+
});
|
|
372
|
+
if (pending.hasClickId) {
|
|
373
|
+
firstTouch.clickIds = pending.clickIds;
|
|
374
|
+
}
|
|
375
|
+
firstTouch.landingUrl = pending.url;
|
|
376
|
+
firstTouch.referrer = pending.referrer;
|
|
377
|
+
firstTouch.timestamp = pending.timestamp;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
utm: utm,
|
|
382
|
+
clickIds: clickIds,
|
|
383
|
+
firstTouch: firstTouch
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/*
|
|
388
|
+
* The same attribution, flattened into the scalar string map Cal.com
|
|
389
|
+
* accepts as booking metadata.
|
|
390
|
+
*
|
|
391
|
+
* Keys are the snake_case wire spelling the server parses in
|
|
392
|
+
* App/API/CalWebhook.ts. The first touch is a nested object, and Cal
|
|
393
|
+
* metadata values are scalars, so it travels as one JSON string under
|
|
394
|
+
* ou_first_touch — which is exactly what the webhook expects to parse.
|
|
395
|
+
*
|
|
396
|
+
* Returns {} when there is nothing to attribute, so a caller can spread
|
|
397
|
+
* it unconditionally without sending an empty metadata bag.
|
|
398
|
+
*/
|
|
399
|
+
/*
|
|
400
|
+
* `bookingKind` says which conversation this embed books. All three
|
|
401
|
+
* embeds use the same Cal event type, so without it the webhook cannot
|
|
402
|
+
* tell a support call from an enterprise demo. It travels in the same
|
|
403
|
+
* bracketed metadata bag as the attribution and is read back by
|
|
404
|
+
* App/API/CalWebhook.ts, which allowlists the value.
|
|
405
|
+
*/
|
|
406
|
+
window.oneUptimeCalAttributionMetadata = function (bookingKind) {
|
|
407
|
+
var attribution = window.oneUptimeGetAttribution();
|
|
408
|
+
var config = {};
|
|
409
|
+
|
|
410
|
+
/*
|
|
411
|
+
* Cal takes booking metadata as FLAT, BRACKETED keys on the embed
|
|
412
|
+
* config — `metadata[utm_source]` — and hands them back on the
|
|
413
|
+
* webhook as payload.metadata.utm_source.
|
|
414
|
+
*
|
|
415
|
+
* Not as a nested `metadata: { utm_source: ... }` object. Cal
|
|
416
|
+
* serialises each config value into a query parameter, so a nested
|
|
417
|
+
* object becomes the string "[object Object]" and every key inside
|
|
418
|
+
* it is lost. Nothing errors; bookings keep being recorded, they
|
|
419
|
+
* just arrive attributable to nothing — which is precisely the bug
|
|
420
|
+
* this whole path exists to fix, so getting the shape wrong here
|
|
421
|
+
* would have been indistinguishable from not having done the work.
|
|
422
|
+
*
|
|
423
|
+
* https://cal.com/help/embedding/prefill-booking-form-embed
|
|
424
|
+
*/
|
|
425
|
+
function put(key, value, maxLength) {
|
|
426
|
+
if (value) {
|
|
427
|
+
config['metadata[' + key + ']'] =
|
|
428
|
+
String(value).slice(0, maxLength || MAX_VALUE_LENGTH);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
Object.keys(UTM_KEY_MAP).forEach(function (param) {
|
|
433
|
+
put(param, attribution.utm[UTM_KEY_MAP[param]]);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
put('utm_url', attribution.utm.utmUrl);
|
|
437
|
+
|
|
438
|
+
Object.keys(attribution.clickIds).forEach(function (param) {
|
|
439
|
+
put(param, attribution.clickIds[param]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
if (attribution.firstTouch) {
|
|
443
|
+
var serialized = JSON.stringify(attribution.firstTouch);
|
|
444
|
+
/*
|
|
445
|
+
* The first touch is JSON, so it gets the JSON bound, NOT the
|
|
446
|
+
* 500-char per-value one. A realistic first touch — five UTM
|
|
447
|
+
* values, a click id, and a landing URL that repeats them all
|
|
448
|
+
* as query parameters — runs past 500 characters easily, and
|
|
449
|
+
* truncating JSON produces a string the webhook's JSON.parse
|
|
450
|
+
* rejects. It would drop the whole first touch, silently.
|
|
451
|
+
*
|
|
452
|
+
* Mirrors MAX_FIRST_TOUCH_LENGTH in App/API/CalWebhook.ts.
|
|
453
|
+
*/
|
|
454
|
+
if (serialized.length <= MAX_FIRST_TOUCH_LENGTH) {
|
|
455
|
+
put('ou_first_touch', serialized, MAX_FIRST_TOUCH_LENGTH);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/*
|
|
460
|
+
* Unconditional, unlike everything above it. The attribution keys
|
|
461
|
+
* describe the visitor and are absent when there is nothing to say
|
|
462
|
+
* about them; the booking kind describes the embed, which always
|
|
463
|
+
* knows what it is. A booking from a visitor who refused
|
|
464
|
+
* attribution storage still arrives knowing it was a demo.
|
|
465
|
+
*/
|
|
466
|
+
if (bookingKind) {
|
|
467
|
+
config['metadata[ou_booking_kind]'] = String(bookingKind).slice(0, MAX_VALUE_LENGTH);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return config;
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
var touch = readTouchFromUrl();
|
|
474
|
+
|
|
475
|
+
if (touch) {
|
|
476
|
+
if (canStore()) {
|
|
477
|
+
storeTouch(touch);
|
|
478
|
+
} else {
|
|
479
|
+
/*
|
|
480
|
+
* Held, not dropped. A visitor who accepts the banner on this
|
|
481
|
+
* very page — the ordinary path for someone arriving from an
|
|
482
|
+
* ad — keeps the click id that brought them here.
|
|
483
|
+
*/
|
|
484
|
+
pending = touch;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (window.oneUptimeConsent) {
|
|
489
|
+
window.oneUptimeConsent.onChange(function (granted) {
|
|
490
|
+
if (granted) {
|
|
491
|
+
if (pending) {
|
|
492
|
+
storeTouch(pending);
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Refused: forget what earlier visits stored, and this page's touch.
|
|
498
|
+
pending = null;
|
|
499
|
+
clearStoredAttribution();
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/*
|
|
504
|
+
* Reporting is the including page's business, not this partial's.
|
|
505
|
+
*
|
|
506
|
+
* The marketing site emits a `home/`-prefixed PostHog event from here;
|
|
507
|
+
* the product origins emit nothing, and must not start emitting under
|
|
508
|
+
* a `home/` name just because they now share the capture code. So the
|
|
509
|
+
* touch is handed out and the page decides what, if anything, to do
|
|
510
|
+
* with it.
|
|
511
|
+
*
|
|
512
|
+
* Wire keys (utm_source), not property keys (utmSource): this is what
|
|
513
|
+
* arrived on the URL.
|
|
514
|
+
*/
|
|
515
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
516
|
+
if (!touch || typeof window.oneUptimeOnAttributionCaptured !== 'function') {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
var eventProps = { utm_url: touch.url };
|
|
521
|
+
Object.keys(UTM_KEY_MAP).forEach(function (param) {
|
|
522
|
+
if (touch.utmValues[UTM_KEY_MAP[param]]) {
|
|
523
|
+
eventProps[param] = touch.utmValues[UTM_KEY_MAP[param]];
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
Object.keys(touch.clickIds).forEach(function (param) {
|
|
527
|
+
eventProps[param] = touch.clickIds[param];
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
window.oneUptimeOnAttributionCaptured(eventProps);
|
|
531
|
+
});
|
|
532
|
+
})();
|
|
533
|
+
</script>
|