@antelopejs/dms-marketing 0.3.3 → 0.4.0

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 (287) hide show
  1. package/README.md +18 -15
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.js +4 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/data-api/funnels.d.ts +2 -0
  6. package/dist/data-api/funnels.js +8 -1
  7. package/dist/data-api/funnels.js.map +1 -1
  8. package/dist/db/models/funnels.model.d.ts +2 -0
  9. package/dist/db/models/funnels.model.js +9 -0
  10. package/dist/db/models/funnels.model.js.map +1 -1
  11. package/dist/db/models/index.d.ts +1 -0
  12. package/dist/db/models/index.js +1 -0
  13. package/dist/db/models/index.js.map +1 -1
  14. package/dist/db/models/marketing_events.model.d.ts +22 -0
  15. package/dist/db/models/marketing_events.model.js +70 -5
  16. package/dist/db/models/marketing_events.model.js.map +1 -1
  17. package/dist/db/models/marketing_preferences.model.d.ts +25 -0
  18. package/dist/db/models/marketing_preferences.model.js +29 -0
  19. package/dist/db/models/marketing_preferences.model.js.map +1 -0
  20. package/dist/db/models/marketing_sessions.model.d.ts +2 -1
  21. package/dist/db/models/marketing_sessions.model.js +11 -1
  22. package/dist/db/models/marketing_sessions.model.js.map +1 -1
  23. package/dist/db/models/page_snapshots.model.d.ts +2 -1
  24. package/dist/db/models/page_snapshots.model.js +7 -7
  25. package/dist/db/models/page_snapshots.model.js.map +1 -1
  26. package/dist/db/models/website_statistics.model.d.ts +4 -1
  27. package/dist/db/models/website_statistics.model.js +15 -1
  28. package/dist/db/models/website_statistics.model.js.map +1 -1
  29. package/dist/db/tables/index.d.ts +1 -0
  30. package/dist/db/tables/index.js +1 -0
  31. package/dist/db/tables/index.js.map +1 -1
  32. package/dist/db/tables/marketing_preferences.table.d.ts +6 -0
  33. package/dist/db/tables/marketing_preferences.table.js +31 -0
  34. package/dist/db/tables/marketing_preferences.table.js.map +1 -0
  35. package/dist/hooks/index.js +8 -0
  36. package/dist/hooks/index.js.map +1 -1
  37. package/dist/index.js +18 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/pages/acquisition/page.d.ts +9 -0
  40. package/dist/pages/acquisition/page.js +181 -0
  41. package/dist/pages/acquisition/page.js.map +1 -0
  42. package/dist/pages/all.d.ts +5 -1
  43. package/dist/pages/all.js +5 -1
  44. package/dist/pages/all.js.map +1 -1
  45. package/dist/pages/blocks.d.ts +18 -0
  46. package/dist/pages/blocks.js +56 -0
  47. package/dist/pages/blocks.js.map +1 -0
  48. package/dist/pages/funnel/page.d.ts +9 -0
  49. package/dist/pages/funnel/page.js +36 -0
  50. package/dist/pages/funnel/page.js.map +1 -0
  51. package/dist/pages/funnel-builder/page.d.ts +9 -0
  52. package/dist/pages/funnel-builder/page.js +29 -0
  53. package/dist/pages/funnel-builder/page.js.map +1 -0
  54. package/dist/pages/funnels/page.d.ts +1 -1
  55. package/dist/pages/funnels/page.js +200 -3
  56. package/dist/pages/funnels/page.js.map +1 -1
  57. package/dist/pages/index.d.ts +1 -0
  58. package/dist/pages/index.js +1 -0
  59. package/dist/pages/index.js.map +1 -1
  60. package/dist/pages/{campaigns → install}/page.d.ts +3 -3
  61. package/dist/pages/{campaigns → install}/page.js +14 -12
  62. package/dist/pages/install/page.js.map +1 -0
  63. package/dist/pages/module.d.ts +3 -0
  64. package/dist/pages/module.js +30 -3
  65. package/dist/pages/module.js.map +1 -1
  66. package/dist/pages/overview/page.d.ts +1 -1
  67. package/dist/pages/overview/page.js +122 -3
  68. package/dist/pages/overview/page.js.map +1 -1
  69. package/dist/pages/pages/page.d.ts +1 -1
  70. package/dist/pages/pages/page.js +9 -4
  71. package/dist/pages/pages/page.js.map +1 -1
  72. package/dist/pages/quick-actions.d.ts +1 -0
  73. package/dist/pages/quick-actions.js +25 -0
  74. package/dist/pages/quick-actions.js.map +1 -0
  75. package/dist/pages/settings/form.d.ts +2 -1
  76. package/dist/pages/settings/form.js +62 -20
  77. package/dist/pages/settings/form.js.map +1 -1
  78. package/dist/pages/settings/page.d.ts +4 -1
  79. package/dist/pages/settings/page.js +50 -4
  80. package/dist/pages/settings/page.js.map +1 -1
  81. package/dist/pages/websites/page.d.ts +11 -0
  82. package/dist/pages/websites/page.js +62 -0
  83. package/dist/pages/websites/page.js.map +1 -0
  84. package/dist/routes/blocks.d.ts +50 -0
  85. package/dist/routes/blocks.js +269 -0
  86. package/dist/routes/blocks.js.map +1 -0
  87. package/dist/routes/collect.d.ts +1 -2
  88. package/dist/routes/collect.js +19 -12
  89. package/dist/routes/collect.js.map +1 -1
  90. package/dist/routes/context.d.ts +8 -0
  91. package/dist/routes/context.js +54 -0
  92. package/dist/routes/context.js.map +1 -0
  93. package/dist/routes/experiments.js +2 -1
  94. package/dist/routes/experiments.js.map +1 -1
  95. package/dist/routes/funnels.d.ts +21 -3
  96. package/dist/routes/funnels.js +150 -7
  97. package/dist/routes/funnels.js.map +1 -1
  98. package/dist/routes/index.d.ts +2 -0
  99. package/dist/routes/index.js +2 -0
  100. package/dist/routes/index.js.map +1 -1
  101. package/dist/routes/settings.d.ts +6 -1
  102. package/dist/routes/settings.js +65 -4
  103. package/dist/routes/settings.js.map +1 -1
  104. package/dist/routes/snapshot.d.ts +1 -1
  105. package/dist/routes/snapshot.js +24 -25
  106. package/dist/routes/snapshot.js.map +1 -1
  107. package/dist/routes/stats.d.ts +14 -23
  108. package/dist/routes/stats.js +73 -97
  109. package/dist/routes/stats.js.map +1 -1
  110. package/dist/routes/tracker.js +6 -4
  111. package/dist/routes/tracker.js.map +1 -1
  112. package/dist/routes/websites.d.ts +4 -0
  113. package/dist/routes/websites.js +111 -12
  114. package/dist/routes/websites.js.map +1 -1
  115. package/dist/services/acquisition-report.d.ts +62 -0
  116. package/dist/services/acquisition-report.js +181 -0
  117. package/dist/services/acquisition-report.js.map +1 -0
  118. package/dist/services/acquisition.d.ts +9 -3
  119. package/dist/services/acquisition.js +35 -5
  120. package/dist/services/acquisition.js.map +1 -1
  121. package/dist/services/catalog.d.ts +3 -0
  122. package/dist/services/catalog.js +17 -0
  123. package/dist/services/catalog.js.map +1 -0
  124. package/dist/services/collect.d.ts +1 -2
  125. package/dist/services/collect.js +49 -24
  126. package/dist/services/collect.js.map +1 -1
  127. package/dist/services/composed.d.ts +9 -0
  128. package/dist/services/composed.js +62 -0
  129. package/dist/services/composed.js.map +1 -0
  130. package/dist/services/context.d.ts +20 -0
  131. package/dist/services/context.js +76 -0
  132. package/dist/services/context.js.map +1 -0
  133. package/dist/services/dashboard.d.ts +74 -0
  134. package/dist/services/dashboard.js +343 -0
  135. package/dist/services/dashboard.js.map +1 -0
  136. package/dist/services/experiments/results.d.ts +12 -1
  137. package/dist/services/experiments/results.js +19 -2
  138. package/dist/services/experiments/results.js.map +1 -1
  139. package/dist/services/experiments/stats.d.ts +7 -1
  140. package/dist/services/experiments/stats.js +43 -5
  141. package/dist/services/experiments/stats.js.map +1 -1
  142. package/dist/services/funnel-report.d.ts +64 -0
  143. package/dist/services/funnel-report.js +280 -0
  144. package/dist/services/funnel-report.js.map +1 -0
  145. package/dist/services/funnels/compute.d.ts +2 -0
  146. package/dist/services/funnels/compute.js +25 -13
  147. package/dist/services/funnels/compute.js.map +1 -1
  148. package/dist/services/funnels/results.js +4 -1
  149. package/dist/services/funnels/results.js.map +1 -1
  150. package/dist/services/funnels/step-path.d.ts +4 -0
  151. package/dist/services/funnels/step-path.js +47 -0
  152. package/dist/services/funnels/step-path.js.map +1 -0
  153. package/dist/services/funnels-table.d.ts +31 -0
  154. package/dist/services/funnels-table.js +230 -0
  155. package/dist/services/funnels-table.js.map +1 -0
  156. package/dist/services/heatmap.d.ts +15 -3
  157. package/dist/services/heatmap.js +54 -11
  158. package/dist/services/heatmap.js.map +1 -1
  159. package/dist/services/hourly-traffic.d.ts +4 -0
  160. package/dist/services/hourly-traffic.js +35 -0
  161. package/dist/services/hourly-traffic.js.map +1 -0
  162. package/dist/services/origin-guard.d.ts +9 -1
  163. package/dist/services/origin-guard.js +46 -1
  164. package/dist/services/origin-guard.js.map +1 -1
  165. package/dist/services/period.d.ts +26 -1
  166. package/dist/services/period.js +99 -9
  167. package/dist/services/period.js.map +1 -1
  168. package/dist/services/prune.d.ts +6 -0
  169. package/dist/services/prune.js +35 -0
  170. package/dist/services/prune.js.map +1 -1
  171. package/dist/services/request-body.d.ts +1 -2
  172. package/dist/services/request-body.js +10 -36
  173. package/dist/services/request-body.js.map +1 -1
  174. package/dist/services/request-meta.d.ts +0 -1
  175. package/dist/services/request-meta.js +0 -11
  176. package/dist/services/request-meta.js.map +1 -1
  177. package/dist/services/rollups.d.ts +25 -0
  178. package/dist/services/rollups.js +102 -0
  179. package/dist/services/rollups.js.map +1 -0
  180. package/dist/services/sessionize.d.ts +5 -1
  181. package/dist/services/sessionize.js +52 -10
  182. package/dist/services/sessionize.js.map +1 -1
  183. package/dist/services/settings.d.ts +41 -2
  184. package/dist/services/settings.js +219 -6
  185. package/dist/services/settings.js.map +1 -1
  186. package/dist/services/snapshots.d.ts +3 -2
  187. package/dist/services/snapshots.js +110 -9
  188. package/dist/services/snapshots.js.map +1 -1
  189. package/dist/services/tenant-website.js +1 -0
  190. package/dist/services/tenant-website.js.map +1 -1
  191. package/dist/services/url.js +12 -4
  192. package/dist/services/url.js.map +1 -1
  193. package/dist/services/website-hosts.d.ts +15 -0
  194. package/dist/services/website-hosts.js +67 -0
  195. package/dist/services/website-hosts.js.map +1 -0
  196. package/dist/services/website-report.d.ts +40 -0
  197. package/dist/services/website-report.js +84 -0
  198. package/dist/services/website-report.js.map +1 -0
  199. package/dist/types/constants.d.ts +14 -2
  200. package/dist/types/constants.js +17 -6
  201. package/dist/types/constants.js.map +1 -1
  202. package/dist/types/events.d.ts +221 -68
  203. package/dist/types/events.js +60 -11
  204. package/dist/types/events.js.map +1 -1
  205. package/dist/types/snapshots.d.ts +6 -0
  206. package/dist/types/snapshots.js +2 -0
  207. package/dist/types/snapshots.js.map +1 -1
  208. package/dist/types/statistics.d.ts +2 -1
  209. package/dist/types/statistics.js +9 -2
  210. package/dist/types/statistics.js.map +1 -1
  211. package/dist/utils/experiment-type.js +1 -1
  212. package/dist/utils/experiment-type.js.map +1 -1
  213. package/dist/utils/funnel-steps-type.d.ts +21 -3
  214. package/dist/utils/funnel-steps-type.js +7 -2
  215. package/dist/utils/funnel-steps-type.js.map +1 -1
  216. package/frontend-vue/app/components/ChannelsCard.vue +265 -0
  217. package/frontend-vue/app/components/Context.vue +479 -0
  218. package/frontend-vue/app/components/ExperimentInput.vue +3 -1
  219. package/frontend-vue/app/components/ExperimentReport.vue +736 -0
  220. package/frontend-vue/app/components/FirstRun.vue +60 -0
  221. package/frontend-vue/app/components/FunnelBuilder.vue +1595 -0
  222. package/frontend-vue/app/components/FunnelFigure.vue +640 -0
  223. package/frontend-vue/app/components/FunnelReport.vue +532 -0
  224. package/frontend-vue/app/components/FunnelStepsInput.vue +3 -1
  225. package/frontend-vue/app/components/FunnelTemplates.vue +161 -0
  226. package/frontend-vue/app/components/HeatmapCanvas.vue +11 -4
  227. package/frontend-vue/app/components/InstallGuide.vue +753 -0
  228. package/frontend-vue/app/components/PagePreview.vue +889 -416
  229. package/frontend-vue/app/components/PagesExplorer.vue +552 -0
  230. package/frontend-vue/app/components/ScrollDepthOverlay.vue +48 -20
  231. package/frontend-vue/app/components/TopListTabs.vue +317 -0
  232. package/frontend-vue/app/components/TrackerSnippet.vue +50 -16
  233. package/frontend-vue/app/components/VariationsInput.vue +15 -5
  234. package/frontend-vue/app/components/WebsitesGrid.vue +799 -0
  235. package/frontend-vue/app/composables/useContextLink.ts +16 -0
  236. package/frontend-vue/app/composables/useExperiment.ts +1 -2
  237. package/frontend-vue/app/composables/useFunnelSteps.ts +1 -2
  238. package/frontend-vue/app/composables/useLatestRequest.ts +2 -4
  239. package/frontend-vue/app/composables/useMarketingApi.ts +298 -133
  240. package/frontend-vue/app/composables/useMarketingContext.ts +55 -0
  241. package/frontend-vue/app/composables/useMarketingRoutes.ts +80 -27
  242. package/frontend-vue/app/composables/useSnapshotFrame.ts +26 -4
  243. package/frontend-vue/app/constants.ts +22 -0
  244. package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +2 -1
  245. package/frontend-vue/app/utils/channels.ts +37 -0
  246. package/frontend-vue/app/utils/format.ts +132 -0
  247. package/frontend-vue/app/utils/funnel-draft.ts +264 -0
  248. package/frontend-vue/app/utils/heatmap.ts +82 -0
  249. package/frontend-vue/app/utils/heatmapPalette.ts +10 -0
  250. package/frontend-vue/app/utils/host-conflict.ts +48 -0
  251. package/frontend-vue/app/utils/sample-size.ts +17 -0
  252. package/frontend-vue/app/utils/snapshotViewportUnits.ts +15 -5
  253. package/frontend-vue/app/utils/snippet.ts +53 -0
  254. package/frontend-vue/app/utils/step-path.ts +32 -0
  255. package/frontend-vue/dms.frontend.ts +4 -3
  256. package/frontend-vue/i18n/locales/marketing-en-GB.json +1083 -316
  257. package/frontend-vue/i18n/locales/marketing-fr-FR.json +1087 -320
  258. package/frontend-vue/package.json +1 -1
  259. package/frontend-vue/tests/drafts.test.ts +3 -3
  260. package/frontend-vue/tests/format.test.ts +73 -0
  261. package/frontend-vue/tests/funnel-draft.test.ts +194 -0
  262. package/frontend-vue/tests/heatmap.test.ts +32 -0
  263. package/frontend-vue/tests/i18n.test.ts +87 -4
  264. package/frontend-vue/tests/latest-request.test.ts +6 -3
  265. package/frontend-vue/tests/locale-typography.test.ts +115 -0
  266. package/frontend-vue/tests/registration.test.ts +21 -11
  267. package/frontend-vue/tests/routes.test.ts +67 -7
  268. package/frontend-vue/tests/server-parity.test.ts +77 -0
  269. package/frontend-vue/tests/stubs/frontend-module.ts +18 -3
  270. package/package.json +9 -7
  271. package/static/snapshot.js +46 -16
  272. package/static/tracker.js +139 -48
  273. package/dist/pages/campaigns/page.js.map +0 -1
  274. package/frontend-vue/app/components/CampaignsView.vue +0 -330
  275. package/frontend-vue/app/components/FunnelDetail.vue +0 -1026
  276. package/frontend-vue/app/components/FunnelsView.vue +0 -493
  277. package/frontend-vue/app/components/NoWebsiteState.vue +0 -18
  278. package/frontend-vue/app/components/OverviewView.vue +0 -493
  279. package/frontend-vue/app/components/PagesView.vue +0 -391
  280. package/frontend-vue/app/components/TextKpiCard.vue +0 -28
  281. package/frontend-vue/app/components/TopListTabsCard.vue +0 -162
  282. package/frontend-vue/app/composables/useChildId.ts +0 -13
  283. package/frontend-vue/app/composables/useMarketingPeriod.ts +0 -65
  284. package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +0 -69
  285. package/frontend-vue/app/composables/useMarketingWebsites.ts +0 -80
  286. package/frontend-vue/app/utils/dmsComponent.ts +0 -18
  287. package/frontend-vue/tests/period.test.ts +0 -35
