@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,378 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import {
4
+ useDmsRoute,
5
+ useDmsRouter,
6
+ useI18n,
7
+ } from '#dms/frontend-module'
8
+ import { useChildId } from '../composables/useChildId'
9
+ import { useLatestRequest } from '../composables/useLatestRequest'
10
+ import {
11
+ useMarketingApi,
12
+ type MarketingPages,
13
+ } from '../composables/useMarketingApi'
14
+ import {
15
+ MARKETING_PERIOD_PRESETS,
16
+ ndToPreset,
17
+ useMarketingPeriod,
18
+ } from '../composables/useMarketingPeriod'
19
+ import { funnelsLink, pagesLink } from '../composables/useMarketingRoutes'
20
+ import { useMarketingWebsites } from '../composables/useMarketingWebsites'
21
+
22
+ /**
23
+ * Tracked pages of a site: the inventory on the left, the clicks and scroll
24
+ * depth of the selected page on the right.
25
+ *
26
+ * The surface is deliberately keyed on nothing but a website id and a bare
27
+ * pathname the tracker itself reported — no route registry, no sitemap, no
28
+ * build manifest, no framework convention. That is what makes it hold for any
29
+ * tracked site, whatever it is built with.
30
+ *
31
+ * The search box is also a path field, and that is not a fallback: a day keeps
32
+ * at most MAX_ENTRIES_PER_TOP_MAP paths and older days keep counters only, so
33
+ * a page can be missing from the inventory while its clicks are perfectly
34
+ * queryable — the backend matches the path exactly either way.
35
+ */
36
+ const PERIOD_SCOPE = 'dms-marketing-pages'
37
+ const SEARCH_DEBOUNCE_MS = 300
38
+
39
+ // Declared, not inherited as attributes: the DMS page renderer hands every
40
+ // component its identity, and an undeclared prop lands on the root element.
41
+ const props = withDefaults(
42
+ defineProps<{ componentId?: string, pageId?: string }>(),
43
+ { componentId: 'marketing-pages', pageId: 'marketing' },
44
+ )
45
+
46
+ const childId = useChildId(() => props.componentId)
47
+
48
+ const api = useMarketingApi()
49
+ const { t } = useI18n()
50
+ const route = useDmsRoute()
51
+ const router = useDmsRouter()
52
+
53
+ const queryParam = (key: string): string => {
54
+ const value = route.query[key]
55
+ return typeof value === 'string' ? value : ''
56
+ }
57
+
58
+ // Read once: the selector seeds its preset at init and never reactively, so a
59
+ // deep link's window has to be resolved before it renders.
60
+ const initialPreset = ndToPreset(queryParam('period'))
61
+
62
+ const {
63
+ websites,
64
+ items: websiteItems,
65
+ selectedId: selectedWebsiteId,
66
+ selected: selectedWebsite,
67
+ loading: loadingWebsites,
68
+ failed: websitesFailed,
69
+ load: loadWebsites,
70
+ create: createWebsite,
71
+ } = useMarketingWebsites(queryParam('website'))
72
+
73
+ // `undefined`, not null: this is what DmsMasterDetail's model is typed on.
74
+ const selectedPath = ref<string | undefined>(queryParam('path') || undefined)
75
+ const search = ref('')
76
+
77
+ const period = useMarketingPeriod(PERIOD_SCOPE)
78
+
79
+ let searchTimer: ReturnType<typeof setTimeout> | null = null
80
+
81
+ const {
82
+ data: inventory,
83
+ loading: loadingPages,
84
+ failed: pagesFailed,
85
+ run: loadPages,
86
+ } = useLatestRequest<MarketingPages>(() =>
87
+ // Server-side there is no session to authenticate with: the fetch belongs to
88
+ // the client, like every other read here.
89
+ selectedWebsiteId.value && !import.meta.env.SSR
90
+ ? api.listPages(selectedWebsiteId.value, period.value, search.value.trim())
91
+ : null,
92
+ )
93
+
94
+ const pages = computed(() => inventory.value?.pages ?? [])
95
+ const truncated = computed(() => inventory.value?.truncated ?? false)
96
+ const sampleRate = computed(() => inventory.value?.heatmapSampleRate ?? 0)
97
+ const trackerEnabled = computed(() => inventory.value?.trackerEnabled ?? true)
98
+
99
+ // Two failures, two flags: the inventory fetch clears its own error on every
100
+ // attempt, and must not clear the one that says the site list never loaded.
101
+ const failed = computed(() => websitesFailed.value || pagesFailed.value)
102
+
103
+ // Land on something rather than on an empty pane, but never override a path
104
+ // that was linked to or typed. Watching the answer rather than writing from
105
+ // the fetch keeps superseded ones out: only the newest ever reaches here.
106
+ watch(inventory, (data) => {
107
+ if (!selectedPath.value) {
108
+ selectedPath.value = data?.pages[0]?.path
109
+ }
110
+ })
111
+
112
+ /**
113
+ * Retry entry point. Both fetches, in order, because either can be the one
114
+ * that failed: on a deep link the inventory runs even when the site list did
115
+ * not, and reloading the list alone leaves the selection unchanged, so the
116
+ * watcher never fires and the inventory is never retried.
117
+ */
118
+ async function reload() {
119
+ if (websites.value.length === 0) {
120
+ await loadWebsites()
121
+ }
122
+ await loadPages()
123
+ }
124
+
125
+ onMounted(loadWebsites)
126
+
127
+ // The watcher owns every inventory fetch, including the first: seeding it from
128
+ // onMounted as well would issue the initial request twice on a bare link, and
129
+ // not at all on a link that already names the site the list would have picked.
130
+ watch([selectedWebsiteId, period], ([websiteId], previous) => {
131
+ // Switching site invalidates the path — the same URL on another site is
132
+ // another page, and usually does not exist there. Only a real site-to-site
133
+ // change may clear: the immediate run hands multi-source watchers an EMPTY
134
+ // previous array (truthy — `previous &&` does not guard it), and the site
135
+ // list resolving undefined → id is not a switch either. Both carry a linked
136
+ // path that must survive.
137
+ const previousWebsiteId = previous?.[0]
138
+ if (previousWebsiteId !== undefined && websiteId !== previousWebsiteId) {
139
+ selectedPath.value = undefined
140
+ }
141
+ void loadPages()
142
+ }, { immediate: true })
143
+
144
+ watch(search, () => {
145
+ if (searchTimer) {
146
+ clearTimeout(searchTimer)
147
+ }
148
+ searchTimer = setTimeout(() => void loadPages(), SEARCH_DEBOUNCE_MS)
149
+ })
150
+
151
+ onBeforeUnmount(() => {
152
+ if (searchTimer) {
153
+ clearTimeout(searchTimer)
154
+ }
155
+ })
156
+
157
+ /** Enter inspects what was typed, whether or not the inventory lists it. */
158
+ function inspectTyped(): void {
159
+ const typed = search.value.trim()
160
+ if (!typed.startsWith('/')) {
161
+ return
162
+ }
163
+ selectedPath.value = typed
164
+ }
165
+
166
+ // State lives in the URL so a heatmap can be linked to. Query-only navigation
167
+ // does not remount the page, so this is free.
168
+ watch([selectedWebsiteId, selectedPath, period], () => {
169
+ void router.replace(
170
+ pagesLink({
171
+ website: selectedWebsiteId.value,
172
+ path: selectedPath.value,
173
+ period: period.value,
174
+ }),
175
+ )
176
+ })
177
+
178
+ const pageviewsBySelected = computed(
179
+ () => pages.value.find(page => page.path === selectedPath.value)?.pageviews,
180
+ )
181
+
182
+ const listItems = computed(() =>
183
+ pages.value.map((page) => {
184
+ // Sampled PAGE LOADS, not clicks: the tracker draws once per page load,
185
+ // so this says whether a heatmap can exist at all — a click estimate here
186
+ // would contradict the real count shown beside it in the detail pane.
187
+ const sampled = Math.round(page.pageviews * sampleRate.value)
188
+ // Two counts, two plural choices: one message could only ever inflect on
189
+ // one of them, and the two rarely agree.
190
+ return {
191
+ value: page.path,
192
+ label: page.path,
193
+ sublabel: [
194
+ t('page.marketing.pages.row_hint.views', { count: page.pageviews }, page.pageviews),
195
+ t('page.marketing.pages.row_hint.sampled', { count: sampled }, sampled),
196
+ ].join(' · '),
197
+ }
198
+ }),
199
+ )
200
+
201
+ // --- Site-wide reset --------------------------------------------------------
202
+
203
+ const { confirm } = useConfirm()
204
+ const toast = useToast()
205
+ const resettingHeatmaps = ref(false)
206
+
207
+ /**
208
+ * The detail pane refetches on this bump: a site-wide reset changes none of
209
+ * the keys it watches (site, path, period), so without it the pane would keep
210
+ * showing clicks that no longer exist. The inventory needs no reload — its
211
+ * pageview counts come from the rollup, which a click purge never touches.
212
+ */
213
+ const heatmapRefreshToken = ref(0)
214
+
215
+ async function resetSiteHeatmaps() {
216
+ const site = selectedWebsite.value
217
+ if (!site) {
218
+ return
219
+ }
220
+ const confirmed = await confirm({
221
+ title: t('page.marketing.pages.reset.site_title'),
222
+ description: t('page.marketing.pages.reset.site_description', { name: site.name }),
223
+ confirmLabel: t('page.marketing.pages.reset.confirm'),
224
+ confirmColor: 'error',
225
+ })
226
+ if (!confirmed) {
227
+ return
228
+ }
229
+ resettingHeatmaps.value = true
230
+ try {
231
+ const { deleted } = await api.resetHeatmap(site._id)
232
+ toast.add({
233
+ color: 'success',
234
+ title: t('page.marketing.pages.reset.done', { count: deleted }, deleted),
235
+ })
236
+ heatmapRefreshToken.value++
237
+ }
238
+ catch {
239
+ toast.add({ color: 'error', title: t('page.marketing.pages.reset.error') })
240
+ }
241
+ finally {
242
+ resettingHeatmaps.value = false
243
+ }
244
+ }
245
+ </script>
246
+
247
+ <template>
248
+ <div class="space-y-6 p-6">
249
+ <div class="flex flex-wrap items-center justify-between gap-4">
250
+ <div>
251
+ <h1 class="text-xl font-semibold">{{ t('page.marketing.pages.title') }}</h1>
252
+ <p class="text-sm text-muted">{{ t('page.marketing.pages.subtitle') }}</p>
253
+ </div>
254
+ <div class="flex flex-wrap items-center gap-3">
255
+ <USelect
256
+ v-if="websites.length > 1"
257
+ v-model="selectedWebsiteId"
258
+ :items="websiteItems"
259
+ class="w-56"
260
+ />
261
+ <UInput
262
+ v-model="search"
263
+ icon="i-ph-magnifying-glass"
264
+ class="w-64"
265
+ :placeholder="t('page.marketing.pages.search_placeholder')"
266
+ @keyup.enter="inspectTyped"
267
+ />
268
+ <DmsPeriodSelector
269
+ :id="PERIOD_SCOPE"
270
+ :component-id="childId('period')"
271
+ :page-id="props.pageId"
272
+ :default-preset="initialPreset"
273
+ :presets="MARKETING_PERIOD_PRESETS"
274
+ :comparisons="['none']"
275
+ :show-range-label="false"
276
+ />
277
+ <UButton
278
+ v-if="selectedWebsiteId"
279
+ icon="i-ph-funnel"
280
+ color="neutral"
281
+ variant="ghost"
282
+ size="sm"
283
+ :to="funnelsLink({ website: selectedWebsiteId })"
284
+ :label="t('page.marketing.pages.to_funnels')"
285
+ />
286
+ <UButton
287
+ v-if="selectedWebsiteId"
288
+ icon="i-ph-eraser"
289
+ color="neutral"
290
+ variant="ghost"
291
+ size="sm"
292
+ :loading="resettingHeatmaps"
293
+ :label="t('page.marketing.pages.reset.site_button')"
294
+ @click="resetSiteHeatmaps"
295
+ />
296
+ </div>
297
+ </div>
298
+
299
+ <UAlert
300
+ v-if="!trackerEnabled"
301
+ icon="i-ph-plugs"
302
+ color="warning"
303
+ variant="subtle"
304
+ :title="t('page.marketing.pages.tracking_off.title')"
305
+ :description="t('page.marketing.pages.tracking_off.description')"
306
+ />
307
+
308
+ <div v-if="failed" class="flex flex-col items-start gap-3">
309
+ <UAlert
310
+ color="error"
311
+ variant="subtle"
312
+ icon="i-ph-warning-circle"
313
+ :title="t('page.marketing.pages.error')"
314
+ :description="t('page.marketing.pages.error_hint')"
315
+ />
316
+ <UButton
317
+ icon="i-ph-arrow-clockwise"
318
+ :label="t('page.marketing.pages.retry')"
319
+ color="neutral"
320
+ variant="subtle"
321
+ size="sm"
322
+ @click="reload"
323
+ />
324
+ </div>
325
+
326
+ <USkeleton v-else-if="loadingWebsites" class="h-96 w-full" />
327
+
328
+ <DmsMarketingNoWebsiteState
329
+ v-else-if="websites.length === 0"
330
+ icon="i-ph-globe"
331
+ :title="t('page.marketing.pages.no_website.title')"
332
+ :description="t('page.marketing.pages.no_website.description')"
333
+ @create="createWebsite"
334
+ />
335
+
336
+ <template v-else>
337
+ <UAlert
338
+ v-if="truncated"
339
+ icon="i-ph-list"
340
+ color="neutral"
341
+ variant="subtle"
342
+ :title="t('page.marketing.pages.list_truncated')"
343
+ :description="t('page.marketing.pages.list_truncated_hint')"
344
+ />
345
+
346
+ <!-- An empty inventory is not an empty surface: a typed path is
347
+ inspectable whether or not the rollup ever listed it. -->
348
+ <UAlert
349
+ v-if="!loadingPages && pages.length === 0 && !selectedPath"
350
+ icon="i-ph-cursor-click"
351
+ :title="t('page.marketing.pages.no_pages.title')"
352
+ :description="t('page.marketing.pages.no_pages.description')"
353
+ />
354
+
355
+ <DmsMasterDetail
356
+ v-else
357
+ v-model="selectedPath"
358
+ :items="listItems"
359
+ :list-label="t('page.marketing.pages.inventory')"
360
+ >
361
+ <DmsMarketingPagePreview
362
+ v-if="selectedPath"
363
+ :website="selectedWebsite"
364
+ :path="selectedPath"
365
+ :period="period"
366
+ :sample-rate="sampleRate"
367
+ :tracker-enabled="trackerEnabled"
368
+ :pageviews="pageviewsBySelected"
369
+ :refresh-token="heatmapRefreshToken"
370
+ @website-updated="loadWebsites"
371
+ />
372
+ <div v-else class="p-8 text-center text-sm text-muted">
373
+ {{ t('page.marketing.pages.select_hint') }}
374
+ </div>
375
+ </DmsMasterDetail>
376
+ </template>
377
+ </div>
378
+ </template>
@@ -0,0 +1,158 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { useI18n } from '#dms/frontend-module'
4
+ import { heatColorAt } from '../utils/heatmapPalette'
5
+
6
+ /**
7
+ * Scroll-depth overlay of one tracked page: a vertical gradient of how many
8
+ * measured views saw each band of the page, reach marks at fixed shares, and
9
+ * a readout following the pointer.
10
+ *
11
+ * Drawn in the pane's scaled space as a sibling of the scaled surface, not
12
+ * inside it: depth only varies vertically, so fractions of the pane's height
13
+ * are fractions of the document — and the labels keep their font size at
14
+ * every zoom instead of shrinking with the page.
15
+ *
16
+ * `foldRatio` is where depth 0 sits. A recorded depth is a fraction of the
17
+ * visitor's *scrollable range*, whose zero already has the first viewport on
18
+ * screen; the probe viewport stands in for visitor viewports this surface
19
+ * cannot know. Bands are trustworthy, pixels are not. Above the fold the
20
+ * shade is flat — every measured view starts there.
21
+ */
22
+ const props = defineProps<{
23
+ /** reached[d] = views whose max depth is ≥ d percent, d = 0..100. */
24
+ reached: number[]
25
+ /** Views that scrolled at all — the denominator of every share shown. */
26
+ views: number
27
+ /** Height of the overlay in the pane's own pixels (already scaled). */
28
+ height: number
29
+ /** 0-1 fraction of the height where depth 0 sits (the first viewport). */
30
+ foldRatio: number
31
+ }>()
32
+
33
+ const DEPTH_SCALE = 100
34
+ /** Gradient resolution; the distribution is monotonic, so 5% steps lose
35
+ * nothing a reader could see. */
36
+ const GRADIENT_STEP = 5
37
+ const OVERLAY_ALPHA = 0.35
38
+ /** Shares worth a labelled line — the "50% of visitors stop here" reading. */
39
+ const REACH_MARKS = [75, 50, 25]
40
+
41
+ const { t } = useI18n()
42
+
43
+ function reachAt(depth: number): number {
44
+ if (props.views <= 0) {
45
+ return 0
46
+ }
47
+ const clamped = Math.min(Math.max(depth, 0), DEPTH_SCALE)
48
+ return (props.reached[clamped] ?? 0) / props.views
49
+ }
50
+
51
+ /** Depth percent → 0-1 fraction of the drawn height. */
52
+ function yRatio(depth: number): number {
53
+ return props.foldRatio + (depth / DEPTH_SCALE) * (1 - props.foldRatio)
54
+ }
55
+
56
+ const gradient = computed(() => {
57
+ const colorFor = (fraction: number) => heatColorAt(fraction, OVERLAY_ALPHA)
58
+ const stops = [`${colorFor(1)} 0%`]
59
+ for (let depth = 0; depth <= DEPTH_SCALE; depth += GRADIENT_STEP) {
60
+ stops.push(`${colorFor(reachAt(depth))} ${(yRatio(depth) * 100).toFixed(2)}%`)
61
+ }
62
+ return `linear-gradient(to bottom, ${stops.join(', ')})`
63
+ })
64
+
65
+ /** Deepest point at least `percent`% of the measured views got to. */
66
+ function depthReachedBy(percent: number): number {
67
+ for (let depth = DEPTH_SCALE; depth >= 0; depth--) {
68
+ if (reachAt(depth) >= percent / DEPTH_SCALE) {
69
+ return depth
70
+ }
71
+ }
72
+ return 0
73
+ }
74
+
75
+ const marks = computed(() => {
76
+ const placed: Array<{ percent: number, y: number }> = []
77
+ for (const percent of REACH_MARKS) {
78
+ const y = yRatio(depthReachedBy(percent)) * props.height
79
+ // Collapsed shares (everyone stops at one depth) would stack identical
80
+ // lines; the strongest claim wins.
81
+ if (placed.every(mark => Math.abs(mark.y - y) > 1)) {
82
+ placed.push({ percent, y })
83
+ }
84
+ }
85
+ return placed
86
+ })
87
+
88
+ const foldY = computed(() => props.foldRatio * props.height)
89
+ const showFold = computed(() => props.foldRatio > 0 && props.foldRatio < 1)
90
+
91
+ const hover = ref<{ y: number, percent: number } | null>(null)
92
+
93
+ function onPointerMove(event: PointerEvent): void {
94
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
95
+ if (rect.height <= 0 || props.foldRatio >= 1) {
96
+ return
97
+ }
98
+ const ratio = Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
99
+ const depth = ratio <= props.foldRatio
100
+ ? 0
101
+ : Math.round(((ratio - props.foldRatio) / (1 - props.foldRatio)) * DEPTH_SCALE)
102
+ hover.value = {
103
+ y: ratio * props.height,
104
+ percent: Math.round(reachAt(depth) * 100),
105
+ }
106
+ }
107
+ </script>
108
+
109
+ <template>
110
+ <div
111
+ class="absolute inset-0"
112
+ @pointermove="onPointerMove"
113
+ @pointerleave="hover = null"
114
+ >
115
+ <div class="pointer-events-none absolute inset-0" :style="{ background: gradient }" />
116
+
117
+ <div
118
+ v-if="showFold"
119
+ class="pointer-events-none absolute inset-x-0"
120
+ :style="{ top: `${foldY}px`, borderTop: '1px solid rgba(255, 255, 255, 0.55)' }"
121
+ >
122
+ <span
123
+ class="absolute left-2 top-0 -translate-y-1/2 rounded-full bg-default/90 px-2 py-0.5 text-xs text-muted shadow"
124
+ >
125
+ {{ t('page.marketing.pages.scroll.fold_line') }}
126
+ </span>
127
+ </div>
128
+
129
+ <div
130
+ v-for="mark in marks"
131
+ :key="mark.percent"
132
+ class="pointer-events-none absolute inset-x-0"
133
+ :style="{
134
+ top: `${mark.y}px`,
135
+ borderTop: '2px dashed rgba(255, 255, 255, 0.85)',
136
+ filter: 'drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4))',
137
+ }"
138
+ >
139
+ <span
140
+ class="absolute right-2 top-0 -translate-y-1/2 rounded-full bg-default/90 px-2 py-0.5 text-xs font-medium text-highlighted shadow"
141
+ >
142
+ {{ t('page.marketing.pages.scroll.reach_line', { percent: mark.percent }) }}
143
+ </span>
144
+ </div>
145
+
146
+ <div
147
+ v-if="hover"
148
+ class="pointer-events-none absolute inset-x-0"
149
+ :style="{ top: `${hover.y}px`, borderTop: '1px solid rgba(255, 255, 255, 0.95)' }"
150
+ >
151
+ <span
152
+ class="absolute left-2 top-0 -translate-y-1/2 rounded-full bg-default/90 px-2 py-0.5 text-xs font-medium text-highlighted shadow"
153
+ >
154
+ {{ t('page.marketing.pages.scroll.reach_line', { percent: hover.percent }) }}
155
+ </span>
156
+ </div>
157
+ </div>
158
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The stat-variant DmsKpiCard look for a value that is not a plain number:
4
+ * dms-ui's card only formats numeric values (number / currency / percent),
5
+ * and a session duration reads as "1m 32s", which is none of them. Classes
6
+ * are copied from KpiCard's stat variant so the card sits in the same row
7
+ * without a seam.
8
+ */
9
+ defineProps<{ title: string, value: string, icon: string }>()
10
+ </script>
11
+
12
+ <template>
13
+ <DmsCard :padded="false" class="p-4 sm:p-5">
14
+ <div class="flex items-start justify-between gap-3">
15
+ <div class="min-w-0 flex-1">
16
+ <p class="text-dimmed truncate font-mono text-[10px] font-medium uppercase tracking-widest">
17
+ {{ title }}
18
+ </p>
19
+ <p class="mt-1 text-2xl font-semibold tracking-tight tabular-nums">
20
+ {{ value }}
21
+ </p>
22
+ </div>
23
+ <div class="text-dimmed flex shrink-0 items-center justify-center">
24
+ <UIcon :name="icon" class="size-4" aria-hidden="true" />
25
+ </div>
26
+ </div>
27
+ </DmsCard>
28
+ </template>