@antelopejs/dms-marketing 0.3.2 → 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 +3 -0
  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
@@ -4,14 +4,27 @@ import { useI18n } from '#dms/frontend-module'
4
4
  import { useLatestRequest } from '../composables/useLatestRequest'
5
5
  import {
6
6
  useMarketingApi,
7
+ type MarketingPeriod,
7
8
  type MarketingHeatmap,
8
9
  type MarketingPageSnapshotResponse,
9
10
  type MarketingScrollDepth,
10
11
  type MarketingSnapshotLayout,
11
12
  type MarketingWebsite,
12
13
  type MarketingWebsitePatch,
14
+ type MarketingFunnelListItem,
13
15
  } from '../composables/useMarketingApi'
16
+ import { funnelLink, settingsLink } from '../composables/useMarketingRoutes'
17
+ import { formatNumber, formatShortDate } from '../utils/format'
14
18
  import {
19
+ heatmapDataLabel,
20
+ heatmapResetImpact,
21
+ } from '../utils/heatmap'
22
+ import { heatLegendGradient } from '../utils/heatmapPalette'
23
+ import {
24
+ DEFAULT_RENDER_HEIGHT,
25
+ MAX_RENDER_HEIGHT,
26
+ MAX_RENDER_WIDTH,
27
+ MIN_RENDER_HEIGHT,
15
28
  useSnapshotFrame,
16
29
  type AnchorBox,
17
30
  type AnchorRequest,
@@ -53,18 +66,29 @@ import { rewriteViewportUnits } from '../utils/snapshotViewportUnits'
53
66
  const props = defineProps<{
54
67
  website: MarketingWebsite | null
55
68
  path: string
56
- period: string
57
- /** Share of visits capturing clicks — why a quiet page looks empty. */
58
- sampleRate: number
69
+ /** Days the counts cover — the context bar's period scope. */
70
+ period: MarketingPeriod
71
+ /**
72
+ * Measured share of the site's page loads sampled for clicks, written for
73
+ * a sentence — why a quiet page looks empty. Null while too few pageviews
74
+ * tell it.
75
+ */
76
+ sampleRateLabel: string | null
59
77
  trackerEnabled: boolean
60
78
  /** Pageviews of this path over the window, for the sampling hint. */
61
79
  pageviews?: number
62
80
  /** Bumped by the parent when it resets clicks out from under this view. */
63
81
  refreshToken?: number
82
+ /** Funnels with a page step on this path. */
83
+ usedIn?: MarketingFunnelListItem[]
64
84
  }>()
65
85
 
66
86
  /** The site's snapshot options were changed here; the parent owns the row. */
67
- const emit = defineEmits<{ 'website-updated': [] }>()
87
+ const emit = defineEmits<{
88
+ 'website-updated': []
89
+ /** The page lost its clicks: the inventory counts them too. */
90
+ 'heatmap-reset': []
91
+ }>()
68
92
 
69
93
  interface LayoutPreset {
70
94
  width: number
@@ -79,9 +103,24 @@ interface LayoutPreset {
79
103
  * breakpoints already dress as desktop — the button would repeat 1280.
80
104
  */
81
105
  const LAYOUT_PRESETS: LayoutPreset[] = [
82
- { width: 1280, layout: 'desktop', icon: 'i-ph-monitor', labelKey: 'page.marketing.pages.preview.layout_desktop' },
83
- { width: 768, layout: 'tablet', icon: 'i-ph-device-tablet', labelKey: 'page.marketing.pages.preview.layout_tablet' },
84
- { width: 390, layout: 'phone', icon: 'i-ph-device-mobile', labelKey: 'page.marketing.pages.preview.layout_phone' },
106
+ {
107
+ width: 1280,
108
+ layout: 'desktop',
109
+ icon: 'i-ph-monitor',
110
+ labelKey: 'page.marketing.pages.preview.layout_desktop',
111
+ },
112
+ {
113
+ width: 768,
114
+ layout: 'tablet',
115
+ icon: 'i-ph-device-tablet',
116
+ labelKey: 'page.marketing.pages.preview.layout_tablet',
117
+ },
118
+ {
119
+ width: 390,
120
+ layout: 'phone',
121
+ icon: 'i-ph-device-mobile',
122
+ labelKey: 'page.marketing.pages.preview.layout_phone',
123
+ },
85
124
  ]
86
125
  const DEFAULT_LAYOUT_WIDTH = 1280
87
126
  /** Viewport the snapshot is measured through when it recorded none. */
@@ -113,12 +152,6 @@ const {
113
152
  : null,
114
153
  )
115
154
 
116
- watch(
117
- () => [props.website?._id, props.path, props.period, props.refreshToken],
118
- () => void loadHeatmap(),
119
- { immediate: true },
120
- )
121
-
122
155
  const points = computed(() => heatmap.value?.points ?? [])
123
156
  const maxWeight = computed(() => heatmap.value?.maxWeight ?? 0)
124
157
  const totalClicks = computed(() => heatmap.value?.totalClicks ?? 0)
@@ -127,6 +160,9 @@ const totalClicks = computed(() => heatmap.value?.totalClicks ?? 0)
127
160
 
128
161
  const overlayMode = ref<'clicks' | 'scroll'>('clicks')
129
162
 
163
+ // Read with the clicks, not when its overlay opens: every sampled view
164
+ // reports one depth, so its count is the page's sampled views the header
165
+ // and the empty states speak of.
130
166
  const {
131
167
  data: scrollDepth,
132
168
  loading: loadingScroll,
@@ -138,34 +174,35 @@ const {
138
174
  : null,
139
175
  )
140
176
 
141
- /**
142
- * Lazy on purpose: nothing is fetched until the overlay is first opened,
143
- * then kept fresh against the same keys the click read watches. The key
144
- * comparison is what keeps a mere clicks↔scroll round trip from refetching.
145
- */
146
- let scrollLoadedKey = ''
147
-
148
- function ensureScrollDepth(): void {
149
- if (overlayMode.value !== 'scroll') {
150
- return
151
- }
152
- const key = [props.website?._id, props.path, props.period, props.refreshToken].join('|')
153
- if (key === scrollLoadedKey) {
154
- return
155
- }
156
- scrollLoadedKey = key
157
- void loadScrollDepth()
158
- }
159
-
160
177
  watch(
161
- () => [props.website?._id, props.path, props.period, props.refreshToken, overlayMode.value],
162
- ensureScrollDepth,
178
+ () => [
179
+ props.website?._id,
180
+ props.path,
181
+ props.period?.range?.from.getTime(),
182
+ props.period?.range?.to.getTime(),
183
+ props.refreshToken,
184
+ ],
185
+ () => {
186
+ void loadHeatmap()
187
+ void loadScrollDepth()
188
+ },
163
189
  { immediate: true },
164
190
  )
165
191
 
166
192
  const scrollViews = computed(() => scrollDepth.value?.views ?? 0)
167
193
  const scrollReached = computed(() => scrollDepth.value?.reached ?? [])
168
194
 
195
+ /** Deepest depth at least half of the sampled views reached. */
196
+ const medianDepth = computed(() => {
197
+ const half = scrollViews.value / 2
198
+ for (let depth = scrollReached.value.length - 1; depth >= 0; depth--) {
199
+ if ((scrollReached.value[depth] ?? 0) >= half) {
200
+ return depth
201
+ }
202
+ }
203
+ return 0
204
+ })
205
+
169
206
  /**
170
207
  * Where depth 0 sits on the drawn page: the bottom of the first screen.
171
208
  * Depth is a fraction of the visitor's scrollable range; the captured
@@ -185,38 +222,49 @@ const toast = useToast()
185
222
  const resetting = ref(false)
186
223
 
187
224
  /** Deletes the clicks, scrolls and snapshots of the page (every period),
188
- * then refetches the proof for whichever overlay is showing. */
225
+ * counted by the server before asking, then refetches both overlays. */
189
226
  async function resetPageHeatmap() {
190
227
  const website = props.website
191
228
  if (!website) {
192
229
  return
193
230
  }
194
- const confirmed = await confirm({
231
+ const path = props.path
232
+ resetting.value = true
233
+ const counts = await api
234
+ .countHeatmap(website._id, path)
235
+ .catch(() => null)
236
+ .finally(() => (resetting.value = false))
237
+ await confirm({
195
238
  title: t('page.marketing.pages.reset.page_title'),
196
- description: t('page.marketing.pages.reset.page_description', { path: props.path }),
197
- confirmLabel: t('page.marketing.pages.reset.confirm'),
198
- confirmColor: 'error',
239
+ description: t('page.marketing.pages.reset.page_description'),
240
+ color: 'error',
241
+ icon: 'i-ph-eraser',
242
+ confirmLabel:
243
+ counts && counts.events + counts.snapshots > 0
244
+ ? t('page.marketing.pages.reset.confirm_count', {
245
+ what: heatmapDataLabel(counts, t, locale.value),
246
+ })
247
+ : t('page.marketing.pages.reset.confirm'),
248
+ confirmText: path,
249
+ impact: heatmapResetImpact(counts, t, locale.value),
250
+ onConfirm: async () => {
251
+ resetting.value = true
252
+ try {
253
+ const deleted = await api.resetHeatmap(website._id, path)
254
+ toast.add({
255
+ color: 'success',
256
+ title: t('page.marketing.pages.reset.page_done'),
257
+ description: t('page.marketing.pages.reset.done', {
258
+ what: heatmapDataLabel(deleted, t, locale.value),
259
+ }),
260
+ })
261
+ emit('heatmap-reset')
262
+ await Promise.all([loadHeatmap(), loadScrollDepth(), loadSnapshot()])
263
+ } finally {
264
+ resetting.value = false
265
+ }
266
+ },
199
267
  })
200
- if (!confirmed) {
201
- return
202
- }
203
- resetting.value = true
204
- try {
205
- const { deleted } = await api.resetHeatmap(website._id, props.path)
206
- toast.add({
207
- color: 'success',
208
- title: t('page.marketing.pages.reset.done', { count: deleted }, deleted),
209
- })
210
- scrollLoadedKey = ''
211
- ensureScrollDepth()
212
- await Promise.all([loadHeatmap(), loadSnapshot()])
213
- }
214
- catch {
215
- toast.add({ color: 'error', title: t('page.marketing.pages.reset.error') })
216
- }
217
- finally {
218
- resetting.value = false
219
- }
220
268
  }
221
269
 
222
270
  // --- Snapshot ---------------------------------------------------------------
@@ -224,10 +272,14 @@ async function resetPageHeatmap() {
224
272
  const layoutWidth = ref(DEFAULT_LAYOUT_WIDTH)
225
273
 
226
274
  const layout = computed<MarketingSnapshotLayout>(
227
- () => LAYOUT_PRESETS.find(preset => preset.width === layoutWidth.value)?.layout ?? 'desktop',
275
+ () =>
276
+ LAYOUT_PRESETS.find((preset) => preset.width === layoutWidth.value)
277
+ ?.layout ?? 'desktop',
228
278
  )
229
279
 
230
- const snapshotsEnabled = computed(() => props.website?.snapshotsEnabled ?? false)
280
+ const snapshotsEnabled = computed(
281
+ () => props.website?.snapshotsEnabled ?? false,
282
+ )
231
283
 
232
284
  // Not fetched while the site has snapshots off: the option's banner is the
233
285
  // whole story then, and a leftover capture must not contradict it.
@@ -242,7 +294,13 @@ const {
242
294
  )
243
295
 
244
296
  watch(
245
- () => [props.website?._id, snapshotsEnabled.value, props.path, layout.value, props.refreshToken],
297
+ () => [
298
+ props.website?._id,
299
+ snapshotsEnabled.value,
300
+ props.path,
301
+ layout.value,
302
+ props.refreshToken,
303
+ ],
246
304
  () => void loadSnapshot(),
247
305
  { immediate: true },
248
306
  )
@@ -262,32 +320,25 @@ const frameDocument = computed(() =>
262
320
  : '',
263
321
  )
264
322
  const noSnapshotYet = computed(
265
- () => snapshotsEnabled.value && snapshotSettled.value && snapshot.value === null,
323
+ () =>
324
+ snapshotsEnabled.value && snapshotSettled.value && snapshot.value === null,
325
+ )
326
+ const tooHeavy = computed(
327
+ () => snapshot.value !== null && backdrop.value === null,
266
328
  )
267
- const tooHeavy = computed(() => snapshot.value !== null && backdrop.value === null)
268
329
  const snapshotScheme = computed(() => snapshot.value?.colorScheme ?? 'light')
269
- const capturedAtLabel = computed(() => {
270
- if (!snapshot.value) {
271
- return ''
272
- }
273
- const date = new Date(snapshot.value.capturedAt).toLocaleString(locale.value)
274
- return t(
275
- tooHeavy.value
276
- ? 'page.marketing.pages.preview.attempted_at'
277
- : 'page.marketing.pages.preview.captured_at',
278
- { date },
279
- )
280
- })
281
330
 
282
331
  function mebibytes(bytes: number): string {
283
- return new Intl.NumberFormat(locale.value, { maximumFractionDigits: 1 }).format(
284
- bytes / (1024 * 1024),
285
- )
332
+ return new Intl.NumberFormat(locale.value, {
333
+ maximumFractionDigits: 1,
334
+ }).format(bytes / (1024 * 1024))
286
335
  }
287
336
 
288
337
  const savingOptions = ref(false)
289
338
 
290
- async function updateSnapshotOptions(patch: MarketingWebsitePatch): Promise<void> {
339
+ async function updateSnapshotOptions(
340
+ patch: MarketingWebsitePatch,
341
+ ): Promise<void> {
291
342
  const website = props.website
292
343
  if (!website) {
293
344
  return
@@ -296,59 +347,35 @@ async function updateSnapshotOptions(patch: MarketingWebsitePatch): Promise<void
296
347
  try {
297
348
  await api.updateWebsite(website._id, patch)
298
349
  emit('website-updated')
299
- }
300
- catch {
301
- toast.add({ color: 'error', title: t('page.marketing.pages.preview.options_error') })
302
- }
303
- finally {
350
+ } catch {
351
+ toast.add({
352
+ color: 'error',
353
+ title: t('page.marketing.pages.preview.options_error'),
354
+ })
355
+ } finally {
304
356
  savingOptions.value = false
305
357
  }
306
358
  }
307
359
 
308
360
  // --- Frame geometry ---------------------------------------------------------
309
361
 
310
- /** Free text: an empty field must stay empty, not refill with the applied value. */
311
- const heightField = ref('')
312
-
313
- const heightOverride = computed(() => {
314
- const typed = Number.parseInt(heightField.value, 10)
315
- return Number.isFinite(typed) && typed > 0 ? typed : null
316
- })
317
-
318
362
  const frame = ref<HTMLIFrameElement | null>(null)
319
- const {
320
- measuredWidth,
321
- measuredHeight,
322
- tier,
323
- close: closeFrame,
324
- measureAnchors,
325
- } = useSnapshotFrame(frame)
326
-
327
- // Typing a height resizes the frame by hand, so the next reading would
328
- // measure that — stop reading rather than latch it.
329
- watch(heightOverride, (value) => {
330
- if (value !== null && tier.value === 'pending') {
331
- closeFrame()
332
- }
333
- })
334
-
335
- // A different width is a different rendering of the snapshot: the frame
336
- // remounts on the key change, and the composable re-arms on the new one.
337
- watch(layoutWidth, () => {
338
- heightField.value = ''
339
- })
340
-
341
- // A typed height belongs to the page it was typed on.
342
- watch(() => [props.website?._id, props.path], () => {
343
- heightField.value = ''
344
- })
363
+ const { measuredWidth, measuredHeight, tier, measureAnchors } =
364
+ useSnapshotFrame(frame)
345
365
 
346
366
  const frameKey = computed(() =>
347
- [props.path, snapshot.value?.layout, snapshot.value?.capturedAt, layoutWidth.value].join('|'),
367
+ [
368
+ props.path,
369
+ snapshot.value?.layout,
370
+ snapshot.value?.capturedAt,
371
+ layoutWidth.value,
372
+ ].join('|'),
348
373
  )
349
374
 
350
375
  /** Viewport the frame is pinned to until its size is read. */
351
- const probeHeight = computed(() => snapshot.value?.viewport.height ?? PROBE_VIEWPORT_HEIGHT)
376
+ const probeHeight = computed(
377
+ () => snapshot.value?.viewport.height ?? PROBE_VIEWPORT_HEIGHT,
378
+ )
352
379
 
353
380
  /**
354
381
  * Height of the drawn surface — the overlay's coordinate space. Falls back to
@@ -356,7 +383,7 @@ const probeHeight = computed(() => snapshot.value?.viewport.height ?? PROBE_VIEW
356
383
  * readable before (and without) any snapshot.
357
384
  */
358
385
  const renderHeight = computed(() => {
359
- const chosen = heightOverride.value ?? measuredHeight.value
386
+ const chosen = measuredHeight.value
360
387
  if (chosen !== null) {
361
388
  return Math.min(Math.max(chosen, MIN_RENDER_HEIGHT), MAX_RENDER_HEIGHT)
362
389
  }
@@ -369,9 +396,6 @@ const renderHeight = computed(() => {
369
396
  * being waited on. An operator override takes it back by hand.
370
397
  */
371
398
  const frameHeight = computed(() => {
372
- if (heightOverride.value !== null) {
373
- return renderHeight.value
374
- }
375
399
  if (measuredHeight.value !== null) {
376
400
  return measuredHeight.value
377
401
  }
@@ -386,7 +410,10 @@ const frameHeight = computed(() => {
386
410
  * part is clipped by the pane, exactly as it is clipped inside the frame.
387
411
  */
388
412
  const overlayWidth = computed(() =>
389
- Math.min(Math.max(measuredWidth.value ?? 0, layoutWidth.value), MAX_RENDER_WIDTH),
413
+ Math.min(
414
+ Math.max(measuredWidth.value ?? 0, layoutWidth.value),
415
+ MAX_RENDER_WIDTH,
416
+ ),
390
417
  )
391
418
 
392
419
  // --- Element anchoring ------------------------------------------------------
@@ -458,7 +485,7 @@ async function refreshAnchorBoxes(): Promise<void> {
458
485
  return
459
486
  }
460
487
  await nextTick()
461
- await new Promise(resolve => requestAnimationFrame(resolve))
488
+ await new Promise((resolve) => requestAnimationFrame(resolve))
462
489
  if (token !== measureToken) {
463
490
  return
464
491
  }
@@ -482,8 +509,8 @@ watch(
482
509
  */
483
510
  const oversized = computed(
484
511
  () =>
485
- (measuredHeight.value ?? 0) > MAX_RENDER_HEIGHT
486
- || (measuredWidth.value ?? 0) > MAX_RENDER_WIDTH,
512
+ (measuredHeight.value ?? 0) > MAX_RENDER_HEIGHT ||
513
+ (measuredWidth.value ?? 0) > MAX_RENDER_WIDTH,
487
514
  )
488
515
 
489
516
  const overflowing = computed(
@@ -518,36 +545,49 @@ watch(container, (el) => {
518
545
 
519
546
  onBeforeUnmount(() => observer?.disconnect())
520
547
 
548
+ // Only ever scaled down: a phone or tablet layout narrower than the pane is
549
+ // shown at its own width, centred like a device, never blown up to fill it.
521
550
  const scale = computed(() =>
522
- containerWidth.value > 0 ? containerWidth.value / layoutWidth.value : 1,
551
+ containerWidth.value > 0
552
+ ? Math.min(1, containerWidth.value / layoutWidth.value)
553
+ : 1,
523
554
  )
524
555
 
556
+ /** Width of the drawn surface in the pane's own pixels. */
557
+ const surfaceWidth = computed(() => Math.round(layoutWidth.value * scale.value))
558
+
525
559
  /** Height of the zoomed surface in the pane's own pixels — the scroll range
526
560
  * of the pane and the space the scroll-depth overlay draws in. */
527
- const scrolledHeight = computed(() => Math.round(renderHeight.value * scale.value))
528
-
529
- // --- Emptiness, explained ---------------------------------------------------
561
+ const scrolledHeight = computed(() =>
562
+ Math.round(renderHeight.value * scale.value),
563
+ )
530
564
 
531
565
  /**
532
- * Page loads whose clicks were captured at all. Sampling is drawn once per
533
- * page load (tracker.js), so this bounds whether a heatmap could exist — it
534
- * is not a click estimate, and must not be shown as one next to the real
535
- * count.
566
+ * The pane grows with the page, up to the window's height: a short capture
567
+ * leaves no empty band below it, and a long one scrolls inside a pane that
568
+ * still fits the screen. The border is counted, or a 2px scroll would remain.
536
569
  */
537
- const expectedSampledPageLoads = computed(() =>
538
- props.pageviews ? Math.round(props.pageviews * props.sampleRate) : null,
539
- )
570
+ const paneStyle = computed(() => ({
571
+ height: `${scrolledHeight.value + 2}px`,
572
+ maxHeight: 'calc(100dvh - 8rem)',
573
+ minHeight: '12rem',
574
+ }))
540
575
 
541
- /** Silence has shared causes (collection off, sampled out) before the
576
+ // --- Emptiness, explained ---------------------------------------------------
577
+
578
+
579
+ /** Silence has shared causes (collection off, no view sampled) before the
542
580
  * per-overlay one; both overlays explain themselves through this. */
543
581
  function captureEmptyReason(fallbackKey: string): string {
544
582
  if (!props.trackerEnabled) {
545
583
  return t('page.marketing.pages.empty.tracking_off')
546
584
  }
547
- if (expectedSampledPageLoads.value !== null && expectedSampledPageLoads.value < 1) {
548
- return t('page.marketing.pages.empty.sampled_out', {
549
- rate: Math.round(props.sampleRate * 100),
550
- })
585
+ if (props.pageviews && scrollViews.value === 0) {
586
+ return props.sampleRateLabel === null
587
+ ? t('page.marketing.pages.empty.none_sampled')
588
+ : t('page.marketing.pages.empty.none_sampled_rate', {
589
+ rate: props.sampleRateLabel,
590
+ })
551
591
  }
552
592
  return t(fallbackKey)
553
593
  }
@@ -562,7 +602,11 @@ const emptyState = computed(() => {
562
602
  description: captureEmptyReason('page.marketing.pages.empty.no_clicks'),
563
603
  }
564
604
  }
565
- if (loadingScroll.value || scrollDepth.value === null || scrollViews.value > 0) {
605
+ if (
606
+ loadingScroll.value ||
607
+ scrollDepth.value === null ||
608
+ scrollViews.value > 0
609
+ ) {
566
610
  return null
567
611
  }
568
612
  return {
@@ -605,311 +649,740 @@ const truncatedState = computed(() => {
605
649
  return null
606
650
  })
607
651
 
608
- /** The real page, on the origin the snapshot was captured from — opened,
609
- * never framed. */
652
+ const LOCAL_HOST = /^(localhost|127\.|\[?::1\]?$)/
653
+
654
+ /** The real page — opened, never framed: on the origin the snapshot was
655
+ * captured from, else on the site's declared domain. */
610
656
  const externalUrl = computed(() => {
611
- if (!snapshot.value || !props.path) {
657
+ const domain = props.website?.domain
658
+ const origin =
659
+ snapshot.value?.origin ??
660
+ (domain
661
+ ? `${LOCAL_HOST.test(domain) ? 'http' : 'https'}://${domain}`
662
+ : null)
663
+ if (!origin || !props.path) {
612
664
  return ''
613
665
  }
614
666
  try {
615
- return new URL(props.path, snapshot.value.origin).toString()
667
+ return new URL(props.path, origin).toString()
668
+ } catch {
669
+ return ''
616
670
  }
617
- catch {
671
+ })
672
+
673
+ // --- One status strip -------------------------------------------------------
674
+
675
+ interface StripAction {
676
+ label: string
677
+ run?: () => void
678
+ to?: string
679
+ }
680
+
681
+ interface StatusStrip {
682
+ tone: 'error' | 'warning' | 'info' | 'primary' | 'success'
683
+ neutral?: boolean
684
+ icon: string
685
+ title: string
686
+ description: string
687
+ action?: StripAction
688
+ }
689
+
690
+ /**
691
+ * Highest priority first: one strip says what needs attention, instead of
692
+ * every applicable alert stacked above the page. The numbers in the header
693
+ * stay exact whatever it says.
694
+ */
695
+ const statusStrip = computed<StatusStrip | null>(() => {
696
+ const candidates: Array<StatusStrip | null> = [
697
+ !props.trackerEnabled
698
+ ? {
699
+ tone: 'error',
700
+ icon: 'i-ph-plugs',
701
+ title: t('page.marketing.pages.strip.collection_off'),
702
+ description: t('page.marketing.pages.strip.collection_off_hint'),
703
+ action: {
704
+ label: t('page.marketing.pages.strip.open_settings'),
705
+ to: settingsLink(),
706
+ },
707
+ }
708
+ : null,
709
+ !snapshotsEnabled.value
710
+ ? {
711
+ tone: 'info',
712
+ icon: 'i-ph-camera-slash',
713
+ title: t('page.marketing.pages.preview.snapshots_off'),
714
+ description: t('page.marketing.pages.preview.snapshots_off_hint'),
715
+ action: {
716
+ label: t('page.marketing.pages.preview.enable_snapshots'),
717
+ run: () => void updateSnapshotOptions({ snapshotsEnabled: true }),
718
+ },
719
+ }
720
+ : null,
721
+ noSnapshotYet.value
722
+ ? {
723
+ tone: 'primary',
724
+ icon: 'i-ph-hourglass',
725
+ title: t('page.marketing.pages.preview.no_snapshot'),
726
+ description:
727
+ props.sampleRateLabel === null
728
+ ? t('page.marketing.pages.preview.no_snapshot_hint')
729
+ : t('page.marketing.pages.preview.no_snapshot_hint_rate', {
730
+ rate: props.sampleRateLabel,
731
+ }),
732
+ }
733
+ : null,
734
+ snapshot.value && tooHeavy.value
735
+ ? {
736
+ tone: 'warning',
737
+ icon: 'i-ph-file-x',
738
+ title: t('page.marketing.pages.preview.too_heavy'),
739
+ description: t('page.marketing.pages.preview.too_heavy_hint', {
740
+ size: mebibytes(snapshot.value.bytes),
741
+ limit: mebibytes(SNAPSHOT_BYTE_CAP),
742
+ }),
743
+ }
744
+ : null,
745
+ truncatedState.value
746
+ ? {
747
+ tone: 'warning',
748
+ icon: 'i-ph-warning',
749
+ title: truncatedState.value.title,
750
+ description: truncatedState.value.description,
751
+ }
752
+ : null,
753
+ oversized.value
754
+ ? {
755
+ tone: 'warning',
756
+ icon: 'i-ph-arrows-out-line-vertical',
757
+ title: t('page.marketing.pages.preview.oversized'),
758
+ description: t('page.marketing.pages.preview.oversized_hint'),
759
+ }
760
+ : null,
761
+ snapshot.value && !snapshot.value.exact
762
+ ? {
763
+ tone: 'info',
764
+ neutral: true,
765
+ icon: 'i-ph-arrows-out-line-horizontal',
766
+ title: t('page.marketing.pages.preview.approximate_layout', {
767
+ layout: t(
768
+ `page.marketing.pages.preview.layout_${snapshot.value.layout}`,
769
+ ).toLowerCase(),
770
+ width: layoutWidth.value,
771
+ }),
772
+ description: t(
773
+ 'page.marketing.pages.preview.approximate_layout_hint',
774
+ ),
775
+ }
776
+ : null,
777
+ overflowing.value
778
+ ? {
779
+ tone: 'info',
780
+ neutral: true,
781
+ icon: 'i-ph-arrows-horizontal',
782
+ title: t('page.marketing.pages.preview.overflow'),
783
+ description: t('page.marketing.pages.preview.overflow_hint', {
784
+ width: measuredWidth.value,
785
+ }),
786
+ }
787
+ : null,
788
+ ]
789
+ return candidates.find((candidate) => candidate !== null) ?? null
790
+ })
791
+
792
+ const statusLine = computed(() => {
793
+ if (!snapshot.value) {
618
794
  return ''
619
795
  }
796
+ const layoutLabel = t(
797
+ `page.marketing.pages.preview.layout_${snapshot.value.layout}`,
798
+ ).toLowerCase()
799
+ return t('page.marketing.pages.preview.status_line', {
800
+ date: formatShortDate(snapshot.value.capturedAt, locale.value),
801
+ layout: layoutLabel,
802
+ width: snapshot.value.viewport.width,
803
+ })
804
+ })
805
+
806
+ const maskLine = computed(() =>
807
+ t(
808
+ props.website?.snapshotMaskText
809
+ ? 'page.marketing.pages.preview.all_masked'
810
+ : 'page.marketing.pages.preview.fields_masked',
811
+ ),
812
+ )
813
+
814
+ // --- Most clicked -----------------------------------------------------------
815
+
816
+ const MOST_CLICKED = 5
817
+
818
+ interface ClickedElement {
819
+ key: string
820
+ selector: string
821
+ weight: number
822
+ text: string
823
+ box: AnchorBox | undefined
824
+ }
825
+
826
+ /** The anchored elements with the most clicks, the backend's own cells summed. */
827
+ const mostClicked = computed<ClickedElement[]>(() => {
828
+ const groups = new Map<string, ClickedElement>()
829
+ for (const point of points.value) {
830
+ if (!point.anchor) {
831
+ continue
832
+ }
833
+ const key = anchorKey(point.anchor)
834
+ const existing = groups.get(key)
835
+ if (existing) {
836
+ existing.weight += point.weight
837
+ continue
838
+ }
839
+ const box = anchorBoxes.value.get(key)
840
+ groups.set(key, {
841
+ key,
842
+ selector: point.anchor.selector,
843
+ weight: point.weight,
844
+ text: box?.text ?? '',
845
+ box,
846
+ })
847
+ }
848
+ return [...groups.values()]
849
+ .sort((a, b) => b.weight - a.weight)
850
+ .slice(0, MOST_CLICKED)
851
+ })
852
+
853
+ /** Half a pin's footprint on screen, and the step that unstacks two pins. */
854
+ const PIN_HALF_WIDTH = 28
855
+ const PIN_HALF_HEIGHT = 11
856
+ const PIN_STEP = 22
857
+
858
+ interface Pin {
859
+ key: string
860
+ weight: number
861
+ left: number
862
+ top: number
863
+ }
864
+
865
+ /**
866
+ * Count badges pinned on the hottest elements, in the surface's own pixels:
867
+ * kept inside the surface (an element flush with the right edge would cut
868
+ * its badge in half) and pushed down past the badges already placed.
869
+ */
870
+ const pins = computed(() => {
871
+ if (overlayMode.value !== 'clicks') {
872
+ return []
873
+ }
874
+ const unit = 1 / scale.value
875
+ const placed: Pin[] = []
876
+ for (const element of mostClicked.value) {
877
+ const box = element.box
878
+ if (!box) {
879
+ continue
880
+ }
881
+ const left = Math.min(
882
+ Math.max(box.x + box.width, PIN_HALF_WIDTH * unit),
883
+ layoutWidth.value - PIN_HALF_WIDTH * unit,
884
+ )
885
+ let top = Math.max(box.y, PIN_HALF_HEIGHT * unit)
886
+ while (
887
+ placed.some(
888
+ (pin) =>
889
+ Math.abs(pin.left - left) < 2 * PIN_HALF_WIDTH * unit &&
890
+ Math.abs(pin.top - top) < PIN_STEP * unit,
891
+ )
892
+ ) {
893
+ top += PIN_STEP * unit
894
+ }
895
+ placed.push({ key: element.key, weight: element.weight, left, top })
896
+ }
897
+ return placed
898
+ })
899
+
900
+ // --- Toolbar ------------------------------------------------------------------
901
+
902
+ const overlayItems = computed(() => [
903
+ {
904
+ value: 'clicks',
905
+ label: t('page.marketing.pages.overlay.clicks'),
906
+ icon: 'i-ph-cursor-click',
907
+ },
908
+ {
909
+ value: 'scroll',
910
+ label: t('page.marketing.pages.overlay.scroll'),
911
+ icon: 'i-ph-caret-double-down',
912
+ },
913
+ ])
914
+
915
+ const layoutItems = computed(() =>
916
+ LAYOUT_PRESETS.map((preset) => ({
917
+ value: preset.width,
918
+ label: '',
919
+ icon: preset.icon,
920
+ })),
921
+ )
922
+
923
+ /**
924
+ * Names the icon-only layout segments: DmsSegmented renders an item's label
925
+ * as its only text and has no per-item aria-label, so a screen reader would
926
+ * read three unnamed radios. Set on the rendered buttons, in preset order.
927
+ */
928
+ const layoutSwitch = ref<HTMLElement | null>(null)
929
+
930
+ watch(
931
+ [layoutSwitch, locale],
932
+ ([wrapper]) => {
933
+ wrapper?.querySelectorAll('[role="radio"]').forEach((radio, index) => {
934
+ const preset = LAYOUT_PRESETS[index]
935
+ if (preset) {
936
+ radio.setAttribute('aria-label', t(preset.labelKey))
937
+ radio.setAttribute('title', t(preset.labelKey))
938
+ }
939
+ })
940
+ },
941
+ { flush: 'post' },
942
+ )
943
+
944
+ const moreItems = computed(() => [
945
+ [
946
+ {
947
+ type: 'checkbox' as const,
948
+ label: t('page.marketing.pages.preview.snapshots_enabled'),
949
+ checked: snapshotsEnabled.value,
950
+ onUpdateChecked: (value: boolean) =>
951
+ void updateSnapshotOptions({ snapshotsEnabled: value }),
952
+ },
953
+ {
954
+ type: 'checkbox' as const,
955
+ label: t('page.marketing.pages.preview.mask_text'),
956
+ checked: props.website?.snapshotMaskText === true,
957
+ disabled: !snapshotsEnabled.value,
958
+ onUpdateChecked: (value: boolean) =>
959
+ void updateSnapshotOptions({ snapshotMaskText: value }),
960
+ },
961
+ ],
962
+ ...(externalUrl.value
963
+ ? [
964
+ [
965
+ {
966
+ label: t('page.marketing.pages.preview.open_in_tab'),
967
+ icon: 'i-ph-arrow-square-out',
968
+ to: externalUrl.value,
969
+ target: '_blank',
970
+ },
971
+ ],
972
+ ]
973
+ : []),
974
+ [
975
+ {
976
+ label: t('page.marketing.pages.reset.page_button'),
977
+ icon: 'i-ph-eraser',
978
+ color: 'error' as const,
979
+ onSelect: () => void resetPageHeatmap(),
980
+ },
981
+ ],
982
+ ])
983
+
984
+ // --- Surface ----------------------------------------------------------------
985
+
986
+ /**
987
+ * What the left cell draws. One empty state whose texts change with the
988
+ * reason, never several swapped in turn: replacing one DmsEmptyState by
989
+ * another landed the new one outside the cell.
990
+ */
991
+ const surface = computed(() => {
992
+ if (activeFailed.value) {
993
+ return 'error'
994
+ }
995
+ if (snapshot.value && backdrop.value !== null) {
996
+ return 'frame'
997
+ }
998
+ if (emptyState.value) {
999
+ return 'empty'
1000
+ }
1001
+ if (snapshotsEnabled.value && !snapshotSettled.value) {
1002
+ return 'loading'
1003
+ }
1004
+ return 'no_backdrop'
1005
+ })
1006
+
1007
+ const surfaceEmpty = computed(() => {
1008
+ if (surface.value === 'error') {
1009
+ return {
1010
+ variant: 'error' as const,
1011
+ icon: undefined,
1012
+ title: t('page.marketing.common.load_error'),
1013
+ description: undefined,
1014
+ }
1015
+ }
1016
+ if (surface.value === 'empty' && emptyState.value) {
1017
+ return {
1018
+ variant: undefined,
1019
+ icon:
1020
+ overlayMode.value === 'clicks'
1021
+ ? 'i-ph-cursor-click'
1022
+ : 'i-ph-caret-double-down',
1023
+ ...emptyState.value,
1024
+ }
1025
+ }
1026
+ return {
1027
+ variant: undefined,
1028
+ icon: 'i-ph-image-broken',
1029
+ title: t('page.marketing.pages.preview.no_backdrop'),
1030
+ description: t('page.marketing.pages.preview.no_backdrop_hint'),
1031
+ }
620
1032
  })
621
1033
  </script>
622
1034
 
623
1035
  <template>
624
- <div class="flex flex-col gap-4 p-4">
1036
+ <section class="dms-card flex min-w-0 flex-col overflow-hidden p-0">
625
1037
  <!-- Toolbar -->
626
- <div class="flex flex-wrap items-center justify-between gap-3">
627
- <div class="min-w-0">
628
- <p class="truncate font-mono text-sm text-highlighted">{{ props.path }}</p>
629
- <p class="text-xs text-muted">
630
- <template v-if="overlayMode === 'clicks'">
631
- {{ t('page.marketing.pages.clicks', { count: totalClicks }, totalClicks) }}
632
- </template>
633
- <template v-else>
634
- {{ t('page.marketing.pages.scroll.views', { count: scrollViews }, scrollViews) }}
635
- </template>
636
- <span v-if="props.pageviews">
637
- · {{ t('page.marketing.pages.pageviews', { count: props.pageviews }, props.pageviews) }}
1038
+ <header
1039
+ class="flex flex-wrap items-center justify-between gap-3 border-b border-default px-4 py-3"
1040
+ >
1041
+ <div class="min-w-0 flex-1 basis-64">
1042
+ <p
1043
+ class="flex min-w-0 items-center gap-2 font-mono text-base font-semibold text-highlighted"
1044
+ >
1045
+ <span class="min-w-0 truncate" :title="props.path">
1046
+ {{ props.path }}
1047
+ </span>
1048
+ <UButton
1049
+ v-if="externalUrl"
1050
+ :to="externalUrl"
1051
+ target="_blank"
1052
+ icon="i-ph-arrow-square-out"
1053
+ size="xs"
1054
+ color="neutral"
1055
+ variant="ghost"
1056
+ class="shrink-0"
1057
+ :aria-label="t('page.marketing.pages.preview.open_in_tab')"
1058
+ />
1059
+ </p>
1060
+ <p class="flex flex-wrap gap-x-3 text-xs text-muted">
1061
+ <span v-if="props.pageviews !== undefined">
1062
+ <b class="font-mono text-highlighted">
1063
+ {{ formatNumber(props.pageviews, locale) }}
1064
+ </b>
1065
+ {{ t('page.marketing.pages.views_unit', props.pageviews) }}
638
1066
  </span>
639
- <span v-if="capturedAtLabel">
640
- · {{ capturedAtLabel }}
1067
+ <span>
1068
+ <b class="font-mono text-highlighted">
1069
+ {{ formatNumber(totalClicks, locale) }}
1070
+ </b>
1071
+ {{ t('page.marketing.pages.clicks_unit', totalClicks) }}
1072
+ </span>
1073
+ <span v-if="scrollDepth !== null">
1074
+ <b class="font-mono text-highlighted">
1075
+ {{ formatNumber(scrollViews, locale) }}
1076
+ </b>
1077
+ {{ t('page.marketing.pages.sampled_unit', scrollViews) }}
641
1078
  </span>
642
1079
  </p>
643
1080
  </div>
644
- <div class="flex flex-wrap items-center gap-2">
645
- <UFieldGroup size="sm">
646
- <UButton
647
- icon="i-ph-cursor-click"
648
- :color="overlayMode === 'clicks' ? 'primary' : 'neutral'"
649
- variant="subtle"
650
- :label="t('page.marketing.pages.overlay.clicks')"
651
- @click="overlayMode = 'clicks'"
652
- />
653
- <UButton
654
- icon="i-ph-caret-double-down"
655
- :color="overlayMode === 'scroll' ? 'primary' : 'neutral'"
656
- variant="subtle"
657
- :label="t('page.marketing.pages.overlay.scroll')"
658
- @click="overlayMode = 'scroll'"
659
- />
660
- </UFieldGroup>
661
- <UFieldGroup size="sm">
662
- <UButton
663
- v-for="preset in LAYOUT_PRESETS"
664
- :key="preset.width"
665
- :icon="preset.icon"
666
- :color="layoutWidth === preset.width ? 'primary' : 'neutral'"
667
- variant="subtle"
668
- :title="`${t(preset.labelKey)} · ${preset.width}px`"
669
- :aria-label="`${t(preset.labelKey)} · ${preset.width}px`"
670
- :ui="{ leadingIcon: 'size-5' }"
671
- @click="layoutWidth = preset.width"
672
- />
673
- </UFieldGroup>
674
- <UInput
675
- v-model="heightField"
676
- type="number"
677
- :min="MIN_RENDER_HEIGHT"
678
- :placeholder="`${renderHeight}`"
679
- step="200"
1081
+ <div class="flex shrink-0 flex-wrap items-center gap-2">
1082
+ <DmsSegmented
1083
+ v-model="overlayMode"
1084
+ :items="overlayItems"
680
1085
  size="xs"
681
- class="w-24"
682
- :title="t('page.marketing.pages.preview.height_override')"
1086
+ :aria-label="t('page.marketing.pages.overlay.label')"
683
1087
  />
684
- <UButton
685
- v-if="externalUrl"
686
- icon="i-ph-arrow-square-out"
687
- :to="externalUrl"
688
- target="_blank"
689
- color="neutral"
690
- variant="ghost"
691
- size="xs"
692
- :label="t('page.marketing.pages.preview.open_in_tab')"
693
- />
694
- <UPopover v-if="props.website">
1088
+ <span ref="layoutSwitch" class="contents">
1089
+ <DmsSegmented
1090
+ v-model="layoutWidth"
1091
+ :items="layoutItems"
1092
+ size="xs"
1093
+ :aria-label="t('page.marketing.pages.preview.layout')"
1094
+ />
1095
+ </span>
1096
+ <UDropdownMenu :items="moreItems" :content="{ align: 'end' }">
695
1097
  <UButton
696
- icon="i-ph-camera"
1098
+ icon="i-ph-dots-three"
1099
+ size="xs"
697
1100
  color="neutral"
698
1101
  variant="ghost"
699
- size="xs"
700
- :loading="savingOptions"
701
- :title="t('page.marketing.pages.preview.options')"
702
- :aria-label="t('page.marketing.pages.preview.options')"
1102
+ :loading="savingOptions || resetting"
1103
+ :aria-label="t('page.marketing.pages.preview.more')"
703
1104
  />
704
- <template #content>
705
- <div class="flex w-80 flex-col gap-4 p-4">
706
- <USwitch
707
- :model-value="snapshotsEnabled"
708
- :disabled="savingOptions"
709
- :label="t('page.marketing.pages.preview.snapshots_enabled')"
710
- :description="t('page.marketing.pages.preview.snapshots_enabled_hint')"
711
- @update:model-value="(value: boolean) => updateSnapshotOptions({ snapshotsEnabled: value })"
712
- />
713
- <USwitch
714
- :model-value="props.website.snapshotMaskText === true"
715
- :disabled="savingOptions || !snapshotsEnabled"
716
- :label="t('page.marketing.pages.preview.mask_text')"
717
- :description="t('page.marketing.pages.preview.mask_text_hint')"
718
- @update:model-value="(value: boolean) => updateSnapshotOptions({ snapshotMaskText: value })"
719
- />
720
- </div>
721
- </template>
722
- </UPopover>
723
- <UButton
724
- icon="i-ph-eraser"
725
- color="error"
726
- variant="ghost"
727
- size="xs"
728
- :loading="resetting"
729
- :title="t('page.marketing.pages.reset.page_button')"
730
- @click="resetPageHeatmap"
731
- />
1105
+ </UDropdownMenu>
732
1106
  </div>
733
- </div>
1107
+ </header>
734
1108
 
735
- <!-- Backdrop-state banners. None of these affect the numbers below. -->
736
- <UAlert
737
- v-if="!snapshotsEnabled"
738
- icon="i-ph-camera-slash"
739
- color="neutral"
740
- variant="subtle"
741
- :title="t('page.marketing.pages.preview.snapshots_off')"
742
- :description="t('page.marketing.pages.preview.snapshots_off_hint')"
743
- :actions="[{
744
- label: t('page.marketing.pages.preview.enable_snapshots'),
745
- color: 'neutral',
746
- variant: 'subtle',
747
- loading: savingOptions,
748
- onClick: () => updateSnapshotOptions({ snapshotsEnabled: true }),
749
- }]"
750
- />
751
-
752
- <UAlert
753
- v-else-if="noSnapshotYet"
754
- icon="i-ph-camera"
755
- color="neutral"
756
- variant="subtle"
757
- :title="t('page.marketing.pages.preview.no_snapshot')"
758
- :description="t('page.marketing.pages.preview.no_snapshot_hint', { rate: Math.round(props.sampleRate * 100) })"
759
- />
760
-
761
- <UAlert
762
- v-else-if="snapshot && tooHeavy"
763
- icon="i-ph-file-x"
764
- color="warning"
765
- variant="subtle"
766
- :title="t('page.marketing.pages.preview.too_heavy')"
767
- :description="t('page.marketing.pages.preview.too_heavy_hint', {
768
- size: mebibytes(snapshot.bytes),
769
- limit: mebibytes(SNAPSHOT_BYTE_CAP),
770
- })"
771
- />
772
-
773
- <UAlert
774
- v-else-if="snapshot && !snapshot.exact"
775
- icon="i-ph-arrows-out-line-horizontal"
776
- color="neutral"
777
- variant="subtle"
778
- :title="t('page.marketing.pages.preview.approximate_layout')"
779
- :description="t('page.marketing.pages.preview.approximate_layout_hint', {
780
- layout: t(`page.marketing.pages.preview.layout_${snapshot.layout}`).toLowerCase(),
781
- })"
782
- />
783
-
784
- <!-- Geometry banners stand on their own: a reflowed layout can overflow
785
- too, and the second fact must not hide behind the first. -->
786
- <UAlert
787
- v-if="oversized"
788
- icon="i-ph-arrows-out-line-vertical"
789
- color="warning"
790
- variant="subtle"
791
- :title="t('page.marketing.pages.preview.oversized')"
792
- :description="t('page.marketing.pages.preview.oversized_hint')"
793
- />
794
-
795
- <UAlert
796
- v-else-if="overflowing"
797
- icon="i-ph-arrows-horizontal"
798
- color="neutral"
799
- variant="subtle"
800
- :title="t('page.marketing.pages.preview.overflow')"
801
- :description="t('page.marketing.pages.preview.overflow_hint', { width: measuredWidth })"
802
- />
803
-
804
- <UAlert
805
- v-if="truncatedState"
806
- icon="i-ph-warning"
807
- color="warning"
808
- variant="subtle"
809
- :title="truncatedState.title"
810
- :description="truncatedState.description"
811
- />
812
-
813
- <UAlert
814
- v-if="emptyState"
815
- :icon="overlayMode === 'clicks' ? 'i-ph-cursor-click' : 'i-ph-caret-double-down'"
816
- :title="emptyState.title"
817
- :description="emptyState.description"
818
- />
819
-
820
- <!-- Surface: only over a snapshot. While the snapshot is being fetched
821
- the space is reserved, so a page that has one does not jump. -->
822
- <div v-if="activeFailed" class="flex flex-col items-start gap-3">
823
- <p class="text-sm text-error">{{ t('page.marketing.pages.error') }}</p>
824
- <UButton
825
- icon="i-ph-arrow-clockwise"
826
- :label="t('page.marketing.pages.retry')"
827
- color="neutral"
828
- variant="subtle"
1109
+ <!-- One status line, or the one strip that needs attention -->
1110
+ <div v-if="statusStrip" class="px-3 pt-3">
1111
+ <DmsBanner
1112
+ :tone="statusStrip.tone"
829
1113
  size="sm"
830
- @click="retryActive"
831
- />
1114
+ :icon="statusStrip.icon"
1115
+ :title="statusStrip.title"
1116
+ :description="statusStrip.description"
1117
+ >
1118
+ <template v-if="statusStrip.action" #actions>
1119
+ <UButton
1120
+ size="xs"
1121
+ color="neutral"
1122
+ variant="outline"
1123
+ :loading="savingOptions"
1124
+ :label="statusStrip.action.label"
1125
+ :to="statusStrip.action.to"
1126
+ @click="statusStrip.action.run?.()"
1127
+ />
1128
+ </template>
1129
+ </DmsBanner>
832
1130
  </div>
1131
+ <p
1132
+ v-else-if="snapshot"
1133
+ class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-default px-4 py-2 text-xs text-muted"
1134
+ >
1135
+ <span class="flex items-center gap-1.5">
1136
+ <UIcon name="i-ph-camera" class="size-3.5 text-success" />
1137
+ {{ statusLine }}
1138
+ </span>
1139
+ <span class="text-dimmed">· {{ maskLine }}</span>
1140
+ </p>
1141
+
1142
+ <div class="grid min-h-0 xl:grid-cols-[minmax(0,1fr)_15rem]">
1143
+ <div class="min-w-0 p-3">
1144
+ <USkeleton
1145
+ v-if="surface === 'loading'"
1146
+ class="w-full rounded-lg"
1147
+ :style="{ height: `${PANE_HEIGHT}px` }"
1148
+ />
833
1149
 
834
- <USkeleton
835
- v-else-if="snapshotsEnabled && !snapshotSettled"
836
- class="w-full rounded-lg"
837
- :style="{ height: `${PANE_HEIGHT}px` }"
838
- />
839
-
840
- <!-- Kept mounted across refetches: tearing it down would re-render the
841
- snapshot — and re-measure it — on every period change. -->
842
- <div v-else-if="snapshot && backdrop !== null" class="relative">
843
- <div
844
- ref="container"
845
- class="overflow-y-scroll overflow-x-hidden rounded-lg border border-default bg-elevated"
846
- :style="{ height: `${PANE_HEIGHT}px` }"
847
- >
848
- <div class="relative" :style="{ height: `${scrolledHeight}px` }">
1150
+ <!-- Kept mounted across refetches: tearing it down would re-render the
1151
+ snapshot — and re-measure it — on every period change. -->
1152
+ <div v-else-if="surface === 'frame'" class="relative">
849
1153
  <div
850
- class="absolute left-0 top-0"
851
- :style="{
852
- width: `${layoutWidth}px`,
853
- height: `${renderHeight}px`,
854
- zoom: scale,
855
- }"
1154
+ ref="container"
1155
+ class="overflow-x-hidden overflow-y-scroll rounded-lg border border-default bg-elevated"
1156
+ :style="paneStyle"
856
1157
  >
857
- <!-- Inert on purpose: pointer-events none means no link, form or
858
- hover state inside the snapshot can fire, and the wheel
859
- scrolls the pane instead of dying in the frame. The sandbox
860
- allows same-origin and nothing else — no script, no form,
861
- no navigation — which is what lets useSnapshotFrame read the
862
- document directly while a capture that arrived through a
863
- public endpoint stays inert. `color-scheme` and the canvas
864
- color follow the visitor's mode at capture: the page's
865
- media queries resolve the way they did for them, and a page
866
- with no background of its own stays on the canvas it had. -->
867
- <iframe
868
- :key="frameKey"
869
- ref="frame"
870
- :srcdoc="frameDocument"
871
- class="pointer-events-none absolute left-0 top-0 w-full border-0"
1158
+ <!-- A layout narrower than the pane keeps its width, centred. -->
1159
+ <div
1160
+ class="relative mx-auto overflow-hidden"
1161
+ :class="
1162
+ surfaceWidth < containerWidth ? 'ring-1 ring-accented' : ''
1163
+ "
872
1164
  :style="{
873
- height: `${frameHeight}px`,
874
- colorScheme: snapshotScheme,
875
- backgroundColor: snapshotScheme === 'dark' ? DARK_CANVAS : LIGHT_CANVAS,
1165
+ width: `${surfaceWidth}px`,
1166
+ height: `${scrolledHeight}px`,
876
1167
  }"
877
- sandbox="allow-same-origin"
878
- scrolling="no"
879
- referrerpolicy="no-referrer"
880
- :title="props.path"
881
- />
882
- <DmsMarketingHeatmapCanvas
883
- v-if="overlayMode === 'clicks'"
884
- :points="drawnPoints"
885
- :max-weight="maxWeight"
886
- :width="overlayWidth"
887
- :height="renderHeight"
888
- />
1168
+ >
1169
+ <div
1170
+ class="absolute top-0 left-0"
1171
+ :style="{
1172
+ width: `${layoutWidth}px`,
1173
+ height: `${renderHeight}px`,
1174
+ zoom: scale,
1175
+ }"
1176
+ >
1177
+ <!-- Inert on purpose: pointer-events none means no link, form or
1178
+ hover state inside the snapshot can fire. The sandbox allows
1179
+ same-origin and nothing else — no script, no form, no
1180
+ navigation — which is what lets useSnapshotFrame read the
1181
+ document while a capture that arrived through a public
1182
+ endpoint stays inert. Hidden from assistive technology:
1183
+ "Most clicked" is its readable alternative. -->
1184
+ <iframe
1185
+ :key="frameKey"
1186
+ ref="frame"
1187
+ :srcdoc="frameDocument"
1188
+ class="pointer-events-none absolute top-0 left-0 w-full border-0"
1189
+ :style="{
1190
+ height: `${frameHeight}px`,
1191
+ colorScheme: snapshotScheme,
1192
+ backgroundColor:
1193
+ snapshotScheme === 'dark' ? DARK_CANVAS : LIGHT_CANVAS,
1194
+ }"
1195
+ sandbox="allow-same-origin"
1196
+ scrolling="no"
1197
+ referrerpolicy="no-referrer"
1198
+ aria-hidden="true"
1199
+ tabindex="-1"
1200
+ inert
1201
+ :title="props.path"
1202
+ />
1203
+ <DmsMarketingHeatmapCanvas
1204
+ v-if="overlayMode === 'clicks'"
1205
+ :points="drawnPoints"
1206
+ :max-weight="maxWeight"
1207
+ :width="overlayWidth"
1208
+ :height="renderHeight"
1209
+ />
1210
+ <span
1211
+ v-for="pin in pins"
1212
+ :key="pin.key"
1213
+ aria-hidden="true"
1214
+ class="pointer-events-none absolute flex -translate-x-1/2 -translate-y-1/2 items-center gap-1 rounded-full bg-neutral-900/90 px-2 py-0.5 font-mono font-semibold text-white shadow"
1215
+ :style="{
1216
+ left: `${pin.left * scale}px`,
1217
+ top: `${pin.top * scale}px`,
1218
+ zoom: 1 / scale,
1219
+ fontSize: '12px',
1220
+ }"
1221
+ >
1222
+ <span class="size-1.5 rounded-full bg-red-500" />
1223
+ {{ formatNumber(pin.weight, locale) }}
1224
+ </span>
1225
+ </div>
1226
+
1227
+ <!-- In the pane's scaled space, not the document's: depth only
1228
+ varies vertically, and the labels must not shrink with the
1229
+ page — see the component. -->
1230
+ <DmsMarketingScrollDepthOverlay
1231
+ v-if="overlayMode === 'scroll' && scrollViews > 0"
1232
+ :reached="scrollReached"
1233
+ :views="scrollViews"
1234
+ :height="scrolledHeight"
1235
+ :fold-ratio="scrollFoldRatio"
1236
+ />
1237
+ </div>
889
1238
  </div>
890
1239
 
891
- <!-- In the pane's scaled space, not the document's: depth only
892
- varies vertically, and the labels must not shrink with the
893
- page — see the component. -->
894
- <DmsMarketingScrollDepthOverlay
895
- v-if="overlayMode === 'scroll' && scrollViews > 0"
896
- :reached="scrollReached"
897
- :views="scrollViews"
898
- :height="scrolledHeight"
899
- :fold-ratio="scrollFoldRatio"
900
- />
901
- </div>
902
-
1240
+ <!-- Outside the scrollport on purpose: inside, it is laid out at the
1241
+ top of the content and scrolls out of sight on a tall page. -->
1242
+ <div
1243
+ v-if="activeLoading"
1244
+ class="absolute inset-0 flex items-center justify-center rounded-lg bg-default/60"
1245
+ >
1246
+ <UIcon
1247
+ name="i-ph-circle-notch"
1248
+ class="size-6 animate-spin text-muted"
1249
+ />
1250
+ </div>
1251
+ </div>
1252
+
1253
+ <!-- One empty state for every reason, in a plain wrapper: see `surface`. -->
1254
+ <div v-else>
1255
+ <DmsEmptyState
1256
+ :variant="surfaceEmpty.variant"
1257
+ :icon="surfaceEmpty.icon"
1258
+ :hatched="surface !== 'error'"
1259
+ :size="surface === 'error' ? 'sm' : undefined"
1260
+ :title="surfaceEmpty.title"
1261
+ :description="surfaceEmpty.description"
1262
+ >
1263
+ <template v-if="surface === 'error'" #actions>
1264
+ <UButton
1265
+ size="xs"
1266
+ color="neutral"
1267
+ variant="outline"
1268
+ icon="i-ph-arrow-clockwise"
1269
+ :label="t('page.marketing.common.retry')"
1270
+ @click="retryActive"
1271
+ />
1272
+ </template>
1273
+ </DmsEmptyState>
1274
+ </div>
903
1275
  </div>
904
1276
 
905
- <!-- Outside the scrollport on purpose: inside, it is laid out at the top
906
- of the content and scrolls out of sight on a tall page. -->
907
- <div
908
- v-if="activeLoading"
909
- class="absolute inset-0 flex items-center justify-center rounded-lg bg-default/60"
1277
+ <aside
1278
+ class="flex flex-col gap-4 border-t border-default p-4 xl:border-t-0 xl:border-l"
910
1279
  >
911
- <UIcon name="i-ph-circle-notch" class="size-6 animate-spin text-muted" />
912
- </div>
1280
+ <template v-if="overlayMode === 'clicks'">
1281
+ <div class="flex flex-col gap-1.5">
1282
+ <DmsEyebrow :label="t('page.marketing.pages.density')" />
1283
+ <span
1284
+ class="h-2 rounded-full"
1285
+ :style="{ backgroundImage: heatLegendGradient() }"
1286
+ />
1287
+ <span class="flex justify-between text-[10px] text-dimmed">
1288
+ <span>{{ t('page.marketing.pages.density_few') }}</span>
1289
+ <span>{{ t('page.marketing.pages.density_many') }}</span>
1290
+ </span>
1291
+ </div>
1292
+ <div class="flex flex-col gap-2">
1293
+ <DmsEyebrow :label="t('page.marketing.pages.most_clicked')" />
1294
+ <p v-if="mostClicked.length === 0" class="text-xs text-muted">
1295
+ {{ t('page.marketing.pages.most_clicked_empty') }}
1296
+ </p>
1297
+ <ul v-else class="flex flex-col gap-2.5">
1298
+ <li
1299
+ v-for="element in mostClicked"
1300
+ :key="element.key"
1301
+ class="flex min-w-0 flex-col"
1302
+ >
1303
+ <span class="flex items-start justify-between gap-2">
1304
+ <span
1305
+ class="truncate text-[13px] font-medium text-highlighted"
1306
+ :class="element.text ? '' : 'font-mono'"
1307
+ :title="element.text || element.selector"
1308
+ >
1309
+ {{
1310
+ element.text
1311
+ ? t('page.marketing.pages.quoted', {
1312
+ text: element.text,
1313
+ })
1314
+ : element.selector
1315
+ }}
1316
+ </span>
1317
+ <span class="font-mono text-xs text-highlighted">
1318
+ {{ formatNumber(element.weight, locale) }}
1319
+ </span>
1320
+ </span>
1321
+ <span
1322
+ v-if="element.text"
1323
+ class="truncate font-mono text-[10px] text-dimmed"
1324
+ :title="element.selector"
1325
+ >
1326
+ {{ element.selector }}
1327
+ </span>
1328
+ </li>
1329
+ </ul>
1330
+ </div>
1331
+ </template>
1332
+ <div v-else class="flex flex-col gap-2">
1333
+ <DmsEyebrow :label="t('page.marketing.pages.scroll.legend_title')" />
1334
+ <span
1335
+ class="h-2 rounded-full"
1336
+ :style="{ backgroundImage: heatLegendGradient(true) }"
1337
+ />
1338
+ <span class="flex justify-between text-[10px] text-dimmed">
1339
+ <span>{{ t('page.marketing.pages.scroll.legend_all') }}</span>
1340
+ <span>{{ t('page.marketing.pages.scroll.legend_few') }}</span>
1341
+ </span>
1342
+ <span
1343
+ v-if="scrollViews > 0 && scrollFoldRatio > 0 && scrollFoldRatio < 1"
1344
+ class="flex items-center gap-2 text-xs text-muted"
1345
+ >
1346
+ <span
1347
+ class="h-2 w-2 shrink-0 rounded-full bg-default shadow ring-1 ring-default"
1348
+ aria-hidden="true"
1349
+ />
1350
+ <span class="h-px w-4 shrink-0 bg-current" aria-hidden="true" />
1351
+ {{ t('page.marketing.pages.scroll.fold_line') }}
1352
+ </span>
1353
+ <p v-if="scrollViews > 0" class="text-xs text-muted">
1354
+ {{
1355
+ t('page.marketing.pages.scroll.median', {
1356
+ depth: medianDepth,
1357
+ })
1358
+ }}
1359
+ </p>
1360
+ <p class="text-xs text-dimmed">
1361
+ {{ t('page.marketing.pages.scroll.lines_hint') }}
1362
+ </p>
1363
+ </div>
1364
+ <div v-if="usedIn && usedIn.length > 0" class="flex flex-col gap-2">
1365
+ <DmsEyebrow :label="t('page.marketing.pages.used_in')" />
1366
+ <DmsAutoLink
1367
+ v-for="funnel in usedIn"
1368
+ :key="funnel._id"
1369
+ :to="funnelLink(funnel._id)"
1370
+ class="flex items-center gap-2 rounded-lg border border-default p-2.5 transition-colors hover:border-primary"
1371
+ >
1372
+ <DmsIconWell icon="i-ph-funnel" size="sm" tone="primary" />
1373
+ <span class="truncate text-sm font-medium text-highlighted">
1374
+ {{ funnel.name }}
1375
+ </span>
1376
+ </DmsAutoLink>
1377
+ </div>
1378
+ <p
1379
+ v-if="overlayMode === 'clicks'"
1380
+ class="mt-auto flex gap-1.5 text-xs text-dimmed"
1381
+ >
1382
+ <UIcon name="i-ph-crosshair" class="mt-0.5 size-3.5 shrink-0" />
1383
+ {{ t('page.marketing.pages.anchoring_note') }}
1384
+ </p>
1385
+ </aside>
913
1386
  </div>
914
- </div>
1387
+ </section>
915
1388
  </template>