package/static/tracker.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * Optional attributes:
9
9
  * data-host-url override the collect origin (defaults to the script origin)
10
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
11
+ * data-do-not-track "false" to ignore the browser DNT and GPC signals
12
12
  *
13
13
  * Cookieless by design: no identifier is generated or stored client-side —
14
14
  * the backend derives an anonymous visitor hash per request. Payload shape
@@ -36,12 +36,17 @@
36
36
  const CONFIG_GLOBAL = "__dmsMarketingTrackerConfig";
37
37
  /** Global the capture script (snapshot.js) reads its page context from. */
38
38
  const SNAPSHOT_CONTEXT_GLOBAL = "__dmsMarketingSnapshotContext";
39
+ /** Set by the first tracker that starts collecting on the page. */
40
+ const INSTALLED_GLOBAL = "__dmsMarketingInstalled";
39
41
 
40
42
  const DEFAULT_SAMPLE_RATE = 0.1;
41
43
  const FLUSH_INTERVAL_MS = 5000;
42
44
  const MAX_BATCH = 25;
43
- /** Mirrors the backend MAX_URL_LENGTH: a longer field fails validation. */
45
+ /** Mirror the backend caps (MAX_URL_LENGTH, MAX_TITLE_LENGTH,
46
+ * MAX_EVENT_NAME_LENGTH): cut here, nothing is lost to validation. */
44
47
  const MAX_URL_LENGTH = 500;
