@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
@@ -0,0 +1,552 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
4
+ import { useLatestRequest } from '../composables/useLatestRequest'
5
+ import {
6
+ useMarketingApi,
7
+ type MarketingFunnelListItem,
8
+ type MarketingPages,
9
+ type MarketingPageEntry,
10
+ type MarketingPagesSort,
11
+ type MarketingWebsite,
12
+ } from '../composables/useMarketingApi'
13
+ import { useMarketingContext } from '../composables/useMarketingContext'
14
+ import { installLink } from '../composables/useMarketingRoutes'
15
+ import { PERIOD_SCOPE } from '../constants'
16
+ import { parseFunnelStepsValue } from '../composables/useFunnelSteps'
17
+ import { formatNumber } from '../utils/format'
18
+ import {
19
+ heatmapDataLabel,
20
+ heatmapResetImpact,
21
+ normalizePagePath,
22
+ } from '../utils/heatmap'
23
+
24
+ /**
25
+ * Pages & heatmaps: the paths visitors loaded on the selected website, with
26
+ * the clicks and scroll depth of the selected one over its snapshot.
27
+ *
28
+ * Keyed on nothing but a website id and a pathname the tracker reported — no
29
+ * route registry, no sitemap — which is what makes it hold for any site. The
30
+ * filter is also a path field: a day keeps a capped number of paths, so a page
31
+ * can be missing from the list while its clicks are perfectly queryable.
32
+ */
33
+ const props = withDefaults(
34
+ defineProps<{
35
+ periodScope?: string
36
+ componentId?: string
37
+ pageId?: string
38
+ }>(),
39
+ { periodScope: PERIOD_SCOPE, componentId: undefined, pageId: undefined },
40
+ )
41
+
42
+ const SEARCH_DEBOUNCE_MS = 300
43
+ const SKELETON_ROWS = 8
44
+ /** Where the list and the preview sit side by side (Tailwind `xl`). */
45
+ const SIDE_BY_SIDE_QUERY = '(min-width: 1280px)'
46
+
47
+ const { t, locale } = useI18n()
48
+ const route = useDmsRoute()
49
+ const router = useDmsRouter()
50
+ const api = useMarketingApi()
51
+ const context = useMarketingContext()
52
+ const toast = useToast()
53
+ const { confirm } = useConfirm()
54
+
55
+ const websiteId = computed(() => context.selected.value?.id ?? '')
56
+ const period = usePeriodScope(props.periodScope)
57
+ /** The days read: a comparison change alone reads nothing again. */
58
+ const periodDays = computed(() => {
59
+ const range = period.value?.range
60
+ return range ? `${range.from.toISOString()}|${range.to.toISOString()}` : ''
61
+ })
62
+
63
+ const queryPath = () =>
64
+ typeof route.query.path === 'string' ? route.query.path : ''
65
+ const selectedPath = ref<string | undefined>(queryPath() || undefined)
66
+ const search = ref('')
67
+ const sort = ref<MarketingPagesSort>('views')
68
+
69
+ // The full row: the context carries the id, the preview needs the snapshot options.
70
+ const website = ref<MarketingWebsite | null>(null)
71
+
72
+ async function loadWebsite(): Promise<void> {
73
+ const list = await api.listWebsites()
74
+ website.value = list.find((site) => site._id === websiteId.value) ?? null
75
+ }
76
+
77
+ const {
78
+ data: inventory,
79
+ loading,
80
+ settled,
81
+ failed,
82
+ run: loadPages,
83
+ } = useLatestRequest<MarketingPages>(() =>
84
+ websiteId.value && !import.meta.env.SSR
85
+ ? api.listPages(
86
+ websiteId.value,
87
+ period.value,
88
+ search.value.trim(),
89
+ sort.value,
90
+ )
91
+ : null,
92
+ )
93
+
94
+ const funnels = ref<MarketingFunnelListItem[]>([])
95
+
96
+ async function loadFunnels(): Promise<void> {
97
+ try {
98
+ funnels.value = (await api.listFunnels(websiteId.value)).results
99
+ } catch {
100
+ funnels.value = []
101
+ }
102
+ }
103
+
104
+ watch(
105
+ [websiteId, context.refreshToken],
106
+ ([id], previous) => {
107
+ if (previous?.[0] !== undefined && id !== previous[0]) {
108
+ selectedPath.value = undefined
109
+ }
110
+ if (id) {
111
+ void loadWebsite()
112
+ void loadFunnels()
113
+ }
114
+ },
115
+ { immediate: true },
116
+ )
117
+
118
+ watch(
119
+ [websiteId, periodDays, context.refreshToken, sort],
120
+ () => void loadPages(),
121
+ { immediate: true },
122
+ )
123
+
124
+ let searchTimer: ReturnType<typeof setTimeout> | null = null
125
+ watch(search, () => {
126
+ if (searchTimer) {
127
+ clearTimeout(searchTimer)
128
+ }
129
+ searchTimer = setTimeout(() => void loadPages(), SEARCH_DEBOUNCE_MS)
130
+ })
131
+ onBeforeUnmount(() => searchTimer && clearTimeout(searchTimer))
132
+
133
+ const pages = computed(() => inventory.value?.pages ?? [])
134
+ const sampleRate = computed(() => inventory.value?.sampleRate ?? null)
135
+ /** The measured sampling share as a sentence writes it: "10%", "0.4%". */
136
+ const sampleRateLabel = computed(() => {
137
+ const rate = sampleRate.value
138
+ return rate === null
139
+ ? null
140
+ : String(
141
+ formatPercentage(rate, locale.value, {
142
+ maximumFractionDigits: rate > 0 && rate < 0.01 ? 1 : 0,
143
+ }),
144
+ )
145
+ })
146
+ const trackerEnabled = computed(() => inventory.value?.trackerEnabled ?? true)
147
+ const waiting = computed(() => context.selected.value?.state === 'waiting')
148
+
149
+ function metricOf(page: { pageviews: number; clicks: number }): number {
150
+ return sort.value === 'clicks' ? page.clicks : page.pageviews
151
+ }
152
+
153
+ const maxMetric = computed(() => Math.max(1, ...pages.value.map(metricOf)))
154
+
155
+ /**
156
+ * A pick in the list is a navigation the Back button undoes; a selection
157
+ * made for the reader (first load, website switch) only completes the URL.
158
+ * Keeps the rest of the query: the context bar mirrors its own state there.
159
+ */
160
+ function selectPath(path: string | undefined, mode: 'push' | 'replace'): void {
161
+ selectedPath.value = path
162
+ void router[mode]({ query: { ...route.query, path: path ?? undefined } })
163
+ }
164
+
165
+ /** Back and Forward move the selection with the URL. */
166
+ watch(queryPath, (path) => {
167
+ if (path && path !== selectedPath.value) {
168
+ selectedPath.value = path
169
+ }
170
+ })
171
+
172
+ // A first impression that shows the feature: the first page with clicks,
173
+ // else the first page.
174
+ watch(inventory, (data) => {
175
+ if (!selectedPath.value && data?.pages.length) {
176
+ const withClicks = data.pages.find((page) => page.clicks > 0)
177
+ selectPath((withClicks ?? data.pages[0])?.path, 'replace')
178
+ }
179
+ })
180
+
181
+ const previewWrapper = ref<HTMLElement | null>(null)
182
+
183
+ /** Stacked under the list (below xl), the preview is out of sight: bring it in. */
184
+ async function pick(path: string): Promise<void> {
185
+ selectPath(path, 'push')
186
+ if (window.matchMedia(SIDE_BY_SIDE_QUERY).matches) {
187
+ return
188
+ }
189
+ await nextTick()
190
+ previewWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
191
+ }
192
+
193
+ const typedPath = computed(() => normalizePagePath(search.value))
194
+
195
+ /** Enter inspects what was typed, listed or not, reduced like a tracked URL. */
196
+ function inspectTyped(): void {
197
+ if (typedPath.value) {
198
+ void pick(typedPath.value)
199
+ }
200
+ }
201
+
202
+ /**
203
+ * The selected path's row, kept while a filter hides it from the list: the
204
+ * preview header keeps its views.
205
+ */
206
+ const selectedPage = ref<MarketingPageEntry>()
207
+ watch([pages, selectedPath], ([list, path]) => {
208
+ const found = list.find((page) => page.path === path)
209
+ if (found || selectedPage.value?.path !== path) {
210
+ selectedPage.value = found
211
+ }
212
+ })
213
+
214
+ /** Funnels with a page step on the selected path, for the "Used in" card. */
215
+ const usedIn = computed(() =>
216
+ funnels.value.filter((funnel) =>
217
+ parseFunnelStepsValue(funnel.steps).some(
218
+ (step) => step.kind === 'url' && step.value === selectedPath.value,
219
+ ),
220
+ ),
221
+ )
222
+
223
+ const refreshToken = ref(0)
224
+
225
+ // Client-only: the server renders no session, so its markup (an empty list)
226
+ // would not match the client's and hydration would misplace the preview.
227
+ const mounted = ref(false)
228
+ onMounted(() => (mounted.value = true))
229
+
230
+ const countLabel = computed(() => {
231
+ const listed = pages.value.length
232
+ const total = inventory.value?.total ?? listed
233
+ return total > listed
234
+ ? t('page.marketing.pages.count_of', { count: listed, total }, total)
235
+ : t('page.marketing.pages.count', { count: listed }, listed)
236
+ })
237
+
238
+ const sortItems = computed(() => [
239
+ { value: 'views', label: t('page.marketing.pages.views') },
240
+ { value: 'clicks', label: t('page.marketing.pages.overlay.clicks') },
241
+ ])
242
+
243
+ async function resetSite(): Promise<void> {
244
+ const site = website.value
245
+ if (!site) {
246
+ return
247
+ }
248
+ const counts = await api.countHeatmap(site._id).catch(() => null)
249
+ await confirm({
250
+ title: t('page.marketing.pages.reset.site_title', { name: site.name }),
251
+ description: t('page.marketing.pages.reset.site_description'),
252
+ color: 'error',
253
+ icon: 'i-ph-eraser',
254
+ confirmLabel:
255
+ counts && counts.events + counts.snapshots > 0
256
+ ? t('page.marketing.pages.reset.confirm_count', {
257
+ what: heatmapDataLabel(counts, t, locale.value),
258
+ })
259
+ : t('page.marketing.pages.reset.confirm'),
260
+ confirmText: site.domain,
261
+ // A hostname is not case-sensitive: neither is the typed check.
262
+ confirmTextCaseInsensitive: true,
263
+ impact: heatmapResetImpact(counts, t, locale.value),
264
+ onConfirm: async () => {
265
+ const deleted = await api.resetHeatmap(site._id)
266
+ toast.add({
267
+ color: 'success',
268
+ title: t('page.marketing.pages.reset.site_done', { name: site.name }),
269
+ description: t('page.marketing.pages.reset.done', {
270
+ what: heatmapDataLabel(deleted, t, locale.value),
271
+ }),
272
+ })
273
+ refreshToken.value++
274
+ },
275
+ })
276
+ }
277
+
278
+ const moreItems = computed(() => [
279
+ [
280
+ {
281
+ label: t('page.marketing.pages.reset.site_button'),
282
+ icon: 'i-ph-eraser',
283
+ color: 'error' as const,
284
+ onSelect: () => void resetSite(),
285
+ },
286
+ ],
287
+ ])
288
+ </script>
289
+
290
+ <template>
291
+ <div class="flex flex-col gap-4">
292
+ <!-- The Pages page's context bar shows its own guide first; this one
293
+ serves the block placed under a bar without it. -->
294
+ <DmsEmptyState
295
+ v-if="waiting"
296
+ icon="i-ph-hourglass"
297
+ class="dms-card"
298
+ :title="
299
+ t('page.marketing.pages.waiting.title', {
300
+ website: context.selected.value?.name ?? '',
301
+ })
302
+ "
303
+ :description="t('page.marketing.pages.waiting.description')"
304
+ >
305
+ <template #actions>
306
+ <UButton
307
+ size="sm"
308
+ icon="i-ph-plugs-connected"
309
+ :label="t('page.marketing.pages.waiting.action')"
310
+ :to="installLink(websiteId)"
311
+ />
312
+ </template>
313
+ </DmsEmptyState>
314
+
315
+ <template v-else>
316
+ <!-- The preview's own strip says it when a page is shown. -->
317
+ <DmsBanner
318
+ v-if="!trackerEnabled && !(selectedPath && website)"
319
+ tone="error"
320
+ size="sm"
321
+ icon="i-ph-plugs"
322
+ :title="t('page.marketing.pages.tracking_off.title')"
323
+ :description="t('page.marketing.pages.tracking_off.description')"
324
+ />
325
+
326
+ <div
327
+ v-if="!mounted"
328
+ class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]"
329
+ aria-hidden="true"
330
+ >
331
+ <USkeleton class="h-[32rem] rounded-xl" />
332
+ <USkeleton class="h-[32rem] rounded-xl" />
333
+ </div>
334
+ <div
335
+ v-else
336
+ class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]"
337
+ >
338
+ <section
339
+ class="dms-card flex min-w-0 flex-col overflow-hidden p-0 xl:sticky xl:top-4"
340
+ >
341
+ <div class="flex items-center gap-2 border-b border-default p-3">
342
+ <UInput
343
+ v-model="search"
344
+ size="sm"
345
+ icon="i-ph-magnifying-glass"
346
+ class="flex-1"
347
+ :placeholder="t('page.marketing.pages.search_placeholder')"
348
+ @keyup.enter="inspectTyped"
349
+ />
350
+ <UDropdownMenu :items="moreItems" :content="{ align: 'end' }">
351
+ <UButton
352
+ icon="i-ph-dots-three"
353
+ size="sm"
354
+ color="neutral"
355
+ variant="ghost"
356
+ :aria-label="t('page.marketing.pages.more_list')"
357
+ />
358
+ </UDropdownMenu>
359
+ </div>
360
+ <div class="flex items-center justify-between gap-2 px-4 py-2">
361
+ <DmsEyebrow :label="countLabel" />
362
+ <DmsSegmented
363
+ v-model="sort"
364
+ :items="sortItems"
365
+ size="xs"
366
+ :aria-label="t('page.marketing.pages.sort')"
367
+ />
368
+ </div>
369
+ <DmsEmptyState
370
+ v-if="failed"
371
+ variant="error"
372
+ size="sm"
373
+ :title="t('page.marketing.common.load_error')"
374
+ >
375
+ <template #actions>
376
+ <UButton
377
+ size="xs"
378
+ color="neutral"
379
+ variant="outline"
380
+ icon="i-ph-arrow-clockwise"
381
+ :label="t('page.marketing.common.retry')"
382
+ @click="loadPages"
383
+ />
384
+ </template>
385
+ </DmsEmptyState>
386
+ <ul
387
+ v-else-if="!settled"
388
+ class="flex flex-col gap-1 p-2"
389
+ aria-hidden="true"
390
+ >
391
+ <li v-for="index in SKELETON_ROWS" :key="index" class="px-2 py-2">
392
+ <USkeleton class="h-8 w-full" />
393
+ </li>
394
+ </ul>
395
+ <DmsEmptyState
396
+ v-else-if="pages.length === 0 && search.trim()"
397
+ icon="i-ph-magnifying-glass"
398
+ size="sm"
399
+ :title="t('page.marketing.pages.no_match.title')"
400
+ :description="t('page.marketing.pages.no_match.description')"
401
+ >
402
+ <template v-if="typedPath" #actions>
403
+ <UButton
404
+ size="xs"
405
+ color="neutral"
406
+ variant="outline"
407
+ icon="i-ph-cursor-click"
408
+ :label="
409
+ t('page.marketing.pages.no_match.inspect', {
410
+ path: typedPath,
411
+ })
412
+ "
413
+ @click="inspectTyped"
414
+ />
415
+ </template>
416
+ </DmsEmptyState>
417
+ <DmsEmptyState
418
+ v-else-if="pages.length === 0"
419
+ icon="i-ph-browser"
420
+ size="sm"
421
+ :title="
422
+ t('page.marketing.pages.no_pages.title', {
423
+ website: context.selected.value?.name ?? '',
424
+ })
425
+ "
426
+ :description="t('page.marketing.pages.no_pages.description')"
427
+ />
428
+ <ul
429
+ v-else
430
+ class="flex max-h-[45vh] flex-col gap-1 overflow-y-auto p-2 xl:max-h-[70vh]"
431
+ :class="loading ? 'opacity-60' : ''"
432
+ >
433
+ <li v-for="page in pages" :key="page.path">
434
+ <button
435
+ type="button"
436
+ class="flex w-full flex-col gap-1 rounded-lg border px-3 py-2 text-left transition-colors"
437
+ :class="
438
+ page.path === selectedPath
439
+ ? 'border-primary bg-primary/10'
440
+ : 'border-transparent hover:bg-elevated'
441
+ "
442
+ :title="page.path"
443
+ :aria-current="page.path === selectedPath ? 'true' : undefined"
444
+ @click="pick(page.path)"
445
+ >
446
+ <span class="flex items-center gap-2">
447
+ <span
448
+ class="min-w-0 flex-1 truncate font-mono text-[13px]"
449
+ :class="
450
+ page.path === selectedPath
451
+ ? 'font-medium text-highlighted'
452
+ : 'text-highlighted'
453
+ "
454
+ >
455
+ {{ page.path }}
456
+ </span>
457
+ <span class="font-mono text-xs tabular-nums text-highlighted">
458
+ {{ formatNumber(metricOf(page), locale) }}
459
+ </span>
460
+ </span>
461
+ <span class="flex items-center gap-2">
462
+ <span
463
+ class="flex flex-1 items-center gap-1 text-[11px]"
464
+ :class="
465
+ page.path === selectedPath ? 'text-muted' : 'text-dimmed'
466
+ "
467
+ >
468
+ <template v-if="sort === 'views'">
469
+ <UIcon name="i-ph-cursor-click" class="size-3" />
470
+ {{
471
+ t(
472
+ 'page.marketing.pages.row_clicks',
473
+ { count: formatNumber(page.clicks, locale) },
474
+ page.clicks,
475
+ )
476
+ }}
477
+ </template>
478
+ <template v-else>
479
+ <UIcon name="i-ph-eye" class="size-3" />
480
+ {{
481
+ t(
482
+ 'page.marketing.pages.row_views',
483
+ { count: formatNumber(page.pageviews, locale) },
484
+ page.pageviews,
485
+ )
486
+ }}
487
+ </template>
488
+ </span>
489
+ <span
490
+ class="h-1 w-14 overflow-hidden rounded-full bg-elevated"
491
+ aria-hidden="true"
492
+ >
493
+ <span
494
+ class="block h-full rounded-full bg-primary"
495
+ :style="{
496
+ width: `${(metricOf(page) / maxMetric) * 100}%`,
497
+ }"
498
+ />
499
+ </span>
500
+ </span>
501
+ </button>
502
+ </li>
503
+ </ul>
504
+ <footer
505
+ class="flex items-center gap-1.5 border-t border-default px-4 py-2.5 text-xs text-dimmed"
506
+ >
507
+ <UIcon name="i-ph-info" class="size-3.5 shrink-0" />
508
+ {{
509
+ inventory?.truncated
510
+ ? t('page.marketing.pages.list_truncated', {
511
+ count: pages.length,
512
+ })
513
+ : t('page.marketing.pages.missing_hint')
514
+ }}
515
+ </footer>
516
+ </section>
517
+
518
+ <div
519
+ v-if="selectedPath && website"
520
+ ref="previewWrapper"
521
+ class="min-w-0 scroll-mt-4"
522
+ >
523
+ <DmsMarketingPagePreview
524
+ :website="website"
525
+ :path="selectedPath"
526
+ :period="period"
527
+ :sample-rate-label="sampleRateLabel"
528
+ :tracker-enabled="trackerEnabled"
529
+ :pageviews="selectedPage?.pageviews"
530
+ :refresh-token="refreshToken + context.refreshToken.value"
531
+ :used-in="usedIn"
532
+ @website-updated="loadWebsite"
533
+ @heatmap-reset="loadPages"
534
+ />
535
+ </div>
536
+ <DmsEmptyState
537
+ v-else-if="settled && pages.length > 0"
538
+ icon="i-ph-cursor-click"
539
+ class="dms-card"
540
+ :title="t('page.marketing.pages.select_hint')"
541
+ />
542
+ </div>
543
+ <p v-if="sampleRateLabel !== null" class="text-xs text-dimmed">
544
+ {{
545
+ t('page.marketing.pages.sampling_note', {
546
+ rate: sampleRateLabel,
547
+ })
548
+ }}
549
+ </p>
550
+ </template>
551
+ </div>
552
+ </template>
@@ -22,7 +22,7 @@ import { heatColorAt } from '../utils/heatmapPalette'
22
22
  const props = defineProps<{
23
23
  /** reached[d] = views whose max depth is ≥ d percent, d = 0..100. */
24
24
  reached: number[]
25
- /** Views that scrolled at all — the denominator of every share shown. */
25
+ /** Sampled views with a measured depth — the denominator of every share. */
26
26
  views: number
27
27
  /** Height of the overlay in the pane's own pixels (already scaled). */
28
28
  height: number
@@ -35,7 +35,9 @@ const DEPTH_SCALE = 100
35
35
  * nothing a reader could see. */
36
36
  const GRADIENT_STEP = 5
37
37
  const OVERLAY_ALPHA = 0.35
38
- /** Shares worth a labelled line — the "50% of visitors stop here" reading. */
38
+ /** Shares worth a marked line, labelled with the share alone so the chips
39
+ * cover as little of the page as possible; the aside explains them and the
40
+ * pointer readout says the full sentence. */
39
41
  const REACH_MARKS = [75, 50, 25]
40
42
 
41
43
  const { t } = useI18n()
@@ -57,7 +59,9 @@ const gradient = computed(() => {
57
59
  const colorFor = (fraction: number) => heatColorAt(fraction, OVERLAY_ALPHA)
58
60
  const stops = [`${colorFor(1)} 0%`]
59
61
  for (let depth = 0; depth <= DEPTH_SCALE; depth += GRADIENT_STEP) {
60
- stops.push(`${colorFor(reachAt(depth))} ${(yRatio(depth) * 100).toFixed(2)}%`)
62
+ stops.push(
63
+ `${colorFor(reachAt(depth))} ${(yRatio(depth) * 100).toFixed(2)}%`,
64
+ )
61
65
  }
62
66
  return `linear-gradient(to bottom, ${stops.join(', ')})`
63
67
  })
@@ -73,12 +77,12 @@ function depthReachedBy(percent: number): number {
73
77
  }
74
78
 
75
79
  const marks = computed(() => {
76
- const placed: Array<{ percent: number, y: number }> = []
80
+ const placed: Array<{ percent: number; y: number }> = []
77
81
  for (const percent of REACH_MARKS) {
78
82
  const y = yRatio(depthReachedBy(percent)) * props.height
79
83
  // Collapsed shares (everyone stops at one depth) would stack identical
80
84
  // lines; the strongest claim wins.
81
- if (placed.every(mark => Math.abs(mark.y - y) > 1)) {
85
+ if (placed.every((mark) => Math.abs(mark.y - y) > 1)) {
82
86
  placed.push({ percent, y })
83
87
  }
84
88
  }
@@ -88,17 +92,23 @@ const marks = computed(() => {
88
92
  const foldY = computed(() => props.foldRatio * props.height)
89
93
  const showFold = computed(() => props.foldRatio > 0 && props.foldRatio < 1)
90
94
 
91
- const hover = ref<{ y: number, percent: number } | null>(null)
95
+ const hover = ref<{ y: number; percent: number } | null>(null)
92
96
 
93
97
  function onPointerMove(event: PointerEvent): void {
94
98
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
95
99
  if (rect.height <= 0 || props.foldRatio >= 1) {
96
100
  return
97
101
  }
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
+ const ratio = Math.min(
103
+ Math.max((event.clientY - rect.top) / rect.height, 0),
104
+ 1,
105
+ )
106
+ const depth =
107
+ ratio <= props.foldRatio
108
+ ? 0
109
+ : Math.round(
110
+ ((ratio - props.foldRatio) / (1 - props.foldRatio)) * DEPTH_SCALE,
111
+ )
102
112
  hover.value = {
103
113
  y: ratio * props.height,
104
114
  percent: Math.round(reachAt(depth) * 100),
@@ -112,18 +122,26 @@ function onPointerMove(event: PointerEvent): void {
112
122
  @pointermove="onPointerMove"
113
123
  @pointerleave="hover = null"
114
124
  >
115
- <div class="pointer-events-none absolute inset-0" :style="{ background: gradient }" />
125
+ <div
126
+ class="pointer-events-none absolute inset-0"
127
+ :style="{ background: gradient }"
128
+ />
116
129
 
130
+ <!-- Named in the legend, not on the page: a label on the line sat on
131
+ whatever the first screen ends with (a button, a price). -->
117
132
  <div
118
133
  v-if="showFold"
119
134
  class="pointer-events-none absolute inset-x-0"
120
- :style="{ top: `${foldY}px`, borderTop: '1px solid rgba(255, 255, 255, 0.55)' }"
135
+ :aria-label="t('page.marketing.pages.scroll.fold_line')"
136
+ role="img"
137
+ :style="{
138
+ top: `${foldY}px`,
139
+ borderTop: '1px solid rgba(255, 255, 255, 0.55)',
140
+ }"
121
141
  >
122
142
  <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>
143
+ class="absolute left-0 top-0 h-2 w-2 -translate-y-1/2 rounded-full bg-default shadow ring-1 ring-white/70"
144
+ />
127
145
  </div>
128
146
 
129
147
  <div
@@ -137,21 +155,31 @@ function onPointerMove(event: PointerEvent): void {
137
155
  }"
138
156
  >
139
157
  <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"
158
+ class="absolute right-2 top-0 -translate-y-1/2 rounded-full bg-default/90 px-1.5 py-0.5 font-mono text-[11px] font-medium text-highlighted shadow"
159
+ :title="
160
+ t('page.marketing.pages.scroll.reach_line', { percent: mark.percent })
161
+ "
141
162
  >
142
- {{ t('page.marketing.pages.scroll.reach_line', { percent: mark.percent }) }}
163
+ {{ t('page.marketing.pages.scroll.mark', { percent: mark.percent }) }}
143
164
  </span>
144
165
  </div>
145
166
 
146
167
  <div
147
168
  v-if="hover"
148
169
  class="pointer-events-none absolute inset-x-0"
149
- :style="{ top: `${hover.y}px`, borderTop: '1px solid rgba(255, 255, 255, 0.95)' }"
170
+ :style="{
171
+ top: `${hover.y}px`,
172
+ borderTop: '1px solid rgba(255, 255, 255, 0.95)',
173
+ }"
150
174
  >
151
175
  <span
152
176
  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
177
  >
154
- {{ t('page.marketing.pages.scroll.reach_line', { percent: hover.percent }) }}
178
+ {{
179
+ t('page.marketing.pages.scroll.reach_line', {
180
+ percent: hover.percent,
181
+ })
182
+ }}
155
183
  </span>
156
184
  </div>
157
185
  </div>