@antelopejs/dms-marketing 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/README.md +18 -15
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.js +4 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/data-api/funnels.d.ts +2 -0
  6. package/dist/data-api/funnels.js +8 -1
  7. package/dist/data-api/funnels.js.map +1 -1
  8. package/dist/db/models/funnels.model.d.ts +2 -0
  9. package/dist/db/models/funnels.model.js +9 -0
  10. package/dist/db/models/funnels.model.js.map +1 -1
  11. package/dist/db/models/index.d.ts +1 -0
  12. package/dist/db/models/index.js +1 -0
  13. package/dist/db/models/index.js.map +1 -1
  14. package/dist/db/models/marketing_events.model.d.ts +22 -0
  15. package/dist/db/models/marketing_events.model.js +70 -5
  16. package/dist/db/models/marketing_events.model.js.map +1 -1
  17. package/dist/db/models/marketing_preferences.model.d.ts +25 -0
  18. package/dist/db/models/marketing_preferences.model.js +29 -0
  19. package/dist/db/models/marketing_preferences.model.js.map +1 -0
  20. package/dist/db/models/marketing_sessions.model.d.ts +2 -1
  21. package/dist/db/models/marketing_sessions.model.js +11 -1
  22. package/dist/db/models/marketing_sessions.model.js.map +1 -1
  23. package/dist/db/models/page_snapshots.model.d.ts +2 -1
  24. package/dist/db/models/page_snapshots.model.js +7 -7
  25. package/dist/db/models/page_snapshots.model.js.map +1 -1
  26. package/dist/db/models/website_statistics.model.d.ts +4 -1
  27. package/dist/db/models/website_statistics.model.js +15 -1
  28. package/dist/db/models/website_statistics.model.js.map +1 -1
  29. package/dist/db/tables/index.d.ts +1 -0
  30. package/dist/db/tables/index.js +1 -0
  31. package/dist/db/tables/index.js.map +1 -1
  32. package/dist/db/tables/marketing_preferences.table.d.ts +6 -0
  33. package/dist/db/tables/marketing_preferences.table.js +31 -0
  34. package/dist/db/tables/marketing_preferences.table.js.map +1 -0
  35. package/dist/hooks/index.js +8 -0
  36. package/dist/hooks/index.js.map +1 -1
  37. package/dist/index.js +18 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/pages/acquisition/page.d.ts +9 -0
  40. package/dist/pages/acquisition/page.js +181 -0
  41. package/dist/pages/acquisition/page.js.map +1 -0
  42. package/dist/pages/all.d.ts +5 -1
  43. package/dist/pages/all.js +5 -1
  44. package/dist/pages/all.js.map +1 -1
  45. package/dist/pages/blocks.d.ts +18 -0
  46. package/dist/pages/blocks.js +56 -0
  47. package/dist/pages/blocks.js.map +1 -0
  48. package/dist/pages/funnel/page.d.ts +9 -0
  49. package/dist/pages/funnel/page.js +36 -0
  50. package/dist/pages/funnel/page.js.map +1 -0
  51. package/dist/pages/funnel-builder/page.d.ts +9 -0
  52. package/dist/pages/funnel-builder/page.js +29 -0
  53. package/dist/pages/funnel-builder/page.js.map +1 -0
  54. package/dist/pages/funnels/page.d.ts +1 -1
  55. package/dist/pages/funnels/page.js +200 -3
  56. package/dist/pages/funnels/page.js.map +1 -1
  57. package/dist/pages/index.d.ts +1 -0
  58. package/dist/pages/index.js +1 -0
  59. package/dist/pages/index.js.map +1 -1
  60. package/dist/pages/{campaigns → install}/page.d.ts +3 -3
  61. package/dist/pages/{campaigns → install}/page.js +14 -12
  62. package/dist/pages/install/page.js.map +1 -0
  63. package/dist/pages/module.d.ts +3 -0
  64. package/dist/pages/module.js +30 -3
  65. package/dist/pages/module.js.map +1 -1
  66. package/dist/pages/overview/page.d.ts +1 -1
  67. package/dist/pages/overview/page.js +122 -3
  68. package/dist/pages/overview/page.js.map +1 -1
  69. package/dist/pages/pages/page.d.ts +1 -1
  70. package/dist/pages/pages/page.js +9 -4
  71. package/dist/pages/pages/page.js.map +1 -1
  72. package/dist/pages/quick-actions.d.ts +1 -0
  73. package/dist/pages/quick-actions.js +25 -0
  74. package/dist/pages/quick-actions.js.map +1 -0
  75. package/dist/pages/settings/form.d.ts +2 -1
  76. package/dist/pages/settings/form.js +62 -20
  77. package/dist/pages/settings/form.js.map +1 -1
  78. package/dist/pages/settings/page.d.ts +4 -1
  79. package/dist/pages/settings/page.js +50 -4
  80. package/dist/pages/settings/page.js.map +1 -1
  81. package/dist/pages/websites/page.d.ts +11 -0
  82. package/dist/pages/websites/page.js +62 -0
  83. package/dist/pages/websites/page.js.map +1 -0
  84. package/dist/routes/blocks.d.ts +50 -0
  85. package/dist/routes/blocks.js +269 -0
  86. package/dist/routes/blocks.js.map +1 -0
  87. package/dist/routes/collect.d.ts +1 -2
  88. package/dist/routes/collect.js +19 -12
  89. package/dist/routes/collect.js.map +1 -1
  90. package/dist/routes/context.d.ts +8 -0
  91. package/dist/routes/context.js +54 -0
  92. package/dist/routes/context.js.map +1 -0
  93. package/dist/routes/experiments.js +2 -1
  94. package/dist/routes/experiments.js.map +1 -1
  95. package/dist/routes/funnels.d.ts +21 -3
  96. package/dist/routes/funnels.js +150 -7
  97. package/dist/routes/funnels.js.map +1 -1
  98. package/dist/routes/index.d.ts +2 -0
  99. package/dist/routes/index.js +2 -0
  100. package/dist/routes/index.js.map +1 -1
  101. package/dist/routes/settings.d.ts +6 -1
  102. package/dist/routes/settings.js +65 -4
  103. package/dist/routes/settings.js.map +1 -1
  104. package/dist/routes/snapshot.d.ts +1 -1
  105. package/dist/routes/snapshot.js +24 -25
  106. package/dist/routes/snapshot.js.map +1 -1
  107. package/dist/routes/stats.d.ts +14 -23
  108. package/dist/routes/stats.js +73 -97
  109. package/dist/routes/stats.js.map +1 -1
  110. package/dist/routes/tracker.js +6 -4
  111. package/dist/routes/tracker.js.map +1 -1
  112. package/dist/routes/websites.d.ts +4 -0
  113. package/dist/routes/websites.js +111 -12
  114. package/dist/routes/websites.js.map +1 -1
  115. package/dist/services/acquisition-report.d.ts +62 -0
  116. package/dist/services/acquisition-report.js +181 -0
  117. package/dist/services/acquisition-report.js.map +1 -0
  118. package/dist/services/acquisition.d.ts +9 -3
  119. package/dist/services/acquisition.js +35 -5
  120. package/dist/services/acquisition.js.map +1 -1
  121. package/dist/services/catalog.d.ts +3 -0
  122. package/dist/services/catalog.js +17 -0
  123. package/dist/services/catalog.js.map +1 -0
  124. package/dist/services/collect.d.ts +1 -2
  125. package/dist/services/collect.js +49 -24
  126. package/dist/services/collect.js.map +1 -1
  127. package/dist/services/composed.d.ts +9 -0
  128. package/dist/services/composed.js +62 -0
  129. package/dist/services/composed.js.map +1 -0
  130. package/dist/services/context.d.ts +20 -0
  131. package/dist/services/context.js +76 -0
  132. package/dist/services/context.js.map +1 -0
  133. package/dist/services/dashboard.d.ts +74 -0
  134. package/dist/services/dashboard.js +343 -0
  135. package/dist/services/dashboard.js.map +1 -0
  136. package/dist/services/experiments/results.d.ts +12 -1
  137. package/dist/services/experiments/results.js +19 -2
  138. package/dist/services/experiments/results.js.map +1 -1
  139. package/dist/services/experiments/stats.d.ts +7 -1
  140. package/dist/services/experiments/stats.js +43 -5
  141. package/dist/services/experiments/stats.js.map +1 -1
  142. package/dist/services/funnel-report.d.ts +64 -0
  143. package/dist/services/funnel-report.js +280 -0
  144. package/dist/services/funnel-report.js.map +1 -0
  145. package/dist/services/funnels/compute.d.ts +2 -0
  146. package/dist/services/funnels/compute.js +25 -13
  147. package/dist/services/funnels/compute.js.map +1 -1
  148. package/dist/services/funnels/results.js +4 -1
  149. package/dist/services/funnels/results.js.map +1 -1
  150. package/dist/services/funnels/step-path.d.ts +4 -0
  151. package/dist/services/funnels/step-path.js +47 -0
  152. package/dist/services/funnels/step-path.js.map +1 -0
  153. package/dist/services/funnels-table.d.ts +31 -0
  154. package/dist/services/funnels-table.js +230 -0
  155. package/dist/services/funnels-table.js.map +1 -0
  156. package/dist/services/heatmap.d.ts +15 -3
  157. package/dist/services/heatmap.js +54 -11
  158. package/dist/services/heatmap.js.map +1 -1
  159. package/dist/services/hourly-traffic.d.ts +4 -0
  160. package/dist/services/hourly-traffic.js +35 -0
  161. package/dist/services/hourly-traffic.js.map +1 -0
  162. package/dist/services/origin-guard.d.ts +9 -1
  163. package/dist/services/origin-guard.js +46 -1
  164. package/dist/services/origin-guard.js.map +1 -1
  165. package/dist/services/period.d.ts +26 -1
  166. package/dist/services/period.js +99 -9
  167. package/dist/services/period.js.map +1 -1
  168. package/dist/services/prune.d.ts +6 -0
  169. package/dist/services/prune.js +35 -0
  170. package/dist/services/prune.js.map +1 -1
  171. package/dist/services/request-body.d.ts +1 -2
  172. package/dist/services/request-body.js +10 -36
  173. package/dist/services/request-body.js.map +1 -1
  174. package/dist/services/request-meta.d.ts +0 -1
  175. package/dist/services/request-meta.js +0 -11
  176. package/dist/services/request-meta.js.map +1 -1
  177. package/dist/services/rollups.d.ts +25 -0
  178. package/dist/services/rollups.js +102 -0
  179. package/dist/services/rollups.js.map +1 -0
  180. package/dist/services/sessionize.d.ts +5 -1
  181. package/dist/services/sessionize.js +52 -10
  182. package/dist/services/sessionize.js.map +1 -1
  183. package/dist/services/settings.d.ts +41 -2
  184. package/dist/services/settings.js +219 -6
  185. package/dist/services/settings.js.map +1 -1
  186. package/dist/services/snapshots.d.ts +3 -2
  187. package/dist/services/snapshots.js +110 -9
  188. package/dist/services/snapshots.js.map +1 -1
  189. package/dist/services/tenant-website.js +1 -0
  190. package/dist/services/tenant-website.js.map +1 -1
  191. package/dist/services/url.js +12 -4
  192. package/dist/services/url.js.map +1 -1
  193. package/dist/services/website-hosts.d.ts +15 -0
  194. package/dist/services/website-hosts.js +67 -0
  195. package/dist/services/website-hosts.js.map +1 -0
  196. package/dist/services/website-report.d.ts +40 -0
  197. package/dist/services/website-report.js +84 -0
  198. package/dist/services/website-report.js.map +1 -0
  199. package/dist/types/constants.d.ts +14 -2
  200. package/dist/types/constants.js +17 -6
  201. package/dist/types/constants.js.map +1 -1
  202. package/dist/types/events.d.ts +221 -68
  203. package/dist/types/events.js +60 -11
  204. package/dist/types/events.js.map +1 -1
  205. package/dist/types/snapshots.d.ts +6 -0
  206. package/dist/types/snapshots.js +2 -0
  207. package/dist/types/snapshots.js.map +1 -1
  208. package/dist/types/statistics.d.ts +2 -1
  209. package/dist/types/statistics.js +9 -2
  210. package/dist/types/statistics.js.map +1 -1
  211. package/dist/utils/experiment-type.js +1 -1
  212. package/dist/utils/experiment-type.js.map +1 -1
  213. package/dist/utils/funnel-steps-type.d.ts +21 -3
  214. package/dist/utils/funnel-steps-type.js +7 -2
  215. package/dist/utils/funnel-steps-type.js.map +1 -1
  216. package/frontend-vue/app/components/ChannelsCard.vue +265 -0
  217. package/frontend-vue/app/components/Context.vue +479 -0
  218. package/frontend-vue/app/components/ExperimentInput.vue +3 -1
  219. package/frontend-vue/app/components/ExperimentReport.vue +736 -0
  220. package/frontend-vue/app/components/FirstRun.vue +60 -0
  221. package/frontend-vue/app/components/FunnelBuilder.vue +1595 -0
  222. package/frontend-vue/app/components/FunnelFigure.vue +640 -0
  223. package/frontend-vue/app/components/FunnelReport.vue +532 -0
  224. package/frontend-vue/app/components/FunnelStepsInput.vue +3 -1
  225. package/frontend-vue/app/components/FunnelTemplates.vue +161 -0
  226. package/frontend-vue/app/components/HeatmapCanvas.vue +11 -4
  227. package/frontend-vue/app/components/InstallGuide.vue +753 -0
  228. package/frontend-vue/app/components/PagePreview.vue +889 -416
  229. package/frontend-vue/app/components/PagesExplorer.vue +552 -0
  230. package/frontend-vue/app/components/ScrollDepthOverlay.vue +48 -20
  231. package/frontend-vue/app/components/TopListTabs.vue +317 -0
  232. package/frontend-vue/app/components/TrackerSnippet.vue +50 -16
  233. package/frontend-vue/app/components/VariationsInput.vue +15 -5
  234. package/frontend-vue/app/components/WebsitesGrid.vue +799 -0
  235. package/frontend-vue/app/composables/useContextLink.ts +16 -0
  236. package/frontend-vue/app/composables/useExperiment.ts +1 -2
  237. package/frontend-vue/app/composables/useFunnelSteps.ts +1 -2
  238. package/frontend-vue/app/composables/useLatestRequest.ts +2 -4
  239. package/frontend-vue/app/composables/useMarketingApi.ts +298 -133
  240. package/frontend-vue/app/composables/useMarketingContext.ts +55 -0
  241. package/frontend-vue/app/composables/useMarketingRoutes.ts +80 -27
  242. package/frontend-vue/app/composables/useSnapshotFrame.ts +26 -4
  243. package/frontend-vue/app/constants.ts +22 -0
  244. package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +2 -1
  245. package/frontend-vue/app/utils/channels.ts +37 -0
  246. package/frontend-vue/app/utils/format.ts +132 -0
  247. package/frontend-vue/app/utils/funnel-draft.ts +264 -0
  248. package/frontend-vue/app/utils/heatmap.ts +82 -0
  249. package/frontend-vue/app/utils/heatmapPalette.ts +10 -0
  250. package/frontend-vue/app/utils/host-conflict.ts +48 -0
  251. package/frontend-vue/app/utils/sample-size.ts +17 -0
  252. package/frontend-vue/app/utils/snapshotViewportUnits.ts +15 -5
  253. package/frontend-vue/app/utils/snippet.ts +53 -0
  254. package/frontend-vue/app/utils/step-path.ts +32 -0
  255. package/frontend-vue/dms.frontend.ts +4 -3
  256. package/frontend-vue/i18n/locales/marketing-en-GB.json +1083 -316
  257. package/frontend-vue/i18n/locales/marketing-fr-FR.json +1087 -320
  258. package/frontend-vue/package.json +1 -1
  259. package/frontend-vue/tests/drafts.test.ts +3 -3
  260. package/frontend-vue/tests/format.test.ts +73 -0
  261. package/frontend-vue/tests/funnel-draft.test.ts +194 -0
  262. package/frontend-vue/tests/heatmap.test.ts +32 -0
  263. package/frontend-vue/tests/i18n.test.ts +87 -4
  264. package/frontend-vue/tests/latest-request.test.ts +6 -3
  265. package/frontend-vue/tests/locale-typography.test.ts +115 -0
  266. package/frontend-vue/tests/registration.test.ts +21 -11
  267. package/frontend-vue/tests/routes.test.ts +67 -7
  268. package/frontend-vue/tests/server-parity.test.ts +77 -0
  269. package/frontend-vue/tests/stubs/frontend-module.ts +18 -3
  270. package/package.json +9 -7
  271. package/static/snapshot.js +46 -16
  272. package/static/tracker.js +139 -48
  273. package/dist/pages/campaigns/page.js.map +0 -1
  274. package/frontend-vue/app/components/CampaignsView.vue +0 -330
  275. package/frontend-vue/app/components/FunnelDetail.vue +0 -1026
  276. package/frontend-vue/app/components/FunnelsView.vue +0 -493
  277. package/frontend-vue/app/components/NoWebsiteState.vue +0 -18
  278. package/frontend-vue/app/components/OverviewView.vue +0 -493
  279. package/frontend-vue/app/components/PagesView.vue +0 -391
  280. package/frontend-vue/app/components/TextKpiCard.vue +0 -28
  281. package/frontend-vue/app/components/TopListTabsCard.vue +0 -162
  282. package/frontend-vue/app/composables/useChildId.ts +0 -13
  283. package/frontend-vue/app/composables/useMarketingPeriod.ts +0 -65
  284. package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +0 -69
  285. package/frontend-vue/app/composables/useMarketingWebsites.ts +0 -80
  286. package/frontend-vue/app/utils/dmsComponent.ts +0 -18
  287. package/frontend-vue/tests/period.test.ts +0 -35