48
+ const MAX_TITLE_LENGTH = 200;
49
+ const MAX_EVENT_NAME_LENGTH = 50;
45
50
  const MAX_SELECTOR_LENGTH = 120;
46
51
  const SELECTOR_DEPTH = 3;
47
52
  const COORDINATE_PRECISION = 4;
@@ -51,6 +56,11 @@
51
56
  const SNAPSHOT_DELAY_MS = 3000;
52
57
  /** SPA navigations may ask again, this many times per page load. */
53
58
  const MAX_SNAPSHOTS_PER_LOAD = 3;
59
+ /** After a SPA navigation, time for the new view to render before its
60
+ * unscrolled depth is measured. */
61
+ const SCROLL_MEASURE_DELAY_MS = 1000;
62
+ /** Where a keyboard-activated click is anchored inside its element. */
63
+ const ELEMENT_CENTER = 0.5;
54
64
 
55
65
  // --- Public API -----------------------------------------------------------
56
66
 
@@ -72,11 +82,6 @@
72
82
 
73
83
  // --- Configuration --------------------------------------------------------
74
84
 
75
- const script = document.currentScript;
76
- if (!script) {
77
- return;
78
- }
79
-
80
85
  /** Read once and removed: the injected settings are not a page-facing API. */
81
86
  function servedConfig() {
82
87
  const injected = window[CONFIG_GLOBAL];
@@ -84,6 +89,21 @@
84
89
  return injected && typeof injected === "object" ? injected : {};
85
90
  }
