@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,1595 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
4
+ import { parseExperimentValue } from '../composables/useExperiment'
5
+ import { parseFunnelStepsValue } from '../composables/useFunnelSteps'
6
+ import { useLatestRequest } from '../composables/useLatestRequest'
7
+ import {
8
+ useMarketingApi,
9
+ type MarketingExperiment,
10
+ type MarketingFunnelPreview,
11
+ type MarketingFunnelStep,
12
+ type MarketingFunnelSuggestions,
13
+ } from '../composables/useMarketingApi'
14
+ import type { ContextWebsite } from '../composables/useMarketingContext'
15
+ import {
16
+ funnelBuilderLink,
17
+ funnelLink,
18
+ funnelsLink,
19
+ } from '../composables/useMarketingRoutes'
20
+ import { useContextLink } from '../composables/useContextLink'
21
+ import {
22
+ draftIssues,
23
+ isWindowHours,
24
+ MAX_EXPERIMENT_KEY_LENGTH,
25
+ MAX_NAME_LENGTH,
26
+ MAX_STEP_VALUE_LENGTH,
27
+ MAX_STEPS,
28
+ MAX_VARIATIONS,
29
+ MAX_WINDOW_HOURS,
30
+ MIN_VARIATIONS,
31
+ splitSampleSize,
32
+ templateSteps,
33
+ localizedSnippetComments,
34
+ variationSnippet,
35
+ type DraftField,
36
+ type FunnelDraftValues,
37
+ type StepValue,
38
+ } from '../utils/funnel-draft'
39
+ import { formatNumber } from '../utils/format'
40
+ import { normalizeFunnelStepPath } from '../utils/step-path'
41
+ import { fractionDigits } from '../constants'
42
+
43
+ /**
44
+ * The funnel builder (`?id=` to edit, `?from=` to start from a copy of a
45
+ * funnel, `?split=1` to open the A/B section, `?steps=` to start from a
46
+ * template). Steps are picked from the pages and events the site already
47
+ * tracks, with their counts; the preview scores the draft over the last 30
48
+ * days while it is edited. Follows the DMS form contract — unsaved changes
49
+ * against a baseline, a guard before leaving, errors under their field — and
50
+ * writes through the funnels data API, which owns the experiment lifecycle.
51
+ */
52
+ const props = withDefaults(
53
+ defineProps<{ componentId?: string; pageId?: string }>(),
54
+ {
55
+ componentId: undefined,
56
+ pageId: undefined,
57
+ },
58
+ )
59
+
60
+ interface StepDraft extends StepValue {
61
+ uid: number
62
+ }
63
+
64
+ interface VariationDraft {
65
+ uid: number
66
+ key: string
67
+ weight: number | string
68
+ }
69
+
70
+ // A funnel is matched within one session, which ends after 30 minutes
71
+ // without activity: a window of days would change nothing (FB-11).
72
+ const WINDOW_PRESETS = [1, 24]
73
+ const PREVIEW_DEBOUNCE_MS = 450
74
+ const SUGGESTIONS_SHOWN = 6
75
+ const MINIMUM_DETECTABLE_LIFT = 0.2
76
+ const LETTER_A = 65
77
+ const PERCENT = 100
78
+ const MS_PER_HOUR = 3_600_000
79
+ const HTTP_NOT_FOUND = 404
80
+ /** Below this, months read "under 0.1". */
81
+ const MIN_MONTHS_SHOWN = 0.1
82
+
83
+ void props
84
+
85
+ const { t, locale } = useI18n()
86
+ const route = useDmsRoute()
87
+ const router = useDmsRouter()
88
+ const api = useMarketingApi()
89
+ const toast = useToast()
90
+ const { confirm } = useConfirm()
91
+
92
+ function queryParam(key: string): string {
93
+ const value = route.query[key]
94
+ return typeof value === 'string' ? value : ''
95
+ }
96
+
97
+ const funnelId = queryParam('id')
98
+ const sourceId = funnelId ? '' : queryParam('copy')
99
+ const isEdit = funnelId !== ''
100
+
101
+ let nextUid = 1
102
+ const uid = () => nextUid++
103
+
104
+ const name = ref('')
105
+ const websiteId = ref<string>('')
106
+ const steps = ref<StepDraft[]>([{ uid: uid(), kind: 'url', value: '' }])
107
+ const windowHours = ref<number | string>(24)
108
+ const splitEnabled = ref(route.query.split === '1')
109
+ const experimentKey = ref('')
110
+ const variations = ref<VariationDraft[]>([
111
+ { uid: uid(), key: 'control', weight: 1 },
112
+ { uid: uid(), key: '', weight: 1 },
113
+ ])
114
+ const original = ref<MarketingExperiment | null>(null)
115
+ /** The goal a started test was saved with, to warn before it changes. */
116
+ const savedObjective = ref('')
117
+ const loading = ref(true)
118
+ const loadFailed = ref<'not_found' | 'error' | null>(null)
119
+ const saving = ref(false)
120
+
121
+ /** A started split keeps its key and arms: changing them would reassign visitors. */
122
+ const frozen = computed(
123
+ () => original.value !== null && original.value.status !== 'draft',
124
+ )
125
+
126
+ // --- Form values, baseline and leave guard -------------------------------------
127
+
128
+ function values(): FunnelDraftValues {
129
+ return {
130
+ name: name.value,
131
+ websiteId: websiteId.value,
132
+ steps: steps.value.map(({ kind, value }) => ({ kind, value })),
133
+ windowHours: windowHours.value,
134
+ split: splitEnabled.value,
135
+ experimentKey: experimentKey.value,
136
+ variations: variations.value.map(({ key, weight }) => ({ key, weight })),
137
+ }
138
+ }
139
+
140
+ function applyValues(next: FunnelDraftValues): void {
141
+ name.value = next.name
142
+ websiteId.value = next.websiteId
143
+ steps.value = next.steps.map((step) => ({ uid: uid(), ...step }))
144
+ windowHours.value = next.windowHours
145
+ splitEnabled.value = next.split
146
+ experimentKey.value = next.experimentKey
147
+ variations.value = next.variations.map((arm) => ({ uid: uid(), ...arm }))
148
+ }
149
+
150
+ const { dirty, baseline, markClean } = useFormDirty(values)
151
+ const { confirmLeave } = useUnsavedChanges({ dirty })
152
+
153
+ // Errors land under their field: the client's checks on a save attempt, and
154
+ // the server's — its codes name the field they belong to.
155
+ const STEP_CONTROL_PREFIX = 'funnel-step-'
156
+ const VARIATION_CONTROL_PREFIX = 'funnel-variation-'
157
+ const flaggedStep = ref<number | null>(null)
158
+ const flaggedVariation = ref<number | null>(null)
159
+
160
+ /** The flagged row's input, else the first row's (a server error names no row). */
161
+ function rowControl(prefix: string, flagged: number | null, first?: number) {
162
+ return document.getElementById(`${prefix}${flagged ?? first}`)
163
+ }
164
+
165
+ const fieldErrors = useFieldErrors<DraftField>({
166
+ fields: {
167
+ name: 'funnel-name',
168
+ websiteId: 'funnel-website',
169
+ steps: () =>
170
+ rowControl(STEP_CONTROL_PREFIX, flaggedStep.value, steps.value[0]?.uid),
171
+ conversionWindowHours: 'funnel-window',
172
+ experiment: 'funnel-experiment-key',
173
+ variations: () =>
174
+ rowControl(
175
+ VARIATION_CONTROL_PREFIX,
176
+ flaggedVariation.value,
177
+ variations.value[0]?.uid,
178
+ ),
179
+ },
180
+ codes: {
181
+ '$page.marketing.errors.invalid_steps': 'steps',
182
+ '$page.marketing.errors.invalid_experiment_key': 'experiment',
183
+ '$page.marketing.errors.duplicate_experiment_key': 'experiment',
184
+ '$page.marketing.errors.experiment_locked': 'experiment',
185
+ '$page.marketing.errors.invalid_variations': 'variations',
186
+ '$page.marketing.errors.website_not_found': 'websiteId',
187
+ },
188
+ })
189
+
190
+ function clearStepError(): void {
191
+ flaggedStep.value = null
192
+ fieldErrors.clear('steps')
193
+ }
194
+
195
+ function clearVariationError(): void {
196
+ flaggedVariation.value = null
197
+ fieldErrors.clear('variations')
198
+ }
199
+
200
+ // --- Loading -------------------------------------------------------------------
201
+
202
+ /** A stored definition as the form holds it. */
203
+ async function fetchDefinition(id: string) {
204
+ const row = await api.getFunnel(id)
205
+ const experiment = parseExperimentValue(row.experiment)
206
+ const draft: FunnelDraftValues = {
207
+ name: row.name,
208
+ websiteId:
209
+ typeof row.websiteId === 'string' ? row.websiteId : row.websiteId._id,
210
+ steps: parseFunnelStepsValue(row.steps),
211
+ windowHours: row.conversionWindowHours,
212
+ split: experiment !== null,
213
+ experimentKey: experiment?.key ?? '',
214
+ variations: experiment?.variations ?? values().variations,
215
+ }
216
+ const stored: MarketingExperiment | null = experiment && {
217
+ key: experiment.key,
218
+ status: experiment.status ?? 'draft',
219
+ variations: experiment.variations,
220
+ runs: experiment.runs,
221
+ }
222
+ return { draft, experiment: stored }
223
+ }
224
+
225
+ function objectiveOf(draft: FunnelDraftValues): string {
226
+ const kept = draft.steps
227
+ .map((step) => ({ kind: step.kind, value: step.value.trim() }))
228
+ .filter((step) => step.value !== '')
229
+ return JSON.stringify([kept, draft.windowHours])
230
+ }
231
+
232
+ async function loadExisting(): Promise<void> {
233
+ const { draft, experiment } = await fetchDefinition(funnelId)
234
+ applyValues(draft)
235
+ original.value = experiment
236
+ savedObjective.value = objectiveOf(draft)
237
+ }
238
+
239
+ /** The copy a Duplicate opened on, what Discard returns to. */
240
+ const copied = ref<FunnelDraftValues | null>(null)
241
+
242
+ /** Duplicate: the copy is a new draft, unsaved until the user saves it — its
243
+ * experiment key must change, keys being unique per site. */
244
+ async function loadCopy(): Promise<void> {
245
+ const { draft } = await fetchDefinition(sourceId)
246
+ applyValues({
247
+ ...draft,
248
+ name: t('page.marketing.builder.copy_name', { name: draft.name }).slice(
249
+ 0,
250
+ MAX_NAME_LENGTH,
251
+ ),
252
+ experimentKey: '',
253
+ })
254
+ copied.value = values()
255
+ }
256
+
257
+ // The builder sits outside the context bar: it reads the website list itself,
258
+ // and starts a new funnel on the website of the page it was opened from, else
259
+ // on the member's selection.
260
+ const websites = ref<ContextWebsite[]>([])
261
+
262
+ async function loadWebsites(): Promise<void> {
263
+ const payload = await api.getContext()
264
+ websites.value = payload.websites
265
+ const linked = queryParam('website')
266
+ websiteId.value ||=
267
+ (payload.websites.some((site) => site.id === linked) ? linked : null) ??
268
+ payload.selectedId ??
269
+ ''
270
+ }
271
+
272
+ async function load(): Promise<void> {
273
+ loading.value = true
274
+ loadFailed.value = null
275
+ try {
276
+ // The funnel first: its site then wins over the context's, and the
277
+ // suggestions are read once, for the right site.
278
+ if (isEdit) {
279
+ await loadExisting()
280
+ }
281
+ await loadWebsites().catch(() => undefined)
282
+ const template = templateSteps(route.query.steps)
283
+ if (!isEdit && template.length > 0) {
284
+ steps.value = template.map((step) => ({ uid: uid(), ...step }))
285
+ }
286
+ // The starting point: what the page opened on. A copy is applied after
287
+ // it, so it counts as a change to save.
288
+ markClean()
289
+ if (sourceId) {
290
+ await loadCopy()
291
+ }
292
+ } catch (error) {
293
+ loadFailed.value =
294
+ apiErrorStatus(error) === HTTP_NOT_FOUND ? 'not_found' : 'error'
295
+ } finally {
296
+ loading.value = false
297
+ }
298
+ }
299
+
300
+ onMounted(load)
301
+
302
+ // --- Suggestions and preview -------------------------------------------------
303
+
304
+ const suggestions = ref<MarketingFunnelSuggestions>({ pages: [], events: [] })
305
+
306
+ async function loadSuggestions(): Promise<void> {
307
+ if (!websiteId.value) {
308
+ return
309
+ }
310
+ try {
311
+ suggestions.value = await api.getFunnelSuggestions(websiteId.value)
312
+ } catch {
313
+ suggestions.value = { pages: [], events: [] }
314
+ }
315
+ }
316
+
317
+ // Fires once the site is known (the context, or the funnel being edited).
318
+ watch(websiteId, () => void loadSuggestions())
319
+
320
+ const focusedStep = ref<number | null>(null)
321
+ /** The step whose list Escape closed, until it is typed in again. */
322
+ const dismissedStep = ref<number | null>(null)
323
+ const activeOption = ref(-1)
324
+
325
+ function matchesFor(step: StepDraft) {
326
+ const pool =
327
+ step.kind === 'url' ? suggestions.value.pages : suggestions.value.events
328
+ const needle = step.value.trim().toLowerCase()
329
+ return pool
330
+ .filter((entry) => !needle || entry.value.toLowerCase().includes(needle))
331
+ .filter((entry) => entry.value !== step.value)
332
+ .slice(0, SUGGESTIONS_SHOWN)
333
+ }
334
+
335
+ function listOpen(step: StepDraft): boolean {
336
+ return (
337
+ focusedStep.value === step.uid &&
338
+ dismissedStep.value !== step.uid &&
339
+ matchesFor(step).length > 0
340
+ )
341
+ }
342
+
343
+ function focusStep(step: StepDraft): void {
344
+ focusedStep.value = step.uid
345
+ activeOption.value = -1
346
+ }
347
+
348
+ const BLUR_DELAY_MS = 150
349
+
350
+ /** Late enough for a click on a suggestion to land first. */
351
+ function blurStep(step: StepDraft): void {
352
+ setTimeout(() => {
353
+ if (focusedStep.value === step.uid) {
354
+ focusedStep.value = null
355
+ }
356
+ }, BLUR_DELAY_MS)
357
+ }
358
+
359
+ function typeStep(): void {
360
+ dismissedStep.value = null
361
+ activeOption.value = -1
362
+ clearStepError()
363
+ }
364
+
365
+ function pick(step: StepDraft, value: string): void {
366
+ step.value = value
367
+ dismissedStep.value = step.uid
368
+ activeOption.value = -1
369
+ clearStepError()
370
+ }
371
+
372
+ /** The combobox keys: arrows move through the list, Enter picks, Escape closes. */
373
+ function onStepKeydown(event: KeyboardEvent, step: StepDraft): void {
374
+ if (!listOpen(step)) {
375
+ return
376
+ }
377
+ const count = matchesFor(step).length
378
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
379
+ event.preventDefault()
380
+ const offset = event.key === 'ArrowDown' ? 1 : -1
381
+ activeOption.value = (activeOption.value + offset + count) % count
382
+ } else if (event.key === 'Enter' && activeOption.value >= 0) {
383
+ event.preventDefault()
384
+ pick(step, matchesFor(step)[activeOption.value]!.value)
385
+ } else if (event.key === 'Escape') {
386
+ event.preventDefault()
387
+ dismissedStep.value = step.uid
388
+ }
389
+ }
390
+
391
+ function optionId(step: StepDraft, index: number): string {
392
+ return `${STEP_CONTROL_PREFIX}${step.uid}-option-${index}`
393
+ }
394
+
395
+ const validSteps = computed(() =>
396
+ steps.value
397
+ .map((step) => ({ kind: step.kind, value: step.value.trim() }))
398
+ .filter((step) => step.value !== ''),
399
+ )
400
+
401
+ /** Where each filled step sits in the preview, which skips the empty ones —
402
+ * by position, so a step repeated later reads its own count. */
403
+ const previewIndex = computed(() => {
404
+ const index = new Map<number, number>()
405
+ for (const step of steps.value) {
406
+ if (step.value.trim() !== '') {
407
+ index.set(step.uid, index.size)
408
+ }
409
+ }
410
+ return index
411
+ })
412
+
413
+ /** The preview scores what the server would accept, nothing else. */
414
+ const previewable = computed(
415
+ () =>
416
+ isWindowHours(windowHours.value) &&
417
+ validSteps.value.every(
418
+ (step) => step.value.length <= MAX_STEP_VALUE_LENGTH,
419
+ ),
420
+ )
421
+
422
+ const {
423
+ data: preview,
424
+ loading: previewing,
425
+ failed: previewFailed,
426
+ run: runPreview,
427
+ } = useLatestRequest<MarketingFunnelPreview>(() =>
428
+ websiteId.value && validSteps.value.length > 0 && previewable.value
429
+ ? api.previewFunnel(
430
+ websiteId.value,
431
+ validSteps.value,
432
+ windowHours.value as number,
433
+ )
434
+ : null,
435
+ )
436
+
437
+ let previewTimer: ReturnType<typeof setTimeout> | null = null
438
+
439
+ watch(
440
+ [validSteps, windowHours, websiteId],
441
+ () => {
442
+ if (previewTimer) {
443
+ clearTimeout(previewTimer)
444
+ }
445
+ previewTimer = setTimeout(() => void runPreview(), PREVIEW_DEBOUNCE_MS)
446
+ },
447
+ { deep: true, immediate: true },
448
+ )
449
+
450
+ onBeforeUnmount(() => previewTimer && clearTimeout(previewTimer))
451
+
452
+ function previewCount(step: StepDraft): number | null {
453
+ const index = previewIndex.value.get(step.uid)
454
+ return index === undefined
455
+ ? null
456
+ : (preview.value?.computation.steps[index]?.sessions ?? null)
457
+ }
458
+
459
+ const previewRows = computed(() => preview.value?.computation.steps ?? [])
460
+
461
+ /** Under a step: the path a pasted URL is matched as, or a step the site
462
+ * never sent over the preview's 30 days (unless the read hit its cap). */
463
+ function stepHint(step: StepDraft): { text: string; warning: boolean } | null {
464
+ const value = step.value.trim()
465
+ if (!value) {
466
+ return null
467
+ }
468
+ const index = previewIndex.value.get(step.uid)
469
+ const row = index === undefined ? undefined : previewRows.value[index]
470
+ if (
471
+ row &&
472
+ row.matches === 0 &&
473
+ (preview.value?.computation.events ?? 0) > 0 &&
474
+ !preview.value?.truncated
475
+ ) {
476
+ return { text: t('page.marketing.builder.step_unseen'), warning: true }
477
+ }
478
+ if (step.kind === 'url') {
479
+ const path = normalizeFunnelStepPath(value)
480
+ if (path !== value) {
481
+ return {
482
+ text: t('page.marketing.builder.step_matched_as', { path }),
483
+ warning: false,
484
+ }
485
+ }
486
+ }
487
+ return null
488
+ }
489
+
490
+ const entered = computed(() => previewRows.value[0]?.sessions ?? 0)
491
+ const converted = computed(() => previewRows.value.at(-1)?.sessions ?? 0)
492
+ const endToEnd = computed(() =>
493
+ entered.value > 0 ? (converted.value / entered.value) * PERCENT : null,
494
+ )
495
+
496
+ /** Share of the previous step's sessions that reached this one. */
497
+ function stepRate(index: number): string | null {
498
+ const before = previewRows.value[index - 1]?.sessions ?? 0
499
+ if (index === 0 || before <= 0) {
500
+ return null
501
+ }
502
+ return String(
503
+ formatPercentage(
504
+ previewRows.value[index]!.sessions / before,
505
+ locale.value,
506
+ fractionDigits(0),
507
+ ),
508
+ )
509
+ }
510
+
511
+ /** How long the split would need, at the traffic the preview measured (the
512
+ * preview covers 30 days, so its entered sessions are a month's). Moot once
513
+ * the test has started: the arms can no longer change. */
514
+ const sampleHint = computed(() => {
515
+ const rate = entered.value > 0 ? converted.value / entered.value : 0
516
+ const total = splitSampleSize(
517
+ rate,
518
+ MINIMUM_DETECTABLE_LIFT,
519
+ variations.value.map((arm) => arm.weight),
520
+ )
521
+ if (frozen.value || total === null) {
522
+ return null
523
+ }
524
+ const months = total / entered.value
525
+ const duration =
526
+ months < MIN_MONTHS_SHOWN
527
+ ? t('page.marketing.builder.months_under', {
528
+ months: formatNumber(MIN_MONTHS_SHOWN, locale.value, 1),
529
+ })
530
+ : t(
531
+ 'page.marketing.builder.months',
532
+ {
533
+ months: formatNumber(months, locale.value, months < 1 ? 1 : 0),
534
+ },
535
+ months <= 1 ? 1 : 2,
536
+ )
537
+ return {
538
+ total: formatNumber(Math.round(total), locale.value),
539
+ rate: formatPercentage(rate, locale.value, fractionDigits(1)),
540
+ duration,
541
+ tooSmall: months > 3,
542
+ }
543
+ })
544
+
545
+ // --- Editing -------------------------------------------------------------------
546
+
547
+ function addStep(kind: MarketingFunnelStep['kind'] = 'url', value = ''): void {
548
+ clearStepError()
549
+ const empty = steps.value.find((step) => step.value.trim() === '')
550
+ if (value && empty) {
551
+ empty.kind = kind
552
+ empty.value = value
553
+ return
554
+ }
555
+ if (steps.value.length < MAX_STEPS) {
556
+ steps.value.push({ uid: uid(), kind, value })
557
+ }
558
+ }
559
+
560
+ function removeStep(index: number): void {
561
+ clearStepError()
562
+ steps.value.splice(index, 1)
563
+ }
564
+
565
+ function moveStep(index: number, offset: number): void {
566
+ const target = index + offset
567
+ if (target < 0 || target >= steps.value.length) {
568
+ return
569
+ }
570
+ const [step] = steps.value.splice(index, 1)
571
+ steps.value.splice(target, 0, step!)
572
+ }
573
+
574
+ const windowItems = computed(() =>
575
+ WINDOW_PRESETS.map((hours) => ({
576
+ value: hours,
577
+ label: t(`page.marketing.builder.window_presets.${hours}`),
578
+ })),
579
+ )
580
+
581
+ const kindItems = computed(() => [
582
+ {
583
+ value: 'url',
584
+ label: t('page.marketing.funnel.kind.url'),
585
+ icon: 'i-ph-browser',
586
+ },
587
+ {
588
+ value: 'custom',
589
+ label: t('page.marketing.funnel.kind.custom'),
590
+ icon: 'i-ph-lightning',
591
+ },
592
+ ])
593
+
594
+ function servedWeight(arm: VariationDraft): number {
595
+ return typeof arm.weight === 'number' && arm.weight > 0 ? arm.weight : 0
596
+ }
597
+
598
+ const totalWeight = computed(() =>
599
+ variations.value.reduce((sum, arm) => sum + servedWeight(arm), 0),
600
+ )
601
+
602
+ function share(arm: VariationDraft): number {
603
+ return totalWeight.value > 0
604
+ ? (servedWeight(arm) / totalWeight.value) * PERCENT
605
+ : 0
606
+ }
607
+
608
+ function addVariation(): void {
609
+ clearVariationError()
610
+ if (variations.value.length < MAX_VARIATIONS) {
611
+ variations.value.push({ uid: uid(), key: '', weight: 1 })
612
+ }
613
+ }
614
+
615
+ function removeVariation(index: number): void {
616
+ clearVariationError()
617
+ variations.value.splice(index, 1)
618
+ }
619
+
620
+ function letter(index: number): string {
621
+ return String.fromCharCode(LETTER_A + index)
622
+ }
623
+
624
+ const firstUrlStep = computed(
625
+ () => validSteps.value.find((step) => step.kind === 'url')?.value ?? '/',
626
+ )
627
+
628
+ const snippetComments = computed(() => localizedSnippetComments(t))
629
+
630
+ const snippet = computed(() =>
631
+ variationSnippet(
632
+ experimentKey.value.trim() || 'experiment-key',
633
+ variations.value[0]?.key.trim() || 'control',
634
+ [variations.value[1]?.key.trim() || 'variation'],
635
+ snippetComments.value,
636
+ ),
637
+ )
638
+
639
+ /** The steps or the window of a started test differ from the saved ones:
640
+ * saving re-scores the whole test against the new goal. */
641
+ const objectiveChanged = computed(
642
+ () => frozen.value && objectiveOf(values()) !== savedObjective.value,
643
+ )
644
+
645
+ // --- Validation and save -------------------------------------------------------
646
+
647
+ async function showDraftIssues(): Promise<boolean> {
648
+ const issues = draftIssues(values(), frozen.value)
649
+ const fields = Object.keys(issues) as DraftField[]
650
+ if (fields.length === 0) {
651
+ return false
652
+ }
653
+ fieldErrors.clear()
654
+ flaggedStep.value =
655
+ issues.steps?.index === undefined
656
+ ? null
657
+ : (steps.value[issues.steps.index]?.uid ?? null)
658
+ flaggedVariation.value =
659
+ issues.variations?.index === undefined
660
+ ? null
661
+ : (variations.value[issues.variations.index]?.uid ?? null)
662
+ for (const field of fields) {
663
+ const issue = issues[field]!
664
+ fieldErrors.errors[field] = t(
665
+ `page.marketing.builder.errors.${issue.message}`,
666
+ issue.params ?? {},
667
+ )
668
+ }
669
+ await fieldErrors.focus(fields[0]!)
670
+ return true
671
+ }
672
+
673
+ function experimentPayload(): MarketingExperiment | null {
674
+ if (!splitEnabled.value) {
675
+ return null
676
+ }
677
+ if (frozen.value && original.value) {
678
+ return original.value
679
+ }
680
+ return {
681
+ key: experimentKey.value.trim(),
682
+ status: original.value?.status ?? 'draft',
683
+ variations: variations.value.map((arm) => ({
684
+ key: arm.key.trim(),
685
+ weight: arm.weight as number,
686
+ })),
687
+ runs: original.value?.runs ?? [],
688
+ }
689
+ }
690
+
691
+ function confirmObjectiveChange(): Promise<boolean> {
692
+ return confirm({
693
+ title: t('page.marketing.builder.objective_confirm.title'),
694
+ description: t('page.marketing.builder.objective_confirm.description'),
695
+ confirmLabel: t('page.marketing.builder.objective_confirm.confirm'),
696
+ cancelLabel: t('page.marketing.builder.objective_confirm.cancel'),
697
+ color: 'warning',
698
+ icon: 'i-ph-warning',
699
+ })
700
+ }
701
+
702
+ async function save(): Promise<void> {
703
+ // Several clicks in one tick would each create a funnel.
704
+ if (saving.value) {
705
+ return
706
+ }
707
+ saving.value = true
708
+ try {
709
+ if (await showDraftIssues()) {
710
+ return
711
+ }
712
+ if (objectiveChanged.value && !(await confirmObjectiveChange())) {
713
+ return
714
+ }
715
+ const input = {
716
+ websiteId: websiteId.value,
717
+ name: name.value.trim(),
718
+ steps: validSteps.value,
719
+ conversionWindowHours: windowHours.value as number,
720
+ experiment: experimentPayload(),
721
+ }
722
+ const id = isEdit ? funnelId : (await api.createFunnel(input))[0]
723
+ if (isEdit) {
724
+ await api.updateFunnel(funnelId, input)
725
+ }
726
+ // Clean before leaving, or the leave guard would ask.
727
+ markClean()
728
+ toast.add({
729
+ color: 'success',
730
+ title: t('page.marketing.builder.saved'),
731
+ })
732
+ await router.push(inContext(id ? funnelLink(id) : funnelsLink()))
733
+ } catch (error) {
734
+ await fieldErrors.handleApiError(error, {
735
+ toastTitle: 'page.marketing.builder.save_error',
736
+ })
737
+ } finally {
738
+ saving.value = false
739
+ }
740
+ }
741
+
742
+ /** Back to what the page opened on: the copy itself for a Duplicate, which
743
+ * stays a change to save. */
744
+ function discard(): void {
745
+ clearStepError()
746
+ clearVariationError()
747
+ fieldErrors.clear()
748
+ applyValues(copied.value ?? baseline.value)
749
+ }
750
+
751
+ /** A link out of the builder, on the website and period it was opened from. */
752
+ const inContext = useContextLink()
753
+
754
+ const backLink = computed(() => {
755
+ if (isEdit) {
756
+ return inContext(funnelLink(funnelId))
757
+ }
758
+ return inContext(sourceId ? funnelLink(sourceId) : funnelsLink())
759
+ })
760
+
761
+ /** `remount` for another builder URL: the same page otherwise stays
762
+ * mounted on the funnel it opened with. */
763
+ async function leave(href: string, remount = false): Promise<void> {
764
+ if (await confirmLeave()) {
765
+ await router.push(href, remount ? { preserveState: false } : undefined)
766
+ }
767
+ }
768
+
769
+ const websiteItems = computed(() =>
770
+ websites.value.map((site) => ({
771
+ value: site.id,
772
+ label: `${site.name} · ${site.domain}`,
773
+ })),
774
+ )
775
+ </script>
776
+
777
+ <template>
778
+ <div class="flex flex-col gap-4">
779
+ <header class="flex items-start gap-4">
780
+ <UButton
781
+ icon="i-ph-arrow-left"
782
+ color="neutral"
783
+ variant="ghost"
784
+ :aria-label="t('page.marketing.funnel.back')"
785
+ @click="leave(backLink)"
786
+ />
787
+ <div class="min-w-0 flex-1">
788
+ <h1 class="text-2xl font-semibold break-words text-highlighted">
789
+ {{
790
+ loadFailed
791
+ ? t('page.marketing.builder.page_title')
792
+ : isEdit
793
+ ? t('page.marketing.builder.edit_title', { name: name || '…' })
794
+ : t('page.marketing.builder.title')
795
+ }}
796
+ </h1>
797
+ <p v-if="!loadFailed" class="mt-1 max-w-3xl text-sm text-muted">
798
+ {{ t('page.marketing.builder.subtitle') }}
799
+ </p>
800
+ </div>
801
+ <UButton
802
+ v-if="isEdit && !loading && !loadFailed"
803
+ icon="i-ph-copy"
804
+ color="neutral"
805
+ variant="outline"
806
+ :label="t('page.marketing.builder.duplicate')"
807
+ @click="
808
+ leave(inContext(funnelBuilderLink(null, { copy: funnelId })), true)
809
+ "
810
+ />
811
+ </header>
812
+
813
+ <div v-if="loading" class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]">
814
+ <USkeleton class="h-[36rem] rounded-xl" />
815
+ <USkeleton class="h-72 rounded-xl" />
816
+ </div>
817
+
818
+ <DmsEmptyState
819
+ v-else-if="loadFailed"
820
+ variant="error"
821
+ class="dms-card"
822
+ :title="
823
+ t(
824
+ loadFailed === 'not_found'
825
+ ? 'page.marketing.errors.funnel_not_found'
826
+ : 'page.marketing.builder.load_error',
827
+ )
828
+ "
829
+ >
830
+ <template #actions>
831
+ <UButton
832
+ v-if="loadFailed === 'error'"
833
+ size="sm"
834
+ color="neutral"
835
+ variant="outline"
836
+ icon="i-ph-arrow-clockwise"
837
+ :label="t('page.marketing.common.retry')"
838
+ @click="load"
839
+ />
840
+ <UButton
841
+ size="sm"
842
+ color="neutral"
843
+ variant="outline"
844
+ icon="i-ph-arrow-left"
845
+ :label="t('page.marketing.funnel.back')"
846
+ @click="router.push(inContext(funnelsLink()))"
847
+ />
848
+ </template>
849
+ </DmsEmptyState>
850
+
851
+ <div
852
+ v-else
853
+ class="grid grid-cols-1 items-start gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]"
854
+ >
855
+ <div class="dms-card flex min-w-0 flex-col divide-y divide-default p-0">
856
+ <section class="flex flex-col gap-4 p-5">
857
+ <h2
858
+ class="flex items-center gap-2 text-sm font-semibold text-highlighted"
859
+ >
860
+ <span class="font-mono text-xs text-primary">01</span>
861
+ {{ t('page.marketing.builder.sections.basics') }}
862
+ </h2>
863
+ <div class="grid gap-4 md:grid-cols-2">
864
+ <DmsFieldRow
865
+ layout="stack"
866
+ spacing="list"
867
+ :inset="false"
868
+ label="$page.marketing.builder.name"
869
+ label-for="funnel-name"
870
+ required
871
+ >
872
+ <UInput
873
+ id="funnel-name"
874
+ v-model="name"
875
+ class="w-full"
876
+ :maxlength="MAX_NAME_LENGTH"
877
+ aria-required="true"
878
+ :placeholder="t('page.marketing.builder.name_placeholder')"
879
+ v-bind="fieldErrors.aria('name')"
880
+ @update:model-value="fieldErrors.clear('name')"
881
+ />
882
+ <DmsFieldError
883
+ :id="fieldErrors.errorId('name')"
884
+ :message="fieldErrors.errors.name"
885
+ />
886
+ </DmsFieldRow>
887
+ <DmsFieldRow
888
+ layout="stack"
889
+ spacing="list"
890
+ :inset="false"
891
+ label="$page.marketing.builder.website"
892
+ :description="
893
+ isEdit && frozen
894
+ ? '$page.marketing.builder.website_locked'
895
+ : undefined
896
+ "
897
+ label-for="funnel-website"
898
+ required
899
+ >
900
+ <USelect
901
+ id="funnel-website"
902
+ v-model="websiteId"
903
+ class="w-full"
904
+ :items="websiteItems"
905
+ :disabled="isEdit && frozen"
906
+ required
907
+ v-bind="fieldErrors.aria('websiteId')"
908
+ @update:model-value="fieldErrors.clear('websiteId')"
909
+ />
910
+ <DmsFieldError
911
+ :id="fieldErrors.errorId('websiteId')"
912
+ :message="fieldErrors.errors.websiteId"
913
+ />
914
+ </DmsFieldRow>
915
+ </div>
916
+ </section>
917
+
918
+ <section class="flex flex-col gap-3 p-5">
919
+ <div
920
+ class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
921
+ >
922
+ <h2
923
+ class="flex items-center gap-2 text-sm font-semibold text-highlighted"
924
+ >
925
+ <span class="font-mono text-xs text-primary">02</span>
926
+ <span>
927
+ {{ t('page.marketing.builder.sections.steps') }}
928
+ <span class="text-error" aria-hidden="true">*</span>
929
+ </span>
930
+ </h2>
931
+ <span class="text-xs text-dimmed">
932
+ {{ t('page.marketing.builder.steps_hint', { max: MAX_STEPS }) }}
933
+ </span>
934
+ </div>
935
+ <DmsBanner
936
+ v-if="objectiveChanged"
937
+ tone="warning"
938
+ size="sm"
939
+ icon="i-ph-warning"
940
+ :title="t('page.marketing.builder.objective_title')"
941
+ :description="t('page.marketing.builder.objective_description')"
942
+ />
943
+ <ol class="flex flex-col gap-2">
944
+ <li
945
+ v-for="(step, index) in steps"
946
+ :key="step.uid"
947
+ class="relative grid grid-cols-[auto_1.75rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-lg border p-2 md:grid-cols-[auto_1.75rem_9rem_minmax(0,1fr)_auto_auto]"
948
+ :class="
949
+ flaggedStep === step.uid
950
+ ? 'border-error'
951
+ : focusedStep === step.uid
952
+ ? 'border-primary'
953
+ : 'border-default'
954
+ "
955
+ >
956
+ <span class="flex flex-col">
957
+ <UButton
958
+ icon="i-ph-caret-up"
959
+ size="xs"
960
+ color="neutral"
961
+ variant="ghost"
962
+ :disabled="index === 0"
963
+ :aria-label="
964
+ t('page.marketing.builder.move_up_step', { n: index + 1 })
965
+ "
966
+ @click="moveStep(index, -1)"
967
+ />
968
+ <UButton
969
+ icon="i-ph-caret-down"
970
+ size="xs"
971
+ color="neutral"
972
+ variant="ghost"
973
+ :disabled="index === steps.length - 1"
974
+ :aria-label="
975
+ t('page.marketing.builder.move_down_step', { n: index + 1 })
976
+ "
977
+ @click="moveStep(index, 1)"
978
+ />
979
+ </span>
980
+ <span
981
+ class="grid size-7 place-items-center rounded-md font-mono text-xs"
982
+ :class="
983
+ index === steps.length - 1 && steps.length > 1
984
+ ? 'bg-primary text-inverted'
985
+ : 'bg-elevated text-toned'
986
+ "
987
+ >
988
+ <UIcon
989
+ v-if="index === steps.length - 1 && steps.length > 1"
990
+ name="i-ph-flag-checkered"
991
+ class="size-3.5"
992
+ />
993
+ <template v-else>{{ index + 1 }}</template>
994
+ </span>
995
+ <USelect
996
+ v-model="step.kind"
997
+ :items="kindItems"
998
+ size="sm"
999
+ :aria-label="
1000
+ t('page.marketing.builder.step_kind_label', { n: index + 1 })
1001
+ "
1002
+ @update:model-value="clearStepError"
1003
+ />
1004
+ <div
1005
+ class="relative order-last col-span-full md:order-none md:col-span-1"
1006
+ >
1007
+ <UInput
1008
+ :id="`${STEP_CONTROL_PREFIX}${step.uid}`"
1009
+ v-model="step.value"
1010
+ size="sm"
1011
+ class="w-full font-mono"
1012
+ :maxlength="MAX_STEP_VALUE_LENGTH"
1013
+ :placeholder="
1014
+ step.kind === 'url' ? '/pricing' : 'signup_submitted'
1015
+ "
1016
+ role="combobox"
1017
+ aria-autocomplete="list"
1018
+ :aria-label="
1019
+ t('page.marketing.builder.step_value_label', {
1020
+ n: index + 1,
1021
+ })
1022
+ "
1023
+ :aria-expanded="listOpen(step)"
1024
+ :aria-controls="`${STEP_CONTROL_PREFIX}${step.uid}-list`"
1025
+ :aria-activedescendant="
1026
+ listOpen(step) && activeOption >= 0
1027
+ ? optionId(step, activeOption)
1028
+ : undefined
1029
+ "
1030
+ v-bind="
1031
+ flaggedStep === step.uid ? fieldErrors.aria('steps') : {}
1032
+ "
1033
+ @focus="focusStep(step)"
1034
+ @blur="blurStep(step)"
1035
+ @keydown="onStepKeydown($event, step)"
1036
+ @update:model-value="typeStep"
1037
+ />
1038
+ <div
1039
+ v-if="listOpen(step)"
1040
+ class="absolute inset-x-0 top-full z-20 mt-1 overflow-hidden rounded-lg border border-default bg-default shadow-lg"
1041
+ >
1042
+ <div class="px-3 pt-2 pb-1">
1043
+ <DmsEyebrow
1044
+ :label="
1045
+ t(
1046
+ step.kind === 'url'
1047
+ ? 'page.marketing.builder.tracked_pages'
1048
+ : 'page.marketing.builder.tracked_events',
1049
+ )
1050
+ "
1051
+ />
1052
+ </div>
1053
+ <ul
1054
+ :id="`${STEP_CONTROL_PREFIX}${step.uid}-list`"
1055
+ role="listbox"
1056
+ >
1057
+ <li
1058
+ v-for="(match, matchIndex) in matchesFor(step)"
1059
+ :id="optionId(step, matchIndex)"
1060
+ :key="match.value"
1061
+ role="option"
1062
+ :aria-selected="activeOption === matchIndex"
1063
+ class="flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-left hover:bg-elevated"
1064
+ :class="{ 'bg-elevated': activeOption === matchIndex }"
1065
+ @mousedown.prevent="pick(step, match.value)"
1066
+ >
1067
+ <UIcon
1068
+ :name="
1069
+ step.kind === 'url'
1070
+ ? 'i-ph-browser'
1071
+ : 'i-ph-lightning'
1072
+ "
1073
+ class="size-3.5 text-dimmed"
1074
+ />
1075
+ <span
1076
+ class="flex-1 truncate font-mono text-[13px] text-highlighted"
1077
+ >
1078
+ {{ match.value }}
1079
+ </span>
1080
+ <span class="font-mono text-[11px] text-dimmed">
1081
+ {{
1082
+ t(
1083
+ step.kind === 'url'
1084
+ ? 'page.marketing.builder.views'
1085
+ : 'page.marketing.builder.triggers',
1086
+ { count: formatNumber(match.count, locale) },
1087
+ match.count,
1088
+ )
1089
+ }}
1090
+ </span>
1091
+ </li>
1092
+ </ul>
1093
+ </div>
1094
+ <p
1095
+ v-if="stepHint(step)"
1096
+ class="mt-1 flex items-center gap-1 text-xs"
1097
+ :class="
1098
+ stepHint(step)!.warning ? 'text-warning' : 'text-dimmed'
1099
+ "
1100
+ >
1101
+ <UIcon
1102
+ :name="
1103
+ stepHint(step)!.warning ? 'i-ph-warning' : 'i-ph-info'
1104
+ "
1105
+ class="size-3.5 shrink-0"
1106
+ />
1107
+ {{ stepHint(step)!.text }}
1108
+ </p>
1109
+ </div>
1110
+ <span
1111
+ class="text-right font-mono text-[11px] text-muted md:min-w-20"
1112
+ >
1113
+ <template v-if="previewCount(step) !== null">
1114
+ <span class="font-semibold text-highlighted">
1115
+ {{ formatNumber(previewCount(step)!, locale) }}
1116
+ </span>
1117
+ {{ t('page.marketing.builder.sessions_short') }}
1118
+ </template>
1119
+ </span>
1120
+ <UButton
1121
+ icon="i-ph-x"
1122
+ size="xs"
1123
+ color="neutral"
1124
+ variant="ghost"
1125
+ :disabled="steps.length === 1"
1126
+ :aria-label="
1127
+ t('page.marketing.builder.remove_step_n', { n: index + 1 })
1128
+ "
1129
+ @click="removeStep(index)"
1130
+ />
1131
+ </li>
1132
+ </ol>
1133
+ <DmsFieldError
1134
+ :id="fieldErrors.errorId('steps')"
1135
+ :message="fieldErrors.errors.steps"
1136
+ />
1137
+ <div class="flex flex-wrap items-center gap-3">
1138
+ <UButton
1139
+ icon="i-ph-plus"
1140
+ size="sm"
1141
+ color="neutral"
1142
+ variant="outline"
1143
+ :disabled="steps.length >= MAX_STEPS"
1144
+ :label="t('page.marketing.builder.add_step')"
1145
+ @click="addStep()"
1146
+ />
1147
+ <span class="text-xs text-dimmed">
1148
+ {{ t('page.marketing.builder.goal_hint') }}
1149
+ </span>
1150
+ </div>
1151
+ </section>
1152
+
1153
+ <section class="flex flex-col gap-3 p-5">
1154
+ <div
1155
+ class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
1156
+ >
1157
+ <h2
1158
+ class="flex items-center gap-2 text-sm font-semibold text-highlighted"
1159
+ >
1160
+ <span class="font-mono text-xs text-primary">03</span>
1161
+ {{ t('page.marketing.builder.sections.window') }}
1162
+ </h2>
1163
+ <span class="text-xs text-dimmed">
1164
+ {{ t('page.marketing.builder.window_hint') }}
1165
+ </span>
1166
+ </div>
1167
+ <div class="flex flex-wrap items-center gap-3">
1168
+ <DmsSegmented
1169
+ v-model="windowHours"
1170
+ :items="windowItems"
1171
+ variant="mono"
1172
+ size="sm"
1173
+ @update:model-value="fieldErrors.clear('conversionWindowHours')"
1174
+ />
1175
+ <span class="text-xs text-dimmed">
1176
+ {{ t('page.marketing.builder.or') }}
1177
+ </span>
1178
+ <UInput
1179
+ id="funnel-window"
1180
+ v-model.number="windowHours"
1181
+ type="number"
1182
+ :min="1"
1183
+ :max="MAX_WINDOW_HOURS"
1184
+ :step="1"
1185
+ size="sm"
1186
+ class="w-28"
1187
+ :aria-label="t('page.marketing.builder.window_label')"
1188
+ v-bind="fieldErrors.aria('conversionWindowHours')"
1189
+ @update:model-value="fieldErrors.clear('conversionWindowHours')"
1190
+ >
1191
+ <template #trailing>
1192
+ <span class="text-xs text-dimmed">
1193
+ {{ t('page.marketing.builder.hours') }}
1194
+ </span>
1195
+ </template>
1196
+ </UInput>
1197
+ <span class="text-xs text-dimmed">
1198
+ {{
1199
+ t('page.marketing.builder.window_max', {
1200
+ max: MAX_WINDOW_HOURS,
1201
+ })
1202
+ }}
1203
+ </span>
1204
+ </div>
1205
+ <DmsFieldError
1206
+ :id="fieldErrors.errorId('conversionWindowHours')"
1207
+ :message="fieldErrors.errors.conversionWindowHours"
1208
+ />
1209
+ </section>
1210
+
1211
+ <section class="flex flex-col gap-3 p-5">
1212
+ <div
1213
+ class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
1214
+ >
1215
+ <h2
1216
+ class="flex items-center gap-2 text-sm font-semibold text-highlighted"
1217
+ >
1218
+ <span class="font-mono text-xs text-primary">04</span>
1219
+ {{ t('page.marketing.builder.sections.split') }}
1220
+ </h2>
1221
+ <span class="text-xs text-dimmed">
1222
+ {{ t('page.marketing.builder.optional') }}
1223
+ </span>
1224
+ </div>
1225
+ <div
1226
+ class="flex items-start gap-3 rounded-lg border border-default p-4"
1227
+ >
1228
+ <DmsIconWell icon="i-ph-flask" tone="primary" size="md" />
1229
+ <span class="min-w-0 flex-1">
1230
+ <span class="block text-sm font-semibold text-highlighted">
1231
+ {{ t('page.marketing.builder.split_title') }}
1232
+ </span>
1233
+ <span class="block text-xs text-muted">
1234
+ {{ t('page.marketing.builder.split_description') }}
1235
+ </span>
1236
+ </span>
1237
+ <USwitch
1238
+ v-model="splitEnabled"
1239
+ :disabled="frozen"
1240
+ :aria-label="t('page.marketing.builder.split_title')"
1241
+ />
1242
+ </div>
1243
+ <template v-if="splitEnabled">
1244
+ <DmsBanner
1245
+ v-if="frozen"
1246
+ tone="info"
1247
+ size="sm"
1248
+ icon="i-ph-lock-simple"
1249
+ :title="t('page.marketing.builder.frozen_title')"
1250
+ :description="t('page.marketing.builder.frozen_description')"
1251
+ />
1252
+ <DmsFieldRow
1253
+ layout="stack"
1254
+ spacing="list"
1255
+ :inset="false"
1256
+ label="$page.marketing.builder.key"
1257
+ description="$page.marketing.builder.key_hint"
1258
+ label-for="funnel-experiment-key"
1259
+ required
1260
+ >
1261
+ <UInput
1262
+ id="funnel-experiment-key"
1263
+ v-model="experimentKey"
1264
+ class="w-full max-w-72 font-mono"
1265
+ placeholder="sale-hero"
1266
+ :maxlength="MAX_EXPERIMENT_KEY_LENGTH"
1267
+ :disabled="frozen"
1268
+ aria-required="true"
1269
+ v-bind="fieldErrors.aria('experiment')"
1270
+ @update:model-value="fieldErrors.clear('experiment')"
1271
+ />
1272
+ <DmsFieldError
1273
+ :id="fieldErrors.errorId('experiment')"
1274
+ :message="fieldErrors.errors.experiment"
1275
+ />
1276
+ </DmsFieldRow>
1277
+ <div class="flex flex-wrap items-center justify-between gap-x-3">
1278
+ <span class="text-sm font-medium text-highlighted">
1279
+ {{ t('page.marketing.builder.variations') }}
1280
+ </span>
1281
+ <span class="text-xs text-dimmed">
1282
+ {{
1283
+ t('page.marketing.builder.variations_hint', {
1284
+ min: MIN_VARIATIONS,
1285
+ max: MAX_VARIATIONS,
1286
+ })
1287
+ }}
1288
+ </span>
1289
+ </div>
1290
+ <ul class="flex flex-col gap-2">
1291
+ <li
1292
+ v-for="(arm, index) in variations"
1293
+ :key="arm.uid"
1294
+ class="grid grid-cols-[1.5rem_minmax(0,1fr)_6rem_3rem_auto] items-center gap-2 sm:grid-cols-[1.5rem_minmax(0,1fr)_6rem_5rem_3rem_auto]"
1295
+ >
1296
+ <span
1297
+ class="grid size-6 place-items-center rounded font-mono text-[11px] font-semibold"
1298
+ :class="
1299
+ index === 0
1300
+ ? 'bg-elevated text-toned'
1301
+ : 'bg-primary/15 text-(--ui-color-primary-800) dark:text-primary'
1302
+ "
1303
+ >
1304
+ {{ letter(index) }}
1305
+ </span>
1306
+ <UInput
1307
+ :id="`${VARIATION_CONTROL_PREFIX}${arm.uid}`"
1308
+ v-model="arm.key"
1309
+ size="sm"
1310
+ class="font-mono"
1311
+ :maxlength="MAX_EXPERIMENT_KEY_LENGTH"
1312
+ :placeholder="index === 0 ? 'control' : 'video-hero'"
1313
+ :disabled="frozen"
1314
+ :aria-label="
1315
+ t('page.marketing.builder.variation_key_label', {
1316
+ letter: letter(index),
1317
+ })
1318
+ "
1319
+ v-bind="
1320
+ flaggedVariation === arm.uid
1321
+ ? fieldErrors.aria('variations')
1322
+ : {}
1323
+ "
1324
+ @update:model-value="clearVariationError"
1325
+ />
1326
+ <UInput
1327
+ v-model.number="arm.weight"
1328
+ type="number"
1329
+ :min="0"
1330
+ size="sm"
1331
+ :disabled="frozen"
1332
+ :aria-label="
1333
+ t('page.marketing.builder.variation_weight_label', {
1334
+ letter: letter(index),
1335
+ })
1336
+ "
1337
+ @update:model-value="clearVariationError"
1338
+ >
1339
+ <template #trailing>
1340
+ <span class="text-xs text-dimmed">
1341
+ {{ t('page.marketing.builder.weight') }}
1342
+ </span>
1343
+ </template>
1344
+ </UInput>
1345
+ <span
1346
+ class="hidden h-1.5 overflow-hidden rounded-full bg-elevated sm:block"
1347
+ >
1348
+ <span
1349
+ class="block h-full rounded-full"
1350
+ :class="
1351
+ index === 0 ? 'bg-(--ui-text-dimmed)' : 'bg-primary'
1352
+ "
1353
+ :style="{ width: `${share(arm)}%` }"
1354
+ />
1355
+ </span>
1356
+ <span
1357
+ class="text-right font-mono text-xs font-semibold text-highlighted"
1358
+ >
1359
+ {{
1360
+ formatPercentage(
1361
+ share(arm) / 100,
1362
+ locale,
1363
+ fractionDigits(0),
1364
+ )
1365
+ }}
1366
+ </span>
1367
+ <UButton
1368
+ icon="i-ph-x"
1369
+ size="xs"
1370
+ color="neutral"
1371
+ variant="ghost"
1372
+ :disabled="frozen || variations.length <= MIN_VARIATIONS"
1373
+ :aria-label="
1374
+ t('page.marketing.builder.remove_variation_n', {
1375
+ letter: letter(index),
1376
+ })
1377
+ "
1378
+ @click="removeVariation(index)"
1379
+ />
1380
+ </li>
1381
+ </ul>
1382
+ <DmsFieldError
1383
+ :id="fieldErrors.errorId('variations')"
1384
+ :message="fieldErrors.errors.variations"
1385
+ />
1386
+ <UButton
1387
+ v-if="!frozen"
1388
+ icon="i-ph-plus"
1389
+ size="sm"
1390
+ color="neutral"
1391
+ variant="ghost"
1392
+ class="self-start"
1393
+ :disabled="variations.length >= MAX_VARIATIONS"
1394
+ :label="t('page.marketing.builder.add_variation')"
1395
+ @click="addVariation"
1396
+ />
1397
+ <div class="overflow-hidden rounded-lg border border-default">
1398
+ <div
1399
+ class="flex items-center justify-between gap-2 border-b border-default px-3 py-1.5"
1400
+ >
1401
+ <DmsEyebrow
1402
+ class="min-w-0 truncate"
1403
+ :label="
1404
+ t('page.marketing.builder.snippet_title', {
1405
+ path: firstUrlStep,
1406
+ })
1407
+ "
1408
+ />
1409
+ <DmsCopyButton :value="snippet" />
1410
+ </div>
1411
+ <pre
1412
+ class="overflow-x-auto px-3 py-2.5 font-mono text-xs leading-relaxed text-toned"
1413
+ >{{ snippet }}</pre>
1414
+ </div>
1415
+ <DmsBanner
1416
+ v-if="!frozen"
1417
+ tone="primary"
1418
+ size="sm"
1419
+ icon="i-ph-lock-simple-open"
1420
+ :title="t('page.marketing.builder.draft_title')"
1421
+ :description="t('page.marketing.builder.draft_description')"
1422
+ />
1423
+ </template>
1424
+ </section>
1425
+
1426
+ <div class="px-5 py-3">
1427
+ <DmsFormRequiredLegend />
1428
+ </div>
1429
+ </div>
1430
+
1431
+ <aside class="flex min-w-0 flex-col gap-4 lg:sticky lg:top-4">
1432
+ <section class="dms-card p-0">
1433
+ <header
1434
+ class="flex items-center justify-between border-b border-default px-4 py-3"
1435
+ >
1436
+ <DmsEyebrow :label="t('page.marketing.builder.preview')" />
1437
+ <UIcon
1438
+ v-if="previewing"
1439
+ name="i-ph-circle-notch"
1440
+ class="size-4 animate-spin text-dimmed"
1441
+ />
1442
+ </header>
1443
+ <div class="flex flex-col gap-2 px-4 py-3">
1444
+ <p
1445
+ v-if="validSteps.length > 0 && !previewable"
1446
+ class="py-4 text-center text-sm text-muted"
1447
+ >
1448
+ {{ t('page.marketing.builder.preview_invalid') }}
1449
+ </p>
1450
+ <p
1451
+ v-else-if="previewFailed"
1452
+ class="py-4 text-center text-sm text-muted"
1453
+ >
1454
+ {{ t('page.marketing.builder.preview_error') }}
1455
+ </p>
1456
+ <p
1457
+ v-else-if="previewRows.length === 0"
1458
+ class="py-4 text-center text-sm text-muted"
1459
+ >
1460
+ {{ t('page.marketing.builder.preview_empty') }}
1461
+ </p>
1462
+ <template v-else>
1463
+ <div
1464
+ v-for="(row, index) in previewRows"
1465
+ :key="index"
1466
+ class="flex flex-col gap-1"
1467
+ >
1468
+ <div class="flex items-center gap-2 text-xs">
1469
+ <span class="w-4 shrink-0 font-mono text-[11px] text-dimmed">
1470
+ {{ index + 1 }}
1471
+ </span>
1472
+ <span
1473
+ class="min-w-0 flex-1 truncate font-mono text-toned"
1474
+ :title="row.step.value"
1475
+ >
1476
+ {{ row.step.value }}
1477
+ </span>
1478
+ <span
1479
+ v-if="stepRate(index)"
1480
+ class="shrink-0 font-mono text-[11px] text-dimmed"
1481
+ :title="t('page.marketing.builder.step_rate')"
1482
+ >
1483
+ {{ stepRate(index) }}
1484
+ </span>
1485
+ <span
1486
+ class="w-14 shrink-0 text-right font-mono font-semibold text-highlighted"
1487
+ >
1488
+ {{ formatNumber(row.sessions, locale) }}
1489
+ </span>
1490
+ </div>
1491
+ <span
1492
+ class="ms-6 h-1.5 overflow-hidden rounded-full bg-elevated"
1493
+ >
1494
+ <span
1495
+ class="block h-full rounded-full bg-primary"
1496
+ :style="{
1497
+ width: `${entered > 0 ? (row.sessions / entered) * 100 : 0}%`,
1498
+ }"
1499
+ />
1500
+ </span>
1501
+ </div>
1502
+ <div
1503
+ class="mt-2 flex items-center justify-between border-t border-default pt-2"
1504
+ >
1505
+ <span class="text-sm text-muted">
1506
+ {{ t('page.marketing.builder.end_to_end') }}
1507
+ </span>
1508
+ <span class="font-mono text-sm font-semibold text-highlighted">
1509
+ {{
1510
+ endToEnd === null
1511
+ ? '—'
1512
+ : formatPercentage(
1513
+ endToEnd / 100,
1514
+ locale,
1515
+ fractionDigits(1),
1516
+ )
1517
+ }}
1518
+ </span>
1519
+ </div>
1520
+ </template>
1521
+ <p
1522
+ v-if="splitEnabled && sampleHint"
1523
+ class="mt-1 flex gap-1.5 text-xs"
1524
+ :class="sampleHint.tooSmall ? 'text-warning' : 'text-muted'"
1525
+ >
1526
+ <UIcon name="i-ph-info" class="mt-0.5 size-3.5 shrink-0" />
1527
+ {{ t('page.marketing.builder.sample_hint', sampleHint) }}
1528
+ </p>
1529
+ </div>
1530
+ </section>
1531
+
1532
+ <section class="dms-card p-0">
1533
+ <header class="border-b border-default px-4 py-3">
1534
+ <DmsEyebrow :label="t('page.marketing.builder.events_title')" />
1535
+ </header>
1536
+ <ul class="divide-y divide-default">
1537
+ <li
1538
+ v-for="event in suggestions.events.slice(0, 6)"
1539
+ :key="event.value"
1540
+ >
1541
+ <button
1542
+ type="button"
1543
+ class="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-elevated/60"
1544
+ :aria-label="
1545
+ t('page.marketing.builder.add_event_step', {
1546
+ name: event.value,
1547
+ })
1548
+ "
1549
+ @click="addStep('custom', event.value)"
1550
+ >
1551
+ <span
1552
+ class="flex-1 truncate font-mono text-[13px] text-highlighted"
1553
+ >
1554
+ {{ event.value }}
1555
+ </span>
1556
+ <span class="font-mono text-[11px] text-dimmed">
1557
+ {{ formatNumber(event.count, locale) }}
1558
+ </span>
1559
+ </button>
1560
+ </li>
1561
+ <li
1562
+ v-if="suggestions.events.length === 0"
1563
+ class="px-4 py-4 text-center text-sm text-muted"
1564
+ >
1565
+ {{ t('page.marketing.builder.no_events') }}
1566
+ </li>
1567
+ </ul>
1568
+ <footer
1569
+ class="flex items-center gap-1.5 border-t border-default px-4 py-2.5 font-mono text-[11px] text-dimmed"
1570
+ >
1571
+ <UIcon name="i-ph-code" class="size-3.5 shrink-0" />
1572
+ <span class="min-w-0 break-words">
1573
+ {{ t('page.marketing.builder.events_hint') }}
1574
+ </span>
1575
+ </footer>
1576
+ </section>
1577
+ </aside>
1578
+ </div>
1579
+
1580
+ <DmsSaveBar
1581
+ v-if="!loading && !loadFailed"
1582
+ :dirty="dirty"
1583
+ :saving="saving"
1584
+ :save-label="
1585
+ isEdit
1586
+ ? '$page.marketing.common.save_changes'
1587
+ : '$page.marketing.builder.save'
1588
+ "
1589
+ cancellable
1590
+ @save="save"
1591
+ @discard="discard"
1592
+ @cancel="leave(backLink)"
1593
+ />
1594
+ </div>
1595
+ </template>