@antelopejs/dms-marketing 0.3.3 → 0.4.1

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 (289) 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 -4
  36. package/dist/hooks/index.js.map +1 -1
  37. package/dist/index.js +18 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/pages/acquisition/page.d.ts +9 -0
  40. package/dist/pages/acquisition/page.js +181 -0
  41. package/dist/pages/acquisition/page.js.map +1 -0
  42. package/dist/pages/all.d.ts +5 -1
  43. package/dist/pages/all.js +5 -1
  44. package/dist/pages/all.js.map +1 -1
  45. package/dist/pages/blocks.d.ts +18 -0
  46. package/dist/pages/blocks.js +56 -0
  47. package/dist/pages/blocks.js.map +1 -0
  48. package/dist/pages/funnel/page.d.ts +9 -0
  49. package/dist/pages/funnel/page.js +36 -0
  50. package/dist/pages/funnel/page.js.map +1 -0
  51. package/dist/pages/funnel-builder/page.d.ts +9 -0
  52. package/dist/pages/funnel-builder/page.js +29 -0
  53. package/dist/pages/funnel-builder/page.js.map +1 -0
  54. package/dist/pages/funnels/page.d.ts +1 -1
  55. package/dist/pages/funnels/page.js +200 -3
  56. package/dist/pages/funnels/page.js.map +1 -1
  57. package/dist/pages/index.d.ts +1 -0
  58. package/dist/pages/index.js +1 -0
  59. package/dist/pages/index.js.map +1 -1
  60. package/dist/pages/{campaigns → install}/page.d.ts +3 -3
  61. package/dist/pages/{campaigns → install}/page.js +14 -12
  62. package/dist/pages/install/page.js.map +1 -0
  63. package/dist/pages/module.d.ts +3 -0
  64. package/dist/pages/module.js +30 -3
  65. package/dist/pages/module.js.map +1 -1
  66. package/dist/pages/overview/page.d.ts +1 -1
  67. package/dist/pages/overview/page.js +122 -3
  68. package/dist/pages/overview/page.js.map +1 -1
  69. package/dist/pages/pages/page.d.ts +1 -1
  70. package/dist/pages/pages/page.js +9 -4
  71. package/dist/pages/pages/page.js.map +1 -1
  72. package/dist/pages/quick-actions.d.ts +1 -0
  73. package/dist/pages/quick-actions.js +25 -0
  74. package/dist/pages/quick-actions.js.map +1 -0
  75. package/dist/pages/settings/form.d.ts +2 -1
  76. package/dist/pages/settings/form.js +62 -20
  77. package/dist/pages/settings/form.js.map +1 -1
  78. package/dist/pages/settings/page.d.ts +4 -1
  79. package/dist/pages/settings/page.js +50 -4
  80. package/dist/pages/settings/page.js.map +1 -1
  81. package/dist/pages/websites/page.d.ts +11 -0
  82. package/dist/pages/websites/page.js +62 -0
  83. package/dist/pages/websites/page.js.map +1 -0
  84. package/dist/routes/blocks.d.ts +50 -0
  85. package/dist/routes/blocks.js +269 -0
  86. package/dist/routes/blocks.js.map +1 -0
  87. package/dist/routes/collect.d.ts +1 -2
  88. package/dist/routes/collect.js +19 -12
  89. package/dist/routes/collect.js.map +1 -1
  90. package/dist/routes/context.d.ts +8 -0
  91. package/dist/routes/context.js +54 -0
  92. package/dist/routes/context.js.map +1 -0
  93. package/dist/routes/experiments.js +2 -1
  94. package/dist/routes/experiments.js.map +1 -1
  95. package/dist/routes/funnels.d.ts +21 -3
  96. package/dist/routes/funnels.js +150 -7
  97. package/dist/routes/funnels.js.map +1 -1
  98. package/dist/routes/index.d.ts +2 -0
  99. package/dist/routes/index.js +2 -0
  100. package/dist/routes/index.js.map +1 -1
  101. package/dist/routes/settings.d.ts +6 -1
  102. package/dist/routes/settings.js +65 -4
  103. package/dist/routes/settings.js.map +1 -1
  104. package/dist/routes/snapshot.d.ts +1 -1
  105. package/dist/routes/snapshot.js +24 -25
  106. package/dist/routes/snapshot.js.map +1 -1
  107. package/dist/routes/stats.d.ts +14 -23
  108. package/dist/routes/stats.js +73 -97
  109. package/dist/routes/stats.js.map +1 -1
  110. package/dist/routes/tracker.js +6 -4
  111. package/dist/routes/tracker.js.map +1 -1
  112. package/dist/routes/websites.d.ts +4 -0
  113. package/dist/routes/websites.js +111 -12
  114. package/dist/routes/websites.js.map +1 -1
  115. package/dist/services/acquisition-report.d.ts +62 -0
  116. package/dist/services/acquisition-report.js +181 -0
  117. package/dist/services/acquisition-report.js.map +1 -0
  118. package/dist/services/acquisition.d.ts +9 -3
  119. package/dist/services/acquisition.js +35 -5
  120. package/dist/services/acquisition.js.map +1 -1
  121. package/dist/services/catalog.d.ts +3 -0
  122. package/dist/services/catalog.js +17 -0
  123. package/dist/services/catalog.js.map +1 -0
  124. package/dist/services/collect.d.ts +1 -2
  125. package/dist/services/collect.js +49 -24
  126. package/dist/services/collect.js.map +1 -1
  127. package/dist/services/composed.d.ts +9 -0
  128. package/dist/services/composed.js +62 -0
  129. package/dist/services/composed.js.map +1 -0
  130. package/dist/services/context.d.ts +20 -0
  131. package/dist/services/context.js +76 -0
  132. package/dist/services/context.js.map +1 -0
  133. package/dist/services/dashboard.d.ts +74 -0
  134. package/dist/services/dashboard.js +343 -0
  135. package/dist/services/dashboard.js.map +1 -0
  136. package/dist/services/experiments/results.d.ts +12 -1
  137. package/dist/services/experiments/results.js +19 -2
  138. package/dist/services/experiments/results.js.map +1 -1
  139. package/dist/services/experiments/stats.d.ts +7 -1
  140. package/dist/services/experiments/stats.js +43 -5
  141. package/dist/services/experiments/stats.js.map +1 -1
  142. package/dist/services/funnel-report.d.ts +64 -0
  143. package/dist/services/funnel-report.js +280 -0
  144. package/dist/services/funnel-report.js.map +1 -0
  145. package/dist/services/funnels/compute.d.ts +2 -0
  146. package/dist/services/funnels/compute.js +25 -13
  147. package/dist/services/funnels/compute.js.map +1 -1
  148. package/dist/services/funnels/results.js +4 -1
  149. package/dist/services/funnels/results.js.map +1 -1
  150. package/dist/services/funnels/step-path.d.ts +4 -0
  151. package/dist/services/funnels/step-path.js +47 -0
  152. package/dist/services/funnels/step-path.js.map +1 -0
  153. package/dist/services/funnels-table.d.ts +31 -0
  154. package/dist/services/funnels-table.js +230 -0
  155. package/dist/services/funnels-table.js.map +1 -0
  156. package/dist/services/heatmap.d.ts +15 -3
  157. package/dist/services/heatmap.js +54 -11
  158. package/dist/services/heatmap.js.map +1 -1
  159. package/dist/services/hourly-traffic.d.ts +4 -0
  160. package/dist/services/hourly-traffic.js +35 -0
  161. package/dist/services/hourly-traffic.js.map +1 -0
  162. package/dist/services/origin-guard.d.ts +9 -1
  163. package/dist/services/origin-guard.js +46 -1
  164. package/dist/services/origin-guard.js.map +1 -1
  165. package/dist/services/period.d.ts +26 -1
  166. package/dist/services/period.js +99 -9
  167. package/dist/services/period.js.map +1 -1
  168. package/dist/services/prune.d.ts +6 -0
  169. package/dist/services/prune.js +35 -0
  170. package/dist/services/prune.js.map +1 -1
  171. package/dist/services/request-body.d.ts +1 -2
  172. package/dist/services/request-body.js +10 -36
  173. package/dist/services/request-body.js.map +1 -1
  174. package/dist/services/request-meta.d.ts +0 -1
  175. package/dist/services/request-meta.js +0 -11
  176. package/dist/services/request-meta.js.map +1 -1
  177. package/dist/services/rollups.d.ts +25 -0
  178. package/dist/services/rollups.js +102 -0
  179. package/dist/services/rollups.js.map +1 -0
  180. package/dist/services/sessionize.d.ts +5 -1
  181. package/dist/services/sessionize.js +52 -10
  182. package/dist/services/sessionize.js.map +1 -1
  183. package/dist/services/settings.d.ts +41 -2
  184. package/dist/services/settings.js +219 -6
  185. package/dist/services/settings.js.map +1 -1
  186. package/dist/services/snapshots.d.ts +3 -2
  187. package/dist/services/snapshots.js +110 -9
  188. package/dist/services/snapshots.js.map +1 -1
  189. package/dist/services/tenant-website.js +1 -0
  190. package/dist/services/tenant-website.js.map +1 -1
  191. package/dist/services/url.js +12 -4
  192. package/dist/services/url.js.map +1 -1
  193. package/dist/services/website-hosts.d.ts +15 -0
  194. package/dist/services/website-hosts.js +67 -0
  195. package/dist/services/website-hosts.js.map +1 -0
  196. package/dist/services/website-report.d.ts +40 -0
  197. package/dist/services/website-report.js +84 -0
  198. package/dist/services/website-report.js.map +1 -0
  199. package/dist/types/constants.d.ts +14 -2
  200. package/dist/types/constants.js +17 -6
  201. package/dist/types/constants.js.map +1 -1
  202. package/dist/types/events.d.ts +221 -68
  203. package/dist/types/events.js +60 -11
  204. package/dist/types/events.js.map +1 -1
  205. package/dist/types/snapshots.d.ts +6 -0
  206. package/dist/types/snapshots.js +2 -0
  207. package/dist/types/snapshots.js.map +1 -1
  208. package/dist/types/statistics.d.ts +2 -1
  209. package/dist/types/statistics.js +9 -2
  210. package/dist/types/statistics.js.map +1 -1
  211. package/dist/utils/experiment-type.js +1 -1
  212. package/dist/utils/experiment-type.js.map +1 -1
  213. package/dist/utils/funnel-steps-type.d.ts +21 -3
  214. package/dist/utils/funnel-steps-type.js +7 -2
  215. package/dist/utils/funnel-steps-type.js.map +1 -1
  216. package/frontend-vue/app/components/ChannelsCard.vue +265 -0
  217. package/frontend-vue/app/components/Context.vue +479 -0
  218. package/frontend-vue/app/components/ExperimentInput.vue +3 -1
  219. package/frontend-vue/app/components/ExperimentReport.vue +736 -0
  220. package/frontend-vue/app/components/FirstRun.vue +60 -0
  221. package/frontend-vue/app/components/FunnelBuilder.vue +1595 -0
  222. package/frontend-vue/app/components/FunnelFigure.vue +640 -0
  223. package/frontend-vue/app/components/FunnelReport.vue +532 -0
  224. package/frontend-vue/app/components/FunnelStepsInput.vue +3 -1
  225. package/frontend-vue/app/components/FunnelTemplates.vue +161 -0
  226. package/frontend-vue/app/components/HeatmapCanvas.vue +11 -4
  227. package/frontend-vue/app/components/InstallGuide.vue +753 -0
  228. package/frontend-vue/app/components/PagePreview.vue +889 -416
  229. package/frontend-vue/app/components/PagesExplorer.vue +552 -0
  230. package/frontend-vue/app/components/ScrollDepthOverlay.vue +48 -20
  231. package/frontend-vue/app/components/TopListTabs.vue +317 -0
  232. package/frontend-vue/app/components/TrackerSnippet.vue +50 -16
  233. package/frontend-vue/app/components/VariationsInput.vue +15 -5
  234. package/frontend-vue/app/components/WebsitesGrid.vue +799 -0
  235. package/frontend-vue/app/composables/useContextLink.ts +16 -0
  236. package/frontend-vue/app/composables/useExperiment.ts +1 -2
  237. package/frontend-vue/app/composables/useFunnelSteps.ts +1 -2
  238. package/frontend-vue/app/composables/useLatestRequest.ts +2 -4
  239. package/frontend-vue/app/composables/useMarketingApi.ts +298 -133
  240. package/frontend-vue/app/composables/useMarketingContext.ts +55 -0
  241. package/frontend-vue/app/composables/useMarketingRoutes.ts +80 -27
  242. package/frontend-vue/app/composables/useSnapshotFrame.ts +26 -4
  243. package/frontend-vue/app/constants.ts +22 -0
  244. package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +2 -1
  245. package/frontend-vue/app/utils/channels.ts +37 -0
  246. package/frontend-vue/app/utils/format.ts +132 -0
  247. package/frontend-vue/app/utils/funnel-draft.ts +264 -0
  248. package/frontend-vue/app/utils/heatmap.ts +82 -0
  249. package/frontend-vue/app/utils/heatmapPalette.ts +10 -0
  250. package/frontend-vue/app/utils/host-conflict.ts +48 -0
  251. package/frontend-vue/app/utils/sample-size.ts +17 -0
  252. package/frontend-vue/app/utils/snapshotViewportUnits.ts +15 -5
  253. package/frontend-vue/app/utils/snippet.ts +53 -0
  254. package/frontend-vue/app/utils/step-path.ts +32 -0
  255. package/frontend-vue/dms.frontend.ts +4 -3
  256. package/frontend-vue/i18n/locales/marketing-en-GB.json +1083 -316
  257. package/frontend-vue/i18n/locales/marketing-fr-FR.json +1087 -320
  258. package/frontend-vue/package.json +1 -2
  259. package/frontend-vue/pnpm-lock.yaml +51 -19
  260. package/frontend-vue/tests/drafts.test.ts +3 -3
  261. package/frontend-vue/tests/format.test.ts +73 -0
  262. package/frontend-vue/tests/funnel-draft.test.ts +194 -0
  263. package/frontend-vue/tests/heatmap.test.ts +32 -0
  264. package/frontend-vue/tests/i18n.test.ts +87 -4
  265. package/frontend-vue/tests/latest-request.test.ts +6 -3
  266. package/frontend-vue/tests/locale-typography.test.ts +115 -0
  267. package/frontend-vue/tests/registration.test.ts +17 -11
  268. package/frontend-vue/tests/routes.test.ts +67 -7
  269. package/frontend-vue/tests/server-parity.test.ts +77 -0
  270. package/frontend-vue/tests/stubs/frontend-module.ts +18 -3
  271. package/frontend-vue/vitest.config.ts +3 -1
  272. package/package.json +10 -8
  273. package/static/snapshot.js +46 -16
  274. package/static/tracker.js +139 -48
  275. package/dist/pages/campaigns/page.js.map +0 -1
  276. package/frontend-vue/app/components/CampaignsView.vue +0 -330
  277. package/frontend-vue/app/components/FunnelDetail.vue +0 -1026
  278. package/frontend-vue/app/components/FunnelsView.vue +0 -493
  279. package/frontend-vue/app/components/NoWebsiteState.vue +0 -18
  280. package/frontend-vue/app/components/OverviewView.vue +0 -493
  281. package/frontend-vue/app/components/PagesView.vue +0 -391
  282. package/frontend-vue/app/components/TextKpiCard.vue +0 -28
  283. package/frontend-vue/app/components/TopListTabsCard.vue +0 -162
  284. package/frontend-vue/app/composables/useChildId.ts +0 -13
  285. package/frontend-vue/app/composables/useMarketingPeriod.ts +0 -65
  286. package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +0 -69
  287. package/frontend-vue/app/composables/useMarketingWebsites.ts +0 -80
  288. package/frontend-vue/app/utils/dmsComponent.ts +0 -18
  289. package/frontend-vue/tests/period.test.ts +0 -35