86
91
 
92
+ const config = servedConfig();
93
+
94
+ // Collection paused (Settings): the page API without the collection, so
95
+ // `variation()` answers the control. A page already running the tracker
96
+ // keeps it.
97
+ if (config.paused === true) {
98
+ window.dmsMarketing = window.dmsMarketing || NOOP_API;
99
+ return;
100
+ }
101
+
102
+ const script = document.currentScript;
103
+ if (!script) {
104
+ return;
105
+ }
106
+
87
107
  function firstFiniteNumber(values) {
88
108
  return values.find((value) => Number.isFinite(value));
89
109
  }
@@ -98,16 +118,27 @@
98
118
  return Math.min(Math.max(rate, 0), 1);
99
119
  }
100
120
 
101
- const config = servedConfig();
102
121
  const websiteId = script.getAttribute("data-website-id");
103
122
  if (!websiteId) {
104
123
  publishApi();
105
124
  return;
106
125
  }
107
126
 
127
+ // A tag included twice (theme and tag manager) would count everything twice.
128
+ if (window[INSTALLED_GLOBAL]) {
129
+ console.warn("[dms-marketing] tracker included twice; ignoring the copy.");
130
+ return;
131
+ }
132
+ window[INSTALLED_GLOBAL] = true;
133
+
134
+ /** Global Privacy Control is the opt-out signal browsers ship now that DNT
135
+ * is deprecated; both are honoured alike. */
108
136
  const respectDnt = script.getAttribute("data-do-not-track") !== "false";
