@antelopejs/dms-marketing 0.2.2
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/LICENSE +190 -0
- package/README.md +143 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +35 -0
- package/dist/config.js.map +1 -0
- package/dist/data-api/body.d.ts +1 -0
- package/dist/data-api/body.js +15 -0
- package/dist/data-api/body.js.map +1 -0
- package/dist/data-api/funnels.d.ts +32 -0
- package/dist/data-api/funnels.js +302 -0
- package/dist/data-api/funnels.js.map +1 -0
- package/dist/data-api/hidden-filter.d.ts +1 -0
- package/dist/data-api/hidden-filter.js +11 -0
- package/dist/data-api/hidden-filter.js.map +1 -0
- package/dist/data-api/index.d.ts +4 -0
- package/dist/data-api/index.js +21 -0
- package/dist/data-api/index.js.map +1 -0
- package/dist/data-api/tenant-scope.d.ts +15 -0
- package/dist/data-api/tenant-scope.js +43 -0
- package/dist/data-api/tenant-scope.js.map +1 -0
- package/dist/data-api/websites.d.ts +25 -0
- package/dist/data-api/websites.js +73 -0
- package/dist/data-api/websites.js.map +1 -0
- package/dist/db/index.d.ts +2 -0
- package/dist/db/index.js +19 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/models/funnels.model.d.ts +25 -0
- package/dist/db/models/funnels.model.js +39 -0
- package/dist/db/models/funnels.model.js.map +1 -0
- package/dist/db/models/index.d.ts +7 -0
- package/dist/db/models/index.js +24 -0
- package/dist/db/models/index.js.map +1 -0
- package/dist/db/models/marketing_events.model.d.ts +49 -0
- package/dist/db/models/marketing_events.model.js +85 -0
- package/dist/db/models/marketing_events.model.js.map +1 -0
- package/dist/db/models/marketing_sessions.model.d.ts +33 -0
- package/dist/db/models/marketing_sessions.model.js +50 -0
- package/dist/db/models/marketing_sessions.model.js.map +1 -0
- package/dist/db/models/marketing_settings.model.d.ts +23 -0
- package/dist/db/models/marketing_settings.model.js +13 -0
- package/dist/db/models/marketing_settings.model.js.map +1 -0
- package/dist/db/models/page_snapshots.model.d.ts +27 -0
- package/dist/db/models/page_snapshots.model.js +32 -0
- package/dist/db/models/page_snapshots.model.js.map +1 -0
- package/dist/db/models/retention.d.ts +6 -0
- package/dist/db/models/retention.js +26 -0
- package/dist/db/models/retention.js.map +1 -0
- package/dist/db/models/website_statistics.model.d.ts +31 -0
- package/dist/db/models/website_statistics.model.js +63 -0
- package/dist/db/models/website_statistics.model.js.map +1 -0
- package/dist/db/models/websites.model.d.ts +26 -0
- package/dist/db/models/websites.model.js +27 -0
- package/dist/db/models/websites.model.js.map +1 -0
- package/dist/db/tables/funnels.table.d.ts +31 -0
- package/dist/db/tables/funnels.table.js +54 -0
- package/dist/db/tables/funnels.table.js.map +1 -0
- package/dist/db/tables/index.d.ts +7 -0
- package/dist/db/tables/index.js +24 -0
- package/dist/db/tables/index.js.map +1 -0
- package/dist/db/tables/marketing_events.table.d.ts +15 -0
- package/dist/db/tables/marketing_events.table.js +66 -0
- package/dist/db/tables/marketing_events.table.js.map +1 -0
- package/dist/db/tables/marketing_sessions.table.d.ts +26 -0
- package/dist/db/tables/marketing_sessions.table.js +115 -0
- package/dist/db/tables/marketing_sessions.table.js.map +1 -0
- package/dist/db/tables/marketing_settings.table.d.ts +11 -0
- package/dist/db/tables/marketing_settings.table.js +51 -0
- package/dist/db/tables/marketing_settings.table.js.map +1 -0
- package/dist/db/tables/page_snapshots.table.d.ts +17 -0
- package/dist/db/tables/page_snapshots.table.js +72 -0
- package/dist/db/tables/page_snapshots.table.js.map +1 -0
- package/dist/db/tables/website_statistics.table.d.ts +14 -0
- package/dist/db/tables/website_statistics.table.js +60 -0
- package/dist/db/tables/website_statistics.table.js.map +1 -0
- package/dist/db/tables/websites.table.d.ts +13 -0
- package/dist/db/tables/websites.table.js +60 -0
- package/dist/db/tables/websites.table.js.map +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +39 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/implementations/dms-marketing/index.d.ts +2 -0
- package/dist/implementations/dms-marketing/index.js +8 -0
- package/dist/implementations/dms-marketing/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/all.d.ts +5 -0
- package/dist/pages/all.js +22 -0
- package/dist/pages/all.js.map +1 -0
- package/dist/pages/campaigns/page.d.ts +9 -0
- package/dist/pages/campaigns/page.js +27 -0
- package/dist/pages/campaigns/page.js.map +1 -0
- package/dist/pages/funnels/page.d.ts +9 -0
- package/dist/pages/funnels/page.js +27 -0
- package/dist/pages/funnels/page.js.map +1 -0
- package/dist/pages/index.d.ts +4 -0
- package/dist/pages/index.js +21 -0
- package/dist/pages/index.js.map +1 -0
- package/dist/pages/module.d.ts +1 -0
- package/dist/pages/module.js +13 -0
- package/dist/pages/module.js.map +1 -0
- package/dist/pages/overview/page.d.ts +9 -0
- package/dist/pages/overview/page.js +27 -0
- package/dist/pages/overview/page.js.map +1 -0
- package/dist/pages/pages/page.d.ts +9 -0
- package/dist/pages/pages/page.js +27 -0
- package/dist/pages/pages/page.js.map +1 -0
- package/dist/pages/settings/form.d.ts +3 -0
- package/dist/pages/settings/form.js +35 -0
- package/dist/pages/settings/form.js.map +1 -0
- package/dist/pages/settings/page.d.ts +9 -0
- package/dist/pages/settings/page.js +27 -0
- package/dist/pages/settings/page.js.map +1 -0
- package/dist/routes/collect.d.ts +7 -0
- package/dist/routes/collect.js +62 -0
- package/dist/routes/collect.js.map +1 -0
- package/dist/routes/experiments.d.ts +6 -0
- package/dist/routes/experiments.js +63 -0
- package/dist/routes/experiments.js.map +1 -0
- package/dist/routes/funnels.d.ts +16 -0
- package/dist/routes/funnels.js +44 -0
- package/dist/routes/funnels.js.map +1 -0
- package/dist/routes/index.d.ts +8 -0
- package/dist/routes/index.js +25 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/settings.d.ts +8 -0
- package/dist/routes/settings.js +50 -0
- package/dist/routes/settings.js.map +1 -0
- package/dist/routes/snapshot.d.ts +10 -0
- package/dist/routes/snapshot.js +147 -0
- package/dist/routes/snapshot.js.map +1 -0
- package/dist/routes/stats.d.ts +44 -0
- package/dist/routes/stats.js +278 -0
- package/dist/routes/stats.js.map +1 -0
- package/dist/routes/tracker.d.ts +6 -0
- package/dist/routes/tracker.js +57 -0
- package/dist/routes/tracker.js.map +1 -0
- package/dist/routes/websites.d.ts +13 -0
- package/dist/routes/websites.js +144 -0
- package/dist/routes/websites.js.map +1 -0
- package/dist/services/acquisition.d.ts +15 -0
- package/dist/services/acquisition.js +153 -0
- package/dist/services/acquisition.js.map +1 -0
- package/dist/services/bounded-cache.d.ts +14 -0
- package/dist/services/bounded-cache.js +45 -0
- package/dist/services/bounded-cache.js.map +1 -0
- package/dist/services/collect.d.ts +11 -0
- package/dist/services/collect.js +193 -0
- package/dist/services/collect.js.map +1 -0
- package/dist/services/experiment-definitions.d.ts +4 -0
- package/dist/services/experiment-definitions.js +36 -0
- package/dist/services/experiment-definitions.js.map +1 -0
- package/dist/services/experiments/assign.d.ts +5 -0
- package/dist/services/experiments/assign.js +52 -0
- package/dist/services/experiments/assign.js.map +1 -0
- package/dist/services/experiments/index.d.ts +3 -0
- package/dist/services/experiments/index.js +20 -0
- package/dist/services/experiments/index.js.map +1 -0
- package/dist/services/experiments/results.d.ts +20 -0
- package/dist/services/experiments/results.js +91 -0
- package/dist/services/experiments/results.js.map +1 -0
- package/dist/services/experiments/stats.d.ts +12 -0
- package/dist/services/experiments/stats.js +62 -0
- package/dist/services/experiments/stats.js.map +1 -0
- package/dist/services/funnels/compute.d.ts +15 -0
- package/dist/services/funnels/compute.js +68 -0
- package/dist/services/funnels/compute.js.map +1 -0
- package/dist/services/funnels/index.d.ts +2 -0
- package/dist/services/funnels/index.js +19 -0
- package/dist/services/funnels/index.js.map +1 -0
- package/dist/services/funnels/results.d.ts +14 -0
- package/dist/services/funnels/results.js +72 -0
- package/dist/services/funnels/results.js.map +1 -0
- package/dist/services/geoip.d.ts +3 -0
- package/dist/services/geoip.js +38 -0
- package/dist/services/geoip.js.map +1 -0
- package/dist/services/heatmap.d.ts +10 -0
- package/dist/services/heatmap.js +180 -0
- package/dist/services/heatmap.js.map +1 -0
- package/dist/services/index.d.ts +26 -0
- package/dist/services/index.js +43 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/ingest-guard.d.ts +11 -0
- package/dist/services/ingest-guard.js +44 -0
- package/dist/services/ingest-guard.js.map +1 -0
- package/dist/services/keyed-queue.d.ts +4 -0
- package/dist/services/keyed-queue.js +19 -0
- package/dist/services/keyed-queue.js.map +1 -0
- package/dist/services/origin-guard.d.ts +5 -0
- package/dist/services/origin-guard.js +43 -0
- package/dist/services/origin-guard.js.map +1 -0
- package/dist/services/period.d.ts +2 -0
- package/dist/services/period.js +19 -0
- package/dist/services/period.js.map +1 -0
- package/dist/services/prune.d.ts +7 -0
- package/dist/services/prune.js +49 -0
- package/dist/services/prune.js.map +1 -0
- package/dist/services/query-param.d.ts +1 -0
- package/dist/services/query-param.js +11 -0
- package/dist/services/query-param.js.map +1 -0
- package/dist/services/request-body.d.ts +2 -0
- package/dist/services/request-body.js +41 -0
- package/dist/services/request-body.js.map +1 -0
- package/dist/services/request-meta.d.ts +3 -0
- package/dist/services/request-meta.js +19 -0
- package/dist/services/request-meta.js.map +1 -0
- package/dist/services/rollup-write.d.ts +4 -0
- package/dist/services/rollup-write.js +84 -0
- package/dist/services/rollup-write.js.map +1 -0
- package/dist/services/sessionize.d.ts +23 -0
- package/dist/services/sessionize.js +78 -0
- package/dist/services/sessionize.js.map +1 -0
- package/dist/services/settings.d.ts +6 -0
- package/dist/services/settings.js +113 -0
- package/dist/services/settings.js.map +1 -0
- package/dist/services/snapshots.d.ts +24 -0
- package/dist/services/snapshots.js +139 -0
- package/dist/services/snapshots.js.map +1 -0
- package/dist/services/tenant-funnel.d.ts +3 -0
- package/dist/services/tenant-funnel.js +17 -0
- package/dist/services/tenant-funnel.js.map +1 -0
- package/dist/services/tenant-website.d.ts +5 -0
- package/dist/services/tenant-website.js +34 -0
- package/dist/services/tenant-website.js.map +1 -0
- package/dist/services/url.d.ts +10 -0
- package/dist/services/url.js +81 -0
- package/dist/services/url.js.map +1 -0
- package/dist/services/user-agent.d.ts +2 -0
- package/dist/services/user-agent.js +17 -0
- package/dist/services/user-agent.js.map +1 -0
- package/dist/services/visitor-id.d.ts +1 -0
- package/dist/services/visitor-id.js +19 -0
- package/dist/services/visitor-id.js.map +1 -0
- package/dist/services/website-cache.d.ts +3 -0
- package/dist/services/website-cache.js +22 -0
- package/dist/services/website-cache.js.map +1 -0
- package/dist/types/constants.d.ts +74 -0
- package/dist/types/constants.js +79 -0
- package/dist/types/constants.js.map +1 -0
- package/dist/types/events.d.ts +114 -0
- package/dist/types/events.js +29 -0
- package/dist/types/events.js.map +1 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +22 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/settings.d.ts +67 -0
- package/dist/types/settings.js +58 -0
- package/dist/types/settings.js.map +1 -0
- package/dist/types/snapshots.d.ts +95 -0
- package/dist/types/snapshots.js +41 -0
- package/dist/types/snapshots.js.map +1 -0
- package/dist/types/statistics.d.ts +38 -0
- package/dist/types/statistics.js +33 -0
- package/dist/types/statistics.js.map +1 -0
- package/dist/utils/experiment-type.d.ts +52 -0
- package/dist/utils/experiment-type.js +94 -0
- package/dist/utils/experiment-type.js.map +1 -0
- package/dist/utils/funnel-steps-type.d.ts +42 -0
- package/dist/utils/funnel-steps-type.js +62 -0
- package/dist/utils/funnel-steps-type.js.map +1 -0
- package/frontend-vue/.editorconfig +12 -0
- package/frontend-vue/.prettierrc +7 -0
- package/frontend-vue/app/components/CampaignsView.vue +330 -0
- package/frontend-vue/app/components/ExperimentInput.vue +128 -0
- package/frontend-vue/app/components/FunnelDetail.vue +1026 -0
- package/frontend-vue/app/components/FunnelStepsInput.vue +154 -0
- package/frontend-vue/app/components/FunnelsView.vue +493 -0
- package/frontend-vue/app/components/HeatmapCanvas.vue +116 -0
- package/frontend-vue/app/components/NoWebsiteState.vue +18 -0
- package/frontend-vue/app/components/OverviewView.vue +493 -0
- package/frontend-vue/app/components/PagePreview.vue +915 -0
- package/frontend-vue/app/components/PagesView.vue +378 -0
- package/frontend-vue/app/components/ScrollDepthOverlay.vue +158 -0
- package/frontend-vue/app/components/TextKpiCard.vue +28 -0
- package/frontend-vue/app/components/TopListTabsCard.vue +162 -0
- package/frontend-vue/app/components/TrackerSnippet.vue +31 -0
- package/frontend-vue/app/components/VariationsInput.vue +164 -0
- package/frontend-vue/app/composables/useChildId.ts +13 -0
- package/frontend-vue/app/composables/useExperiment.ts +59 -0
- package/frontend-vue/app/composables/useFunnelSteps.ts +25 -0
- package/frontend-vue/app/composables/useLatestRequest.ts +90 -0
- package/frontend-vue/app/composables/useMarketingApi.ts +420 -0
- package/frontend-vue/app/composables/useMarketingPeriod.ts +66 -0
- package/frontend-vue/app/composables/useMarketingRoutes.ts +51 -0
- package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +69 -0
- package/frontend-vue/app/composables/useMarketingWebsites.ts +80 -0
- package/frontend-vue/app/composables/useSnapshotFrame.ts +183 -0
- package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +46 -0
- package/frontend-vue/app/utils/dmsComponent.ts +18 -0
- package/frontend-vue/app/utils/heatmapPalette.ts +41 -0
- package/frontend-vue/app/utils/snapshotViewportUnits.ts +63 -0
- package/frontend-vue/dms.frontend.ts +35 -0
- package/frontend-vue/eslint.config.mjs +16 -0
- package/frontend-vue/i18n/locales/marketing-en-GB.json +382 -0
- package/frontend-vue/i18n/locales/marketing-fr-FR.json +382 -0
- package/frontend-vue/package.json +30 -0
- package/frontend-vue/pnpm-lock.yaml +5226 -0
- package/frontend-vue/pnpm-workspace.yaml +2 -0
- package/frontend-vue/tests/drafts.test.ts +54 -0
- package/frontend-vue/tests/i18n.test.ts +86 -0
- package/frontend-vue/tests/period.test.ts +35 -0
- package/frontend-vue/tests/registration.test.ts +63 -0
- package/frontend-vue/tests/routes.test.ts +31 -0
- package/frontend-vue/tests/stubs/frontend-module.ts +56 -0
- package/frontend-vue/vitest.config.ts +20 -0
- package/package.json +91 -0
- package/static/snapshot.js +497 -0
- package/static/tracker.js +503 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dms-marketing first-party tracker.
|
|
3
|
+
*
|
|
4
|
+
* Embed on any page rendered by a DMS-powered site:
|
|
5
|
+
* <script defer src="https://<backend>/api/marketing/tracker.js"
|
|
6
|
+
* data-website-id="<website id>"></script>
|
|
7
|
+
*
|
|
8
|
+
* Optional attributes:
|
|
9
|
+
* data-host-url override the collect origin (defaults to the script origin)
|
|
10
|
+
* data-heatmap-sample 0-1 click/scroll sampling rate; overrides the module setting
|
|
11
|
+
* data-do-not-track "false" to ignore the browser DNT signal
|
|
12
|
+
*
|
|
13
|
+
* Cookieless by design: no identifier is generated or stored client-side —
|
|
14
|
+
* the backend derives an anonymous visitor hash per request. Payload shape
|
|
15
|
+
* follows the Umami tracker fields; events are batched and flushed with
|
|
16
|
+
* sendBeacon so page unloads lose nothing.
|
|
17
|
+
*
|
|
18
|
+
* A/B experiments: `await window.dmsMarketing.variation("key")` resolves the
|
|
19
|
+
* variation the backend assigned this visitor (loaded lazily from
|
|
20
|
+
* experiments.js) and reports the exposure. Browser-side only — assignment
|
|
21
|
+
* derives from the visitor's own request.
|
|
22
|
+
*
|
|
23
|
+
* Served, not static: the tracker route prepends the module's effective
|
|
24
|
+
* settings (see docs/usage/tracking.md) so the Settings page
|
|
25
|
+
* actually drives sampling.
|
|
26
|
+
*
|
|
27
|
+
* Page snapshots: on a visit sampled for clicks, once the page has loaded
|
|
28
|
+
* and gone idle, the tracker asks the backend whether it wants a capture of
|
|
29
|
+
* this page at this width. The answer is the capture script itself
|
|
30
|
+
* (static/snapshot.js) or nothing — so a visit never serializes a page
|
|
31
|
+
* nobody will keep, and a page is captured about once a day however busy
|
|
32
|
+
* it is. Off unless the website opted in.
|
|
33
|
+
*/
|
|
34
|
+
(() => {
|
|
35
|
+
/** Global the tracker route injects the module settings through. */
|
|
36
|
+
const CONFIG_GLOBAL = "__dmsMarketingTrackerConfig";
|
|
37
|
+
/** Global the capture script (snapshot.js) reads its page context from. */
|
|
38
|
+
const SNAPSHOT_CONTEXT_GLOBAL = "__dmsMarketingSnapshotContext";
|
|
39
|
+
|
|
40
|
+
const DEFAULT_SAMPLE_RATE = 0.1;
|
|
41
|
+
const FLUSH_INTERVAL_MS = 5000;
|
|
42
|
+
const MAX_BATCH = 25;
|
|
43
|
+
/** Mirrors the backend MAX_URL_LENGTH: a longer field fails validation. */
|
|
44
|
+
const MAX_URL_LENGTH = 500;
|
|
45
|
+
const MAX_SELECTOR_LENGTH = 120;
|
|
46
|
+
const SELECTOR_DEPTH = 3;
|
|
47
|
+
const COORDINATE_PRECISION = 4;
|
|
48
|
+
const PERCENT_SCALE = 100;
|
|
49
|
+
/** Grace after load before asking for a capture: lazy content and
|
|
50
|
+
* hydration have usually landed; the capture waits for DOM quiet on top. */
|
|
51
|
+
const SNAPSHOT_DELAY_MS = 3000;
|
|
52
|
+
/** SPA navigations may ask again, this many times per page load. */
|
|
53
|
+
const MAX_SNAPSHOTS_PER_LOAD = 3;
|
|
54
|
+
|
|
55
|
+
// --- Public API -----------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** The no-op variation() resolves null: a visitor that collects nothing
|
|
58
|
+
* (DNT) renders the control. */
|
|
59
|
+
const NOOP_API = {
|
|
60
|
+
track() {},
|
|
61
|
+
exposure() {},
|
|
62
|
+
variation() {
|
|
63
|
+
return Promise.resolve(null);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Installed on every path, including the ones that collect nothing, so
|
|
68
|
+
* `window.dmsMarketing.track(…)` in page code never throws. */
|
|
69
|
+
function publishApi(api) {
|
|
70
|
+
window.dmsMarketing = api || NOOP_API;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Configuration --------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const script = document.currentScript;
|
|
76
|
+
if (!script) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read once and removed: the injected settings are not a page-facing API. */
|
|
81
|
+
function servedConfig() {
|
|
82
|
+
const injected = window[CONFIG_GLOBAL];
|
|
83
|
+
delete window[CONFIG_GLOBAL];
|
|
84
|
+
return injected && typeof injected === "object" ? injected : {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function firstFiniteNumber(values) {
|
|
88
|
+
return values.find((value) => Number.isFinite(value));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Embed attribute wins over the module setting, which wins over the default. */
|
|
92
|
+
function resolveSampleRate(attribute, served) {
|
|
93
|
+
const rate = firstFiniteNumber([
|
|
94
|
+
attribute === null ? Number.NaN : Number.parseFloat(attribute),
|
|
95
|
+
served.heatmapSampleRate,
|
|
96
|
+
DEFAULT_SAMPLE_RATE,
|
|
97
|
+
]);
|
|
98
|
+
return Math.min(Math.max(rate, 0), 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const config = servedConfig();
|
|
102
|
+
const websiteId = script.getAttribute("data-website-id");
|
|
103
|
+
if (!websiteId) {
|
|
104
|
+
publishApi();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const respectDnt = script.getAttribute("data-do-not-track") !== "false";
|
|
109
|
+
const dntEnabled = navigator.doNotTrack === "1" || window.doNotTrack === "1";
|
|
110
|
+
if (respectDnt && dntEnabled) {
|
|
111
|
+
publishApi();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const hostUrl =
|
|
116
|
+
script.getAttribute("data-host-url") || new URL(script.src).origin;
|
|
117
|
+
const endpoint = `${hostUrl}/api/marketing/collect`;
|
|
118
|
+
const sampleRate = resolveSampleRate(
|
|
119
|
+
script.getAttribute("data-heatmap-sample"),
|
|
120
|
+
config,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const heatmapSampled = Math.random() < sampleRate;
|
|
124
|
+
const queue = [];
|
|
125
|
+
let flushTimer = null;
|
|
126
|
+
|
|
127
|
+
/** Absolute: the backend stores the path only, but needs the hostname to
|
|
128
|
+
* tell an internal navigation from an acquisition referrer. */
|
|
129
|
+
const currentUrl = () =>
|
|
130
|
+
(location.origin + location.pathname + location.search).slice(
|
|
131
|
+
0,
|
|
132
|
+
MAX_URL_LENGTH,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const base = () => ({
|
|
136
|
+
url: currentUrl(),
|
|
137
|
+
referrer: document.referrer.slice(0, MAX_URL_LENGTH) || undefined,
|
|
138
|
+
title: document.title || undefined,
|
|
139
|
+
screen: `${screen.width}x${screen.height}`,
|
|
140
|
+
language: navigator.language || undefined,
|
|
141
|
+
at: Date.now(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/** text/plain is CORS-safelisted: no preflight, no CORS grant needed. */
|
|
145
|
+
function send(body) {
|
|
146
|
+
if (navigator.sendBeacon) {
|
|
147
|
+
navigator.sendBeacon(endpoint, new Blob([body], { type: "text/plain" }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
fetch(endpoint, { method: "POST", body, keepalive: true }).catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function flush() {
|
|
154
|
+
if (flushTimer) {
|
|
155
|
+
clearTimeout(flushTimer);
|
|
156
|
+
flushTimer = null;
|
|
157
|
+
}
|
|
158
|
+
while (queue.length > 0) {
|
|
159
|
+
const events = queue.splice(0, MAX_BATCH);
|
|
160
|
+
send(JSON.stringify({ website: websiteId, events }));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function enqueue(event) {
|
|
165
|
+
queue.push(event);
|
|
166
|
+
if (queue.length >= MAX_BATCH) {
|
|
167
|
+
flush();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!flushTimer) {
|
|
171
|
+
flushTimer = setTimeout(flush, FLUSH_INTERVAL_MS);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- Pageviews (initial + SPA navigations) --------------------------------
|
|
176
|
+
|
|
177
|
+
let lastPath = null;
|
|
178
|
+
/** URL the scroll depth is being measured against — kept because a SPA
|
|
179
|
+
* navigation rewrites `location` before the depth of the page being left
|
|
180
|
+
* is flushed. */
|
|
181
|
+
let scrolledUrl = null;
|
|
182
|
+
let maxScrollDepth = 0;
|
|
183
|
+
|
|
184
|
+
function trackPageview() {
|
|
185
|
+
if (location.pathname === lastPath) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
flushScrollDepth();
|
|
189
|
+
lastPath = location.pathname;
|
|
190
|
+
scrolledUrl = currentUrl();
|
|
191
|
+
maxScrollDepth = 0;
|
|
192
|
+
enqueue(Object.assign(base(), { kind: "pageview" }));
|
|
193
|
+
scheduleSnapshot();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const originalPushState = history.pushState;
|
|
197
|
+
history.pushState = function pushState(...args) {
|
|
198
|
+
originalPushState.apply(this, args);
|
|
199
|
+
trackPageview();
|
|
200
|
+
};
|
|
201
|
+
window.addEventListener("popstate", trackPageview);
|
|
202
|
+
|
|
203
|
+
// --- Scroll depth (max reached per page, flushed on leave) ----------------
|
|
204
|
+
|
|
205
|
+
function currentScrollDepth() {
|
|
206
|
+
const root = document.documentElement;
|
|
207
|
+
const scrollable = root.scrollHeight - root.clientHeight;
|
|
208
|
+
if (scrollable <= 0) {
|
|
209
|
+
return PERCENT_SCALE;
|
|
210
|
+
}
|
|
211
|
+
const scrolled = root.scrollTop || document.body.scrollTop || 0;
|
|
212
|
+
return Math.min(
|
|
213
|
+
PERCENT_SCALE,
|
|
214
|
+
Math.round((scrolled / scrollable) * PERCENT_SCALE),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
window.addEventListener(
|
|
219
|
+
"scroll",
|
|
220
|
+
() => {
|
|
221
|
+
const depth = currentScrollDepth();
|
|
222
|
+
if (depth > maxScrollDepth) {
|
|
223
|
+
maxScrollDepth = depth;
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
{ passive: true },
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
function flushScrollDepth() {
|
|
230
|
+
if (!heatmapSampled || maxScrollDepth <= 0) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
enqueue(
|
|
234
|
+
Object.assign(base(), {
|
|
235
|
+
kind: "scroll",
|
|
236
|
+
url: scrolledUrl || currentUrl(),
|
|
237
|
+
data: { depth: maxScrollDepth },
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
maxScrollDepth = 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- Click capture (sampled, viewport-normalized) -------------------------
|
|
244
|
+
|
|
245
|
+
function shortSelector(element) {
|
|
246
|
+
const parts = [];
|
|
247
|
+
let node = element;
|
|
248
|
+
for (let depth = 0; node && depth < SELECTOR_DEPTH; depth++) {
|
|
249
|
+
if (node.id) {
|
|
250
|
+
parts.unshift(`#${node.id}`);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
const classes = Array.from(node.classList).slice(0, 2).join(".");
|
|
254
|
+
parts.unshift(classes ? `${node.localName}.${classes}` : node.localName);
|
|
255
|
+
node = node.parentElement;
|
|
256
|
+
}
|
|
257
|
+
return parts.join(">").slice(0, MAX_SELECTOR_LENGTH);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalized(value, total) {
|
|
261
|
+
if (total <= 0) {
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
return Number(Math.min(value / total, 1).toFixed(COORDINATE_PRECISION));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function rounded(value) {
|
|
268
|
+
return Number(
|
|
269
|
+
Math.min(Math.max(value, 0), 1).toFixed(COORDINATE_PRECISION),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Where the click landed inside the element it hit, so the point survives
|
|
275
|
+
* being read back at another window size. Document fractions do not: they
|
|
276
|
+
* divide by `scrollHeight`, which on a page shorter than the window IS the
|
|
277
|
+
* window, and the same button then stores a different fraction per visitor.
|
|
278
|
+
*
|
|
279
|
+
* The document itself is excluded — its box is the viewport, so anchoring
|
|
280
|
+
* to it would carry the very dependency being escaped, at the cost of a
|
|
281
|
+
* lookup. Those clicks keep the document fractions alone.
|
|
282
|
+
*
|
|
283
|
+
* `nth` disambiguates: `shortSelector` stops at three levels and can match
|
|
284
|
+
* several elements, and the embedder resolving it would otherwise land on
|
|
285
|
+
* whichever comes first in the document.
|
|
286
|
+
*/
|
|
287
|
+
function elementAnchor(target, event) {
|
|
288
|
+
if (target === document.body || target === document.documentElement) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
const selector = shortSelector(target);
|
|
292
|
+
if (!selector) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const box = target.getBoundingClientRect();
|
|
296
|
+
if (box.width <= 0 || box.height <= 0) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
let matches;
|
|
300
|
+
try {
|
|
301
|
+
matches = document.querySelectorAll(selector);
|
|
302
|
+
} catch {
|
|
303
|
+
// A class containing CSS-significant characters can produce a selector
|
|
304
|
+
// that never parses; the click is still worth its document fractions.
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
const nth = Array.prototype.indexOf.call(matches, target);
|
|
308
|
+
if (nth < 0) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
selector: selector,
|
|
313
|
+
ox: rounded((event.clientX - box.left) / box.width),
|
|
314
|
+
oy: rounded((event.clientY - box.top) / box.height),
|
|
315
|
+
nth: nth,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
document.addEventListener(
|
|
320
|
+
"click",
|
|
321
|
+
(event) => {
|
|
322
|
+
if (!heatmapSampled) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const target = event.target;
|
|
326
|
+
if (!(target instanceof Element)) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const doc = document.documentElement;
|
|
330
|
+
enqueue(
|
|
331
|
+
Object.assign(base(), {
|
|
332
|
+
kind: "click",
|
|
333
|
+
data: Object.assign(
|
|
334
|
+
{
|
|
335
|
+
x: normalized(event.clientX, innerWidth),
|
|
336
|
+
y: normalized(event.clientY, innerHeight),
|
|
337
|
+
dx: normalized(event.pageX, doc.scrollWidth),
|
|
338
|
+
dy: normalized(event.pageY, doc.scrollHeight),
|
|
339
|
+
selector: shortSelector(target),
|
|
340
|
+
},
|
|
341
|
+
elementAnchor(target, event),
|
|
342
|
+
),
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
},
|
|
346
|
+
{ capture: true, passive: true },
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// --- Page snapshots (negotiated, sampled with the clicks) -----------------
|
|
350
|
+
|
|
351
|
+
const snapshotEndpoint = `${hostUrl}/api/marketing/snapshot`;
|
|
352
|
+
let snapshotsRequested = 0;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Ask whether the backend wants a capture of this page at this width; the
|
|
356
|
+
* answer is the capture script itself, or nothing. Only on visits already
|
|
357
|
+
* sampled for clicks — a backdrop is for pages with clicks — and never
|
|
358
|
+
* from inside a frame, a hidden tab or a prerender: none of them is what
|
|
359
|
+
* the visitor saw.
|
|
360
|
+
*/
|
|
361
|
+
function requestSnapshot() {
|
|
362
|
+
if (
|
|
363
|
+
!heatmapSampled ||
|
|
364
|
+
snapshotsRequested >= MAX_SNAPSHOTS_PER_LOAD ||
|
|
365
|
+
window.top !== window ||
|
|
366
|
+
document.visibilityState !== "visible" ||
|
|
367
|
+
innerWidth <= 0
|
|
368
|
+
) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
snapshotsRequested++;
|
|
372
|
+
const path = location.pathname;
|
|
373
|
+
window[SNAPSHOT_CONTEXT_GLOBAL] = {
|
|
374
|
+
website: websiteId,
|
|
375
|
+
endpoint: snapshotEndpoint,
|
|
376
|
+
path: path,
|
|
377
|
+
};
|
|
378
|
+
const loader = document.createElement("script");
|
|
379
|
+
loader.async = true;
|
|
380
|
+
loader.src =
|
|
381
|
+
`${hostUrl}/api/marketing/snapshot.js?website=${encodeURIComponent(websiteId)}` +
|
|
382
|
+
`&path=${encodeURIComponent(path)}&width=${innerWidth}`;
|
|
383
|
+
const settle = () => loader.remove();
|
|
384
|
+
loader.addEventListener("load", settle);
|
|
385
|
+
loader.addEventListener("error", settle);
|
|
386
|
+
document.head.appendChild(loader);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function whenIdle(callback) {
|
|
390
|
+
if (typeof requestIdleCallback === "function") {
|
|
391
|
+
requestIdleCallback(callback);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
setTimeout(callback, 0);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** After load, a grace period and in idle time: the capture must never
|
|
398
|
+
* compete with the page's own work. */
|
|
399
|
+
function scheduleSnapshot() {
|
|
400
|
+
const later = () => {
|
|
401
|
+
setTimeout(() => whenIdle(requestSnapshot), SNAPSHOT_DELAY_MS);
|
|
402
|
+
};
|
|
403
|
+
if (document.readyState === "complete") {
|
|
404
|
+
later();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
window.addEventListener("load", later, { once: true });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// --- Experiments ----------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
/** Global the assignments script (experiments.js) publishes through. */
|
|
413
|
+
const ASSIGNMENTS_GLOBAL = "__dmsMarketingAssignments";
|
|
414
|
+
|
|
415
|
+
let assignmentsPromise = null;
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Lazy: the request fires on the first variation() call only, so the many
|
|
419
|
+
* pages without experiments never pay it. A <script src> rather than a
|
|
420
|
+
* fetch() because reading a cross-origin response would need the CORS
|
|
421
|
+
* grant client sites are promised never to need; per-visitor, so the
|
|
422
|
+
* backend serves it no-store and it must never ride inside tracker.js
|
|
423
|
+
* (which is cached shared for an hour).
|
|
424
|
+
*/
|
|
425
|
+
function loadAssignments() {
|
|
426
|
+
if (!assignmentsPromise) {
|
|
427
|
+
assignmentsPromise = new Promise((resolve) => {
|
|
428
|
+
const loader = document.createElement("script");
|
|
429
|
+
loader.async = true;
|
|
430
|
+
loader.src =
|
|
431
|
+
`${hostUrl}/api/marketing/experiments.js?website=` +
|
|
432
|
+
encodeURIComponent(websiteId);
|
|
433
|
+
function settle() {
|
|
434
|
+
const assignments = window[ASSIGNMENTS_GLOBAL];
|
|
435
|
+
delete window[ASSIGNMENTS_GLOBAL];
|
|
436
|
+
loader.remove();
|
|
437
|
+
resolve(
|
|
438
|
+
assignments && typeof assignments === "object" ? assignments : {},
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
loader.addEventListener("load", settle);
|
|
442
|
+
loader.addEventListener("error", settle);
|
|
443
|
+
document.head.appendChild(loader);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return assignmentsPromise;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** One exposure per experiment per page load — all the per-session variation
|
|
450
|
+
* split needs, and what keeps long SPA sessions from flooding collect. */
|
|
451
|
+
const exposedExperiments = new Set();
|
|
452
|
+
|
|
453
|
+
function reportExposure(experiment, variation) {
|
|
454
|
+
if (exposedExperiments.has(experiment)) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
exposedExperiments.add(experiment);
|
|
458
|
+
enqueue(
|
|
459
|
+
Object.assign(base(), {
|
|
460
|
+
kind: "exposure",
|
|
461
|
+
name: experiment,
|
|
462
|
+
data: { experiment: experiment, variation: variation },
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
publishApi({
|
|
468
|
+
track(name, data) {
|
|
469
|
+
enqueue(Object.assign(base(), { kind: "custom", name, data }));
|
|
470
|
+
},
|
|
471
|
+
/** Assigned variation of a running experiment, or null. Reports the
|
|
472
|
+
* exposure, so only call it where the variation is actually applied. */
|
|
473
|
+
variation(experiment) {
|
|
474
|
+
return loadAssignments().then((assignments) => {
|
|
475
|
+
const assigned = assignments[experiment];
|
|
476
|
+
if (typeof assigned !== "string") {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
reportExposure(experiment, assigned);
|
|
480
|
+
return assigned;
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
/** Low-level escape hatch for integrations that assign on their own. */
|
|
484
|
+
exposure(experiment, variation) {
|
|
485
|
+
reportExposure(experiment, variation);
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// --- Lifecycle ------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
window.addEventListener("visibilitychange", () => {
|
|
492
|
+
if (document.visibilityState === "hidden") {
|
|
493
|
+
flushScrollDepth();
|
|
494
|
+
flush();
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
window.addEventListener("pagehide", () => {
|
|
498
|
+
flushScrollDepth();
|
|
499
|
+
flush();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
trackPageview();
|
|
503
|
+
})();
|