@antelopejs/dms-marketing 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +143 -0
  3. package/dist/config.d.ts +13 -0
  4. package/dist/config.js +35 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/data-api/body.d.ts +1 -0
  7. package/dist/data-api/body.js +15 -0
  8. package/dist/data-api/body.js.map +1 -0
  9. package/dist/data-api/funnels.d.ts +32 -0
  10. package/dist/data-api/funnels.js +302 -0
  11. package/dist/data-api/funnels.js.map +1 -0
  12. package/dist/data-api/hidden-filter.d.ts +1 -0
  13. package/dist/data-api/hidden-filter.js +11 -0
  14. package/dist/data-api/hidden-filter.js.map +1 -0
  15. package/dist/data-api/index.d.ts +4 -0
  16. package/dist/data-api/index.js +21 -0
  17. package/dist/data-api/index.js.map +1 -0
  18. package/dist/data-api/tenant-scope.d.ts +15 -0
  19. package/dist/data-api/tenant-scope.js +43 -0
  20. package/dist/data-api/tenant-scope.js.map +1 -0
  21. package/dist/data-api/websites.d.ts +25 -0
  22. package/dist/data-api/websites.js +73 -0
  23. package/dist/data-api/websites.js.map +1 -0
  24. package/dist/db/index.d.ts +2 -0
  25. package/dist/db/index.js +19 -0
  26. package/dist/db/index.js.map +1 -0
  27. package/dist/db/models/funnels.model.d.ts +25 -0
  28. package/dist/db/models/funnels.model.js +39 -0
  29. package/dist/db/models/funnels.model.js.map +1 -0
  30. package/dist/db/models/index.d.ts +7 -0
  31. package/dist/db/models/index.js +24 -0
  32. package/dist/db/models/index.js.map +1 -0
  33. package/dist/db/models/marketing_events.model.d.ts +49 -0
  34. package/dist/db/models/marketing_events.model.js +85 -0
  35. package/dist/db/models/marketing_events.model.js.map +1 -0
  36. package/dist/db/models/marketing_sessions.model.d.ts +33 -0
  37. package/dist/db/models/marketing_sessions.model.js +50 -0
  38. package/dist/db/models/marketing_sessions.model.js.map +1 -0
  39. package/dist/db/models/marketing_settings.model.d.ts +23 -0
  40. package/dist/db/models/marketing_settings.model.js +13 -0
  41. package/dist/db/models/marketing_settings.model.js.map +1 -0
  42. package/dist/db/models/page_snapshots.model.d.ts +27 -0
  43. package/dist/db/models/page_snapshots.model.js +32 -0
  44. package/dist/db/models/page_snapshots.model.js.map +1 -0
  45. package/dist/db/models/retention.d.ts +6 -0
  46. package/dist/db/models/retention.js +26 -0
  47. package/dist/db/models/retention.js.map +1 -0
  48. package/dist/db/models/website_statistics.model.d.ts +31 -0
  49. package/dist/db/models/website_statistics.model.js +63 -0
  50. package/dist/db/models/website_statistics.model.js.map +1 -0
  51. package/dist/db/models/websites.model.d.ts +26 -0
  52. package/dist/db/models/websites.model.js +27 -0
  53. package/dist/db/models/websites.model.js.map +1 -0
  54. package/dist/db/tables/funnels.table.d.ts +31 -0
  55. package/dist/db/tables/funnels.table.js +54 -0
  56. package/dist/db/tables/funnels.table.js.map +1 -0
  57. package/dist/db/tables/index.d.ts +7 -0
  58. package/dist/db/tables/index.js +24 -0
  59. package/dist/db/tables/index.js.map +1 -0
  60. package/dist/db/tables/marketing_events.table.d.ts +15 -0
  61. package/dist/db/tables/marketing_events.table.js +66 -0
  62. package/dist/db/tables/marketing_events.table.js.map +1 -0
  63. package/dist/db/tables/marketing_sessions.table.d.ts +26 -0
  64. package/dist/db/tables/marketing_sessions.table.js +115 -0
  65. package/dist/db/tables/marketing_sessions.table.js.map +1 -0
  66. package/dist/db/tables/marketing_settings.table.d.ts +11 -0
  67. package/dist/db/tables/marketing_settings.table.js +51 -0
  68. package/dist/db/tables/marketing_settings.table.js.map +1 -0
  69. package/dist/db/tables/page_snapshots.table.d.ts +17 -0
  70. package/dist/db/tables/page_snapshots.table.js +72 -0
  71. package/dist/db/tables/page_snapshots.table.js.map +1 -0
  72. package/dist/db/tables/website_statistics.table.d.ts +14 -0
  73. package/dist/db/tables/website_statistics.table.js +60 -0
  74. package/dist/db/tables/website_statistics.table.js.map +1 -0
  75. package/dist/db/tables/websites.table.d.ts +13 -0
  76. package/dist/db/tables/websites.table.js +60 -0
  77. package/dist/db/tables/websites.table.js.map +1 -0
  78. package/dist/hooks/index.d.ts +1 -0
  79. package/dist/hooks/index.js +39 -0
  80. package/dist/hooks/index.js.map +1 -0
  81. package/dist/implementations/dms-marketing/index.d.ts +2 -0
  82. package/dist/implementations/dms-marketing/index.js +8 -0
  83. package/dist/implementations/dms-marketing/index.js.map +1 -0
  84. package/dist/index.d.ts +7 -0
  85. package/dist/index.js +89 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/pages/all.d.ts +5 -0
  88. package/dist/pages/all.js +22 -0
  89. package/dist/pages/all.js.map +1 -0
  90. package/dist/pages/campaigns/page.d.ts +9 -0
  91. package/dist/pages/campaigns/page.js +27 -0
  92. package/dist/pages/campaigns/page.js.map +1 -0
  93. package/dist/pages/funnels/page.d.ts +9 -0
  94. package/dist/pages/funnels/page.js +27 -0
  95. package/dist/pages/funnels/page.js.map +1 -0
  96. package/dist/pages/index.d.ts +4 -0
  97. package/dist/pages/index.js +21 -0
  98. package/dist/pages/index.js.map +1 -0
  99. package/dist/pages/module.d.ts +1 -0
  100. package/dist/pages/module.js +13 -0
  101. package/dist/pages/module.js.map +1 -0
  102. package/dist/pages/overview/page.d.ts +9 -0
  103. package/dist/pages/overview/page.js +27 -0
  104. package/dist/pages/overview/page.js.map +1 -0
  105. package/dist/pages/pages/page.d.ts +9 -0
  106. package/dist/pages/pages/page.js +27 -0
  107. package/dist/pages/pages/page.js.map +1 -0
  108. package/dist/pages/settings/form.d.ts +3 -0
  109. package/dist/pages/settings/form.js +35 -0
  110. package/dist/pages/settings/form.js.map +1 -0
  111. package/dist/pages/settings/page.d.ts +9 -0
  112. package/dist/pages/settings/page.js +27 -0
  113. package/dist/pages/settings/page.js.map +1 -0
  114. package/dist/routes/collect.d.ts +7 -0
  115. package/dist/routes/collect.js +62 -0
  116. package/dist/routes/collect.js.map +1 -0
  117. package/dist/routes/experiments.d.ts +6 -0
  118. package/dist/routes/experiments.js +63 -0
  119. package/dist/routes/experiments.js.map +1 -0
  120. package/dist/routes/funnels.d.ts +16 -0
  121. package/dist/routes/funnels.js +44 -0
  122. package/dist/routes/funnels.js.map +1 -0
  123. package/dist/routes/index.d.ts +8 -0
  124. package/dist/routes/index.js +25 -0
  125. package/dist/routes/index.js.map +1 -0
  126. package/dist/routes/settings.d.ts +8 -0
  127. package/dist/routes/settings.js +50 -0
  128. package/dist/routes/settings.js.map +1 -0
  129. package/dist/routes/snapshot.d.ts +10 -0
  130. package/dist/routes/snapshot.js +147 -0
  131. package/dist/routes/snapshot.js.map +1 -0
  132. package/dist/routes/stats.d.ts +44 -0
  133. package/dist/routes/stats.js +278 -0
  134. package/dist/routes/stats.js.map +1 -0
  135. package/dist/routes/tracker.d.ts +6 -0
  136. package/dist/routes/tracker.js +57 -0
  137. package/dist/routes/tracker.js.map +1 -0
  138. package/dist/routes/websites.d.ts +13 -0
  139. package/dist/routes/websites.js +144 -0
  140. package/dist/routes/websites.js.map +1 -0
  141. package/dist/services/acquisition.d.ts +15 -0
  142. package/dist/services/acquisition.js +153 -0
  143. package/dist/services/acquisition.js.map +1 -0
  144. package/dist/services/bounded-cache.d.ts +14 -0
  145. package/dist/services/bounded-cache.js +45 -0
  146. package/dist/services/bounded-cache.js.map +1 -0
  147. package/dist/services/collect.d.ts +11 -0
  148. package/dist/services/collect.js +193 -0
  149. package/dist/services/collect.js.map +1 -0
  150. package/dist/services/experiment-definitions.d.ts +4 -0
  151. package/dist/services/experiment-definitions.js +36 -0
  152. package/dist/services/experiment-definitions.js.map +1 -0
  153. package/dist/services/experiments/assign.d.ts +5 -0
  154. package/dist/services/experiments/assign.js +52 -0
  155. package/dist/services/experiments/assign.js.map +1 -0
  156. package/dist/services/experiments/index.d.ts +3 -0
  157. package/dist/services/experiments/index.js +20 -0
  158. package/dist/services/experiments/index.js.map +1 -0
  159. package/dist/services/experiments/results.d.ts +20 -0
  160. package/dist/services/experiments/results.js +91 -0
  161. package/dist/services/experiments/results.js.map +1 -0
  162. package/dist/services/experiments/stats.d.ts +12 -0
  163. package/dist/services/experiments/stats.js +62 -0
  164. package/dist/services/experiments/stats.js.map +1 -0
  165. package/dist/services/funnels/compute.d.ts +15 -0
  166. package/dist/services/funnels/compute.js +68 -0
  167. package/dist/services/funnels/compute.js.map +1 -0
  168. package/dist/services/funnels/index.d.ts +2 -0
  169. package/dist/services/funnels/index.js +19 -0
  170. package/dist/services/funnels/index.js.map +1 -0
  171. package/dist/services/funnels/results.d.ts +14 -0
  172. package/dist/services/funnels/results.js +72 -0
  173. package/dist/services/funnels/results.js.map +1 -0
  174. package/dist/services/geoip.d.ts +3 -0
  175. package/dist/services/geoip.js +38 -0
  176. package/dist/services/geoip.js.map +1 -0
  177. package/dist/services/heatmap.d.ts +10 -0
  178. package/dist/services/heatmap.js +180 -0
  179. package/dist/services/heatmap.js.map +1 -0
  180. package/dist/services/index.d.ts +26 -0
  181. package/dist/services/index.js +43 -0
  182. package/dist/services/index.js.map +1 -0
  183. package/dist/services/ingest-guard.d.ts +11 -0
  184. package/dist/services/ingest-guard.js +44 -0
  185. package/dist/services/ingest-guard.js.map +1 -0
  186. package/dist/services/keyed-queue.d.ts +4 -0
  187. package/dist/services/keyed-queue.js +19 -0
  188. package/dist/services/keyed-queue.js.map +1 -0
  189. package/dist/services/origin-guard.d.ts +5 -0
  190. package/dist/services/origin-guard.js +43 -0
  191. package/dist/services/origin-guard.js.map +1 -0
  192. package/dist/services/period.d.ts +2 -0
  193. package/dist/services/period.js +19 -0
  194. package/dist/services/period.js.map +1 -0
  195. package/dist/services/prune.d.ts +7 -0
  196. package/dist/services/prune.js +49 -0
  197. package/dist/services/prune.js.map +1 -0
  198. package/dist/services/query-param.d.ts +1 -0
  199. package/dist/services/query-param.js +11 -0
  200. package/dist/services/query-param.js.map +1 -0
  201. package/dist/services/request-body.d.ts +2 -0
  202. package/dist/services/request-body.js +41 -0
  203. package/dist/services/request-body.js.map +1 -0
  204. package/dist/services/request-meta.d.ts +3 -0
  205. package/dist/services/request-meta.js +19 -0
  206. package/dist/services/request-meta.js.map +1 -0
  207. package/dist/services/rollup-write.d.ts +4 -0
  208. package/dist/services/rollup-write.js +84 -0
  209. package/dist/services/rollup-write.js.map +1 -0
  210. package/dist/services/sessionize.d.ts +23 -0
  211. package/dist/services/sessionize.js +78 -0
  212. package/dist/services/sessionize.js.map +1 -0
  213. package/dist/services/settings.d.ts +6 -0
  214. package/dist/services/settings.js +113 -0
  215. package/dist/services/settings.js.map +1 -0
  216. package/dist/services/snapshots.d.ts +24 -0
  217. package/dist/services/snapshots.js +139 -0
  218. package/dist/services/snapshots.js.map +1 -0
  219. package/dist/services/tenant-funnel.d.ts +3 -0
  220. package/dist/services/tenant-funnel.js +17 -0
  221. package/dist/services/tenant-funnel.js.map +1 -0
  222. package/dist/services/tenant-website.d.ts +5 -0
  223. package/dist/services/tenant-website.js +34 -0
  224. package/dist/services/tenant-website.js.map +1 -0
  225. package/dist/services/url.d.ts +10 -0
  226. package/dist/services/url.js +81 -0
  227. package/dist/services/url.js.map +1 -0
  228. package/dist/services/user-agent.d.ts +2 -0
  229. package/dist/services/user-agent.js +17 -0
  230. package/dist/services/user-agent.js.map +1 -0
  231. package/dist/services/visitor-id.d.ts +1 -0
  232. package/dist/services/visitor-id.js +19 -0
  233. package/dist/services/visitor-id.js.map +1 -0
  234. package/dist/services/website-cache.d.ts +3 -0
  235. package/dist/services/website-cache.js +22 -0
  236. package/dist/services/website-cache.js.map +1 -0
  237. package/dist/types/constants.d.ts +74 -0
  238. package/dist/types/constants.js +79 -0
  239. package/dist/types/constants.js.map +1 -0
  240. package/dist/types/events.d.ts +114 -0
  241. package/dist/types/events.js +29 -0
  242. package/dist/types/events.js.map +1 -0
  243. package/dist/types/index.d.ts +5 -0
  244. package/dist/types/index.js +22 -0
  245. package/dist/types/index.js.map +1 -0
  246. package/dist/types/settings.d.ts +67 -0
  247. package/dist/types/settings.js +58 -0
  248. package/dist/types/settings.js.map +1 -0
  249. package/dist/types/snapshots.d.ts +95 -0
  250. package/dist/types/snapshots.js +41 -0
  251. package/dist/types/snapshots.js.map +1 -0
  252. package/dist/types/statistics.d.ts +38 -0
  253. package/dist/types/statistics.js +33 -0
  254. package/dist/types/statistics.js.map +1 -0
  255. package/dist/utils/experiment-type.d.ts +52 -0
  256. package/dist/utils/experiment-type.js +94 -0
  257. package/dist/utils/experiment-type.js.map +1 -0
  258. package/dist/utils/funnel-steps-type.d.ts +42 -0
  259. package/dist/utils/funnel-steps-type.js +62 -0
  260. package/dist/utils/funnel-steps-type.js.map +1 -0
  261. package/frontend-vue/.editorconfig +12 -0
  262. package/frontend-vue/.prettierrc +7 -0
  263. package/frontend-vue/app/components/CampaignsView.vue +330 -0
  264. package/frontend-vue/app/components/ExperimentInput.vue +128 -0
  265. package/frontend-vue/app/components/FunnelDetail.vue +1026 -0
  266. package/frontend-vue/app/components/FunnelStepsInput.vue +154 -0
  267. package/frontend-vue/app/components/FunnelsView.vue +493 -0
  268. package/frontend-vue/app/components/HeatmapCanvas.vue +116 -0
  269. package/frontend-vue/app/components/NoWebsiteState.vue +18 -0
  270. package/frontend-vue/app/components/OverviewView.vue +493 -0
  271. package/frontend-vue/app/components/PagePreview.vue +915 -0
  272. package/frontend-vue/app/components/PagesView.vue +378 -0
  273. package/frontend-vue/app/components/ScrollDepthOverlay.vue +158 -0
  274. package/frontend-vue/app/components/TextKpiCard.vue +28 -0
  275. package/frontend-vue/app/components/TopListTabsCard.vue +162 -0
  276. package/frontend-vue/app/components/TrackerSnippet.vue +31 -0
  277. package/frontend-vue/app/components/VariationsInput.vue +164 -0
  278. package/frontend-vue/app/composables/useChildId.ts +13 -0
  279. package/frontend-vue/app/composables/useExperiment.ts +59 -0
  280. package/frontend-vue/app/composables/useFunnelSteps.ts +25 -0
  281. package/frontend-vue/app/composables/useLatestRequest.ts +90 -0
  282. package/frontend-vue/app/composables/useMarketingApi.ts +420 -0
  283. package/frontend-vue/app/composables/useMarketingPeriod.ts +66 -0
  284. package/frontend-vue/app/composables/useMarketingRoutes.ts +51 -0
  285. package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +69 -0
  286. package/frontend-vue/app/composables/useMarketingWebsites.ts +80 -0
  287. package/frontend-vue/app/composables/useSnapshotFrame.ts +183 -0
  288. package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +46 -0
  289. package/frontend-vue/app/utils/dmsComponent.ts +18 -0
  290. package/frontend-vue/app/utils/heatmapPalette.ts +41 -0
  291. package/frontend-vue/app/utils/snapshotViewportUnits.ts +63 -0
  292. package/frontend-vue/dms.frontend.ts +35 -0
  293. package/frontend-vue/eslint.config.mjs +16 -0
  294. package/frontend-vue/i18n/locales/marketing-en-GB.json +382 -0
  295. package/frontend-vue/i18n/locales/marketing-fr-FR.json +382 -0
  296. package/frontend-vue/package.json +30 -0
  297. package/frontend-vue/pnpm-lock.yaml +5226 -0
  298. package/frontend-vue/pnpm-workspace.yaml +2 -0
  299. package/frontend-vue/tests/drafts.test.ts +54 -0
  300. package/frontend-vue/tests/i18n.test.ts +86 -0
  301. package/frontend-vue/tests/period.test.ts +35 -0
  302. package/frontend-vue/tests/registration.test.ts +63 -0
  303. package/frontend-vue/tests/routes.test.ts +31 -0
  304. package/frontend-vue/tests/stubs/frontend-module.ts +56 -0
  305. package/frontend-vue/vitest.config.ts +20 -0
  306. package/package.json +91 -0
  307. package/static/snapshot.js +497 -0
  308. package/static/tracker.js +503 -0