109
- const dntEnabled = navigator.doNotTrack === "1" || window.doNotTrack === "1";
110
- if (respectDnt && dntEnabled) {
137
+ const optedOut =
138
+ navigator.doNotTrack === "1" ||
139
+ window.doNotTrack === "1" ||
140
+ navigator.globalPrivacyControl === true;
141
+ if (respectDnt && optedOut) {
111
142
  publishApi();
112
143
  return;
113
144
  }
@@ -135,7 +166,7 @@
135
166
  const base = () => ({
136
167
  url: currentUrl(),
137
168
  referrer: document.referrer.slice(0, MAX_URL_LENGTH) || undefined,
138
- title: document.title || undefined,
169
+ title: document.title.slice(0, MAX_TITLE_LENGTH) || undefined,
139
170
  screen: `${screen.width}x${screen.height}`,
140
171
  language: navigator.language || undefined,
141
172
  at: Date.now(),
@@ -179,7 +210,8 @@
179
210
  * navigation rewrites `location` before the depth of the page being left
180
211
  * is flushed. */
181
212
  let scrolledUrl = null;
182
- let maxScrollDepth = 0;
213
+ /** -1 until the page has been measured once. */
214
+ let maxScrollDepth = -1;
183
215
 
184
216
  function trackPageview() {
185
217
  if (location.pathname === lastPath) {
@@ -188,17 +220,34 @@
188
220
  flushScrollDepth();
189
221
  lastPath = location.pathname;
190
222
  scrolledUrl = currentUrl();
191
- maxScrollDepth = 0;
192
- enqueue(Object.assign(base(), { kind: "pageview" }));
223
+ maxScrollDepth = -1;
224
+ // The rate it applies, for the server's "recorded on N % of page loads":
225
+ // counting scroll events against pageviews undercounts the views left
226
+ // before their depth is sent.
227
+ enqueue(
228
+ Object.assign(base(), { kind: "pageview", data: { sample: sampleRate } }),
229
+ );
230
+ scheduleScrollMeasure();
193
231
  scheduleSnapshot();
194
232
  }
195
233
 
196
- const originalPushState = history.pushState;
197
- history.pushState = function pushState(...args) {
198
- originalPushState.apply(this, args);
199
- trackPageview();
200
- };
234
+ /** pushState and replaceState (a client-side redirect lands on a page
235
+ * too); a same-path call — a query or hash update — is no navigation. */
236
+ for (const method of ["pushState", "replaceState"]) {
237
+ const original = history[method];
238
+ history[method] = function navigate(...args) {
239
+ original.apply(this, args);
240
+ trackPageview();
241
+ };
242
+ }
201
243
  window.addEventListener("popstate", trackPageview);
244
+ // A page restored from the back/forward cache runs no script again.
245
+ window.addEventListener("pageshow", (event) => {
246
+ if (event.persisted) {
247
+ lastPath = null;
248
+ trackPageview();
249
+ }
250
+ });
202
251
 
203
252
  // --- Scroll depth (max reached per page, flushed on leave) ----------------
204
253
 
@@ -215,19 +264,30 @@
215
264
  );
216
265
  }
217
266
 
218
- window.addEventListener(
219
- "scroll",
220
- () => {
221
- const depth = currentScrollDepth();
222
- if (depth > maxScrollDepth) {
223
- maxScrollDepth = depth;
224
- }
225
- },
226
- { passive: true },
227
- );
267
+ function measureScrollDepth() {
268
+ const depth = currentScrollDepth();
269
+ if (depth > maxScrollDepth) {
270
+ maxScrollDepth = depth;
271
+ }
272
+ }
273
+
274
+ window.addEventListener("scroll", measureScrollDepth, { passive: true });
275
+
276
+ /**
277
+ * Every sampled view reports a depth, scrolled or not: what shows without
278
+ * scrolling is reached too (all of a page shorter than the window), and a
279
+ * view that never scrolls belongs in the denominator.
280
+ */
281
+ function scheduleScrollMeasure() {
282
+ if (document.readyState === "complete") {
283
+ setTimeout(measureScrollDepth, SCROLL_MEASURE_DELAY_MS);
284
+ return;
285
+ }
286
+ window.addEventListener("load", measureScrollDepth, { once: true });
287
+ }
228
288
 
229
289
  function flushScrollDepth() {
230
- if (!heatmapSampled || maxScrollDepth <= 0) {
290
+ if (!heatmapSampled || maxScrollDepth < 0) {
231
291
  return;
232
292
  }
233
293
  enqueue(
@@ -237,7 +297,7 @@
237
297
  data: { depth: maxScrollDepth },
238
298
  }),
239
299
  );
240
- maxScrollDepth = 0;
300
+ maxScrollDepth = -1;
241
301
  }
242
302
 
243
303
  // --- Click capture (sampled, viewport-normalized) -------------------------
@@ -257,19 +317,16 @@
257
317
  return parts.join(">").slice(0, MAX_SELECTOR_LENGTH);
258
318
  }
259
319
 
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
320
  function rounded(value) {
268
321
  return Number(
269
322
  Math.min(Math.max(value, 0), 1).toFixed(COORDINATE_PRECISION),
270
323
  );
271
324
  }
272
325
 
326
+ function normalized(value, total) {
327
+ return total <= 0 ? 0 : rounded(value / total);
328
+ }
329
+
273
330
  /**
274
331
  * Where the click landed inside the element it hit, so the point survives
275
332
  * being read back at another window size. Document fractions do not: they
@@ -284,7 +341,7 @@
284
341
  * several elements, and the embedder resolving it would otherwise land on
285
342
  * whichever comes first in the document.
286
343
  */
287
- function elementAnchor(target, event) {
344
+ function elementAnchor(target, point) {
288
345
  if (target === document.body || target === document.documentElement) {
289
346
  return null;
290
347
  }
@@ -310,16 +367,37 @@
310
367
  }
311
368
  return {
312
369
  selector: selector,
313
- ox: rounded((event.clientX - box.left) / box.width),
314
- oy: rounded((event.clientY - box.top) / box.height),
370
+ ox: rounded((point.clientX - box.left) / box.width),
371
+ oy: rounded((point.clientY - box.top) / box.height),
315
372
  nth: nth,
316
373
  };
317
374
  }
