@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
@@ -0,0 +1,532 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
4
+ import { useMarketingContext } from '../composables/useMarketingContext'
5
+ import {
6
+ useMarketingApi,
7
+ type MarketingExperimentStatus,
8
+ type MarketingFunnelResults,
9
+ } from '../composables/useMarketingApi'
10
+ import {
11
+ funnelBuilderLink,
12
+ funnelsLink,
13
+ } from '../composables/useMarketingRoutes'
14
+ import { useContextLink } from '../composables/useContextLink'
15
+ import { formatShortDate } from '../utils/format'
16
+ import { MS_PER_DAY, fractionDigits } from '../constants'
17
+
18
+ /**
19
+ * The page of one funnel (`?id=`): its title and actions, then the funnel
20
+ * report, or the A/B test report once the funnel carries a started split.
21
+ * Reads the results with the period scope; a split ignores the period and is
22
+ * read over its own runs, which its subtitle says.
23
+ *
24
+ * The report belongs to the funnel's website: opened under another one (a
25
+ * link, the reader's saved site), the page URL is put on the funnel's; a site
26
+ * picked in the bar afterwards leads to that site's funnels.
27
+ */
28
+ const props = withDefaults(
29
+ defineProps<{
30
+ periodScope?: string
31
+ componentId?: string
32
+ pageId?: string
33
+ }>(),
34
+ { periodScope: undefined, componentId: undefined, pageId: undefined },
35
+ )
36
+
37
+ const MS_PER_HOUR = 3_600_000
38
+ const HTTP_NOT_FOUND = 404
39
+
40
+ const { t, locale } = useI18n()
41
+ const route = useDmsRoute()
42
+ const router = useDmsRouter()
43
+ const api = useMarketingApi()
44
+ const context = useMarketingContext()
45
+ const toast = useToast()
46
+ const { confirm } = useConfirm()
47
+ const { confirmFrom } = useConfirmFrom()
48
+
49
+ const funnelId = computed(() => {
50
+ const id = route.query.id
51
+ return typeof id === 'string' ? id : ''
52
+ })
53
+
54
+ /** In the page's own context: website and period follow the link. */
55
+ const inContext = useContextLink()
56
+
57
+ const reloadToken = ref(0)
58
+
59
+ // The id is read off the URL on every request, so another `?id=` opened in
60
+ // the same page reads that funnel, not the one first mounted.
61
+ const { data, isLoading, error, refresh } =
62
+ useChartFetch<MarketingFunnelResults>({
63
+ fetchUrl: '/api/marketing/funnels/{{query.id}}/results',
64
+ periodScope: props.periodScope,
65
+ watchSource: () => reloadToken.value,
66
+ })
67
+
68
+ /** Data of the funnel the URL names: a previous one's is not shown under it. */
69
+ const current = computed(() =>
70
+ data.value?.funnel._id === funnelId.value ? data.value : null,
71
+ )
72
+ const funnel = computed(() => current.value?.funnel ?? null)
73
+ const experiment = computed(() => funnel.value?.experiment ?? null)
74
+ const status = computed<MarketingExperimentStatus | null>(
75
+ () => experiment.value?.status ?? null,
76
+ )
77
+ const isSplit = computed(() => experiment.value !== null)
78
+ const notFound = computed(
79
+ () => !current.value && apiErrorStatus(error.value) === HTTP_NOT_FOUND,
80
+ )
81
+
82
+ const websiteName = computed(
83
+ () =>
84
+ context.websites.value.find((site) => site.id === funnel.value?.websiteId)
85
+ ?.name ?? '',
86
+ )
87
+
88
+ // --- Website -------------------------------------------------------------------
89
+
90
+ /** The funnel the page URL was last put on the website of. */
91
+ const aligned = ref<string | null>(null)
92
+
93
+ watch(
94
+ [funnel, () => context.selected.value?.id],
95
+ ([shown, selected]) => {
96
+ if (!shown || !selected || shown.websiteId === selected) {
97
+ if (shown && shown.websiteId === selected) {
98
+ aligned.value = shown._id
99
+ }
100
+ return
101
+ }
102
+ if (aligned.value !== shown._id) {
103
+ // Opened under another site: the funnel's own, without a history entry
104
+ // and without saving it as the reader's site.
105
+ aligned.value = shown._id
106
+ void router.replace({
107
+ query: { ...route.query, website: shown.websiteId },
108
+ })
109
+ return
110
+ }
111
+ // A site picked in the bar: this funnel is not one of its funnels.
112
+ void router.push(inContext(funnelsLink()))
113
+ },
114
+ { immediate: true },
115
+ )
116
+
117
+ // --- Subtitle ------------------------------------------------------------------
118
+
119
+ function runDays(since: number, until: number): string {
120
+ const days = Math.floor((until - since) / MS_PER_DAY)
121
+ return days < 1
122
+ ? t('page.marketing.funnel.run_today')
123
+ : t('page.marketing.funnel.run_days', { days }, days)
124
+ }
125
+
126
+ const subtitle = computed(() => {
127
+ const shown = funnel.value
128
+ const results = current.value
129
+ if (!shown || !results) {
130
+ return ''
131
+ }
132
+ const split = experiment.value
133
+ const website = websiteName.value
134
+ if (split && split.status !== 'draft') {
135
+ const { since, until } = results.window
136
+ return t(`page.marketing.funnel.subtitle_${split.status}`, {
137
+ key: split.key,
138
+ website,
139
+ date: formatShortDate(since, locale.value),
140
+ until: formatShortDate(until, locale.value),
141
+ days: runDays(since, until),
142
+ })
143
+ }
144
+ if (split) {
145
+ return t('page.marketing.funnel.subtitle_draft', {
146
+ key: split.key,
147
+ website,
148
+ })
149
+ }
150
+ return t('page.marketing.funnel.subtitle', {
151
+ steps: shown.steps.length,
152
+ hours: Math.round(shown.conversionWindowMs / MS_PER_HOUR),
153
+ website,
154
+ })
155
+ })
156
+
157
+ // --- Lifecycle -------------------------------------------------------------------
158
+
159
+ const changing = ref(false)
160
+
161
+ async function writeStatus(target: MarketingExperimentStatus): Promise<void> {
162
+ const shown = funnel.value
163
+ const split = experiment.value
164
+ if (!shown || !split) {
165
+ return
166
+ }
167
+ changing.value = true
168
+ try {
169
+ await api.updateFunnel(shown._id, {
170
+ websiteId: shown.websiteId,
171
+ name: shown.name,
172
+ steps: shown.steps,
173
+ conversionWindowHours: Math.round(shown.conversionWindowMs / MS_PER_HOUR),
174
+ experiment: { ...split, status: target },
175
+ })
176
+ toast.add({
177
+ color: 'success',
178
+ title: t(`page.marketing.funnel.lifecycle.${target}_done`),
179
+ })
180
+ reloadToken.value++
181
+ } catch {
182
+ toast.add({
183
+ color: 'error',
184
+ title: t('page.marketing.funnel.lifecycle.error'),
185
+ })
186
+ } finally {
187
+ changing.value = false
188
+ }
189
+ }
190
+
191
+ /** Steps the site never sent over the period, while it sent other events;
192
+ * a read cut at its cap concludes nothing. */
193
+ const unseenSteps = computed(() => {
194
+ const computation = current.value?.computation
195
+ if (!computation || computation.events === 0 || current.value?.truncated) {
196
+ return []
197
+ }
198
+ return computation.steps
199
+ .filter((row) => row.matches === 0)
200
+ .map((row) => row.step.value)
201
+ })
202
+
203
+ async function start(): Promise<void> {
204
+ const split = experiment.value
205
+ if (!split || !funnel.value) {
206
+ return
207
+ }
208
+ const total =
209
+ split.variations.reduce((sum, arm) => sum + Math.max(arm.weight, 0), 0) || 1
210
+ const shares = split.variations
211
+ .map(
212
+ (arm) =>
213
+ `${arm.key} ${formatPercentage(Math.max(arm.weight, 0) / total, locale.value, fractionDigits(0))}`,
214
+ )
215
+ .join(' · ')
216
+ await confirm({
217
+ title: t('page.marketing.funnel.lifecycle.start_title', {
218
+ name: funnel.value.name,
219
+ }),
220
+ description: t('page.marketing.funnel.lifecycle.start_description', {
221
+ website: websiteName.value,
222
+ }),
223
+ color: 'success',
224
+ icon: 'i-ph-play',
225
+ confirmLabel: t('page.marketing.funnel.lifecycle.start_confirm'),
226
+ impact: [
227
+ ...unseenSteps.value.map((step) => ({
228
+ icon: 'i-ph-warning',
229
+ label: t('page.marketing.funnel.lifecycle.start_unseen', {
230
+ step,
231
+ website: websiteName.value,
232
+ }),
233
+ })),
234
+ {
235
+ icon: 'i-ph-lock-simple',
236
+ label: t('page.marketing.funnel.lifecycle.start_freezes', {
237
+ key: split.key,
238
+ shares,
239
+ }),
240
+ },
241
+ {
242
+ icon: 'i-ph-arrows-clockwise',
243
+ label: t('page.marketing.funnel.lifecycle.start_rescored'),
244
+ },
245
+ {
246
+ icon: 'i-ph-code',
247
+ label: t('page.marketing.funnel.lifecycle.start_code', {
248
+ key: split.key,
249
+ }),
250
+ },
251
+ ],
252
+ onConfirm: () => writeStatus('running'),
253
+ })
254
+ }
255
+
256
+ /** What the reading says at the moment of stopping, in a line: the report's
257
+ * own verdict, so a test too early to call never names a winner. */
258
+ const readingAtStop = computed(() => {
259
+ const verdict = current.value?.verdict
260
+ const arms = current.value?.experiment?.variations ?? []
261
+ const leader = verdict?.leader ?? null
262
+ const best = leader === null ? undefined : arms[leader]
263
+ const kind = verdict?.kind ?? 'withheld'
264
+ if (
265
+ (kind === 'winner' || kind === 'loser') &&
266
+ best !== undefined &&
267
+ best.uplift !== null
268
+ ) {
269
+ return t(`page.marketing.funnel.lifecycle.stop_reading_${kind}`, {
270
+ key: best.key,
271
+ lift: formatPercentage(
272
+ Math.abs(best.uplift),
273
+ locale.value,
274
+ fractionDigits(1),
275
+ ),
276
+ })
277
+ }
278
+ if (kind === 'early' || kind === 'withheld') {
279
+ return t(`page.marketing.funnel.lifecycle.stop_reading_${kind}`)
280
+ }
281
+ return t('page.marketing.funnel.lifecycle.stop_reading_flat')
282
+ })
283
+
284
+ async function stop(): Promise<void> {
285
+ const shown = funnel.value
286
+ const split = experiment.value
287
+ if (!shown || !split) {
288
+ return
289
+ }
290
+ await confirm({
291
+ title: t('page.marketing.funnel.lifecycle.stop_title'),
292
+ description: t('page.marketing.funnel.lifecycle.stop_description'),
293
+ color: 'warning',
294
+ icon: 'i-ph-stop',
295
+ confirmLabel: t('page.marketing.funnel.lifecycle.stop_confirm'),
296
+ impact: [
297
+ { icon: 'i-ph-scales', label: readingAtStop.value },
298
+ {
299
+ icon: 'i-ph-hourglass',
300
+ label: t('page.marketing.funnel.lifecycle.stop_settles', {
301
+ hours: Math.round(shown.conversionWindowMs / MS_PER_HOUR),
302
+ }),
303
+ },
304
+ {
305
+ icon: 'i-ph-code',
306
+ label: t('page.marketing.funnel.lifecycle.stop_code', {
307
+ key: split.key,
308
+ }),
309
+ },
310
+ ],
311
+ onConfirm: () => writeStatus('stopped'),
312
+ })
313
+ }
314
+
315
+ async function resume(): Promise<void> {
316
+ await confirm({
317
+ title: t('page.marketing.funnel.lifecycle.resume_title'),
318
+ description: t('page.marketing.funnel.lifecycle.resume_description'),
319
+ confirmLabel: t('page.marketing.funnel.lifecycle.resume_confirm'),
320
+ onConfirm: () => writeStatus('running'),
321
+ })
322
+ }
323
+
324
+ /** Worded by the server, as the funnels table's own Delete. */
325
+ const DELETE_CONFIRMATION_URL =
326
+ '/api/marketing/funnels/{{row.id}}/delete-confirmation'
327
+
328
+ async function remove(): Promise<void> {
329
+ const shown = funnel.value
330
+ if (!shown) {
331
+ return
332
+ }
333
+ await confirmFrom(DELETE_CONFIRMATION_URL, {
334
+ params: { id: shown._id },
335
+ onConfirm: async () => {
336
+ await api.deleteFunnel(shown._id)
337
+ toast.add({
338
+ color: 'success',
339
+ title: t('page.marketing.funnel.delete.done'),
340
+ })
341
+ await router.push(inContext(funnelsLink()))
342
+ },
343
+ })
344
+ }
345
+
346
+ const moreItems = computed(() => [
347
+ [
348
+ {
349
+ label: t('page.marketing.funnel.duplicate'),
350
+ icon: 'i-ph-copy',
351
+ to: funnel.value
352
+ ? inContext(funnelBuilderLink(null, { copy: funnel.value._id }))
353
+ : undefined,
354
+ },
355
+ ],
356
+ [
357
+ {
358
+ label: t('page.marketing.funnel.delete.action'),
359
+ icon: 'i-ph-trash',
360
+ color: 'error' as const,
361
+ onSelect: () => void remove(),
362
+ },
363
+ ],
364
+ ])
365
+ </script>
366
+
367
+ <template>
368
+ <div class="flex flex-col gap-4">
369
+ <header class="flex flex-wrap items-start gap-4">
370
+ <UButton
371
+ icon="i-ph-arrow-left"
372
+ color="neutral"
373
+ variant="ghost"
374
+ :to="inContext(funnelsLink())"
375
+ :aria-label="t('page.marketing.funnel.back')"
376
+ />
377
+ <div class="min-w-[min(16rem,100%)] flex-1">
378
+ <USkeleton v-if="!funnel && !notFound && !error" class="h-8 w-72" />
379
+ <h1
380
+ v-else
381
+ class="flex flex-wrap items-center gap-2 text-2xl font-semibold text-highlighted"
382
+ >
383
+ {{ funnel?.name ?? t('page.marketing.funnel.title') }}
384
+ <DmsStatusPill
385
+ v-if="funnel"
386
+ size="sm"
387
+ uppercase
388
+ :icon="isSplit ? 'i-ph-flask' : 'i-ph-funnel'"
389
+ :label="
390
+ t(
391
+ isSplit
392
+ ? 'page.marketing.funnel.kind_ab'
393
+ : 'page.marketing.funnel.kind_funnel',
394
+ )
395
+ "
396
+ />
397
+ <DmsStatusPill
398
+ v-if="status"
399
+ size="sm"
400
+ :tone="
401
+ status === 'running'
402
+ ? 'success'
403
+ : status === 'draft'
404
+ ? 'info'
405
+ : 'neutral'
406
+ "
407
+ :dot="status === 'running' ? 'pulse' : 'static'"
408
+ :label="t(`page.marketing.funnels.status.${status}`)"
409
+ />
410
+ </h1>
411
+ <p v-if="subtitle" class="mt-1 max-w-3xl text-sm text-muted">
412
+ {{ subtitle }}
413
+ </p>
414
+ </div>
415
+ <div v-if="funnel" class="flex flex-wrap items-center gap-2">
416
+ <UButton
417
+ icon="i-ph-pencil-simple"
418
+ color="neutral"
419
+ variant="outline"
420
+ :label="
421
+ t(
422
+ isSplit
423
+ ? 'page.marketing.funnel.edit'
424
+ : 'page.marketing.funnel.edit_steps',
425
+ )
426
+ "
427
+ :to="inContext(funnelBuilderLink(funnel._id))"
428
+ />
429
+ <UButton
430
+ v-if="!isSplit"
431
+ icon="i-ph-flask"
432
+ color="neutral"
433
+ variant="outline"
434
+ :label="t('page.marketing.funnel.split')"
435
+ :to="inContext(funnelBuilderLink(funnel._id, { split: true }))"
436
+ />
437
+ <UButton
438
+ v-if="status === 'draft'"
439
+ icon="i-ph-play"
440
+ color="success"
441
+ :loading="changing"
442
+ :label="t('page.marketing.funnel.lifecycle.start')"
443
+ @click="start"
444
+ />
445
+ <UButton
446
+ v-else-if="status === 'running'"
447
+ icon="i-ph-stop"
448
+ color="warning"
449
+ variant="outline"
450
+ :loading="changing"
451
+ :label="t('page.marketing.funnel.lifecycle.stop')"
452
+ @click="stop"
453
+ />
454
+ <UButton
455
+ v-else-if="status === 'stopped'"
456
+ icon="i-ph-play"
457
+ color="neutral"
458
+ variant="outline"
459
+ :loading="changing"
460
+ :label="t('page.marketing.funnel.lifecycle.resume')"
461
+ @click="resume"
462
+ />
463
+ <UDropdownMenu :items="moreItems" :content="{ align: 'end' }">
464
+ <UButton
465
+ icon="i-ph-dots-three"
466
+ color="neutral"
467
+ variant="outline"
468
+ :aria-label="t('page.marketing.common.more')"
469
+ />
470
+ </UDropdownMenu>
471
+ </div>
472
+ </header>
473
+
474
+ <DmsEmptyState
475
+ v-if="notFound"
476
+ icon="i-ph-funnel"
477
+ hatched
478
+ class="dms-card"
479
+ :title="t('page.marketing.errors.funnel_not_found')"
480
+ :description="t('page.marketing.funnel.not_found_description')"
481
+ >
482
+ <template #actions>
483
+ <UButton
484
+ size="sm"
485
+ color="neutral"
486
+ variant="outline"
487
+ icon="i-ph-arrow-left"
488
+ :label="t('page.marketing.funnel.back')"
489
+ :to="inContext(funnelsLink())"
490
+ />
491
+ </template>
492
+ </DmsEmptyState>
493
+
494
+ <DmsEmptyState
495
+ v-else-if="error && !current"
496
+ variant="error"
497
+ class="dms-card"
498
+ :title="t('page.marketing.funnel.error')"
499
+ :description="t('page.marketing.common.data_safe')"
500
+ >
501
+ <template #actions>
502
+ <UButton
503
+ size="sm"
504
+ color="neutral"
505
+ variant="outline"
506
+ icon="i-ph-arrow-clockwise"
507
+ :label="t('page.marketing.common.retry')"
508
+ @click="refresh"
509
+ />
510
+ </template>
511
+ </DmsEmptyState>
512
+
513
+ <div v-else-if="!current" class="grid gap-4 md:grid-cols-4">
514
+ <USkeleton v-for="index in 4" :key="index" class="h-24 rounded-xl" />
515
+ <USkeleton class="h-80 rounded-xl md:col-span-4" />
516
+ </div>
517
+
518
+ <DmsMarketingExperimentReport
519
+ v-else-if="isSplit && status !== 'draft'"
520
+ :results="current"
521
+ :loading="isLoading"
522
+ :website-name="websiteName"
523
+ />
524
+
525
+ <DmsMarketingFunnelFigure
526
+ v-else
527
+ :results="current"
528
+ :loading="isLoading"
529
+ :website-name="websiteName"
530
+ />
531
+ </div>
532
+ </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_STEP_VALUE_LENGTH } from '../utils/funnel-draft'
4
5
  import {
5
6
  parseFunnelStepsValue,
6
7
  type FunnelStepDraft,
@@ -60,7 +61,7 @@ function parseValue(
60
61
  ): FunnelStepDraft[] {
61
62
  // Copies, not references: the drafts are mutated by the inputs below and
62
63
  // must never write through to the prop value.
63
- const steps = parseFunnelStepsValue(raw).map(step => ({ ...step }))
64
+ const steps = parseFunnelStepsValue(raw).map((step) => ({ ...step }))
64
65
  while (steps.length < MIN_STEPS) {
65
66
  steps.push(emptyStep())
66
67
  }
@@ -125,6 +126,7 @@ watch(
125
126
  <UInput
126
127
  v-model="step.value"
127
128
  :placeholder="stepKindMeta[step.kind].placeholder"
129
+ :maxlength="MAX_STEP_VALUE_LENGTH"
128
130
  :disabled="props.disabled"
129
131
  class="flex-1"
130
132
  size="sm"