@@ -0,0 +1,915 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
3
+ import { useI18n } from '#dms/frontend-module'
4
+ import { useLatestRequest } from '../composables/useLatestRequest'
5
+ import {
6
+ useMarketingApi,
7
+ type MarketingHeatmap,
8
+ type MarketingPageSnapshotResponse,
9
+ type MarketingScrollDepth,
10
+ type MarketingSnapshotLayout,
11
+ type MarketingWebsite,
12
+ type MarketingWebsitePatch,
13
+ } from '../composables/useMarketingApi'
14
+ import {
15
+ useSnapshotFrame,
16
+ type AnchorBox,
17
+ type AnchorRequest,
18
+ } from '../composables/useSnapshotFrame'
19
+ import { rewriteViewportUnits } from '../utils/snapshotViewportUnits'
20
+
21
+ /**
22
+ * Click and scroll-depth overlays of one tracked page, over a snapshot of
23
+ * that page. The two overlays switch above ONE persistent frame — plain
24
+ * buttons, never a Tab block, whose default unmounting of hidden slots would
25
+ * tear the frame down and re-measure it on every switch.
26
+ *
27
+ * The invariant of this surface: THE NUMBERS NEVER DEGRADE. Points, weights
28
+ * and totals come from the database; the snapshot is the surface they are
29
+ * drawn on. Without one — snapshots off for the site, no sampled visit
30
+ * captured yet — nothing is drawn: the counts stay in the header, the
31
+ * banner says why the page has no backdrop and when it will, and no empty
32
+ * pane pretends to be a page.
33
+ *
34
+ * The wallpaper is a captured DOM, not the live site: what a sampled visitor
35
+ * saw, rendered in a `srcdoc` frame sandboxed without scripts. Same-origin,
36
+ * so its geometry is read directly; inert, so pointer events never reach it
37
+ * and nothing in it can be clicked, hovered or navigated. Interacting with
38
+ * the real page goes through "open in a new tab".
39
+ *
40
+ * ## Geometry
41
+ *
42
+ * The frame is laid out at the chosen width and pinned to the captured
43
+ * viewport height until the snapshot's size is read (see useSnapshotFrame)
44
+ * — never at the height it is about to report, which is what would make
45
+ * every `100vh` page grow without bound. Once a size is published the frame
46
+ * expands to it, once. Viewport-relative units are rewritten to the pixels
47
+ * they had in the visitor's viewport before the document is rendered (see
48
+ * rewriteViewportUnits), so the reading is the visitor's page height and
49
+ * the resize changes nothing inside. The frame is remounted (`:key`)
50
+ * whenever the snapshot or the layout width changes, so the composable
51
+ * re-arms on a fresh document.
52
+ */
53
+ const props = defineProps<{
54
+ website: MarketingWebsite | null
55
+ path: string
56
+ period: string
57
+ /** Share of visits capturing clicks — why a quiet page looks empty. */
58
+ sampleRate: number
59
+ trackerEnabled: boolean
60
+ /** Pageviews of this path over the window, for the sampling hint. */
61
+ pageviews?: number
62
+ /** Bumped by the parent when it resets clicks out from under this view. */
63
+ refreshToken?: number
64
+ }>()
65
+
66
+ /** The site's snapshot options were changed here; the parent owns the row. */
67
+ const emit = defineEmits<{ 'website-updated': [] }>()
68
+
69
+ interface LayoutPreset {
70
+ width: number
71
+ layout: MarketingSnapshotLayout
72
+ icon: string
73
+ labelKey: string
74
+ }
75
+
76
+ /**
77
+ * Layouts offered for inspection; a mobile layout is a different page. The
78
+ * tablet is upright on purpose: lying flat it is 1024+ wide, a width most
79
+ * breakpoints already dress as desktop — the button would repeat 1280.
80
+ */
81
+ 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' },
85
+ ]
86
+ const DEFAULT_LAYOUT_WIDTH = 1280
87
+ /** Viewport the snapshot is measured through when it recorded none. */
88
+ const PROBE_VIEWPORT_HEIGHT = 900
89
+ /** Height of the scrollable pane the scaled surface lives in. */
90
+ const PANE_HEIGHT = 620
91
+ /** Overflow beyond the chosen layout width worth telling the operator about. */
92
+ const OVERFLOW_TOLERANCE = 1.05
93
+ const SNAPSHOT_BYTE_CAP = 1024 * 1024
94
+ const LIGHT_CANVAS = '#ffffff'
95
+ const DARK_CANVAS = '#111111'
96
+
97
+ const api = useMarketingApi()
98
+ const { t, locale } = useI18n()
99
+
100
+ // --- Heatmap ----------------------------------------------------------------
101
+
102
+ // Path, site and period all retrigger a load and the answers can land out of
103
+ // order: a slow answer for the page the user just left would otherwise repaint
104
+ // over the one on screen — see useLatestRequest.
105
+ const {
106
+ data: heatmap,
107
+ loading,
108
+ failed,
109
+ run: loadHeatmap,
110
+ } = useLatestRequest<MarketingHeatmap>(() =>
111
+ props.website && props.path && !import.meta.env.SSR
112
+ ? api.getHeatmap(props.website._id, props.path, props.period)
113
+ : null,
114
+ )
115
+
116
+ watch(
117
+ () => [props.website?._id, props.path, props.period, props.refreshToken],
118
+ () => void loadHeatmap(),
119
+ { immediate: true },
120
+ )
121
+
122
+ const points = computed(() => heatmap.value?.points ?? [])
123
+ const maxWeight = computed(() => heatmap.value?.maxWeight ?? 0)
124
+ const totalClicks = computed(() => heatmap.value?.totalClicks ?? 0)
125
+
126
+ // --- Scroll depth -----------------------------------------------------------
127
+
128
+ const overlayMode = ref<'clicks' | 'scroll'>('clicks')
129
+
130
+ const {
131
+ data: scrollDepth,
132
+ loading: loadingScroll,
133
+ failed: scrollFailed,
134
+ run: loadScrollDepth,
135
+ } = useLatestRequest<MarketingScrollDepth>(() =>
136
+ props.website && props.path && !import.meta.env.SSR
137
+ ? api.getScrollDepth(props.website._id, props.path, props.period)
138
+ : null,
139
+ )
140
+
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
+ watch(
161
+ () => [props.website?._id, props.path, props.period, props.refreshToken, overlayMode.value],
162
+ ensureScrollDepth,
163
+ { immediate: true },
164
+ )
165
+
166
+ const scrollViews = computed(() => scrollDepth.value?.views ?? 0)
167
+ const scrollReached = computed(() => scrollDepth.value?.reached ?? [])
168
+
169
+ /**
170
+ * Where depth 0 sits on the drawn page: the bottom of the first screen.
171
+ * Depth is a fraction of the visitor's scrollable range; the captured
172
+ * viewport stands in for the visitors' real ones, so the overlay claims
173
+ * bands, not pixels.
174
+ */
175
+ const scrollFoldRatio = computed(() =>
176
+ renderHeight.value > 0
177
+ ? Math.min(probeHeight.value / renderHeight.value, 1)
178
+ : 1,
179
+ )
180
+
181
+ // --- Reset ------------------------------------------------------------------
182
+
183
+ const { confirm } = useConfirm()
184
+ const toast = useToast()
185
+ const resetting = ref(false)
186
+
187
+ /** Deletes the clicks, scrolls and snapshots of the page (every period),
188
+ * then refetches the proof for whichever overlay is showing. */
189
+ async function resetPageHeatmap() {
190
+ const website = props.website
191
+ if (!website) {
192
+ return
193
+ }
194
+ const confirmed = await confirm({
195
+ 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',
199
+ })
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
+ }
221
+
222
+ // --- Snapshot ---------------------------------------------------------------
223
+
224
+ const layoutWidth = ref(DEFAULT_LAYOUT_WIDTH)
225
+
226
+ const layout = computed<MarketingSnapshotLayout>(
227
+ () => LAYOUT_PRESETS.find(preset => preset.width === layoutWidth.value)?.layout ?? 'desktop',
228
+ )
229
+
230
+ const snapshotsEnabled = computed(() => props.website?.snapshotsEnabled ?? false)
231
+
232
+ // Not fetched while the site has snapshots off: the option's banner is the
233
+ // whole story then, and a leftover capture must not contradict it.
234
+ const {
235
+ data: snapshotResponse,
236
+ settled: snapshotSettled,
237
+ run: loadSnapshot,
238
+ } = useLatestRequest<MarketingPageSnapshotResponse>(() =>
239
+ props.website && snapshotsEnabled.value && props.path && !import.meta.env.SSR
240
+ ? api.getSnapshot(props.website._id, props.path, layout.value)
241
+ : null,
242
+ )
243
+
244
+ watch(
245
+ () => [props.website?._id, snapshotsEnabled.value, props.path, layout.value, props.refreshToken],
246
+ () => void loadSnapshot(),
247
+ { immediate: true },
248
+ )
249
+
250
+ const snapshot = computed(() => snapshotResponse.value?.snapshot ?? null)
251
+
252
+ const backdrop = computed(() => snapshot.value?.html ?? null)
253
+
254
+ /** The document the frame renders: the capture, its viewport units fixed
255
+ * to the visitor's screen at the width being looked at. */
256
+ const frameDocument = computed(() =>
257
+ snapshot.value && backdrop.value !== null
258
+ ? rewriteViewportUnits(backdrop.value, {
259
+ width: layoutWidth.value,
260
+ height: snapshot.value.viewport.height,
261
+ })
262
+ : '',
263
+ )
264
+ const noSnapshotYet = computed(
265
+ () => snapshotsEnabled.value && snapshotSettled.value && snapshot.value === null,
266
+ )
267
+ const tooHeavy = computed(() => snapshot.value !== null && backdrop.value === null)
268
+ 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
+
282
+ function mebibytes(bytes: number): string {
283
+ return new Intl.NumberFormat(locale.value, { maximumFractionDigits: 1 }).format(
284
+ bytes / (1024 * 1024),
285
+ )
286
+ }
287
+
288
+ const savingOptions = ref(false)
289
+
290
+ async function updateSnapshotOptions(patch: MarketingWebsitePatch): Promise<void> {
291
+ const website = props.website
292
+ if (!website) {
293
+ return
294
+ }
295
+ savingOptions.value = true
296
+ try {
297
+ await api.updateWebsite(website._id, patch)
298
+ emit('website-updated')
299
+ }
300
+ catch {
301
+ toast.add({ color: 'error', title: t('page.marketing.pages.preview.options_error') })
302
+ }
303
+ finally {
304
+ savingOptions.value = false
305
+ }
306
+ }
307
+
308
+ // --- Frame geometry ---------------------------------------------------------
309
+
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
+ 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
+ })
345
+
346
+ const frameKey = computed(() =>
347
+ [props.path, snapshot.value?.layout, snapshot.value?.capturedAt, layoutWidth.value].join('|'),
348
+ )
349
+
350
+ /** Viewport the frame is pinned to until its size is read. */
351
+ const probeHeight = computed(() => snapshot.value?.viewport.height ?? PROBE_VIEWPORT_HEIGHT)
352
+
353
+ /**
354
+ * Height of the drawn surface — the overlay's coordinate space. Falls back to
355
+ * a typical long page while the real one is unknown, so the heatmap is
356
+ * readable before (and without) any snapshot.
357
+ */
358
+ const renderHeight = computed(() => {
359
+ const chosen = heightOverride.value ?? measuredHeight.value
360
+ if (chosen !== null) {
361
+ return Math.min(Math.max(chosen, MIN_RENDER_HEIGHT), MAX_RENDER_HEIGHT)
362
+ }
363
+ return DEFAULT_RENDER_HEIGHT
364
+ })
365
+
366
+ /**
367
+ * Height of the frame itself. Pinned to the probe viewport while a reading
368
+ * is still possible — resizing it earlier would corrupt the very number
369
+ * being waited on. An operator override takes it back by hand.
370
+ */
371
+ const frameHeight = computed(() => {
372
+ if (heightOverride.value !== null) {
373
+ return renderHeight.value
374
+ }
375
+ if (measuredHeight.value !== null) {
376
+ return measuredHeight.value
377
+ }
378
+ return tier.value === 'pending' ? probeHeight.value : renderHeight.value
379
+ })
380
+
381
+ /**
382
+ * Width the overlay maps onto. Clicks are normalized against the document's
383
+ * `scrollWidth`, so a document wider than the frame must widen the canvas or
384
+ * every x is compressed — while the frame itself stays at the chosen layout
385
+ * width, which is what keeps the width buttons meaningful. The overflowing
386
+ * part is clipped by the pane, exactly as it is clipped inside the frame.
387
+ */
388
+ const overlayWidth = computed(() =>
389
+ Math.min(Math.max(measuredWidth.value ?? 0, layoutWidth.value), MAX_RENDER_WIDTH),
390
+ )
391
+
392
+ // --- Element anchoring ------------------------------------------------------
393
+
394
+ /**
395
+ * A click is stored twice: as a document fraction, and as an offset inside
396
+ * the element it hit. Only the second survives being read back at another
397
+ * size — a document fraction divides by a box that, on a page shorter than
398
+ * the visitor's window, IS that window, so the same button arrives here as a
399
+ * different fraction per visitor. Given the element's box in the layout on
400
+ * screen, the offset puts the point back where the visitor put it.
401
+ *
402
+ * The boxes come from the snapshot document. Every way that can fail — no
403
+ * snapshot, an element that no longer exists, a selector that became
404
+ * ambiguous — resolves to no box, and a point with no box keeps its document
405
+ * fraction, never a blank one.
406
+ */
407
+ const anchorBoxes = ref(new Map<string, AnchorBox>())
408
+
409
+ function anchorKey(anchor: AnchorRequest): string {
410
+ return `${anchor.selector}\0${anchor.nth}`
411
+ }
412
+
413
+ const requestedAnchors = computed(() => {
414
+ const unique = new Map<string, AnchorRequest>()
415
+ for (const point of points.value) {
416
+ if (!point.anchor) {
417
+ continue
418
+ }
419
+ const request = { selector: point.anchor.selector, nth: point.anchor.nth }
420
+ unique.set(anchorKey(request), request)
421
+ }
422
+ return [...unique.values()]
423
+ })
424
+
425
+ /** Points in the overlay's coordinate space, anchored where one resolved. */
426
+ const drawnPoints = computed(() =>
427
+ points.value.map((point) => {
428
+ const anchor = point.anchor
429
+ if (!anchor) {
430
+ return point
431
+ }
432
+ const box = anchorBoxes.value.get(anchorKey(anchor))
433
+ if (!box || overlayWidth.value <= 0 || renderHeight.value <= 0) {
434
+ return point
435
+ }
436
+ return {
437
+ ...point,
438
+ x: (box.x + anchor.ox * box.width) / overlayWidth.value,
439
+ y: (box.y + anchor.oy * box.height) / renderHeight.value,
440
+ }
441
+ }),
442
+ )
443
+
444
+ let measureToken = 0
445
+
446
+ /**
447
+ * Re-measure whenever the layout the boxes were taken in changes. The token
448
+ * drops a pass for a geometry that is no longer on screen: the frame is
449
+ * resized from the published reading, and a box read before that resize has
450
+ * been laid out measures the probe viewport.
451
+ */
452
+ async function refreshAnchorBoxes(): Promise<void> {
453
+ const token = ++measureToken
454
+ if (tier.value !== 'measured' || requestedAnchors.value.length === 0) {
455
+ if (anchorBoxes.value.size > 0) {
456
+ anchorBoxes.value = new Map()
457
+ }
458
+ return
459
+ }
460
+ await nextTick()
461
+ await new Promise(resolve => requestAnimationFrame(resolve))
462
+ if (token !== measureToken) {
463
+ return
464
+ }
465
+ const resolved = new Map<string, AnchorBox>()
466
+ for (const box of measureAnchors(requestedAnchors.value)) {
467
+ resolved.set(anchorKey(box), box)
468
+ }
469
+ anchorBoxes.value = resolved
470
+ }
471
+
472
+ watch(
473
+ [requestedAnchors, tier, renderHeight, overlayWidth],
474
+ () => void refreshAnchorBoxes(),
475
+ )
476
+
477
+ /**
478
+ * The document does not fit the surface the canvas can allocate. Both are
479
+ * clamped, so the overlay is uniformly compressed against a backdrop that
480
+ * is merely cut off — the two no longer line up, and that is worth saying
481
+ * rather than showing.
482
+ */
483
+ const oversized = computed(
484
+ () =>
485
+ (measuredHeight.value ?? 0) > MAX_RENDER_HEIGHT
486
+ || (measuredWidth.value ?? 0) > MAX_RENDER_WIDTH,
487
+ )
488
+
489
+ const overflowing = computed(
490
+ () => (measuredWidth.value ?? 0) > layoutWidth.value * OVERFLOW_TOLERANCE,
491
+ )
492
+
493
+ // The surface is laid out at `layoutWidth` and zoomed down to the pane, so the
494
+ // overlay and the page share one coordinate space at any container size.
495
+ // `zoom`, not `transform: scale()`: a transformed frame is rasterized at its
496
+ // natural size and resampled, and at a fractional scale the thin borders of
497
+ // inputs and cards drop out; zoom reaches the frame's document as a page
498
+ // zoom, laid out at the same CSS width and painted crisp at the scale. The
499
+ // pane always reserves its scrollbar (`overflow-y-scroll`): letting it appear
500
+ // and disappear would change the width the scale is derived from, which
501
+ // changes the height, which decides whether the scrollbar is there at all.
502
+ const container = ref<HTMLElement | null>(null)
503
+ const containerWidth = ref(0)
504
+ let observer: ResizeObserver | null = null
505
+
506
+ watch(container, (el) => {
507
+ observer?.disconnect()
508
+ observer = null
509
+ if (!el) {
510
+ return
511
+ }
512
+ containerWidth.value = el.clientWidth
513
+ observer = new ResizeObserver(() => {
514
+ containerWidth.value = el.clientWidth
515
+ })
516
+ observer.observe(el)
517
+ })
518
+
519
+ onBeforeUnmount(() => observer?.disconnect())
520
+
521
+ const scale = computed(() =>
522
+ containerWidth.value > 0 ? containerWidth.value / layoutWidth.value : 1,
523
+ )
524
+
525
+ /** Height of the zoomed surface in the pane's own pixels — the scroll range
526
+ * 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 ---------------------------------------------------
530
+
531
+ /**
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.
536
+ */
537
+ const expectedSampledPageLoads = computed(() =>
538
+ props.pageviews ? Math.round(props.pageviews * props.sampleRate) : null,
539
+ )
540
+
541
+ /** Silence has shared causes (collection off, sampled out) before the
542
+ * per-overlay one; both overlays explain themselves through this. */
543
+ function captureEmptyReason(fallbackKey: string): string {
544
+ if (!props.trackerEnabled) {
545
+ return t('page.marketing.pages.empty.tracking_off')
546
+ }
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
+ })
551
+ }
552
+ return t(fallbackKey)
553
+ }
554
+
555
+ const emptyState = computed(() => {
556
+ if (overlayMode.value === 'clicks') {
557
+ if (loading.value || heatmap.value === null || totalClicks.value > 0) {
558
+ return null
559
+ }
560
+ return {
561
+ title: t('page.marketing.pages.empty.title'),
562
+ description: captureEmptyReason('page.marketing.pages.empty.no_clicks'),
563
+ }
564
+ }
565
+ if (loadingScroll.value || scrollDepth.value === null || scrollViews.value > 0) {
566
+ return null
567
+ }
568
+ return {
569
+ title: t('page.marketing.pages.empty.scroll_title'),
570
+ description: captureEmptyReason('page.marketing.pages.empty.no_scrolls'),
571
+ }
572
+ })
573
+
574
+ // --- Per-overlay surface state ----------------------------------------------
575
+
576
+ const activeFailed = computed(() =>
577
+ overlayMode.value === 'clicks' ? failed.value : scrollFailed.value,
578
+ )
579
+
580
+ const activeLoading = computed(() =>
581
+ overlayMode.value === 'clicks' ? loading.value : loadingScroll.value,
582
+ )
583
+
584
+ function retryActive(): void {
585
+ if (overlayMode.value === 'clicks') {
586
+ void loadHeatmap()
587
+ return
588
+ }
589
+ void loadScrollDepth()
590
+ }
591
+
592
+ const truncatedState = computed(() => {
593
+ if (overlayMode.value === 'clicks' && heatmap.value?.truncated) {
594
+ return {
595
+ title: t('page.marketing.pages.truncated'),
596
+ description: t('page.marketing.pages.truncated_hint'),
597
+ }
598
+ }
599
+ if (overlayMode.value === 'scroll' && scrollDepth.value?.truncated) {
600
+ return {
601
+ title: t('page.marketing.pages.scroll.truncated'),
602
+ description: t('page.marketing.pages.scroll.truncated_hint'),
603
+ }
604
+ }
605
+ return null
606
+ })
607
+
608
+ /** The real page, on the origin the snapshot was captured from — opened,
609
+ * never framed. */
610
+ const externalUrl = computed(() => {
611
+ if (!snapshot.value || !props.path) {
612
+ return ''
613
+ }
614
+ try {
615
+ return new URL(props.path, snapshot.value.origin).toString()
616
+ }
617
+ catch {
618
+ return ''
619
+ }
620
+ })
621
+ </script>
622
+
623
+ <template>
624
+ <div class="flex flex-col gap-4 p-4">
625
+ <!-- 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) }}
638
+ </span>
639
+ <span v-if="capturedAtLabel">
640
+ · {{ capturedAtLabel }}
641
+ </span>
642
+ </p>
643
+ </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"
680
+ size="xs"
681
+ class="w-24"
682
+ :title="t('page.marketing.pages.preview.height_override')"
683
+ />
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">
695
+ <UButton
696
+ icon="i-ph-camera"
697
+ color="neutral"
698
+ 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')"
703
+ />
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
+ />
732
+ </div>
733
+ </div>
734
+
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"
829
+ size="sm"
830
+ @click="retryActive"
831
+ />
832
+ </div>
833
+
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` }">
849
+ <div
850
+ class="absolute left-0 top-0"
851
+ :style="{
852
+ width: `${layoutWidth}px`,
853
+ height: `${renderHeight}px`,
854
+ zoom: scale,
855
+ }"
856
+ >
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"
872
+ :style="{
873
+ height: `${frameHeight}px`,
874
+ colorScheme: snapshotScheme,
875
+ backgroundColor: snapshotScheme === 'dark' ? DARK_CANVAS : LIGHT_CANVAS,
876
+ }"
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
+ />
889
+ </div>
890
+
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
+
903
+ </div>
904
+
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"
910
+ >
911
+ <UIcon name="i-ph-circle-notch" class="size-6 animate-spin text-muted" />
912
+ </div>
913
+ </div>
914
+ </div>
915
+ </template>