@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.
Files changed (308) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +143 -0
  3. package/dist/config.d.ts +13 -0
  4. package/dist/config.js +35 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/data-api/body.d.ts +1 -0
  7. package/dist/data-api/body.js +15 -0
  8. package/dist/data-api/body.js.map +1 -0
  9. package/dist/data-api/funnels.d.ts +32 -0
  10. package/dist/data-api/funnels.js +302 -0
  11. package/dist/data-api/funnels.js.map +1 -0
  12. package/dist/data-api/hidden-filter.d.ts +1 -0
  13. package/dist/data-api/hidden-filter.js +11 -0
  14. package/dist/data-api/hidden-filter.js.map +1 -0
  15. package/dist/data-api/index.d.ts +4 -0
  16. package/dist/data-api/index.js +21 -0
  17. package/dist/data-api/index.js.map +1 -0
  18. package/dist/data-api/tenant-scope.d.ts +15 -0
  19. package/dist/data-api/tenant-scope.js +43 -0
  20. package/dist/data-api/tenant-scope.js.map +1 -0
  21. package/dist/data-api/websites.d.ts +25 -0
  22. package/dist/data-api/websites.js +73 -0
  23. package/dist/data-api/websites.js.map +1 -0
  24. package/dist/db/index.d.ts +2 -0
  25. package/dist/db/index.js +19 -0
  26. package/dist/db/index.js.map +1 -0
  27. package/dist/db/models/funnels.model.d.ts +25 -0
  28. package/dist/db/models/funnels.model.js +39 -0
  29. package/dist/db/models/funnels.model.js.map +1 -0
  30. package/dist/db/models/index.d.ts +7 -0
  31. package/dist/db/models/index.js +24 -0
  32. package/dist/db/models/index.js.map +1 -0
  33. package/dist/db/models/marketing_events.model.d.ts +49 -0
  34. package/dist/db/models/marketing_events.model.js +85 -0
  35. package/dist/db/models/marketing_events.model.js.map +1 -0
  36. package/dist/db/models/marketing_sessions.model.d.ts +33 -0
  37. package/dist/db/models/marketing_sessions.model.js +50 -0
  38. package/dist/db/models/marketing_sessions.model.js.map +1 -0
  39. package/dist/db/models/marketing_settings.model.d.ts +23 -0
  40. package/dist/db/models/marketing_settings.model.js +13 -0
  41. package/dist/db/models/marketing_settings.model.js.map +1 -0
  42. package/dist/db/models/page_snapshots.model.d.ts +27 -0
  43. package/dist/db/models/page_snapshots.model.js +32 -0
  44. package/dist/db/models/page_snapshots.model.js.map +1 -0
  45. package/dist/db/models/retention.d.ts +6 -0
  46. package/dist/db/models/retention.js +26 -0
  47. package/dist/db/models/retention.js.map +1 -0
  48. package/dist/db/models/website_statistics.model.d.ts +31 -0
  49. package/dist/db/models/website_statistics.model.js +63 -0
  50. package/dist/db/models/website_statistics.model.js.map +1 -0
  51. package/dist/db/models/websites.model.d.ts +26 -0
  52. package/dist/db/models/websites.model.js +27 -0
  53. package/dist/db/models/websites.model.js.map +1 -0
  54. package/dist/db/tables/funnels.table.d.ts +31 -0
  55. package/dist/db/tables/funnels.table.js +54 -0
  56. package/dist/db/tables/funnels.table.js.map +1 -0
  57. package/dist/db/tables/index.d.ts +7 -0
  58. package/dist/db/tables/index.js +24 -0
  59. package/dist/db/tables/index.js.map +1 -0
  60. package/dist/db/tables/marketing_events.table.d.ts +15 -0
  61. package/dist/db/tables/marketing_events.table.js +66 -0
  62. package/dist/db/tables/marketing_events.table.js.map +1 -0
  63. package/dist/db/tables/marketing_sessions.table.d.ts +26 -0
  64. package/dist/db/tables/marketing_sessions.table.js +115 -0
  65. package/dist/db/tables/marketing_sessions.table.js.map +1 -0
  66. package/dist/db/tables/marketing_settings.table.d.ts +11 -0
  67. package/dist/db/tables/marketing_settings.table.js +51 -0
  68. package/dist/db/tables/marketing_settings.table.js.map +1 -0
  69. package/dist/db/tables/page_snapshots.table.d.ts +17 -0
  70. package/dist/db/tables/page_snapshots.table.js +72 -0
  71. package/dist/db/tables/page_snapshots.table.js.map +1 -0
  72. package/dist/db/tables/website_statistics.table.d.ts +14 -0
  73. package/dist/db/tables/website_statistics.table.js +60 -0
  74. package/dist/db/tables/website_statistics.table.js.map +1 -0
  75. package/dist/db/tables/websites.table.d.ts +13 -0
  76. package/dist/db/tables/websites.table.js +60 -0
  77. package/dist/db/tables/websites.table.js.map +1 -0
  78. package/dist/hooks/index.d.ts +1 -0
  79. package/dist/hooks/index.js +39 -0
  80. package/dist/hooks/index.js.map +1 -0
  81. package/dist/implementations/dms-marketing/index.d.ts +2 -0
  82. package/dist/implementations/dms-marketing/index.js +8 -0
  83. package/dist/implementations/dms-marketing/index.js.map +1 -0
  84. package/dist/index.d.ts +7 -0
  85. package/dist/index.js +89 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/pages/all.d.ts +5 -0
  88. package/dist/pages/all.js +22 -0
  89. package/dist/pages/all.js.map +1 -0
  90. package/dist/pages/campaigns/page.d.ts +9 -0
  91. package/dist/pages/campaigns/page.js +27 -0
  92. package/dist/pages/campaigns/page.js.map +1 -0
  93. package/dist/pages/funnels/page.d.ts +9 -0
  94. package/dist/pages/funnels/page.js +27 -0
  95. package/dist/pages/funnels/page.js.map +1 -0
  96. package/dist/pages/index.d.ts +4 -0
  97. package/dist/pages/index.js +21 -0
  98. package/dist/pages/index.js.map +1 -0
  99. package/dist/pages/module.d.ts +1 -0
  100. package/dist/pages/module.js +13 -0
  101. package/dist/pages/module.js.map +1 -0
  102. package/dist/pages/overview/page.d.ts +9 -0
  103. package/dist/pages/overview/page.js +27 -0
  104. package/dist/pages/overview/page.js.map +1 -0
  105. package/dist/pages/pages/page.d.ts +9 -0
  106. package/dist/pages/pages/page.js +27 -0
  107. package/dist/pages/pages/page.js.map +1 -0
  108. package/dist/pages/settings/form.d.ts +3 -0
  109. package/dist/pages/settings/form.js +35 -0
  110. package/dist/pages/settings/form.js.map +1 -0
  111. package/dist/pages/settings/page.d.ts +9 -0
  112. package/dist/pages/settings/page.js +27 -0
  113. package/dist/pages/settings/page.js.map +1 -0
  114. package/dist/routes/collect.d.ts +7 -0
  115. package/dist/routes/collect.js +62 -0
  116. package/dist/routes/collect.js.map +1 -0
  117. package/dist/routes/experiments.d.ts +6 -0
  118. package/dist/routes/experiments.js +63 -0
  119. package/dist/routes/experiments.js.map +1 -0
  120. package/dist/routes/funnels.d.ts +16 -0
  121. package/dist/routes/funnels.js +44 -0
  122. package/dist/routes/funnels.js.map +1 -0
  123. package/dist/routes/index.d.ts +8 -0
  124. package/dist/routes/index.js +25 -0
  125. package/dist/routes/index.js.map +1 -0
  126. package/dist/routes/settings.d.ts +8 -0
  127. package/dist/routes/settings.js +50 -0
  128. package/dist/routes/settings.js.map +1 -0
  129. package/dist/routes/snapshot.d.ts +10 -0
  130. package/dist/routes/snapshot.js +147 -0
  131. package/dist/routes/snapshot.js.map +1 -0
  132. package/dist/routes/stats.d.ts +44 -0
  133. package/dist/routes/stats.js +278 -0
  134. package/dist/routes/stats.js.map +1 -0
  135. package/dist/routes/tracker.d.ts +6 -0
  136. package/dist/routes/tracker.js +57 -0
  137. package/dist/routes/tracker.js.map +1 -0
  138. package/dist/routes/websites.d.ts +13 -0
  139. package/dist/routes/websites.js +144 -0
  140. package/dist/routes/websites.js.map +1 -0
  141. package/dist/services/acquisition.d.ts +15 -0
  142. package/dist/services/acquisition.js +153 -0
  143. package/dist/services/acquisition.js.map +1 -0
  144. package/dist/services/bounded-cache.d.ts +14 -0
  145. package/dist/services/bounded-cache.js +45 -0
  146. package/dist/services/bounded-cache.js.map +1 -0
  147. package/dist/services/collect.d.ts +11 -0
  148. package/dist/services/collect.js +193 -0
  149. package/dist/services/collect.js.map +1 -0
  150. package/dist/services/experiment-definitions.d.ts +4 -0
  151. package/dist/services/experiment-definitions.js +36 -0
  152. package/dist/services/experiment-definitions.js.map +1 -0
  153. package/dist/services/experiments/assign.d.ts +5 -0
  154. package/dist/services/experiments/assign.js +52 -0
  155. package/dist/services/experiments/assign.js.map +1 -0
  156. package/dist/services/experiments/index.d.ts +3 -0
  157. package/dist/services/experiments/index.js +20 -0
  158. package/dist/services/experiments/index.js.map +1 -0
  159. package/dist/services/experiments/results.d.ts +20 -0
  160. package/dist/services/experiments/results.js +91 -0
  161. package/dist/services/experiments/results.js.map +1 -0
  162. package/dist/services/experiments/stats.d.ts +12 -0
  163. package/dist/services/experiments/stats.js +62 -0
  164. package/dist/services/experiments/stats.js.map +1 -0
  165. package/dist/services/funnels/compute.d.ts +15 -0
  166. package/dist/services/funnels/compute.js +68 -0
  167. package/dist/services/funnels/compute.js.map +1 -0
  168. package/dist/services/funnels/index.d.ts +2 -0
  169. package/dist/services/funnels/index.js +19 -0
  170. package/dist/services/funnels/index.js.map +1 -0
  171. package/dist/services/funnels/results.d.ts +14 -0
  172. package/dist/services/funnels/results.js +72 -0
  173. package/dist/services/funnels/results.js.map +1 -0
  174. package/dist/services/geoip.d.ts +3 -0
  175. package/dist/services/geoip.js +38 -0
  176. package/dist/services/geoip.js.map +1 -0
  177. package/dist/services/heatmap.d.ts +10 -0
  178. package/dist/services/heatmap.js +180 -0
  179. package/dist/services/heatmap.js.map +1 -0
  180. package/dist/services/index.d.ts +26 -0
  181. package/dist/services/index.js +43 -0
  182. package/dist/services/index.js.map +1 -0
  183. package/dist/services/ingest-guard.d.ts +11 -0
  184. package/dist/services/ingest-guard.js +44 -0
  185. package/dist/services/ingest-guard.js.map +1 -0
  186. package/dist/services/keyed-queue.d.ts +4 -0
  187. package/dist/services/keyed-queue.js +19 -0
  188. package/dist/services/keyed-queue.js.map +1 -0
  189. package/dist/services/origin-guard.d.ts +5 -0
  190. package/dist/services/origin-guard.js +43 -0
  191. package/dist/services/origin-guard.js.map +1 -0
  192. package/dist/services/period.d.ts +2 -0
  193. package/dist/services/period.js +19 -0
  194. package/dist/services/period.js.map +1 -0
  195. package/dist/services/prune.d.ts +7 -0
  196. package/dist/services/prune.js +49 -0
  197. package/dist/services/prune.js.map +1 -0
  198. package/dist/services/query-param.d.ts +1 -0
  199. package/dist/services/query-param.js +11 -0
  200. package/dist/services/query-param.js.map +1 -0
  201. package/dist/services/request-body.d.ts +2 -0
  202. package/dist/services/request-body.js +41 -0
  203. package/dist/services/request-body.js.map +1 -0
  204. package/dist/services/request-meta.d.ts +3 -0
  205. package/dist/services/request-meta.js +19 -0
  206. package/dist/services/request-meta.js.map +1 -0
  207. package/dist/services/rollup-write.d.ts +4 -0
  208. package/dist/services/rollup-write.js +84 -0
  209. package/dist/services/rollup-write.js.map +1 -0
  210. package/dist/services/sessionize.d.ts +23 -0
  211. package/dist/services/sessionize.js +78 -0
  212. package/dist/services/sessionize.js.map +1 -0
  213. package/dist/services/settings.d.ts +6 -0
  214. package/dist/services/settings.js +113 -0
  215. package/dist/services/settings.js.map +1 -0
  216. package/dist/services/snapshots.d.ts +24 -0
  217. package/dist/services/snapshots.js +139 -0
  218. package/dist/services/snapshots.js.map +1 -0
  219. package/dist/services/tenant-funnel.d.ts +3 -0
  220. package/dist/services/tenant-funnel.js +17 -0
  221. package/dist/services/tenant-funnel.js.map +1 -0
  222. package/dist/services/tenant-website.d.ts +5 -0
  223. package/dist/services/tenant-website.js +34 -0
  224. package/dist/services/tenant-website.js.map +1 -0
  225. package/dist/services/url.d.ts +10 -0
  226. package/dist/services/url.js +81 -0
  227. package/dist/services/url.js.map +1 -0
  228. package/dist/services/user-agent.d.ts +2 -0
  229. package/dist/services/user-agent.js +17 -0
  230. package/dist/services/user-agent.js.map +1 -0
  231. package/dist/services/visitor-id.d.ts +1 -0
  232. package/dist/services/visitor-id.js +19 -0
  233. package/dist/services/visitor-id.js.map +1 -0
  234. package/dist/services/website-cache.d.ts +3 -0
  235. package/dist/services/website-cache.js +22 -0
  236. package/dist/services/website-cache.js.map +1 -0
  237. package/dist/types/constants.d.ts +74 -0
  238. package/dist/types/constants.js +79 -0
  239. package/dist/types/constants.js.map +1 -0
  240. package/dist/types/events.d.ts +114 -0
  241. package/dist/types/events.js +29 -0
  242. package/dist/types/events.js.map +1 -0
  243. package/dist/types/index.d.ts +5 -0
  244. package/dist/types/index.js +22 -0
  245. package/dist/types/index.js.map +1 -0
  246. package/dist/types/settings.d.ts +67 -0
  247. package/dist/types/settings.js +58 -0
  248. package/dist/types/settings.js.map +1 -0
  249. package/dist/types/snapshots.d.ts +95 -0
  250. package/dist/types/snapshots.js +41 -0
  251. package/dist/types/snapshots.js.map +1 -0
  252. package/dist/types/statistics.d.ts +38 -0
  253. package/dist/types/statistics.js +33 -0
  254. package/dist/types/statistics.js.map +1 -0
  255. package/dist/utils/experiment-type.d.ts +52 -0
  256. package/dist/utils/experiment-type.js +94 -0
  257. package/dist/utils/experiment-type.js.map +1 -0
  258. package/dist/utils/funnel-steps-type.d.ts +42 -0
  259. package/dist/utils/funnel-steps-type.js +62 -0
  260. package/dist/utils/funnel-steps-type.js.map +1 -0
  261. package/frontend-vue/.editorconfig +12 -0
  262. package/frontend-vue/.prettierrc +7 -0
  263. package/frontend-vue/app/components/CampaignsView.vue +330 -0
  264. package/frontend-vue/app/components/ExperimentInput.vue +128 -0
  265. package/frontend-vue/app/components/FunnelDetail.vue +1026 -0
  266. package/frontend-vue/app/components/FunnelStepsInput.vue +154 -0
  267. package/frontend-vue/app/components/FunnelsView.vue +493 -0
  268. package/frontend-vue/app/components/HeatmapCanvas.vue +116 -0
  269. package/frontend-vue/app/components/NoWebsiteState.vue +18 -0
  270. package/frontend-vue/app/components/OverviewView.vue +493 -0
  271. package/frontend-vue/app/components/PagePreview.vue +915 -0
  272. package/frontend-vue/app/components/PagesView.vue +378 -0
  273. package/frontend-vue/app/components/ScrollDepthOverlay.vue +158 -0
  274. package/frontend-vue/app/components/TextKpiCard.vue +28 -0
  275. package/frontend-vue/app/components/TopListTabsCard.vue +162 -0
  276. package/frontend-vue/app/components/TrackerSnippet.vue +31 -0
  277. package/frontend-vue/app/components/VariationsInput.vue +164 -0
  278. package/frontend-vue/app/composables/useChildId.ts +13 -0
  279. package/frontend-vue/app/composables/useExperiment.ts +59 -0
  280. package/frontend-vue/app/composables/useFunnelSteps.ts +25 -0
  281. package/frontend-vue/app/composables/useLatestRequest.ts +90 -0
  282. package/frontend-vue/app/composables/useMarketingApi.ts +420 -0
  283. package/frontend-vue/app/composables/useMarketingPeriod.ts +66 -0
  284. package/frontend-vue/app/composables/useMarketingRoutes.ts +51 -0
  285. package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +69 -0
  286. package/frontend-vue/app/composables/useMarketingWebsites.ts +80 -0
  287. package/frontend-vue/app/composables/useSnapshotFrame.ts +183 -0
  288. package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +46 -0
  289. package/frontend-vue/app/utils/dmsComponent.ts +18 -0
  290. package/frontend-vue/app/utils/heatmapPalette.ts +41 -0
  291. package/frontend-vue/app/utils/snapshotViewportUnits.ts +63 -0
  292. package/frontend-vue/dms.frontend.ts +35 -0
  293. package/frontend-vue/eslint.config.mjs +16 -0
  294. package/frontend-vue/i18n/locales/marketing-en-GB.json +382 -0
  295. package/frontend-vue/i18n/locales/marketing-fr-FR.json +382 -0
  296. package/frontend-vue/package.json +30 -0
  297. package/frontend-vue/pnpm-lock.yaml +5226 -0
  298. package/frontend-vue/pnpm-workspace.yaml +2 -0
  299. package/frontend-vue/tests/drafts.test.ts +54 -0
  300. package/frontend-vue/tests/i18n.test.ts +86 -0
  301. package/frontend-vue/tests/period.test.ts +35 -0
  302. package/frontend-vue/tests/registration.test.ts +63 -0
  303. package/frontend-vue/tests/routes.test.ts +31 -0
  304. package/frontend-vue/tests/stubs/frontend-module.ts +56 -0
  305. package/frontend-vue/vitest.config.ts +20 -0
  306. package/package.json +91 -0
  307. package/static/snapshot.js +497 -0
  308. 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
+ })();