@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,116 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref, watch } from 'vue'
3
+ import type { MarketingHeatmapPoint } from '../composables/useMarketingApi'
4
+ import { HEAT_STOPS, heatRgba } from '../utils/heatmapPalette'
5
+
6
+ /**
7
+ * Dependency-free heatmap renderer: points are drawn as radial alpha
8
+ * gradients on an offscreen grayscale pass, then the alpha channel is mapped
9
+ * through a color palette (classic heatmap.js technique, ~none of its bytes).
10
+ * Coordinates are 0-1, relative to the drawn surface.
11
+ *
12
+ * `width`/`height` are CSS pixels as much as they are the backing store: the
13
+ * element sizes itself from them. Letting the parent's box decide instead
14
+ * would silently rescale x and y — a document wider than its container would
15
+ * have every click squeezed leftwards, and the round blobs would render as
16
+ * ellipses. The caller positions this; it does not stretch it.
17
+ */
18
+ const props = defineProps<{
19
+ points: MarketingHeatmapPoint[]
20
+ maxWeight: number
21
+ width: number
22
+ height: number
23
+ }>()
24
+
25
+ const POINT_RADIUS = 28
26
+ const MAX_RENDER_ALPHA = 210
27
+ /** Floor on a point's intensity so single clicks stay visible. */
28
+ const MIN_POINT_ALPHA = 0.08
29
+ /** Flat opacity boost applied when colorising, capped at MAX_RENDER_ALPHA. */
30
+ const ALPHA_BOOST = 40
31
+ const PALETTE_SIZE = 256
32
+
33
+ const canvasRef = ref<HTMLCanvasElement | null>(null)
34
+
35
+ function buildPalette(): Uint8ClampedArray | null {
36
+ const strip = document.createElement('canvas')
37
+ strip.width = PALETTE_SIZE
38
+ strip.height = 1
39
+ const ctx = strip.getContext('2d')
40
+ if (!ctx) {
41
+ return null
42
+ }
43
+ const gradient = ctx.createLinearGradient(0, 0, PALETTE_SIZE, 0)
44
+ for (const [stop, rgb] of HEAT_STOPS) {
45
+ gradient.addColorStop(stop, heatRgba(rgb, 1))
46
+ }
47
+ ctx.fillStyle = gradient
48
+ ctx.fillRect(0, 0, PALETTE_SIZE, 1)
49
+ return ctx.getImageData(0, 0, PALETTE_SIZE, 1).data
50
+ }
51
+
52
+ function drawIntensity(ctx: CanvasRenderingContext2D): void {
53
+ const reference = Math.max(props.maxWeight, 1)
54
+ for (const point of props.points) {
55
+ const px = point.x * props.width
56
+ const py = point.y * props.height
57
+ const alpha = Math.max(point.weight / reference, MIN_POINT_ALPHA)
58
+ const gradient = ctx.createRadialGradient(px, py, 0, px, py, POINT_RADIUS)
59
+ gradient.addColorStop(0, `rgba(0, 0, 0, ${alpha})`)
60
+ gradient.addColorStop(1, 'rgba(0, 0, 0, 0)')
61
+ ctx.fillStyle = gradient
62
+ ctx.fillRect(px - POINT_RADIUS, py - POINT_RADIUS, POINT_RADIUS * 2, POINT_RADIUS * 2)
63
+ }
64
+ }
65
+
66
+ function colorize(ctx: CanvasRenderingContext2D): void {
67
+ const palette = buildPalette()
68
+ if (!palette) {
69
+ return
70
+ }
71
+ const image = ctx.getImageData(0, 0, props.width, props.height)
72
+ const data = image.data
73
+ for (let i = 0; i < data.length; i += 4) {
74
+ const alpha = data[i + 3]
75
+ if (alpha === 0) {
76
+ continue
77
+ }
78
+ const offset = Math.min(alpha, PALETTE_SIZE - 1) * 4
79
+ data[i] = palette[offset]
80
+ data[i + 1] = palette[offset + 1]
81
+ data[i + 2] = palette[offset + 2]
82
+ data[i + 3] = Math.min(alpha + ALPHA_BOOST, MAX_RENDER_ALPHA)
83
+ }
84
+ ctx.putImageData(image, 0, 0)
85
+ }
86
+
87
+ function render(): void {
88
+ const canvas = canvasRef.value
89
+ if (!canvas || props.width <= 0 || props.height <= 0) {
90
+ return
91
+ }
92
+ canvas.width = props.width
93
+ canvas.height = props.height
94
+ const ctx = canvas.getContext('2d')
95
+ if (!ctx) {
96
+ return
97
+ }
98
+ ctx.clearRect(0, 0, props.width, props.height)
99
+ if (props.points.length === 0) {
100
+ return
101
+ }
102
+ drawIntensity(ctx)
103
+ colorize(ctx)
104
+ }
105
+
106
+ watch(() => [props.points, props.width, props.height], render, { deep: true })
107
+ onMounted(render)
108
+ </script>
109
+
110
+ <template>
111
+ <canvas
112
+ ref="canvasRef"
113
+ class="pointer-events-none absolute left-0 top-0"
114
+ :style="{ width: `${props.width}px`, height: `${props.height}px` }"
115
+ />
116
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import { useI18n } from '#dms/frontend-module'
3
+
4
+ defineProps<{ icon: string, title: string, description: string }>()
5
+ const emit = defineEmits<{ create: [] }>()
6
+ const { t } = useI18n()
7
+ </script>
8
+
9
+ <template>
10
+ <div class="flex flex-col items-start gap-3">
11
+ <UAlert :icon="icon" :title="title" :description="description" />
12
+ <UButton
13
+ icon="i-ph-plus"
14
+ :label="t('page.marketing.websites.create')"
15
+ @click="emit('create')"
16
+ />
17
+ </div>
18
+ </template>
@@ -0,0 +1,493 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, watch } from 'vue'
3
+ import { useI18n } from '#dms/frontend-module'
4
+ import { useChildId } from '../composables/useChildId'
5
+ import { useLatestRequest } from '../composables/useLatestRequest'
6
+ import {
7
+ useMarketingApi,
8
+ type MarketingOverview,
9
+ } from '../composables/useMarketingApi'
10
+ import {
11
+ DEFAULT_PERIOD_PRESET,
12
+ MARKETING_PERIOD_PRESETS,
13
+ MS_PER_DAY,
14
+ periodDays,
15
+ useMarketingPeriod,
16
+ } from '../composables/useMarketingPeriod'
17
+ import {
18
+ campaignsLink,
19
+ funnelsLink,
20
+ pagesLink,
21
+ } from '../composables/useMarketingRoutes'
22
+ import { useMarketingWebsites } from '../composables/useMarketingWebsites'
23
+
24
+ /**
25
+ * Overview dashboard: period-scoped traffic area chart, device donut and
26
+ * the top dimensions grouped into four cards — content, acquisition,
27
+ * audience, events — each a tabbed stack of top lists linking out to the
28
+ * surface that owns the detail (pages, campaigns, funnels). All of it is
29
+ * fed by one rollup-backed overview fetch. Chart/KPI primitives come from
30
+ * the dms-ui layer so the look is inherited, not ported; UTM sources have
31
+ * no card here on purpose — the campaigns table is their surface.
32
+ */
33
+ const PERIOD_SCOPE = 'dms-marketing-overview'
34
+ const TOP_LIST_LIMIT = 8
35
+
36
+ // The DMS page renderer hands every component its identity; keeping it is what
37
+ // lets dms-ui primitives key their events and persisted state per child.
38
+ const props = withDefaults(
39
+ defineProps<{ componentId?: string, pageId?: string }>(),
40
+ { componentId: 'marketing-overview', pageId: 'marketing' },
41
+ )
42
+
43
+ const childId = useChildId(() => props.componentId)
44
+
45
+ const api = useMarketingApi()
46
+ const { t, te, locale } = useI18n()
47
+
48
+ const {
49
+ websites,
50
+ items: websiteItems,
51
+ selectedId,
52
+ loading: loadingWebsites,
53
+ failed: websitesFailed,
54
+ load: loadWebsites,
55
+ create: createWebsite,
56
+ showSnippet,
57
+ } = useMarketingWebsites()
58
+
59
+ // Period comes from the DMS-standard <DmsPeriodSelector>, read back through
60
+ // its scope registry and mapped onto the `Nd` windows the backend accepts.
61
+ const period = useMarketingPeriod(PERIOD_SCOPE)
62
+
63
+ const windowLength = computed(() => periodDays(period.value))
64
+
65
+ const {
66
+ data: overview,
67
+ loading: loadingOverview,
68
+ failed: overviewFailed,
69
+ settled: overviewSettled,
70
+ run: loadOverview,
71
+ } = useLatestRequest<MarketingOverview>(() =>
72
+ // Server-side there is no session to authenticate with: the fetch belongs to
73
+ // the client, like every other read here.
74
+ selectedId.value && !import.meta.env.SSR
75
+ ? api.getOverview(selectedId.value, period.value)
76
+ : null,
77
+ )
78
+
79
+ // The watcher owns every overview fetch, including the first: seeding it from
80
+ // onMounted as well would issue the initial request twice. The endpoint lists
81
+ // websites by last activity, so the default selection is the one that has
82
+ // something to show. Immediate costs nothing while the selection starts empty
83
+ // — the fetcher just answers null — and is what keeps this correct the day a
84
+ // deep link seeds it: resolving the site list would then write the same id
85
+ // back, which is no change to watch, and a non-immediate watcher would never
86
+ // fetch at all.
87
+ watch([selectedId, period], () => {
88
+ void loadOverview()
89
+ }, { immediate: true })
90
+
91
+ /**
92
+ * The window between a site being selected and the watcher firing on the next
93
+ * tick counts as loading: dropping the flag there would flash an empty
94
+ * dashboard between the two fetches.
95
+ */
96
+ const loading = computed(
97
+ () =>
98
+ loadingWebsites.value
99
+ || loadingOverview.value
100
+ || (!!selectedId.value && !overviewSettled.value),
101
+ )
102
+
103
+ // Two failures, two flags: retrying the overview must not clear the one that
104
+ // says the site list itself never loaded.
105
+ const failed = computed(() => websitesFailed.value || overviewFailed.value)
106
+
107
+ /** Retry entry point: whichever of the two fetches has nothing to show. */
108
+ function reload() {
109
+ return websites.value.length === 0 ? loadWebsites() : loadOverview()
110
+ }
111
+
112
+ onMounted(loadWebsites)
113
+
114
+ // --- KPIs -------------------------------------------------------------------
115
+
116
+ /** "1m 32s" the way Plausible writes it — locale-neutral abbreviations. */
117
+ function formatDuration(ms: number): string {
118
+ const totalSeconds = Math.round(ms / 1000)
119
+ const hours = Math.floor(totalSeconds / 3600)
120
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
121
+ if (hours > 0) {
122
+ return `${hours}h ${minutes}m`
123
+ }
124
+ const seconds = totalSeconds % 60
125
+ return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`
126
+ }
127
+
128
+ interface OverviewKpi {
129
+ id: string
130
+ label: string
131
+ icon: string
132
+ /** Rendered by DmsKpiCard when numeric… */
133
+ value?: number
134
+ format?: 'number' | 'percent'
135
+ /** …and by the local text card when already composed (duration). */
136
+ display?: string
137
+ }
138
+
139
+ const kpis = computed<OverviewKpi[]>(() => {
140
+ const totals = overview.value?.totals
141
+ const sessions = totals?.sessions ?? 0
142
+ // Transition-counted bounces can sum below zero on windows cutting a
143
+ // session in two; a negative rate is never worth showing.
144
+ const bounceRate = sessions > 0
145
+ ? (Math.max(0, totals?.bouncedSessions ?? 0) / sessions) * 100
146
+ : 0
147
+ const pagesPerSession = sessions > 0
148
+ ? Math.round(((totals?.pageviews ?? 0) / sessions) * 10) / 10
149
+ : 0
150
+ const avgDuration = sessions > 0 ? (totals?.sessionDurationMs ?? 0) / sessions : 0
151
+ return [
152
+ { id: 'pageviews', label: t('page.marketing.overview.kpis.pageviews'), value: totals?.pageviews ?? 0, format: 'number', icon: 'i-ph-eye' },
153
+ { id: 'sessions', label: t('page.marketing.overview.kpis.sessions'), value: totals?.sessions ?? 0, format: 'number', icon: 'i-ph-users' },
154
+ { id: 'new-visitors', label: t('page.marketing.overview.kpis.new_visitors'), value: totals?.newVisitors ?? 0, format: 'number', icon: 'i-ph-user-plus' },
155
+ { id: 'custom-events', label: t('page.marketing.overview.kpis.custom_events'), value: totals?.customEvents ?? 0, format: 'number', icon: 'i-ph-cursor-click' },
156
+ { id: 'bounce-rate', label: t('page.marketing.overview.kpis.bounce_rate'), value: bounceRate, format: 'percent', icon: 'i-ph-arrow-u-up-left' },
157
+ { id: 'avg-session-duration', label: t('page.marketing.overview.kpis.avg_session_duration'), display: formatDuration(avgDuration), icon: 'i-ph-timer' },
158
+ { id: 'pages-per-session', label: t('page.marketing.overview.kpis.pages_per_session'), value: pagesPerSession, format: 'number', icon: 'i-ph-stack' },
159
+ ]
160
+ })
161
+
162
+ // --- Traffic chart ----------------------------------------------------------
163
+
164
+ function utcMidnightToday(): number {
165
+ const now = new Date()
166
+ return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
167
+ }
168
+
169
+ /** Every day of the window, so days without traffic plot at zero. */
170
+ const windowDays = computed(() => {
171
+ const today = utcMidnightToday()
172
+ const count = windowLength.value
173
+ return Array.from({ length: count }, (_, i) => today - (count - 1 - i) * MS_PER_DAY)
174
+ })
175
+
176
+ const trafficSeries = computed(() => {
177
+ const days = overview.value?.days ?? []
178
+ const byDay = new Map(days.map(d => [d.day, d]))
179
+ const point = (day: number, pick: (v: { pageviews: number, sessions: number }) => number) => ({
180
+ x: new Date(day),
181
+ y: pick(byDay.get(day) ?? { pageviews: 0, sessions: 0 }),
182
+ })
183
+ return [
184
+ {
185
+ name: t('page.marketing.overview.traffic.pageviews'),
186
+ data: windowDays.value.map(day => point(day, v => v.pageviews)),
187
+ },
188
+ {
189
+ name: t('page.marketing.overview.traffic.sessions'),
190
+ data: windowDays.value.map(day => point(day, v => v.sessions)),
191
+ },
192
+ ]
193
+ })
194
+
195
+ const hasTraffic = computed(() => (overview.value?.totals.pageviews ?? 0) > 0)
196
+
197
+ // --- Devices donut ----------------------------------------------------------
198
+
199
+ const deviceRecords = computed(() =>
200
+ Object.entries(overview.value?.topDevices ?? {})
201
+ .sort((a, b) => b[1] - a[1])
202
+ .map(([label, value]) => ({ label, value })),
203
+ )
204
+
205
+ // --- Top lists --------------------------------------------------------------
206
+
207
+ /**
208
+ * `linkTo` is only passed for pages: a path has a surface of its own (its
209
+ * heatmap), while a referrer or a browser has nowhere to lead.
210
+ */
211
+ function toTopItems(
212
+ record: Record<string, number> | undefined,
213
+ linkTo?: (key: string) => ReturnType<typeof pagesLink>,
214
+ ) {
215
+ return Object.entries(record ?? {})
216
+ .sort((a, b) => b[1] - a[1])
217
+ .slice(0, TOP_LIST_LIMIT)
218
+ .map(([label, value]) => ({
219
+ id: label,
220
+ title: label,
221
+ value,
222
+ ...(linkTo ? { to: linkTo(label) } : {}),
223
+ }))
224
+ }
225
+
226
+ const regionNames = computed(() => new Intl.DisplayNames([locale.value], { type: 'region' }))
227
+
228
+ /** Rollup keys are ISO codes but historical map entries can be anything, and
229
+ * `DisplayNames.of` throws on a malformed region code. */
230
+ function countryLabel(code: string): string {
231
+ try {
232
+ return regionNames.value.of(code) ?? code
233
+ }
234
+ catch {
235
+ return code
236
+ }
237
+ }
238
+
239
+ const languageNames = computed(() => new Intl.DisplayNames([locale.value], { type: 'language' }))
240
+
241
+ /** Same guard as countries: keys are whatever `navigator.language` reported. */
242
+ function languageLabel(tag: string): string {
243
+ try {
244
+ return languageNames.value.of(tag) ?? tag
245
+ }
246
+ catch {
247
+ return tag
248
+ }
249
+ }
250
+
251
+ /** Channel keys localize on the campaigns page's labels; a key minted outside
252
+ * the known five still shows raw. */
253
+ function channelLabel(channel: string): string {
254
+ const key = `page.marketing.campaigns.channels.${channel}`
255
+ return te(key) ? t(key) : channel
256
+ }
257
+
258
+ const groupCards = computed(() => {
259
+ const toPage = (path: string) =>
260
+ pagesLink({ website: selectedId.value, path, period: period.value })
261
+ const label = (key: string) => t(`page.marketing.overview.groups.${key}`)
262
+ return [
263
+ // Entry and exit are paths too, so every row leads to the page surface.
264
+ {
265
+ id: 'content',
266
+ title: label('content.title'),
267
+ stateKey: 'content_tab',
268
+ footerLabel: label('content.view_all'),
269
+ footerTo: pagesLink({ website: selectedId.value, period: period.value }),
270
+ tabs: [
271
+ { id: 'pages', label: label('content.tabs.pages'), items: toTopItems(overview.value?.topPages, toPage) },
272
+ { id: 'entry', label: label('content.tabs.entry'), items: toTopItems(overview.value?.topEntryPages, toPage) },
273
+ { id: 'exit', label: label('content.tabs.exit'), items: toTopItems(overview.value?.topExitPages, toPage) },
274
+ ],
275
+ },
276
+ // No UTM tab: the campaigns table owns that detail, the footer leads there.
277
+ {
278
+ id: 'acquisition',
279
+ title: label('acquisition.title'),
280
+ stateKey: 'acquisition_tab',
281
+ footerLabel: label('acquisition.view_all'),
282
+ footerTo: campaignsLink({ website: selectedId.value, period: period.value }),
283
+ tabs: [
284
+ {
285
+ id: 'channels',
286
+ label: label('acquisition.tabs.channels'),
287
+ items: toTopItems(overview.value?.topChannels).map(item =>
288
+ ({ ...item, title: channelLabel(item.title) })),
289
+ },
290
+ { id: 'referrers', label: label('acquisition.tabs.referrers'), items: toTopItems(overview.value?.topReferrers) },
291
+ ],
292
+ },
293
+ {
294
+ id: 'audience',
295
+ title: label('audience.title'),
296
+ stateKey: 'audience_tab',
297
+ tabs: [
298
+ { id: 'browsers', label: label('audience.tabs.browsers'), items: toTopItems(overview.value?.topBrowsers) },
299
+ {
300
+ id: 'languages',
301
+ label: label('audience.tabs.languages'),
302
+ items: toTopItems(overview.value?.topLanguages).map(item =>
303
+ ({ ...item, title: languageLabel(item.title) })),
304
+ },
305
+ // Without a GeoIP database the map can only ever be empty — no tab
306
+ // at all beats a permanently empty one.
307
+ ...(overview.value?.geoipEnabled
308
+ ? [{
309
+ id: 'countries',
310
+ label: label('audience.tabs.countries'),
311
+ items: toTopItems(overview.value?.topCountries).map(item =>
312
+ ({ ...item, title: countryLabel(item.title) })),
313
+ }]
314
+ : []),
315
+ ],
316
+ },
317
+ // The names behind the custom-events KPI: which CTA gets clicked.
318
+ {
319
+ id: 'events',
320
+ title: label('events.title'),
321
+ footerLabel: label('events.view_all'),
322
+ footerTo: funnelsLink({ website: selectedId.value, period: period.value }),
323
+ tabs: [
324
+ { id: 'events', label: label('events.title'), items: toTopItems(overview.value?.topEvents) },
325
+ ],
326
+ },
327
+ ]
328
+ })
329
+ </script>
330
+
331
+ <template>
332
+ <div class="space-y-6 p-6">
333
+ <div class="flex flex-wrap items-center justify-between gap-4">
334
+ <div>
335
+ <h1 class="text-xl font-semibold">{{ t('page.marketing.overview.title') }}</h1>
336
+ <p class="text-sm text-muted">{{ t('page.marketing.overview.subtitle') }}</p>
337
+ </div>
338
+ <div class="flex items-center gap-3">
339
+ <USelect
340
+ v-if="websites.length > 1"
341
+ v-model="selectedId"
342
+ :items="websiteItems"
343
+ class="w-56"
344
+ />
345
+ <UButton
346
+ v-if="selectedId"
347
+ icon="i-ph-code"
348
+ color="neutral"
349
+ variant="ghost"
350
+ size="sm"
351
+ :label="t('page.marketing.websites.snippet.button')"
352
+ @click="showSnippet"
353
+ />
354
+ <UButton
355
+ v-if="websites.length > 0"
356
+ icon="i-ph-plus"
357
+ color="neutral"
358
+ variant="ghost"
359
+ size="sm"
360
+ :label="t('page.marketing.websites.create')"
361
+ @click="createWebsite"
362
+ />
363
+ <DmsPeriodSelector
364
+ :id="PERIOD_SCOPE"
365
+ :component-id="childId('period')"
366
+ :page-id="props.pageId"
367
+ :default-preset="DEFAULT_PERIOD_PRESET"
368
+ :presets="MARKETING_PERIOD_PRESETS"
369
+ :comparisons="['none']"
370
+ :show-range-label="false"
371
+ />
372
+ <UButton
373
+ icon="i-ph-arrow-clockwise"
374
+ color="neutral"
375
+ variant="subtle"
376
+ size="sm"
377
+ :loading="loading"
378
+ @click="loadOverview"
379
+ />
380
+ </div>
381
+ </div>
382
+
383
+ <div v-if="failed" class="flex flex-col items-start gap-3">
384
+ <UAlert
385
+ color="error"
386
+ variant="subtle"
387
+ icon="i-ph-warning-circle"
388
+ :title="t('page.marketing.overview.error.title')"
389
+ :description="t('page.marketing.overview.error.description')"
390
+ />
391
+ <UButton
392
+ icon="i-ph-arrow-clockwise"
393
+ :label="t('page.marketing.overview.retry')"
394
+ color="neutral"
395
+ variant="subtle"
396
+ size="sm"
397
+ @click="reload"
398
+ />
399
+ </div>
400
+
401
+ <DmsMarketingNoWebsiteState
402
+ v-else-if="!loading && websites.length === 0"
403
+ icon="i-ph-chart-line-up"
404
+ :title="t('page.marketing.overview.empty.title')"
405
+ :description="t('page.marketing.overview.empty.description')"
406
+ @create="createWebsite"
407
+ />
408
+
409
+ <!-- Only before the first answer: a refetch keeps the dashboard on screen
410
+ and spins the refresh button instead. -->
411
+ <USkeleton v-else-if="loading && !overview" class="h-96 w-full" />
412
+
413
+ <template v-else>
414
+ <div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
415
+ <template v-for="kpi in kpis" :key="kpi.id">
416
+ <DmsMarketingTextKpiCard
417
+ v-if="kpi.display !== undefined"
418
+ :title="kpi.label"
419
+ :value="kpi.display"
420
+ :icon="kpi.icon"
421
+ />
422
+ <DmsKpiCard
423
+ v-else
424
+ :component-id="childId(`kpi-${kpi.id}`)"
425
+ :page-id="props.pageId"
426
+ variant="stat"
427
+ :title="kpi.label"
428
+ :icon="kpi.icon"
429
+ :static-value="kpi.value"
430
+ :show-delta="false"
431
+ :value-format="kpi.format"
432
+ />
433
+ </template>
434
+ </div>
435
+
436
+ <div class="grid gap-4 lg:grid-cols-3">
437
+ <UCard class="lg:col-span-2">
438
+ <template #header>
439
+ <div>
440
+ <p class="font-medium">{{ t('page.marketing.overview.traffic.title') }}</p>
441
+ <p class="text-sm text-muted">{{ t('page.marketing.overview.traffic.subtitle') }}</p>
442
+ </div>
443
+ </template>
444
+ <DmsChart
445
+ v-if="hasTraffic"
446
+ :component-id="childId('traffic')"
447
+ :page-id="props.pageId"
448
+ type="area"
449
+ :static-dataset="trafficSeries"
450
+ :smooth="true"
451
+ :show-legend="true"
452
+ xaxis-type="datetime"
453
+ height="300px"
454
+ />
455
+ <p v-else class="py-12 text-center text-sm text-muted">
456
+ {{ t('page.marketing.overview.traffic.empty') }}
457
+ </p>
458
+ </UCard>
459
+
460
+ <UCard>
461
+ <template #header>
462
+ <p class="font-medium">{{ t('page.marketing.overview.devices.title') }}</p>
463
+ </template>
464
+ <DmsChart
465
+ v-if="deviceRecords.length > 0"
466
+ :component-id="childId('devices')"
467
+ :page-id="props.pageId"
468
+ type="donut"
469
+ :static-dataset="deviceRecords"
470
+ height="300px"
471
+ :show-total="true"
472
+ />
473
+ <p v-else class="py-12 text-center text-sm text-muted">
474
+ {{ t('page.marketing.overview.devices.empty') }}
475
+ </p>
476
+ </UCard>
477
+ </div>
478
+
479
+ <div class="grid gap-4 md:grid-cols-2">
480
+ <DmsMarketingTopListTabsCard
481
+ v-for="card in groupCards"
482
+ :key="card.id"
483
+ :title="card.title"
484
+ :tabs="card.tabs"
485
+ :state-key="card.stateKey"
486
+ :empty-label="t('page.marketing.overview.groups.empty')"
487
+ :footer-label="card.footerLabel"
488
+ :footer-to="card.footerTo"
489
+ />
490
+ </div>
491
+ </template>
492
+ </div>
493
+ </template>