@@ -0,0 +1,479 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
3
+ import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
4
+ import {
5
+ MARKETING_CONTEXT_KEY,
6
+ type ContextWebsite,
7
+ type MarketingContextPayload,
8
+ } from '../composables/useMarketingContext'
9
+ import { useMarketingApi } from '../composables/useMarketingApi'
10
+ import { installLink } from '../composables/useMarketingRoutes'
11
+ import { formatRelativeTime, initials } from '../utils/format'
12
+ import { MAX_PERIOD_DAYS, MS_PER_DAY, PERIOD_SCOPE } from '../constants'
13
+
14
+ /**
15
+ * The context bar of every analytics page and the gate in front of its blocks.
16
+ *
17
+ * The page URL is the context: a reload, a copied link or Back opens on what
18
+ * it names, and each tab keeps its own. The period is the DMS
19
+ * `PeriodSelector` in the bar, publishing under `periodScope`, the scope every
20
+ * block of the page binds to: it keeps the period in the URL
21
+ * (`?<scope>.period=…&<scope>.compare=…`, `<scope>.from`/`.to` for a custom
22
+ * range) and the user's last choice. The website is this bar's own
23
+ * (`?website=`): the blocks read it through a `{{query.website}}` token, so a
24
+ * switch reads them again.
25
+ *
26
+ * What the URL leaves out comes from the reader's last explicit pick: the
27
+ * selector's remembered period and the website saved server-side. Only a pick
28
+ * in the bar writes the website, so opening a link never moves another tab.
29
+ *
30
+ * Its children render once the URL names one of the tenant's websites; with
31
+ * no website at all the first-run hero takes their place, and with
32
+ * `waitingGuide` a website still waiting for its first event shows the way
33
+ * to its install guide instead.
34
+ */
35
+ const props = withDefaults(
36
+ defineProps<{
37
+ periodScope?: string
38
+ waitingGuide?: boolean
39
+ componentId?: string
40
+ pageId?: string
41
+ }>(),
42
+ {
43
+ periodScope: PERIOD_SCOPE,
44
+ waitingGuide: false,
45
+ componentId: undefined,
46
+ pageId: undefined,
47
+ },
48
+ )
49
+
50
+ const PRESETS = [
51
+ 'today',
52
+ 'last-7-days',
53
+ 'last-30-days',
54
+ 'last-90-days',
55
+ 'custom',
56
+ ]
57
+ const COMPARISONS = ['previous-period', 'previous-year', 'none']
58
+ const CLOCK_TICK_MS = 15_000
59
+ /**
60
+ * How often the blocks of a period reaching today and the websites' last
61
+ * events are read again (the pages' `live` option caps the blocks alike).
62
+ */
63
+ const LIVE_REFRESH_MS = 60_000
64
+ /** A website is live while its last event is this recent. */
65
+ const LIVE_WINDOW_MS = 30 * 60 * 1000
66
+
67
+ const { t, locale } = useI18n()
68
+ const api = useMarketingApi()
69
+ const toast = useToast()
70
+ const route = useDmsRoute()
71
+ const router = useDmsRouter()
72
+
73
+ const presetLabels = computed(() =>
74
+ Object.fromEntries(
75
+ PRESETS.map((preset) => [
76
+ preset,
77
+ t(`page.marketing.context.presets.${preset}`),
78
+ ]),
79
+ ),
80
+ )
81
+
82
+ const comparisonLabels = computed(() =>
83
+ Object.fromEntries(
84
+ COMPARISONS.map((comparison) => [
85
+ comparison,
86
+ t(`page.marketing.context.comparisons.${comparison}`),
87
+ ]),
88
+ ),
89
+ )
90
+
91
+ const context = ref<MarketingContextPayload | null>(null)
92
+ const loading = ref(true)
93
+ const failed = ref(false)
94
+ const switching = ref(false)
95
+ const refreshToken = ref(0)
96
+
97
+ const websites = computed(() => context.value?.websites ?? [])
98
+ /** The URL's website, while it is one of the tenant's. */
99
+ const linkedId = computed(() => {
100
+ const id = route.query.website
101
+ return typeof id === 'string' && websites.value.some((site) => site.id === id)
102
+ ? id
103
+ : null
104
+ })
105
+ const selectedId = computed(
106
+ () => linkedId.value ?? context.value?.selectedId ?? null,
107
+ )
108
+ const selected = computed<ContextWebsite | null>(
109
+ () => websites.value.find((site) => site.id === selectedId.value) ?? null,
110
+ )
111
+
112
+ async function reload(): Promise<void> {
113
+ loading.value = true
114
+ failed.value = false
115
+ try {
116
+ context.value = await api.getContext()
117
+ } catch {
118
+ failed.value = true
119
+ } finally {
120
+ loading.value = false
121
+ }
122
+ }
123
+
124
+ /** The websites' last events again, quietly: a failure keeps the bar as is. */
125
+ async function refreshActivity(): Promise<void> {
126
+ try {
127
+ context.value = await api.getContext()
128
+ } catch {
129
+ // The next refresh tries again.
130
+ }
131
+ }
132
+
133
+ async function select(id: string): Promise<void> {
134
+ if (id === selectedId.value) {
135
+ return
136
+ }
137
+ switching.value = true
138
+ try {
139
+ // Saved first, as the website of the pages opened without one; the bar
140
+ // and the blocks follow the URL once it names it. A pick is a history
141
+ // entry, so Back takes it back.
142
+ context.value = await api.selectContextWebsite(id)
143
+ await router.push({ query: { ...route.query, website: id } })
144
+ } catch {
145
+ toast.add({
146
+ color: 'error',
147
+ title: t('page.marketing.context.switch_error'),
148
+ })
149
+ await reload()
150
+ } finally {
151
+ switching.value = false
152
+ }
153
+ }
154
+
155
+ // The URL names no website, or one the tenant does not have: fill in the
156
+ // saved one, without a history entry. Watched with the query too, so a block
157
+ // rewriting the URL for its own state (the heatmap path) keeps it.
158
+ watch(
159
+ [selectedId, () => route.query],
160
+ ([id]) => {
161
+ if (id && route.query.website !== id) {
162
+ void router.replace({ query: { ...route.query, website: id } })
163
+ }
164
+ },
165
+ { immediate: true },
166
+ )
167
+
168
+ watch(refreshToken, () => refreshPageBlocks())
169
+
170
+ // --- Period --------------------------------------------------------------------
171
+
172
+ const period = usePeriodScope(props.periodScope)
173
+
174
+ // Read once the bar shows the selector: the scope registers with it, and a
175
+ // read before has the DMS warn about a missing scope.
176
+ const range = computed(() => (selected.value ? period.value?.range : null))
177
+
178
+ /** A custom range longer than the routes read: only its last days are shown. */
179
+ const cutHint = computed(() => {
180
+ if (!range.value) {
181
+ return ''
182
+ }
183
+ const { from, to } = range.value
184
+ const days = Math.round((to.getTime() - from.getTime()) / MS_PER_DAY)
185
+ return days > MAX_PERIOD_DAYS
186
+ ? t('page.marketing.context.range_cut', { days: MAX_PERIOD_DAYS })
187
+ : ''
188
+ })
189
+
190
+ provide(MARKETING_CONTEXT_KEY, {
191
+ websites,
192
+ selected,
193
+ refreshToken,
194
+ select,
195
+ reload,
196
+ })
197
+
198
+ onMounted(reload)
199
+
200
+ // --- Clock -------------------------------------------------------------------
201
+ // A period reaching today follows the clock: its blocks read again every
202
+ // minute, through the page's live gate. The selector itself moves a day
203
+ // preset on at midnight.
204
+
205
+ const now = ref(Date.now())
206
+
207
+ /** The period covers the present: its counts are still moving. */
208
+ const reachesToday = computed(
209
+ () => !!range.value && range.value.to.getTime() >= now.value,
210
+ )
211
+
212
+ /** Refresh: every block reads again. */
213
+ function refresh(): void {
214
+ refreshToken.value++
215
+ void refreshActivity()
216
+ }
217
+
218
+ function tick(): void {
219
+ now.value = Date.now()
220
+ }
221
+
222
+ function liveRefresh(): void {
223
+ if (reachesToday.value && linkedId.value) {
224
+ refreshLivePageBlocks()
225
+ }
226
+ void refreshActivity()
227
+ }
228
+
229
+ let clock: ReturnType<typeof setInterval> | null = null
230
+ let live: ReturnType<typeof setInterval> | null = null
231
+ onMounted(() => {
232
+ clock = setInterval(tick, CLOCK_TICK_MS)
233
+ live = setInterval(liveRefresh, LIVE_REFRESH_MS)
234
+ })
235
+ onBeforeUnmount(() => {
236
+ if (clock) {
237
+ clearInterval(clock)
238
+ }
239
+ if (live) {
240
+ clearInterval(live)
241
+ }
242
+ })
243
+
244
+ // --- Bar ---------------------------------------------------------------------
245
+
246
+ /** `live` only while events are recent; a quiet site is `idle`. */
247
+ type DisplayState = ContextWebsite['state'] | 'idle'
248
+
249
+ const STATE_TONES: Record<DisplayState, string> = {
250
+ live: 'bg-success',
251
+ idle: 'bg-(--ui-text-muted)',
252
+ waiting: 'bg-warning',
253
+ paused: 'bg-(--ui-text-dimmed)',
254
+ }
255
+
256
+ function displayState(site: ContextWebsite): DisplayState {
257
+ if (
258
+ site.state === 'live' &&
259
+ (site.lastActivityAt === null ||
260
+ now.value - site.lastActivityAt > LIVE_WINDOW_MS)
261
+ ) {
262
+ return 'idle'
263
+ }
264
+ return site.state
265
+ }
266
+
267
+ function stateLine(site: ContextWebsite): string {
268
+ const state = displayState(site)
269
+ if ((state === 'live' || state === 'idle') && site.lastActivityAt) {
270
+ return t(`page.marketing.context.${state}`, {
271
+ ago: formatRelativeTime(site.lastActivityAt, now.value, locale.value),
272
+ })
273
+ }
274
+ return t(`page.marketing.context.state.${site.state}`)
275
+ }
276
+
277
+ const websiteItems = computed(() => [
278
+ websites.value.map((site) => ({
279
+ label: site.name,
280
+ description: site.domain,
281
+ type: 'checkbox' as const,
282
+ checked: site.id === selectedId.value,
283
+ slot: 'website' as const,
284
+ site,
285
+ onSelect: () => void select(site.id),
286
+ })),
287
+ [
288
+ {
289
+ label: t('page.marketing.context.add_website'),
290
+ icon: 'i-ph-plus',
291
+ to: installLink(),
292
+ },
293
+ ],
294
+ ])
295
+
296
+ /**
297
+ * The statistics are kept per UTC day: where the reader's midnight is not
298
+ * UTC's, the bar says when their days really start.
299
+ */
300
+ const utcHint = computed(() => {
301
+ const today = new Date(now.value)
302
+ if (today.getTimezoneOffset() === 0) {
303
+ return ''
304
+ }
305
+ const utcMidnight = new Date(
306
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),
307
+ )
308
+ const time = new Intl.DateTimeFormat(locale.value, {
309
+ hour: '2-digit',
310
+ minute: '2-digit',
311
+ }).format(utcMidnight)
312
+ return t('page.marketing.context.utc_days', { time })
313
+ })
314
+ </script>
315
+
316
+ <template>
317
+ <div class="flex flex-col gap-4">
318
+ <div
319
+ class="dms-card flex flex-wrap items-center gap-x-4 gap-y-2 px-3 py-2"
320
+ role="toolbar"
321
+ :aria-label="t('page.marketing.context.label')"
322
+ >
323
+ <USkeleton v-if="loading && !context" class="h-7 w-72" />
324
+ <template v-else-if="selected">
325
+ <UDropdownMenu :items="websiteItems" :content="{ align: 'start' }">
326
+ <UButton
327
+ color="neutral"
328
+ variant="outline"
329
+ size="sm"
330
+ trailing-icon="i-ph-caret-up-down"
331
+ :loading="switching"
332
+ :aria-label="t('page.marketing.context.switch_website')"
333
+ >
334
+ <span
335
+ class="grid size-5 place-items-center rounded bg-primary/15 font-mono text-[10px] font-semibold text-(--ui-color-primary-800) dark:text-primary"
336
+ >
337
+ {{ initials(selected.name) }}
338
+ </span>
339
+ <span class="font-semibold">{{ selected.name }}</span>
340
+ </UButton>
341
+ <template #website="{ item }">
342
+ <span
343
+ class="grid size-6 shrink-0 place-items-center rounded bg-primary/15 font-mono text-[10px] font-semibold text-(--ui-color-primary-800) dark:text-primary"
344
+ >
345
+ {{ initials(item.site.name) }}
346
+ </span>
347
+ <span class="min-w-0 flex-1 text-left">
348
+ <span class="block truncate text-sm font-medium text-highlighted">
349
+ {{ item.site.name }}
350
+ </span>
351
+ <span
352
+ class="flex min-w-0 items-center gap-1.5 text-xs text-dimmed"
353
+ >
354
+ <span
355
+ class="size-1.5 shrink-0 rounded-full"
356
+ :class="STATE_TONES[displayState(item.site)]"
357
+ aria-hidden="true"
358
+ />
359
+ <span class="truncate font-mono">{{ item.site.domain }}</span>
360
+ <span class="shrink-0">· {{ stateLine(item.site) }}</span>
361
+ </span>
362
+ </span>
363
+ <UIcon
364
+ v-if="item.checked"
365
+ name="i-ph-check"
366
+ class="size-4 shrink-0 text-primary"
367
+ />
368
+ </template>
369
+ </UDropdownMenu>
370
+
371
+ <span class="flex items-center gap-2 font-mono text-xs text-muted">
372
+ <span
373
+ class="size-2 rounded-full"
374
+ :class="STATE_TONES[displayState(selected)]"
375
+ aria-hidden="true"
376
+ />
377
+ {{ stateLine(selected) }}
378
+ </span>
379
+
380
+ <span class="hidden h-5 w-px bg-(--ui-border) md:block" />
381
+
382
+ <div class="flex flex-wrap items-center gap-2">
383
+ <DmsPeriodSelector
384
+ :id="periodScope"
385
+ :component-id="`${componentId ?? 'marketing-context'}-period`"
386
+ :page-id="pageId"
387
+ variant="segmented"
388
+ size="xs"
389
+ align="left"
390
+ default-preset="last-30-days"
391
+ default-comparison="previous-period"
392
+ :presets="PRESETS"
393
+ :comparisons="COMPARISONS"
394
+ :preset-labels="presetLabels"
395
+ :comparison-labels="comparisonLabels"
396
+ show-range-label
397
+ />
398
+ <span
399
+ v-if="cutHint"
400
+ class="flex items-center gap-1 text-xs text-warning"
401
+ role="status"
402
+ >
403
+ <UIcon name="i-ph-warning" class="size-3.5 shrink-0" />
404
+ {{ cutHint }}
405
+ </span>
406
+ <UTooltip v-if="utcHint" :text="utcHint">
407
+ <span
408
+ class="cursor-help rounded border border-default px-1 font-mono text-[10px] text-dimmed"
409
+ tabindex="0"
410
+ :aria-label="utcHint"
411
+ >
412
+ UTC
413
+ </span>
414
+ </UTooltip>
415
+ </div>
416
+
417
+ <UButton
418
+ class="ms-auto"
419
+ icon="i-ph-arrow-clockwise"
420
+ color="neutral"
421
+ variant="ghost"
422
+ size="xs"
423
+ :aria-label="t('page.marketing.context.refresh')"
424
+ @click="refresh"
425
+ />
426
+ </template>
427
+ <span v-else-if="!failed" class="text-sm text-muted">
428
+ {{ t('page.marketing.context.no_website') }}
429
+ </span>
430
+ </div>
431
+
432
+ <DmsEmptyState
433
+ v-if="failed"
434
+ variant="error"
435
+ :title="t('page.marketing.context.error.title')"
436
+ :description="t('page.marketing.context.error.description')"
437
+ class="dms-card"
438
+ >
439
+ <template #actions>
440
+ <UButton
441
+ icon="i-ph-arrow-clockwise"
442
+ color="neutral"
443
+ variant="outline"
444
+ size="sm"
445
+ :label="t('page.marketing.common.retry')"
446
+ @click="reload"
447
+ />
448
+ </template>
449
+ </DmsEmptyState>
450
+
451
+ <DmsMarketingFirstRun v-else-if="context && websites.length === 0" />
452
+
453
+ <DmsEmptyState
454
+ v-else-if="waitingGuide && linkedId && selected?.state === 'waiting'"
455
+ icon="i-ph-plugs"
456
+ :title="
457
+ t('page.marketing.context.waiting_guide.title', { name: selected.name })
458
+ "
459
+ :description="t('page.marketing.context.waiting_guide.description')"
460
+ class="dms-card"
461
+ >
462
+ <template #actions>
463
+ <UButton
464
+ icon="i-ph-book-open-text"
465
+ size="sm"
466
+ :label="t('page.marketing.context.waiting_guide.action')"
467
+ @click="router.push(installLink(selected.id))"
468
+ />
469
+ </template>
470
+ </DmsEmptyState>
471
+
472
+ <slot v-else-if="linkedId" />
473
+
474
+ <div v-else class="grid gap-4 md:grid-cols-4">
475
+ <USkeleton v-for="index in 4" :key="index" class="h-28 rounded-xl" />
476
+ <USkeleton class="h-72 rounded-xl md:col-span-4" />
477
+ </div>
478
+ </div>
479
+ </template>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import { useI18n } from '#dms/frontend-module'
4
+ import { MAX_EXPERIMENT_KEY_LENGTH } from '../utils/funnel-draft'
4
5
  import {
5
6
  parseExperimentValue,
6
7
  type ExperimentDraft,
@@ -57,7 +58,7 @@ function currentValue(): ExperimentDraft | null {
57
58
  }
58
59
  return {
59
60
  ...draft.value,
60
- variations: draft.value.variations.map(variation => ({ ...variation })),
61
+ variations: draft.value.variations.map((variation) => ({ ...variation })),
61
62
  }
62
63
  }
63
64
 
@@ -98,6 +99,7 @@ watch(
98
99
  <UInput
99
100
  v-model="draft.key"
100
101
  :placeholder="t('page.marketing.funnels.form.key')"
102
+ :maxlength="MAX_EXPERIMENT_KEY_LENGTH"
101
103
  :disabled="inputsDisabled"
102
104
  class="font-mono"
103
105
  size="sm"