318
375
 
376
+ /**
377
+ * Where the click happened. A click from the keyboard (Enter, Space) has no
378
+ * pointer — detail 0, coordinates 0,0 — and is placed at the centre of the
379
+ * element it activated instead of the window's corner.
380
+ */
381
+ function clickPoint(target, event) {
382
+ if (event.detail !== 0 || event.clientX !== 0 || event.clientY !== 0) {
383
+ return event;
384
+ }
385
+ const box = target.getBoundingClientRect();
386
+ const clientX = box.left + box.width * ELEMENT_CENTER;
387
+ const clientY = box.top + box.height * ELEMENT_CENTER;
388
+ return {
389
+ clientX: clientX,
390
+ clientY: clientY,
391
+ pageX: clientX + scrollX,
392
+ pageY: clientY + scrollY,
393
+ };
394
+ }
395
+
319
396
  document.addEventListener(
320
397
  "click",
321
398
  (event) => {
322
- if (!heatmapSampled) {
399
+ // Script-dispatched clicks (element.click()) are no visitor's.
400
+ if (!heatmapSampled || !event.isTrusted) {
323
401
  return;
324
402
  }
325
403
  const target = event.target;
@@ -327,18 +405,19 @@
327
405
  return;
328
406
  }
329
407
  const doc = document.documentElement;
408
+ const point = clickPoint(target, event);
330
409
  enqueue(
331
410
  Object.assign(base(), {
332
411
  kind: "click",
333
412
  data: Object.assign(
334
413
  {
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),
414
+ x: normalized(point.clientX, innerWidth),
415
+ y: normalized(point.clientY, innerHeight),
416
+ dx: normalized(point.pageX, doc.scrollWidth),
417
+ dy: normalized(point.pageY, doc.scrollHeight),
339
418
  selector: shortSelector(target),
340
419
  },
341
- elementAnchor(target, event),
420
+ elementAnchor(target, point),
342
421
  ),
343
422
  }),
344
423
  );
@@ -369,6 +448,9 @@
369
448
  return;
370
449
  }
371
450
  snapshotsRequested++;
451
+ // The upload is only accepted for the page the visit's last pageview
452
+ // named: that pageview must be on its way before the capture is.
453
+ flush();
372
454
  const path = location.pathname;