@@ -1,22 +1,21 @@
1
1
  /**
2
2
  * Links between the module's surfaces.
3
3
  *
4
- * The `/modules/marketing` prefix comes from the page being registered under
5
- * a DMS module, which also makes it platform-owner-only. Should the surfaces
6
- * ever move to a tenant-reachable category, this file is the only place that
7
- * has to know.
4
+ * The `/modules/marketing` prefix comes from the pages being registered under
5
+ * a DMS module. The context bar reads the website and the period from the
6
+ * page URL, the reader's saved ones standing in for what it leaves out: a
7
+ * link followed from an analytics page carries the page's own, or it would
8
+ * open on whatever another tab saved last: {@link withWebsite} for the
9
+ * website, the DMS `withPeriodQuery` for the period selector's keys.
8
10
  */
9
11
 
10
12
  const MODULE_BASE = '/modules/marketing'
11
13
 
12
- interface PagesLink {
13
- website?: string | null
14
- path?: string | null
15
- period?: string | null
16
- }
17
-
18
14
  /** Query without the empty keys — a bare link must stay bare. */
19
- function withQuery(base: string, query: Record<string, string | null | undefined>): string {
15
+ function withQuery(
16
+ base: string,
17
+ query: Record<string, string | null | undefined>,
18
+ ): string {
20
19
  const params = new URLSearchParams()
21
20
  for (const [key, value] of Object.entries(query)) {
22
21
  if (value) {
@@ -27,28 +26,82 @@ function withQuery(base: string, query: Record<string, string | null | undefined
27
26
  return suffix ? `${base}?${suffix}` : base
28
27
  }
29
28
 
30
- /** The tracked-pages surface, optionally on a given site, path and window. */
31
- export function pagesLink({ website, path, period }: PagesLink = {}) {
32
- return withQuery(`${MODULE_BASE}/pages`, { website, path, period })
29
+ /** The overview, on `website` when given. */
30
+ export function overviewLink(website?: string | null): string {
31
+ return withQuery(`${MODULE_BASE}/overview`, { website })
32
+ }
33
+
34
+ /** Pages & heatmaps, optionally on a given path. */
35
+ export function pagesLink(path?: string | null): string {
36
+ return withQuery(`${MODULE_BASE}/pages`, { path })
37
+ }
38
+
39
+ export function acquisitionLink(): string {
40
+ return `${MODULE_BASE}/acquisition`
41
+ }
42
+
43
+ export function funnelsLink(): string {
44
+ return `${MODULE_BASE}/funnels`
45
+ }
46
+
47
+ /** One funnel's report (an A/B test's when it carries one). */
48
+ export function funnelLink(id: string): string {
49
+ return withQuery(`${MODULE_BASE}/funnel`, { id })
50
+ }
51
+
52
+ interface BuilderOptions {
53
+ /** Open with the A/B section switched on. */
54
+ split?: boolean
55
+ /** Prefill the steps of a new funnel (a template). */
56
+ steps?: { kind: 'url' | 'custom'; value: string }[]
57
+ /** Start a new funnel as a copy of this one (Duplicate). */
58
+ copy?: string
59
+ }
60
+
61
+ /** The builder: a new funnel, or the one `id` names. */
62
+ export function funnelBuilderLink(
63
+ id?: string | null,
64
+ options: BuilderOptions = {},
65
+ ): string {
66
+ return withQuery(`${MODULE_BASE}/funnel-builder`, {
67
+ id,
68
+ split: options.split ? '1' : null,
69
+ steps: options.steps ? JSON.stringify(options.steps) : null,
70
+ copy: options.copy,
71
+ })
33
72
  }
34
73
 
35
- interface CampaignsLink {
36
- website?: string | null
37
- period?: string | null
74
+ export function websitesLink(): string {
75
+ return `${MODULE_BASE}/websites`
38
76
  }
39
77
 
40
- /** The acquisition surface, optionally on a given site and window. */
41
- export function campaignsLink({ website, period }: CampaignsLink = {}) {
42
- return withQuery(`${MODULE_BASE}/campaigns`, { website, period })
78
+ /** The install guide: a new website, or the one `website` names. */
79
+ export function installLink(website?: string | null): string {
80
+ return withQuery(`${MODULE_BASE}/install`, { website })
43
81
  }
44
82
 
45
- interface FunnelsLink {
46
- website?: string | null
47
- funnel?: string | null
48
- period?: string | null
83
+ export function settingsLink(): string {
84
+ return `${MODULE_BASE}/settings`
49
85
  }
50
86
 
51
- /** The funnels surface, optionally on a given site, funnel and window. */
52
- export function funnelsLink({ website, funnel, period }: FunnelsLink = {}) {
53
- return withQuery(`${MODULE_BASE}/funnels`, { website, funnel, period })
87
+ /**
88
+ * `link` on the website of the page it is followed from (`query`, that page's
89
+ * route query), unless the link names one itself. `useContextLink` adds the
90
+ * period through the DMS `withPeriodQuery`.
91
+ */
92
+ export function withWebsite(
93
+ link: string,
94
+ query: Record<string, unknown>,
95
+ ): string {
96
+ const website = query.website
97
+ if (typeof website !== 'string' || !website) {
98
+ return link
99
+ }
100
+ const [path, search = ''] = link.split('?')
101
+ const params = new URLSearchParams(search)
102
+ if (params.has('website')) {
103
+ return link
104
+ }
105
+ params.set('website', website)
106
+ return `${path}?${params.toString()}`
54
107
  }
@@ -46,6 +46,27 @@ export interface AnchorBox extends AnchorRequest {
46
46
  y: number
47
47
  width: number
48
48
  height: number
49
+ /** What the element reads, for the "Most clicked" list; empty on an image. */
50
+ text: string
51
+ }
52
+
53
+ const ANCHOR_TEXT_LENGTH = 40
54
+
55
+ function readableText(element: Element): string {
56
+ const text = (element.textContent ?? '').replace(/\s+/g, ' ').trim()
57
+ // An image, an icon or a picture reads through its own alternative, or the
58
+ // one of the image inside it.
59
+ const label =
60
+ text ||
61
+ element.getAttribute('aria-label') ||
62
+ element.getAttribute('alt') ||
63
+ element.getAttribute('title') ||
64
+ element.querySelector('img[alt]')?.getAttribute('alt') ||
65
+ element.querySelector('svg title')?.textContent?.trim() ||
66
+ ''
67
+ return label.length > ANCHOR_TEXT_LENGTH
68
+ ? `${label.slice(0, ANCHOR_TEXT_LENGTH - 1)}…`
69
+ : label
49
70
  }
50
71
 
51
72
  export function useSnapshotFrame(frame: Ref<HTMLIFrameElement | null>) {
@@ -143,12 +164,12 @@ export function useSnapshotFrame(frame: Ref<HTMLIFrameElement | null>) {
143
164
  let matches: NodeListOf<Element>
144
165
  try {
145
166
  matches = doc.querySelectorAll(anchor.selector)
146
- }
147
- catch {
167
+ } catch {
148
168
  continue
149
169
  }
150
- const box = matches[anchor.nth]?.getBoundingClientRect()
151
- if (!box || box.width <= 0 || box.height <= 0) {
170
+ const element = matches[anchor.nth]
171
+ const box = element?.getBoundingClientRect()
172
+ if (!element || !box || box.width <= 0 || box.height <= 0) {
152
173
  continue
153
174
  }
154
175
  boxes.push({
@@ -157,6 +178,7 @@ export function useSnapshotFrame(frame: Ref<HTMLIFrameElement | null>) {
157
178
  y: box.top + view.scrollY,
158
179
  width: box.width,
159
180
  height: box.height,
181
+ text: readableText(element),
160
182
  })
161
183
  }
162
184
  return boxes
@@ -8,3 +8,25 @@
8
8
  */
9
9
 
10
10
  export const MS_PER_DAY = 86_400_000
11
+
12
+ /** A website's name and domain caps (MAX_NAME_LENGTH, MAX_DOMAIN_LENGTH on the server). */
13
+ export const MAX_WEBSITE_NAME_LENGTH = 100
14
+ export const MAX_WEBSITE_DOMAIN_LENGTH = 253
15
+
16
+ /** Longest period the routes read (MAX_QUERY_PERIOD_DAYS on the server). */
17
+ export const MAX_PERIOD_DAYS = 90
18
+
19
+ /**
20
+ * The id of the context bar's period selector: the scope the analytics blocks
21
+ * bind to and the prefix of its keys in the page URL (`?marketing.period=`).
22
+ * MARKETING_PERIOD_SCOPE on the server.
23
+ */
24
+ export const PERIOD_SCOPE = 'marketing'
25
+
26
+ /**
27
+ * Exactly `digits` decimals, for the DMS `formatPercentage`: a rate keeps
28
+ * its width from one row to the next.
29
+ */
30
+ export function fractionDigits(digits: number): Intl.NumberFormatOptions {
31
+ return { minimumFractionDigits: digits, maximumFractionDigits: digits }
32
+ }
@@ -21,7 +21,8 @@ const FunnelStepsDisplay = defineComponent({
21
21
  name: 'FunnelStepsDisplay',
22
22
  props: {
23
23
  modelValue: {
24
- type: [Array, String] as unknown as () => FunnelStepDraft[] | string | null,
24
+ type: [Array, String] as unknown as () =>
25
+ FunnelStepDraft[] | string | null,
25
26
  default: null,
26
27
  },
27
28
  },
@@ -0,0 +1,37 @@
1
+ /** Icon and bar colour of each acquisition channel, in one place. */
2
+ export interface ChannelStyle {
3
+ icon: string
4
+ bar: string
5
+ }
6
+
7
+ const NEUTRAL_STYLE: ChannelStyle = {
8
+ icon: 'i-ph-question',
9
+ bar: 'bg-(--ui-border-accented)',
10
+ }
11
+
12
+ // A categorical palette: six hues told apart in both themes, none of them the
13
+ // green and orange that mean "good" and "watch out" elsewhere in the module.
14
+ const CHANNEL_STYLES: Record<string, ChannelStyle> = {
15
+ direct: { icon: 'i-ph-arrow-elbow-down-right', bar: 'bg-primary' },
16
+ organic: {
17
+ icon: 'i-ph-magnifying-glass',
18
+ bar: 'bg-blue-600 dark:bg-blue-400',
19
+ },
20
+ email: {
21
+ icon: 'i-ph-envelope-simple',
22
+ bar: 'bg-violet-600 dark:bg-violet-400',
23
+ },
24
+ referral: { icon: 'i-ph-link', bar: 'bg-amber-500 dark:bg-amber-300' },
25
+ social: {
26
+ icon: 'i-ph-share-network',
27
+ bar: 'bg-pink-600 dark:bg-pink-400',
28
+ },
29
+ paid: {
30
+ icon: 'i-ph-currency-eur',
31
+ bar: 'bg-slate-700 dark:bg-slate-300',
32
+ },
33
+ }
34
+
35
+ export function channelStyle(channel: string): ChannelStyle {
36
+ return CHANNEL_STYLES[channel] ?? NEUTRAL_STYLE
37
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Locale formatting shared by the module's blocks. Pure functions of their
3
+ * inputs, so the blocks stay thin and the rules stay testable.
4
+ */
5
+
6
+ import { fractionDigits } from '../constants'
7
+
8
+ const SECONDS_PER_MINUTE = 60
9
+ const SECONDS_PER_HOUR = 3600
10
+ const MS_PER_SECOND = 1000
11
+ const INITIALS_LENGTH = 2
12
+
13
+ export function formatNumber(
14
+ value: number,
15
+ locale: string,
16
+ digits = 0,
17
+ ): string {
18
+ return new Intl.NumberFormat(locale, {
19
+ minimumFractionDigits: digits,
20
+ maximumFractionDigits: digits,
21
+ }).format(value)
22
+ }
23
+
24
+ /** "2m 48s", the way analytics tools write a visit length. */
25
+ export function formatDuration(seconds: number): string {
26
+ const total = Math.max(0, Math.round(seconds))
27
+ const hours = Math.floor(total / SECONDS_PER_HOUR)
28
+ const minutes = Math.floor((total % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE)
29
+ const rest = total % SECONDS_PER_MINUTE
30
+ if (hours > 0) {
31
+ return `${hours}h ${minutes}m`
32
+ }
33
+ return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`
34
+ }
35
+
36
+ /**
37
+ * A change in percent under half a point rounds to "0 %": it reads as no
38
+ * change, whatever its sign.
39
+ */
40
+ const FLAT_PERCENT_CHANGE = 0.5
41
+
42
+ /** Which way a change in percent (+12.5 for +12.5 %) moved, flat when it rounds to 0 %. */
43
+ export function percentChangeTrend(delta: number): 'up' | 'down' | 'flat' {
44
+ if (Math.abs(delta) < FLAT_PERCENT_CHANGE) {
45
+ return 'flat'
46
+ }
47
+ return delta > 0 ? 'up' : 'down'
48
+ }
49
+
50
+ /** Signed difference with its unit: "+8s", "−2.3 pt", "+0.1". */
51
+ export function formatSignedDelta(
52
+ value: number,
53
+ unit: 'points' | 'seconds' | 'number' | 'percent',
54
+ locale: string,
55
+ ): string {
56
+ const rounded = Number(value.toFixed(unit === 'seconds' ? 0 : 1))
57
+ const sign = rounded > 0 ? '+' : rounded < 0 ? '−' : '±'
58
+ const magnitude = Math.abs(rounded)
59
+ const formatters: Record<typeof unit, () => string> = {
60
+ points: () => `${formatNumber(magnitude, locale, 1)} pt`,
61
+ seconds: () => formatDuration(magnitude),
62
+ number: () => formatNumber(magnitude, locale, 1),
63
+ percent: () =>
64
+ String(formatPercentage(magnitude / 100, locale, fractionDigits(1))),
65
+ }
66
+ return `${sign}${formatters[unit]()}`
67
+ }
68
+
69
+ const RELATIVE_STEPS: [Intl.RelativeTimeFormatUnit, number][] = [
70
+ ['second', SECONDS_PER_MINUTE],
71
+ ['minute', SECONDS_PER_MINUTE],
72
+ ['hour', 24],
73
+ ['day', 30],
74
+ ['month', 12],
75
+ ['year', Number.POSITIVE_INFINITY],
76
+ ]
77
+
78
+ /** "12 seconds ago", "4 minutes ago" — the age of the last event. */
79
+ export function formatRelativeTime(
80
+ at: number,
81
+ now: number,
82
+ locale: string,
83
+ ): string {
84
+ let value = Math.round((at - now) / MS_PER_SECOND)
85
+ const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
86
+ for (const [unit, size] of RELATIVE_STEPS) {
87
+ if (Math.abs(value) < size) {
88
+ return format.format(value, unit)
89
+ }
90
+ value = Math.round(value / size)
91
+ }
92
+ return format.format(value, 'year')
93
+ }
94
+
95
+ /** "Aug 31 – Sep 29, 2026", the resolved range of the period. */
96
+ export function formatDateRange(from: Date, to: Date, locale: string): string {
97
+ return new Intl.DateTimeFormat(locale, {
98
+ month: 'short',
99
+ day: 'numeric',
100
+ year: 'numeric',
101
+ }).formatRange(from, to)
102
+ }
103
+
104
+ export function formatShortDate(at: number | Date, locale: string): string {
105
+ return new Intl.DateTimeFormat(locale, {
106
+ month: 'short',
107
+ day: 'numeric',
108
+ }).format(at)
109
+ }
110
+
111
+ /** "AS" for "Acme shop": the avatar of a website. */
112
+ export function initials(name: string): string {
113
+ const words = name.trim().split(/\s+/).filter(Boolean)
114
+ const letters =
115
+ words.length > 1
116
+ ? words.slice(0, INITIALS_LENGTH).map((word) => word[0])
117
+ : [...(words[0] ?? '?')].slice(0, INITIALS_LENGTH)
118
+ return letters.join('').toUpperCase()
119
+ }
120
+
121
+ /** Region and language names, falling back to the raw code on a bad one. */
122
+ export function displayName(
123
+ code: string,
124
+ type: 'region' | 'language',
125
+ locale: string,
126
+ ): string {
127
+ try {
128
+ return new Intl.DisplayNames([locale], { type }).of(code) ?? code
129
+ } catch {
130
+ return code
131
+ }
132
+ }
@@ -0,0 +1,264 @@
1
+ import { sampleSizePerArm } from './sample-size'
2
+
3
+ /**
4
+ * The rules of a funnel draft, as the builder checks them before a save.
5
+ *
6
+ * They mirror the backend's (src/types/constants.ts, src/utils/funnel-steps-type.ts
7
+ * and src/utils/experiment-type.ts): what passes here passes there, so a
8
+ * refusal shows under its field before the request instead of after it.
9
+ */
10
+ export const MAX_NAME_LENGTH = 100
11
+ export const MAX_STEPS = 10
12
+ export const MAX_STEP_VALUE_LENGTH = 500
13
+ export const MAX_WINDOW_HOURS = 720
14
+ export const MIN_VARIATIONS = 2
15
+ export const MAX_VARIATIONS = 8
16
+ export const MAX_EXPERIMENT_KEY_LENGTH = 64
17
+
18
+ /** The slug page code passes to `dmsMarketing.variation(…)`, both for the
19
+ * experiment and its arms: it starts with a letter or a digit. */
20
+ const EXPERIMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
21
+
22
+ export function isExperimentKey(key: string): boolean {
23
+ return (
24
+ key.length <= MAX_EXPERIMENT_KEY_LENGTH && EXPERIMENT_KEY_PATTERN.test(key)
25
+ )
26
+ }
27
+
28
+ export interface StepValue {
29
+ kind: 'url' | 'custom'
30
+ value: string
31
+ }
32
+
33
+ export interface VariationValue {
34
+ key: string
35
+ /** A number, or '' while its input is empty. */
36
+ weight: number | string
37
+ }
38
+
39
+ export interface FunnelDraftValues {
40
+ name: string
41
+ websiteId: string
42
+ steps: StepValue[]
43
+ windowHours: number | string
44
+ split: boolean
45
+ experimentKey: string
46
+ variations: VariationValue[]
47
+ }
48
+
49
+ /** The request body keys, which also name the fields the server's errors
50
+ * land on. `variations` has no key of its own: it is the experiment's arms. */
51
+ export type DraftField =
52
+ | 'name'
53
+ | 'websiteId'
54
+ | 'steps'
55
+ | 'conversionWindowHours'
56
+ | 'experiment'
57
+ | 'variations'
58
+
59
+ export interface DraftIssue {
60
+ /** i18n key of the message, under page.marketing.builder.errors. */
61
+ message: string
62
+ params?: Record<string, unknown>
63
+ /** The step or variation row the issue names. */
64
+ index?: number
65
+ }
66
+
67
+ function isStep(step: unknown): step is StepValue {
68
+ const candidate = step as Partial<StepValue> | null
69
+ return (
70
+ typeof candidate === 'object' &&
71
+ candidate !== null &&
72
+ (candidate.kind === 'url' || candidate.kind === 'custom') &&
73
+ typeof candidate.value === 'string'
74
+ )
75
+ }
76
+
77
+ /**
78
+ * The steps of a `?steps=` template link a funnel can hold. A link is typed
79
+ * or edited by hand: anything malformed is dropped, never thrown.
80
+ */
81
+ export function templateSteps(raw: unknown): StepValue[] {
82
+ if (typeof raw !== 'string') {
83
+ return []
84
+ }
85
+ let parsed: unknown
86
+ try {
87
+ parsed = JSON.parse(raw)
88
+ } catch {
89
+ return []
90
+ }
91
+ if (!Array.isArray(parsed)) {
92
+ return []
93
+ }
94
+ return parsed
95
+ .filter(isStep)
96
+ .map((step) => ({ kind: step.kind, value: step.value.trim() }))
97
+ .filter(
98
+ (step) => step.value !== '' && step.value.length <= MAX_STEP_VALUE_LENGTH,
99
+ )
100
+ .slice(0, MAX_STEPS)
101
+ }
102
+
103
+ /** Whether a window is an hour count the server stores as is. */
104
+ export function isWindowHours(hours: number | string): hours is number {
105
+ return (
106
+ typeof hours === 'number' &&
107
+ Number.isInteger(hours) &&
108
+ hours >= 1 &&
109
+ hours <= MAX_WINDOW_HOURS
110
+ )
111
+ }
112
+
113
+ function isWeight(weight: number | string): weight is number {
114
+ return typeof weight === 'number' && Number.isFinite(weight) && weight >= 0
115
+ }
116
+
117
+ function stepIssue(steps: StepValue[]): DraftIssue | undefined {
118
+ const index = steps.findIndex(
119
+ (step) => step.value.trim().length > MAX_STEP_VALUE_LENGTH,
120
+ )
121
+ if (index >= 0) {
122
+ return {
123
+ message: 'step_length',
124
+ params: { index: index + 1, max: MAX_STEP_VALUE_LENGTH },
125
+ index,
126
+ }
127
+ }
128
+ if (!steps.some((step) => step.value.trim() !== '')) {
129
+ return { message: 'steps', index: 0 }
130
+ }
131
+ return undefined
132
+ }
133
+
134
+ function variationIssue(variations: VariationValue[]): DraftIssue | undefined {
135
+ const keys = variations.map((arm) => arm.key.trim())
136
+ const badKey = keys.findIndex(
137
+ (key, index) => !isExperimentKey(key) || keys.indexOf(key) !== index,
138
+ )
139
+ if (badKey >= 0) {
140
+ return {
141
+ message: 'variations',
142
+ params: { max: MAX_EXPERIMENT_KEY_LENGTH },
143
+ index: badKey,
144
+ }
145
+ }
146
+ const badWeight = variations.findIndex((arm) => !isWeight(arm.weight))
147
+ const total = variations.reduce(
148
+ (sum, arm) => sum + (isWeight(arm.weight) ? arm.weight : 0),
149
+ 0,
150
+ )
151
+ if (badWeight >= 0 || total <= 0) {
152
+ return { message: 'weights', index: Math.max(badWeight, 0) }
153
+ }
154
+ return undefined
155
+ }
156
+
157
+ /**
158
+ * The first issue of each field, in the order the form shows them. A split
159
+ * that has started keeps its stored key and arms, so they are not checked.
160
+ */
161
+ export function draftIssues(
162
+ draft: FunnelDraftValues,
163
+ frozen: boolean,
164
+ ): Partial<Record<DraftField, DraftIssue>> {
165
+ const issues: Partial<Record<DraftField, DraftIssue>> = {}
166
+ const name = draft.name.trim()
167
+ if (!name) {
168
+ issues.name = { message: 'name' }
169
+ } else if (name.length > MAX_NAME_LENGTH) {
170
+ issues.name = { message: 'name_length', params: { max: MAX_NAME_LENGTH } }
171
+ }
172
+ if (!draft.websiteId) {
173
+ issues.websiteId = { message: 'website' }
174
+ }
175
+ const steps = stepIssue(draft.steps)
176
+ if (steps) {
177
+ issues.steps = steps
178
+ }
179
+ if (!isWindowHours(draft.windowHours)) {
180
+ issues.conversionWindowHours = {
181
+ message: 'window',
182
+ params: { max: MAX_WINDOW_HOURS },
183
+ }
184
+ }
185
+ if (draft.split && !frozen) {
186
+ if (!isExperimentKey(draft.experimentKey.trim())) {
187
+ issues.experiment = {
188
+ message: 'key',
189
+ params: { max: MAX_EXPERIMENT_KEY_LENGTH },
190
+ }
191
+ }
192
+ const variations = variationIssue(draft.variations)
193
+ if (variations) {
194
+ issues.variations = variations
195
+ }
196
+ }
197
+ return issues
198
+ }
199
+
200
+ /**
201
+ * Entered sessions a split needs before a relative `lift` on `rate` can be
202
+ * told from noise: each arm needs `sampleSizePerArm`, and the arm with the
203
+ * smallest share of the traffic is the one that takes longest to get it.
204
+ * Null without two arms that receive traffic.
205
+ */
206
+ export function splitSampleSize(
207
+ rate: number,
208
+ lift: number,
209
+ weights: (number | string)[],
210
+ ): number | null {
211
+ const served = weights.filter(
212
+ (weight): weight is number => isWeight(weight) && weight > 0,
213
+ )
214
+ if (rate <= 0 || served.length < MIN_VARIATIONS) {
215
+ return null
216
+ }
217
+ const total = served.reduce((sum, weight) => sum + weight, 0)
218
+ const smallestShare = Math.min(...served) / total
219
+ return sampleSizePerArm(rate, lift) / smallestShare
220
+ }
221
+
222
+ /** The comments of the snippet, in the reader's language. */
223
+ export interface VariationSnippetComments {
224
+ show: (variation: string) => string
225
+ keep: (control: string) => string
226
+ }
227
+
228
+ const ENGLISH_COMMENTS: VariationSnippetComments = {
229
+ show: (variation) => `show ${variation}`,
230
+ keep: (control) => `null or "${control}": keep the current page`,
231
+ }
232
+
233
+ /** The snippet comments read from the locale files. */
234
+ export function localizedSnippetComments(
235
+ t: (key: string, named: Record<string, unknown>) => string,
236
+ ): VariationSnippetComments {
237
+ return {
238
+ show: (variation) =>
239
+ t('page.marketing.experiment.code_show', { variation }),
240
+ keep: (control) => t('page.marketing.experiment.code_keep', { control }),
241
+ }
242
+ }
243
+
244
+ /**
245
+ * The lines page code needs to serve an arm. Promise-based rather than a
246
+ * top-level `await`, which a classic `<script>` refuses.
247
+ */
248
+ export function variationSnippet(
249
+ key: string,
250
+ control: string,
251
+ challengers: string[],
252
+ comments: VariationSnippetComments = ENGLISH_COMMENTS,
253
+ ): string {
254
+ return [
255
+ `window.dmsMarketing?.variation("${key}").then((arm) => {`,
256
+ ...challengers.flatMap((challenger, index) => [
257
+ ` ${index === 0 ? 'if' : '} else if'} (arm === "${challenger}") {`,
258
+ ` // ${comments.show(challenger)}`,
259
+ ]),
260
+ ' }',
261
+ ` // ${comments.keep(control)}`,
262
+ '});',
263
+ ].join('\n')
264
+ }