373
455
  window[SNAPSHOT_CONTEXT_GLOBAL] = {
374
456
  website: websiteId,
@@ -466,7 +548,16 @@
466
548
 
467
549
  publishApi({
468
550
  track(name, data) {
469
- enqueue(Object.assign(base(), { kind: "custom", name, data }));
551
+ enqueue(
552
+ Object.assign(base(), {
553
+ kind: "custom",
554
+ name:
555
+ typeof name === "string"
556
+ ? name.slice(0, MAX_EVENT_NAME_LENGTH)
557
+ : name,
558
+ data,
559
+ }),
560
+ );
470
561
  },
471
562
  /** Assigned variation of a running experiment, or null. Reports the
472
563
  * exposure, so only call it where the variation is actually applied. */
@@ -1 +0,0 @@
1
- {"version":3,"file":"page.js","sourceRoot":"","sources":["../../../src/pages/campaigns/page.ts"],"names":[],"mappings":";;;;;;;;;AAAA,kEAAwE;AACxE,oEAAuE;AACvE,yDAA8E;AAC9E,iDAAwD;AASjD,IAAM,sBAAsB,GAA5B,MAAM,sBAAuB,SAAQ,IAAA,qBAAc,EACxD,WAAW,EACX;IACE,WAAW,EAAE,iCAAiC;IAC9C,WAAW,EAAE,uCAAuC;IACpD,IAAI,EAAE,gBAAgB;IACtB,MAAM,EAAE,+BAAmB;IAC3B,KAAK,EAAE,CAAC;CACT,EACD,IAAA,uBAAa,EAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CACnC;IACC,MAAM,CAAC,OAAO,GAAG,IAAA,wBAAe,EAAC,2BAA2B,CAAC,CAAC;;AAXnD,wDAAsB;iCAAtB,sBAAsB;IADlC,IAAA,mBAAY,GAAE;GACF,sBAAsB,CAYlC"}
@@ -1,330 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
- import { useDmsRoute, useI18n } from '#dms/frontend-module'
4
- import { useChildId } from '../composables/useChildId'
5
- import { useLatestRequest } from '../composables/useLatestRequest'
6
- import {
7
- useMarketingApi,
8
- type MarketingCampaignEntry,
9
- type MarketingCampaigns,
10
- type MarketingChannel,
11
- } from '../composables/useMarketingApi'
12
- import {
13
- MARKETING_PERIOD_PRESETS,
14
- ndToPreset,
15
- useMarketingPeriod,
16
- } from '../composables/useMarketingPeriod'
17
- import { useMarketingWebsites } from '../composables/useMarketingWebsites'
18
-
19
- import type { TableColumn } from '@nuxt/ui'
20
-
21
- /**
22
- * Acquisition surface: the five-channel split of sessions (donut) next to the
23
- * UTM campaign table — one row per (source, medium, campaign) triple observed
24
- * on new sessions — with UTM terms and contents as top lists below.
25
- *
26
- * Everything reads the daily rollups through one campaigns fetch; the search
27
- * box filters the table server-side because a day's map caps its entries and
28
- * the merged table can exceed what one response carries.
29
- */
30
- const PERIOD_SCOPE = 'dms-marketing-campaigns'
31
- const SEARCH_DEBOUNCE_MS = 300
32
-
33
- // The DMS page renderer hands every component its identity; keeping it is what
34
- // lets dms-ui primitives key their events and persisted state per child.
35
- const props = withDefaults(
36
- defineProps<{ componentId?: string, pageId?: string }>(),
37
- { componentId: 'marketing-campaigns', pageId: 'marketing' },
38
- )
39
-
40
- const childId = useChildId(() => props.componentId)
41
-
42
- const api = useMarketingApi()
43
- const { t, te } = useI18n()
44
- const route = useDmsRoute()
45
-
46
- const queryParam = (key: string): string => {
47
- const value = route.query[key]
48
- return typeof value === 'string' ? value : ''
49
- }
50
-
51
- // Read once: the selector seeds its preset at init and never reactively, so a
52
- // deep link's window has to be resolved before it renders.
53
- const initialPreset = ndToPreset(queryParam('period'))
54
-
55
- const {
56
- websites,
57
- items: websiteItems,
58
- selectedId,
59
- loading: loadingWebsites,
60
- failed: websitesFailed,
61
- load: loadWebsites,
62
- create: createWebsite,
63
- } = useMarketingWebsites(queryParam('website'))
64
-
65
- const period = useMarketingPeriod(PERIOD_SCOPE)
66
- const search = ref('')
67
-
68
- let searchTimer: ReturnType<typeof setTimeout> | null = null
69
-
70
- const {
71
- data: acquisition,
72
- loading: loadingCampaigns,
73
- failed: campaignsFailed,
74
- settled: campaignsSettled,
75
- run: loadCampaigns,
76
- } = useLatestRequest<MarketingCampaigns>(() =>
77
- // Server-side there is no session to authenticate with: the fetch belongs to
78
- // the client, like every other read here.
79
- selectedId.value && !import.meta.env.SSR
80
- ? api.getCampaigns(selectedId.value, period.value, search.value.trim())
81
- : null,
82
- )
83
-
84
- // The watcher owns every fetch, including the first: seeding it from onMounted
85
- // as well would issue the initial request twice on a bare link, and not at all
86
- // on a link that already names the site the list would have picked — resolving
87
- // it writes back the same id, which is no change to watch.
88
- watch([selectedId, period], () => {
89
- void loadCampaigns()
90
- }, { immediate: true })
91
-
92
- watch(search, () => {
93
- if (searchTimer) {
94
- clearTimeout(searchTimer)
95
- }
96
- searchTimer = setTimeout(() => void loadCampaigns(), SEARCH_DEBOUNCE_MS)
97
- })
98
-
99
- onBeforeUnmount(() => {
100
- if (searchTimer) {
101
- clearTimeout(searchTimer)
102
- }
103
- })
104
-
105
- const loading = computed(
106
- () =>
107
- loadingWebsites.value
108
- || loadingCampaigns.value
109
- || (!!selectedId.value && !campaignsSettled.value),
110
- )
111
-
112
- // Two failures, two flags: retrying the campaigns fetch must not clear the
113
- // one that says the site list itself never loaded.
114
- const failed = computed(() => websitesFailed.value || campaignsFailed.value)
115
-
116
- /** Retry entry point: whichever of the two fetches has nothing to show. */
117
- function reload() {
118
- return websites.value.length === 0 ? loadWebsites() : loadCampaigns()
119
- }
120
-
121
- onMounted(loadWebsites)
122
-
123
- // --- Channels ---------------------------------------------------------------
124
-
125
- /** Display order mirrors the backend's MARKETING_CHANNELS. */
126
- const CHANNEL_ORDER: MarketingChannel[] = ['direct', 'organic', 'social', 'referral', 'paid']
127
-
128
- /** Known channels localize; a key minted by a future classifier still shows. */
129
- function channelLabel(channel: string): string {
130
- const key = `page.marketing.campaigns.channels.${channel}`
131
- return te(key) ? t(key) : channel
132
- }
133
-
134
- const channelRecords = computed(() => {
135
- const channels = acquisition.value?.channels ?? {}
136
- return CHANNEL_ORDER
137
- .map(channel => ({ label: channelLabel(channel), value: channels[channel] ?? 0 }))
138
- .filter(record => record.value > 0)
139
- })
140
-
141
- // --- Campaign table ---------------------------------------------------------
142
-
143
- const campaignRows = computed(() => acquisition.value?.campaigns ?? [])
144
- const truncated = computed(() => acquisition.value?.truncated ?? false)
145
-
146
- const columns = computed<TableColumn<MarketingCampaignEntry>[]>(() => [
147
- { accessorKey: 'source', header: t('page.marketing.campaigns.table.source') },
148
- { accessorKey: 'medium', header: t('page.marketing.campaigns.table.medium') },
149
- { accessorKey: 'campaign', header: t('page.marketing.campaigns.table.campaign') },
150
- {
151
- accessorKey: 'sessions',
152
- header: t('page.marketing.campaigns.table.sessions'),
153
- meta: { class: { th: 'text-right', td: 'text-right' } },
154
- },
155
- ])
156
-
157
- // --- Referrer / term / content top lists ------------------------------------
158
-
159
- const TOP_LIST_LIMIT = 8
160
-
161
- function toTopItems(record: Record<string, number> | undefined) {
162
- return Object.entries(record ?? {})
163
- .sort((a, b) => b[1] - a[1])
164
- .slice(0, TOP_LIST_LIMIT)
165
- .map(([label, value]) => ({ id: label, title: label, value }))
166
- }
167
-
168
- const topLists = computed(() => [
169
- // Referrer domains across all channels — the overview's acquisition card
170
- // links here for this detail.
171
- {
172
- id: 'referrers',
173
- title: t('page.marketing.campaigns.top.referrers'),
174
- items: toTopItems(acquisition.value?.referrers),
175
- },
176
- {
177
- id: 'terms',
178
- title: t('page.marketing.campaigns.top.terms'),
179
- items: toTopItems(acquisition.value?.terms),
180
- },
181
- {
182
- id: 'contents',
183
- title: t('page.marketing.campaigns.top.contents'),
184
- items: toTopItems(acquisition.value?.contents),
185
- },
186
- ])
187
- </script>
188
-
189
- <template>
190
- <div class="space-y-6 p-6">
191
- <div class="flex flex-wrap items-center justify-between gap-4">
192
- <div>
193
- <h1 class="text-xl font-semibold">{{ t('page.marketing.campaigns.title') }}</h1>
194
- <p class="text-sm text-muted">{{ t('page.marketing.campaigns.subtitle') }}</p>
195
- </div>
196
- <div class="flex items-center gap-3">
197
- <USelect
198
- v-if="websites.length > 1"
199
- v-model="selectedId"
200
- :items="websiteItems"
201
- class="w-56"
202
- />
203
- <DmsPeriodSelector
204
- :id="PERIOD_SCOPE"
205
- :component-id="childId('period')"
206
- :page-id="props.pageId"
207
- :default-preset="initialPreset"
208
- :presets="MARKETING_PERIOD_PRESETS"
209
- :comparisons="['none']"
210
- :show-range-label="false"
211
- />
212
- <UButton
213
- icon="i-ph-arrow-clockwise"
214
- color="neutral"
215
- variant="subtle"
216
- size="sm"
217
- :loading="loading"
218
- @click="loadCampaigns"
219
- />
220
- </div>
221
- </div>
222
-
223
- <div v-if="failed" class="flex flex-col items-start gap-3">
224
- <UAlert
225
- color="error"
226
- variant="subtle"
227
- icon="i-ph-warning-circle"
228
- :title="t('page.marketing.campaigns.error.title')"
229
- :description="t('page.marketing.campaigns.error.description')"
230
- />
231
- <UButton
232
- icon="i-ph-arrow-clockwise"
233
- :label="t('page.marketing.campaigns.retry')"
234
- color="neutral"
235
- variant="subtle"
236
- size="sm"
237
- @click="reload"
238
- />
239
- </div>
240
-
241
- <DmsMarketingNoWebsiteState
242
- v-else-if="!loading && websites.length === 0"
243
- icon="i-ph-megaphone"
244
- :title="t('page.marketing.campaigns.empty.title')"
245
- :description="t('page.marketing.campaigns.empty.description')"
246
- @create="createWebsite"
247
- />
248
-
249
- <!-- Only before the first answer: a refetch keeps the tables on screen
250
- and spins the refresh button instead. -->
251
- <USkeleton v-else-if="loading && !acquisition" class="h-96 w-full" />
252
-
253
- <template v-else>
254
- <div class="grid gap-4 lg:grid-cols-3">
255
- <UCard>
256
- <template #header>
257
- <div>
258
- <p class="font-medium">{{ t('page.marketing.campaigns.channels.title') }}</p>
259
- <p class="text-sm text-muted">{{ t('page.marketing.campaigns.channels.subtitle') }}</p>
260
- </div>
261
- </template>
262
- <DmsChart
263
- v-if="channelRecords.length > 0"
264
- :component-id="childId('channels')"
265
- :page-id="props.pageId"
266
- type="donut"
267
- :static-dataset="channelRecords"
268
- height="300px"
269
- :show-total="true"
270
- />
271
- <p v-else class="py-12 text-center text-sm text-muted">
272
- {{ t('page.marketing.campaigns.channels.empty') }}
273
- </p>
274
- </UCard>
275
-
276
- <UCard class="lg:col-span-2">
277
- <template #header>
278
- <div class="flex flex-wrap items-center justify-between gap-3">
279
- <div>
280
- <p class="font-medium">{{ t('page.marketing.campaigns.table.title') }}</p>
281
- <p class="text-sm text-muted">{{ t('page.marketing.campaigns.table.subtitle') }}</p>
282
- </div>
283
- <UInput
284
- v-model="search"
285
- icon="i-ph-magnifying-glass"
286
- :placeholder="t('page.marketing.campaigns.table.search_placeholder')"
287
- class="w-56"
288
- />
289
- </div>
290
- </template>
291
- <UTable :data="campaignRows" :columns="columns">
292
- <template #source-cell="{ row }">
293
- <span v-if="row.original.source">{{ row.original.source }}</span>
294
- <span v-else class="text-muted">—</span>
295
- </template>
296
- <template #medium-cell="{ row }">
297
- <span v-if="row.original.medium">{{ row.original.medium }}</span>
298
- <span v-else class="text-muted">—</span>
299
- </template>
300
- <template #campaign-cell="{ row }">
301
- <span v-if="row.original.campaign">{{ row.original.campaign }}</span>
302
- <span v-else class="text-muted">—</span>
303
- </template>
304
- <template #empty>
305
- <p class="py-8 text-center text-sm text-muted">
306
- {{ t('page.marketing.campaigns.table.empty') }}
307
- </p>
308
- </template>
309
- </UTable>
310
- <p v-if="truncated" class="mt-2 text-sm text-muted">
311
- {{ t('page.marketing.campaigns.table.truncated') }}
312
- </p>
313
- </UCard>
314
- </div>
315
-
316
- <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
317
- <DmsTopListCard
318
- v-for="list in topLists"
319
- :key="list.id"
320
- :component-id="childId(`top-${list.id}`)"
321
- :page-id="props.pageId"
322
- :title="list.title"
323
- :static-items="list.items"
324
- :show-delta="false"
325
- :empty-label="t('page.marketing.campaigns.top.empty')"
326
- />
327
- </div>
328
- </template>
329
- </div>
330
- </template>