@antelopejs/dms-marketing 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +143 -0
  3. package/dist/config.d.ts +13 -0
  4. package/dist/config.js +35 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/data-api/body.d.ts +1 -0
  7. package/dist/data-api/body.js +15 -0
  8. package/dist/data-api/body.js.map +1 -0
  9. package/dist/data-api/funnels.d.ts +32 -0
  10. package/dist/data-api/funnels.js +302 -0
  11. package/dist/data-api/funnels.js.map +1 -0
  12. package/dist/data-api/hidden-filter.d.ts +1 -0
  13. package/dist/data-api/hidden-filter.js +11 -0
  14. package/dist/data-api/hidden-filter.js.map +1 -0
  15. package/dist/data-api/index.d.ts +4 -0
  16. package/dist/data-api/index.js +21 -0
  17. package/dist/data-api/index.js.map +1 -0
  18. package/dist/data-api/tenant-scope.d.ts +15 -0
  19. package/dist/data-api/tenant-scope.js +43 -0
  20. package/dist/data-api/tenant-scope.js.map +1 -0
  21. package/dist/data-api/websites.d.ts +25 -0
  22. package/dist/data-api/websites.js +73 -0
  23. package/dist/data-api/websites.js.map +1 -0
  24. package/dist/db/index.d.ts +2 -0
  25. package/dist/db/index.js +19 -0
  26. package/dist/db/index.js.map +1 -0
  27. package/dist/db/models/funnels.model.d.ts +25 -0
  28. package/dist/db/models/funnels.model.js +39 -0
  29. package/dist/db/models/funnels.model.js.map +1 -0
  30. package/dist/db/models/index.d.ts +7 -0
  31. package/dist/db/models/index.js +24 -0
  32. package/dist/db/models/index.js.map +1 -0
  33. package/dist/db/models/marketing_events.model.d.ts +49 -0
  34. package/dist/db/models/marketing_events.model.js +85 -0
  35. package/dist/db/models/marketing_events.model.js.map +1 -0
  36. package/dist/db/models/marketing_sessions.model.d.ts +33 -0
  37. package/dist/db/models/marketing_sessions.model.js +50 -0
  38. package/dist/db/models/marketing_sessions.model.js.map +1 -0
  39. package/dist/db/models/marketing_settings.model.d.ts +23 -0
  40. package/dist/db/models/marketing_settings.model.js +13 -0
  41. package/dist/db/models/marketing_settings.model.js.map +1 -0
  42. package/dist/db/models/page_snapshots.model.d.ts +27 -0
  43. package/dist/db/models/page_snapshots.model.js +32 -0
  44. package/dist/db/models/page_snapshots.model.js.map +1 -0
  45. package/dist/db/models/retention.d.ts +6 -0
  46. package/dist/db/models/retention.js +26 -0
  47. package/dist/db/models/retention.js.map +1 -0
  48. package/dist/db/models/website_statistics.model.d.ts +31 -0
  49. package/dist/db/models/website_statistics.model.js +63 -0
  50. package/dist/db/models/website_statistics.model.js.map +1 -0
  51. package/dist/db/models/websites.model.d.ts +26 -0
  52. package/dist/db/models/websites.model.js +27 -0
  53. package/dist/db/models/websites.model.js.map +1 -0
  54. package/dist/db/tables/funnels.table.d.ts +31 -0
  55. package/dist/db/tables/funnels.table.js +54 -0
  56. package/dist/db/tables/funnels.table.js.map +1 -0
  57. package/dist/db/tables/index.d.ts +7 -0
  58. package/dist/db/tables/index.js +24 -0
  59. package/dist/db/tables/index.js.map +1 -0
  60. package/dist/db/tables/marketing_events.table.d.ts +15 -0
  61. package/dist/db/tables/marketing_events.table.js +66 -0
  62. package/dist/db/tables/marketing_events.table.js.map +1 -0
  63. package/dist/db/tables/marketing_sessions.table.d.ts +26 -0
  64. package/dist/db/tables/marketing_sessions.table.js +115 -0
  65. package/dist/db/tables/marketing_sessions.table.js.map +1 -0
  66. package/dist/db/tables/marketing_settings.table.d.ts +11 -0
  67. package/dist/db/tables/marketing_settings.table.js +51 -0
  68. package/dist/db/tables/marketing_settings.table.js.map +1 -0
  69. package/dist/db/tables/page_snapshots.table.d.ts +17 -0
  70. package/dist/db/tables/page_snapshots.table.js +72 -0
  71. package/dist/db/tables/page_snapshots.table.js.map +1 -0
  72. package/dist/db/tables/website_statistics.table.d.ts +14 -0
  73. package/dist/db/tables/website_statistics.table.js +60 -0
  74. package/dist/db/tables/website_statistics.table.js.map +1 -0
  75. package/dist/db/tables/websites.table.d.ts +13 -0
  76. package/dist/db/tables/websites.table.js +60 -0
  77. package/dist/db/tables/websites.table.js.map +1 -0
  78. package/dist/hooks/index.d.ts +1 -0
  79. package/dist/hooks/index.js +39 -0
  80. package/dist/hooks/index.js.map +1 -0
  81. package/dist/implementations/dms-marketing/index.d.ts +2 -0
  82. package/dist/implementations/dms-marketing/index.js +8 -0
  83. package/dist/implementations/dms-marketing/index.js.map +1 -0
  84. package/dist/index.d.ts +7 -0
  85. package/dist/index.js +89 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/pages/all.d.ts +5 -0
  88. package/dist/pages/all.js +22 -0
  89. package/dist/pages/all.js.map +1 -0
  90. package/dist/pages/campaigns/page.d.ts +9 -0
  91. package/dist/pages/campaigns/page.js +27 -0
  92. package/dist/pages/campaigns/page.js.map +1 -0
  93. package/dist/pages/funnels/page.d.ts +9 -0
  94. package/dist/pages/funnels/page.js +27 -0
  95. package/dist/pages/funnels/page.js.map +1 -0
  96. package/dist/pages/index.d.ts +4 -0
  97. package/dist/pages/index.js +21 -0
  98. package/dist/pages/index.js.map +1 -0
  99. package/dist/pages/module.d.ts +1 -0
  100. package/dist/pages/module.js +13 -0
  101. package/dist/pages/module.js.map +1 -0
  102. package/dist/pages/overview/page.d.ts +9 -0
  103. package/dist/pages/overview/page.js +27 -0
  104. package/dist/pages/overview/page.js.map +1 -0
  105. package/dist/pages/pages/page.d.ts +9 -0
  106. package/dist/pages/pages/page.js +27 -0
  107. package/dist/pages/pages/page.js.map +1 -0
  108. package/dist/pages/settings/form.d.ts +3 -0
  109. package/dist/pages/settings/form.js +35 -0
  110. package/dist/pages/settings/form.js.map +1 -0
  111. package/dist/pages/settings/page.d.ts +9 -0
  112. package/dist/pages/settings/page.js +27 -0
  113. package/dist/pages/settings/page.js.map +1 -0
  114. package/dist/routes/collect.d.ts +7 -0
  115. package/dist/routes/collect.js +62 -0
  116. package/dist/routes/collect.js.map +1 -0
  117. package/dist/routes/experiments.d.ts +6 -0
  118. package/dist/routes/experiments.js +63 -0
  119. package/dist/routes/experiments.js.map +1 -0
  120. package/dist/routes/funnels.d.ts +16 -0
  121. package/dist/routes/funnels.js +44 -0
  122. package/dist/routes/funnels.js.map +1 -0
  123. package/dist/routes/index.d.ts +8 -0
  124. package/dist/routes/index.js +25 -0
  125. package/dist/routes/index.js.map +1 -0
  126. package/dist/routes/settings.d.ts +8 -0
  127. package/dist/routes/settings.js +50 -0
  128. package/dist/routes/settings.js.map +1 -0
  129. package/dist/routes/snapshot.d.ts +10 -0
  130. package/dist/routes/snapshot.js +147 -0
  131. package/dist/routes/snapshot.js.map +1 -0
  132. package/dist/routes/stats.d.ts +44 -0
  133. package/dist/routes/stats.js +278 -0
  134. package/dist/routes/stats.js.map +1 -0
  135. package/dist/routes/tracker.d.ts +6 -0
  136. package/dist/routes/tracker.js +57 -0
  137. package/dist/routes/tracker.js.map +1 -0
  138. package/dist/routes/websites.d.ts +13 -0
  139. package/dist/routes/websites.js +144 -0
  140. package/dist/routes/websites.js.map +1 -0
  141. package/dist/services/acquisition.d.ts +15 -0
  142. package/dist/services/acquisition.js +153 -0
  143. package/dist/services/acquisition.js.map +1 -0
  144. package/dist/services/bounded-cache.d.ts +14 -0
  145. package/dist/services/bounded-cache.js +45 -0
  146. package/dist/services/bounded-cache.js.map +1 -0
  147. package/dist/services/collect.d.ts +11 -0
  148. package/dist/services/collect.js +193 -0
  149. package/dist/services/collect.js.map +1 -0
  150. package/dist/services/experiment-definitions.d.ts +4 -0
  151. package/dist/services/experiment-definitions.js +36 -0
  152. package/dist/services/experiment-definitions.js.map +1 -0
  153. package/dist/services/experiments/assign.d.ts +5 -0
  154. package/dist/services/experiments/assign.js +52 -0
  155. package/dist/services/experiments/assign.js.map +1 -0
  156. package/dist/services/experiments/index.d.ts +3 -0
  157. package/dist/services/experiments/index.js +20 -0
  158. package/dist/services/experiments/index.js.map +1 -0
  159. package/dist/services/experiments/results.d.ts +20 -0
  160. package/dist/services/experiments/results.js +91 -0
  161. package/dist/services/experiments/results.js.map +1 -0
  162. package/dist/services/experiments/stats.d.ts +12 -0
  163. package/dist/services/experiments/stats.js +62 -0
  164. package/dist/services/experiments/stats.js.map +1 -0
  165. package/dist/services/funnels/compute.d.ts +15 -0
  166. package/dist/services/funnels/compute.js +68 -0
  167. package/dist/services/funnels/compute.js.map +1 -0
  168. package/dist/services/funnels/index.d.ts +2 -0
  169. package/dist/services/funnels/index.js +19 -0
  170. package/dist/services/funnels/index.js.map +1 -0
  171. package/dist/services/funnels/results.d.ts +14 -0
  172. package/dist/services/funnels/results.js +72 -0
  173. package/dist/services/funnels/results.js.map +1 -0
  174. package/dist/services/geoip.d.ts +3 -0
  175. package/dist/services/geoip.js +38 -0
  176. package/dist/services/geoip.js.map +1 -0
  177. package/dist/services/heatmap.d.ts +10 -0
  178. package/dist/services/heatmap.js +180 -0
  179. package/dist/services/heatmap.js.map +1 -0
  180. package/dist/services/index.d.ts +26 -0
  181. package/dist/services/index.js +43 -0
  182. package/dist/services/index.js.map +1 -0
  183. package/dist/services/ingest-guard.d.ts +11 -0
  184. package/dist/services/ingest-guard.js +44 -0
  185. package/dist/services/ingest-guard.js.map +1 -0
  186. package/dist/services/keyed-queue.d.ts +4 -0
  187. package/dist/services/keyed-queue.js +19 -0
  188. package/dist/services/keyed-queue.js.map +1 -0
  189. package/dist/services/origin-guard.d.ts +5 -0
  190. package/dist/services/origin-guard.js +43 -0
  191. package/dist/services/origin-guard.js.map +1 -0
  192. package/dist/services/period.d.ts +2 -0
  193. package/dist/services/period.js +19 -0
  194. package/dist/services/period.js.map +1 -0
  195. package/dist/services/prune.d.ts +7 -0
  196. package/dist/services/prune.js +49 -0
  197. package/dist/services/prune.js.map +1 -0
  198. package/dist/services/query-param.d.ts +1 -0
  199. package/dist/services/query-param.js +11 -0
  200. package/dist/services/query-param.js.map +1 -0
  201. package/dist/services/request-body.d.ts +2 -0
  202. package/dist/services/request-body.js +41 -0
  203. package/dist/services/request-body.js.map +1 -0
  204. package/dist/services/request-meta.d.ts +3 -0
  205. package/dist/services/request-meta.js +19 -0
  206. package/dist/services/request-meta.js.map +1 -0
  207. package/dist/services/rollup-write.d.ts +4 -0
  208. package/dist/services/rollup-write.js +84 -0
  209. package/dist/services/rollup-write.js.map +1 -0
  210. package/dist/services/sessionize.d.ts +23 -0
  211. package/dist/services/sessionize.js +78 -0
  212. package/dist/services/sessionize.js.map +1 -0
  213. package/dist/services/settings.d.ts +6 -0
  214. package/dist/services/settings.js +113 -0
  215. package/dist/services/settings.js.map +1 -0
  216. package/dist/services/snapshots.d.ts +24 -0
  217. package/dist/services/snapshots.js +139 -0
  218. package/dist/services/snapshots.js.map +1 -0
  219. package/dist/services/tenant-funnel.d.ts +3 -0
  220. package/dist/services/tenant-funnel.js +17 -0
  221. package/dist/services/tenant-funnel.js.map +1 -0
  222. package/dist/services/tenant-website.d.ts +5 -0
  223. package/dist/services/tenant-website.js +34 -0
  224. package/dist/services/tenant-website.js.map +1 -0
  225. package/dist/services/url.d.ts +10 -0
  226. package/dist/services/url.js +81 -0
  227. package/dist/services/url.js.map +1 -0
  228. package/dist/services/user-agent.d.ts +2 -0
  229. package/dist/services/user-agent.js +17 -0
  230. package/dist/services/user-agent.js.map +1 -0
  231. package/dist/services/visitor-id.d.ts +1 -0
  232. package/dist/services/visitor-id.js +19 -0
  233. package/dist/services/visitor-id.js.map +1 -0
  234. package/dist/services/website-cache.d.ts +3 -0
  235. package/dist/services/website-cache.js +22 -0
  236. package/dist/services/website-cache.js.map +1 -0
  237. package/dist/types/constants.d.ts +74 -0
  238. package/dist/types/constants.js +79 -0
  239. package/dist/types/constants.js.map +1 -0
  240. package/dist/types/events.d.ts +114 -0
  241. package/dist/types/events.js +29 -0
  242. package/dist/types/events.js.map +1 -0
  243. package/dist/types/index.d.ts +5 -0
  244. package/dist/types/index.js +22 -0
  245. package/dist/types/index.js.map +1 -0
  246. package/dist/types/settings.d.ts +67 -0
  247. package/dist/types/settings.js +58 -0
  248. package/dist/types/settings.js.map +1 -0
  249. package/dist/types/snapshots.d.ts +95 -0
  250. package/dist/types/snapshots.js +41 -0
  251. package/dist/types/snapshots.js.map +1 -0
  252. package/dist/types/statistics.d.ts +38 -0
  253. package/dist/types/statistics.js +33 -0
  254. package/dist/types/statistics.js.map +1 -0
  255. package/dist/utils/experiment-type.d.ts +52 -0
  256. package/dist/utils/experiment-type.js +94 -0
  257. package/dist/utils/experiment-type.js.map +1 -0
  258. package/dist/utils/funnel-steps-type.d.ts +42 -0
  259. package/dist/utils/funnel-steps-type.js +62 -0
  260. package/dist/utils/funnel-steps-type.js.map +1 -0
  261. package/frontend-vue/.editorconfig +12 -0
  262. package/frontend-vue/.prettierrc +7 -0
  263. package/frontend-vue/app/components/CampaignsView.vue +330 -0
  264. package/frontend-vue/app/components/ExperimentInput.vue +128 -0
  265. package/frontend-vue/app/components/FunnelDetail.vue +1026 -0
  266. package/frontend-vue/app/components/FunnelStepsInput.vue +154 -0
  267. package/frontend-vue/app/components/FunnelsView.vue +493 -0
  268. package/frontend-vue/app/components/HeatmapCanvas.vue +116 -0
  269. package/frontend-vue/app/components/NoWebsiteState.vue +18 -0
  270. package/frontend-vue/app/components/OverviewView.vue +493 -0
  271. package/frontend-vue/app/components/PagePreview.vue +915 -0
  272. package/frontend-vue/app/components/PagesView.vue +378 -0
  273. package/frontend-vue/app/components/ScrollDepthOverlay.vue +158 -0
  274. package/frontend-vue/app/components/TextKpiCard.vue +28 -0
  275. package/frontend-vue/app/components/TopListTabsCard.vue +162 -0
  276. package/frontend-vue/app/components/TrackerSnippet.vue +31 -0
  277. package/frontend-vue/app/components/VariationsInput.vue +164 -0
  278. package/frontend-vue/app/composables/useChildId.ts +13 -0
  279. package/frontend-vue/app/composables/useExperiment.ts +59 -0
  280. package/frontend-vue/app/composables/useFunnelSteps.ts +25 -0
  281. package/frontend-vue/app/composables/useLatestRequest.ts +90 -0
  282. package/frontend-vue/app/composables/useMarketingApi.ts +420 -0
  283. package/frontend-vue/app/composables/useMarketingPeriod.ts +66 -0
  284. package/frontend-vue/app/composables/useMarketingRoutes.ts +51 -0
  285. package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +69 -0
  286. package/frontend-vue/app/composables/useMarketingWebsites.ts +80 -0
  287. package/frontend-vue/app/composables/useSnapshotFrame.ts +183 -0
  288. package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +46 -0
  289. package/frontend-vue/app/utils/dmsComponent.ts +18 -0
  290. package/frontend-vue/app/utils/heatmapPalette.ts +41 -0
  291. package/frontend-vue/app/utils/snapshotViewportUnits.ts +63 -0
  292. package/frontend-vue/dms.frontend.ts +35 -0
  293. package/frontend-vue/eslint.config.mjs +16 -0
  294. package/frontend-vue/i18n/locales/marketing-en-GB.json +382 -0
  295. package/frontend-vue/i18n/locales/marketing-fr-FR.json +382 -0
  296. package/frontend-vue/package.json +30 -0
  297. package/frontend-vue/pnpm-lock.yaml +5226 -0
  298. package/frontend-vue/pnpm-workspace.yaml +2 -0
  299. package/frontend-vue/tests/drafts.test.ts +54 -0
  300. package/frontend-vue/tests/i18n.test.ts +86 -0
  301. package/frontend-vue/tests/period.test.ts +35 -0
  302. package/frontend-vue/tests/registration.test.ts +63 -0
  303. package/frontend-vue/tests/routes.test.ts +31 -0
  304. package/frontend-vue/tests/stubs/frontend-module.ts +56 -0
  305. package/frontend-vue/vitest.config.ts +20 -0
  306. package/package.json +91 -0
  307. package/static/snapshot.js +497 -0
  308. package/static/tracker.js +503 -0
@@ -0,0 +1,1026 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { useI18n } from '#dms/frontend-module'
4
+ import type {
5
+ ExperimentDraft,
6
+ ExperimentVariationDraft,
7
+ } from '../composables/useExperiment'
8
+ import { useLatestRequest } from '../composables/useLatestRequest'
9
+ import {
10
+ useMarketingApi,
11
+ type MarketingExperimentStatus,
12
+ type MarketingFunnelResults,
13
+ type MarketingFunnelStep,
14
+ type MarketingFunnelStepResult,
15
+ type MarketingVariationResult,
16
+ } from '../composables/useMarketingApi'
17
+ import { pagesLink } from '../composables/useMarketingRoutes'
18
+
19
+ import type { DropdownMenuItem } from '@nuxt/ui'
20
+
21
+ /**
22
+ * Detail pane of the funnels master/detail: one funnel as a figure — centred
23
+ * bars tied by a taper carrying the drop — drawn once over the sessions that
24
+ * entered it, or once per arm when the funnel carries an experiment past
25
+ * draft, with the verdict banner (control vs best arm) above and the
26
+ * lifecycle actions in the header.
27
+ *
28
+ * Deletion and the status transitions are owned here (confirm included):
29
+ * they need nothing the parent does not already hand over. Editing is only
30
+ * emitted — the form needs the list row and the parent owns the list.
31
+ */
32
+
33
+ /** Bars carry their own count: below this the number would not fit inside. */
34
+ const MIN_BAR_PERCENT = 12
35
+
36
+ type ArmColor = 'primary' | 'warning' | 'success'
37
+
38
+ /** Literal class names: Tailwind only ships what it can read. */
39
+ const ARM_CLASSES: Record<ArmColor, { text: string, bar: string, taper: string }> = {
40
+ primary: { text: 'text-primary', bar: 'bg-primary', taper: 'bg-primary/20' },
41
+ warning: { text: 'text-warning', bar: 'bg-warning', taper: 'bg-warning/20' },
42
+ success: { text: 'text-success', bar: 'bg-success', taper: 'bg-success/20' },
43
+ }
44
+
45
+ const props = defineProps<{
46
+ funnelId: string
47
+ /** From the parent's list row: always names `funnelId`, while `results`
48
+ * may still carry the previous funnel during its (slow) fetch. */
49
+ funnelName: string
50
+ /** The row's A/B facet, parsed by the parent; null on a plain funnel. */
51
+ experiment: ExperimentDraft | null
52
+ period: string
53
+ /** Bumped by the parent after an edit: same id, new definition. */
54
+ refreshToken?: number
55
+ }>()
56
+
57
+ const emit = defineEmits<{
58
+ 'edit': []
59
+ 'deleted': [id: string]
60
+ 'statusChanged': [status: MarketingExperimentStatus]
61
+ }>()
62
+
63
+ const api = useMarketingApi()
64
+ const { t, locale } = useI18n()
65
+ const { confirm } = useConfirm()
66
+ const toast = useToast()
67
+
68
+ // Funnel results are the slowest read in the module and the selection, the
69
+ // period and the post-edit refresh all retrigger them, so a superseded answer
70
+ // landing on the current selection is the failure mode — see useLatestRequest.
71
+ const {
72
+ data: results,
73
+ loading,
74
+ failed,
75
+ run: loadResults,
76
+ } = useLatestRequest<MarketingFunnelResults>(() =>
77
+ api.getFunnelResults(props.funnelId, props.period),
78
+ )
79
+
80
+ watch(
81
+ () => [props.funnelId, props.period, props.refreshToken],
82
+ () => {
83
+ void loadResults()
84
+ },
85
+ { immediate: true },
86
+ )
87
+
88
+ const status = computed(() => props.experiment?.status ?? null)
89
+
90
+ /** The per-arm read applies once the experiment has served traffic; a draft
91
+ * still reads as the plain funnel it also is. */
92
+ const armsMode = computed(() => status.value !== null && status.value !== 'draft')
93
+
94
+ const statusMeta = computed<
95
+ Record<MarketingExperimentStatus, { label: string, color: 'neutral' | 'success' | 'warning' }>
96
+ >(() => ({
97
+ draft: { label: t('page.marketing.funnels.status.draft'), color: 'neutral' },
98
+ running: { label: t('page.marketing.funnels.status.running'), color: 'success' },
99
+ stopped: { label: t('page.marketing.funnels.status.stopped'), color: 'warning' },
100
+ }))
101
+
102
+ const variations = computed(() => props.experiment?.variations ?? [])
103
+
104
+ const totalWeight = computed(() =>
105
+ variations.value.reduce(
106
+ (sum, variation) => sum + Math.max(variation.weight || 0, 0),
107
+ 0,
108
+ ),
109
+ )
110
+
111
+ function weightPercent(variation: ExperimentVariationDraft): number {
112
+ if (totalWeight.value <= 0) {
113
+ return Math.round(100 / Math.max(variations.value.length, 1))
114
+ }
115
+ return Math.round(
116
+ (Math.max(variation.weight || 0, 0) / totalWeight.value) * 100,
117
+ )
118
+ }
119
+
120
+ const totalExposed = computed(() =>
121
+ (results.value?.experiment?.variations ?? []).reduce(
122
+ (sum, variation) => sum + variation.exposedSessions,
123
+ 0,
124
+ ),
125
+ )
126
+
127
+ function ratePercent(rate: number): string {
128
+ return `${(rate * 100).toFixed(1)}%`
129
+ }
130
+
131
+ function signedPercent(value: number): string {
132
+ return `${value >= 0 ? '+' : ''}${(value * 100).toFixed(1)}%`
133
+ }
134
+
135
+ /** Share of a base count; the arm's exposure or the funnel's entries. */
136
+ function share(sessions: number, base: number): number {
137
+ return base > 0 ? sessions / base : 0
138
+ }
139
+
140
+ /**
141
+ * The one-line read under the title. Guarded on `loading` because `results`
142
+ * may still be the previous funnel's during a fetch — a stale count under a
143
+ * fresh name would read as this funnel's.
144
+ */
145
+ const metaLine = computed(() => {
146
+ const payload = loading.value ? null : results.value
147
+ const experiment = props.experiment
148
+ if (!experiment) {
149
+ if (!payload) {
150
+ return ''
151
+ }
152
+ const steps = payload.computation.steps
153
+ const entered = payload.computation.totalSessions
154
+ const last = steps[steps.length - 1]
155
+ return t('page.marketing.funnels.summary', {
156
+ entered,
157
+ rate: ((last?.conversionRate ?? 0) * 100).toFixed(1),
158
+ }, entered)
159
+ }
160
+ const split = experiment.variations.map(variation => weightPercent(variation)).join('/')
161
+ if (experiment.status === 'draft') {
162
+ return t('page.marketing.funnels.results.meta_draft', { split })
163
+ }
164
+ const splitPart = t('page.marketing.funnels.results.meta_frozen', { split })
165
+ if (!payload) {
166
+ return splitPart
167
+ }
168
+ const exposedPart = t(
169
+ 'page.marketing.funnels.results.exposed',
170
+ { count: totalExposed.value },
171
+ totalExposed.value,
172
+ )
173
+ return [exposedPart, windowLabel(payload), splitPart].join(' · ')
174
+ })
175
+
176
+ function shortDate(epochMs: number): string {
177
+ return new Date(epochMs).toLocaleDateString(locale.value, {
178
+ day: 'numeric',
179
+ month: 'short',
180
+ })
181
+ }
182
+
183
+ /**
184
+ * Which stretch of time the arms cover. A started split is read over its own
185
+ * runs and not over the selected period, so the pane has to say so: the
186
+ * period picker moves the rest of the module and leaves these numbers alone.
187
+ * Past one run the count comes with it — the dates alone would read as an
188
+ * unbroken stretch, and the pause inside counts for nothing.
189
+ */
190
+ function windowLabel(payload: MarketingFunnelResults): string {
191
+ const since = shortDate(payload.window.since)
192
+ const span = status.value === 'stopped'
193
+ ? t('page.marketing.funnels.results.window_stopped', {
194
+ since,
195
+ until: shortDate(payload.window.until),
196
+ })
197
+ : t('page.marketing.funnels.results.window_running', { since })
198
+ const runs = props.experiment?.runs.length ?? 1
199
+ if (runs < 2) {
200
+ return span
201
+ }
202
+ return `${span} (${t('page.marketing.funnels.results.window_runs', { count: runs }, runs)})`
203
+ }
204
+
205
+ /**
206
+ * Every comparison unanswered for thinness: rates and bars on a sample this
207
+ * size would dress noise as signal, so the read degrades to raw counts.
208
+ * Truncation also withholds every verdict, but on a large sample — there the
209
+ * bars stay, under their own warning.
210
+ */
211
+ const insufficient = computed(() => {
212
+ const payload = results.value
213
+ return payload !== null && !payload.truncated && payload.experiment !== undefined
214
+ && payload.experiment.variations.every(variation => variation.significant === null)
215
+ })
216
+
217
+ function goalSessions(variation: MarketingVariationResult): number {
218
+ const steps = variation.computation.steps
219
+ return steps[steps.length - 1]?.sessions ?? 0
220
+ }
221
+
222
+ /** Arms display as positional letters (A is the control): the key is
223
+ * definition detail — only the tooltips still carry it. */
224
+ function armLetter(index: number): string {
225
+ return String.fromCharCode(65 + index)
226
+ }
227
+
228
+ /** Per-arm verdicts only clarify past one challenger; with a single one the
229
+ * verdict banner already says everything a third column would repeat. */
230
+ const multiArm = computed(
231
+ () => (results.value?.experiment?.variations.length ?? 0) > 2,
232
+ )
233
+
234
+ interface ExperimentVerdict {
235
+ control: { key: string, label: string, rate: string }
236
+ best: { key: string, label: string, rate: string }
237
+ /** Two-tone bar split, each arm's share of the summed rates. */
238
+ controlWidth: number
239
+ bestWidth: number
240
+ significant: boolean
241
+ /** null when the control never converted — a ratio over zero. */
242
+ liftLabel: string | null
243
+ liftPositive: boolean
244
+ confidence: string
245
+ pLabel: string
246
+ }
247
+
248
+ /**
249
+ * The banner's read: control against the best-converting challenger that has
250
+ * a p-value. null while any warning state owns the pane — truncated and
251
+ * insufficient both null every challenger's p-value, so they fall out here
252
+ * without restating those conditions.
253
+ */
254
+ const verdict = computed<ExperimentVerdict | null>(() => {
255
+ const payload = results.value
256
+ const experiment = payload?.experiment
257
+ if (!payload || !experiment || payload.truncated || !armsMode.value) {
258
+ return null
259
+ }
260
+ const controlIndex = experiment.variations.findIndex(
261
+ variation => variation.key === experiment.control,
262
+ )
263
+ const control = experiment.variations[controlIndex]
264
+ let bestIndex = -1
265
+ experiment.variations.forEach((variation, index) => {
266
+ if (variation.key === experiment.control || variation.pValue === null) {
267
+ return
268
+ }
269
+ if (
270
+ bestIndex === -1
271
+ || variation.conversionRate > experiment.variations[bestIndex]!.conversionRate
272
+ ) {
273
+ bestIndex = index
274
+ }
275
+ })
276
+ if (!control || bestIndex === -1) {
277
+ return null
278
+ }
279
+ const best = experiment.variations[bestIndex]!
280
+ const pValue = best.pValue!
281
+ const rateSum = control.conversionRate + best.conversionRate
282
+ return {
283
+ control: {
284
+ key: control.key,
285
+ label: t('page.marketing.funnels.results.arm_control', {
286
+ letter: armLetter(controlIndex),
287
+ }),
288
+ rate: ratePercent(control.conversionRate),
289
+ },
290
+ best: {
291
+ key: best.key,
292
+ label: t('page.marketing.funnels.results.arm_variation', {
293
+ letter: armLetter(bestIndex),
294
+ }),
295
+ rate: ratePercent(best.conversionRate),
296
+ },
297
+ controlWidth: rateSum > 0 ? (control.conversionRate / rateSum) * 100 : 50,
298
+ bestWidth: rateSum > 0 ? (best.conversionRate / rateSum) * 100 : 50,
299
+ significant: best.significant === true,
300
+ liftLabel: best.uplift === null ? null : signedPercent(best.uplift),
301
+ liftPositive: (best.uplift ?? 0) >= 0,
302
+ confidence:
303
+ pValue < 0.001 ? '99.9%+' : `${((1 - pValue) * 100).toFixed(1)}%`,
304
+ pLabel: pValue < 0.001 ? 'p < 0.001' : `p = ${pValue.toFixed(3)}`,
305
+ }
306
+ })
307
+
308
+ /** One column of the figure: an arm, or the whole funnel on a plain one. */
309
+ interface ArmSource {
310
+ key: string
311
+ label: string
312
+ color: ArmColor
313
+ /** What every bar of the column is a share of. */
314
+ base: number
315
+ steps: MarketingFunnelStepResult[]
316
+ endToEnd: string | null
317
+ }
318
+
319
+ interface FigureArm {
320
+ key: string
321
+ label: string
322
+ color: ArmColor
323
+ /** Flex order: with two arms the step column sits between them. */
324
+ order: number
325
+ /** Right-hand arm of a mirrored funnel — its own columns read outwards. */
326
+ reversed: boolean
327
+ /** Share of the arm's exposed sessions still there at the goal. */
328
+ endToEnd: string | null
329
+ }
330
+
331
+ interface FigureCell {
332
+ key: string
333
+ color: ArmColor
334
+ order: number
335
+ reversed: boolean
336
+ sessions: number
337
+ /** Bar width as a share of its column, floored so the count fits inside. */
338
+ width: number
339
+ retained: string
340
+ /** Trapezoid tying this bar to the next step's; null on the goal step. */
341
+ taper: string | null
342
+ /** What the next step costs this column, e.g. `−85%`. */
343
+ drop: string | null
344
+ }
345
+
346
+ interface FigureRow {
347
+ step: MarketingFunnelStep
348
+ isGoal: boolean
349
+ kindLabel: string
350
+ /** The same loss in sessions, when a single column can own it. */
351
+ lostLabel: string | null
352
+ cells: FigureCell[]
353
+ }
354
+
355
+ interface Figure {
356
+ websiteId: string
357
+ arms: FigureArm[]
358
+ rows: FigureRow[]
359
+ /** Exactly two arms: the funnel mirrors around a central step column, so
360
+ * the same step reads across both at a glance. More arms fall back to
361
+ * plain columns — a mirror only has two sides. */
362
+ mirrored: boolean
363
+ /** Order of the step column itself, between the arms or before them. */
364
+ stepOrder: number
365
+ single: boolean
366
+ }
367
+
368
+ function barPercent(sessions: number, base: number): number {
369
+ return Math.max(share(sessions, base) * 100, MIN_BAR_PERCENT)
370
+ }
371
+
372
+ /** The bars are centred, so the connector between two of them is an
373
+ * isosceles trapezoid over the full column width. */
374
+ function taperPolygon(current: number, next: number): string {
375
+ const top = (100 - current) / 2
376
+ const bottom = (100 - next) / 2
377
+ return `polygon(${top.toFixed(1)}% 0, ${(100 - top).toFixed(1)}% 0, ${(100 - bottom).toFixed(1)}% 100%, ${bottom.toFixed(1)}% 100%)`
378
+ }
379
+
380
+ function armSources(payload: MarketingFunnelResults): ArmSource[] {
381
+ const experiment = armsMode.value ? payload.experiment : undefined
382
+ if (!experiment) {
383
+ return [{
384
+ key: 'sessions',
385
+ label: t('page.marketing.funnels.sessions'),
386
+ color: 'primary',
387
+ base: payload.computation.totalSessions,
388
+ steps: payload.computation.steps,
389
+ endToEnd: null,
390
+ }]
391
+ }
392
+ // Bars sized against the arm's own exposure, not its funnel entries:
393
+ // entering is an outcome the variation influences, so only exposure makes
394
+ // arms comparable — at every step, not only at the goal.
395
+ return experiment.variations.map((variation, index) => {
396
+ const isControl = variation.key === experiment.control
397
+ const letter = armLetter(index)
398
+ return {
399
+ key: variation.key,
400
+ label: isControl
401
+ ? t('page.marketing.funnels.results.arm_control', { letter })
402
+ : t('page.marketing.funnels.results.arm_variation', { letter }),
403
+ color: isControl ? 'warning' : 'success',
404
+ base: variation.exposedSessions,
405
+ steps: variation.computation.steps,
406
+ endToEnd: ratePercent(
407
+ share(goalSessions(variation), variation.exposedSessions),
408
+ ),
409
+ }
410
+ })
411
+ }
412
+
413
+ /**
414
+ * The funnel as one figure per column, step by step: bars tapered into the
415
+ * next step, which puts the drop where it happens.
416
+ */
417
+ const figure = computed<Figure | null>(() => {
418
+ const payload = results.value
419
+ if (!payload) {
420
+ return null
421
+ }
422
+ const sources = armSources(payload)
423
+ const single = sources.length === 1
424
+ const mirrored = sources.length === 2
425
+ const armOrder = (index: number) => (mirrored ? index * 2 : index + 1)
426
+ const isReversed = (index: number) => mirrored && index === 1
427
+ const arms = sources.map((source, index) => ({
428
+ key: source.key,
429
+ label: source.label,
430
+ color: source.color,
431
+ order: armOrder(index),
432
+ reversed: isReversed(index),
433
+ endToEnd: source.endToEnd,
434
+ }))
435
+ const steps = payload.funnel.steps
436
+ const lastIndex = steps.length - 1
437
+ const rows = steps.map((step, index) => {
438
+ const isGoal = index === lastIndex
439
+ const kind = t(`page.marketing.funnels.step_kind.${step.kind}`)
440
+ const only = single ? sources[0]! : null
441
+ const lost = only && !isGoal
442
+ ? (only.steps[index]?.sessions ?? 0) - (only.steps[index + 1]?.sessions ?? 0)
443
+ : 0
444
+ return {
445
+ step,
446
+ isGoal,
447
+ kindLabel: isGoal
448
+ ? `${kind} · ${t('page.marketing.funnels.goal_suffix')}`
449
+ : kind,
450
+ lostLabel: lost > 0
451
+ ? t('page.marketing.funnels.lost', { count: lost }, lost)
452
+ : null,
453
+ cells: sources.map((source, arm) => {
454
+ const sessions = source.steps[index]?.sessions ?? 0
455
+ const next = isGoal ? null : source.steps[index + 1]?.sessions ?? 0
456
+ const width = barPercent(sessions, source.base)
457
+ const dropped = next === null || sessions <= 0
458
+ ? 0
459
+ : Math.round((1 - next / sessions) * 100)
460
+ return {
461
+ key: source.key,
462
+ color: source.color,
463
+ order: armOrder(arm),
464
+ reversed: isReversed(arm),
465
+ sessions,
466
+ width,
467
+ retained: ratePercent(share(sessions, source.base)),
468
+ taper: next === null
469
+ ? null
470
+ : taperPolygon(width, barPercent(next, source.base)),
471
+ drop: next === null ? null : dropped > 0 ? `−${dropped}%` : '0%',
472
+ }
473
+ }),
474
+ }
475
+ })
476
+ return {
477
+ websiteId: payload.funnel.websiteId,
478
+ arms,
479
+ rows,
480
+ mirrored,
481
+ stepOrder: mirrored ? 1 : 0,
482
+ single,
483
+ }
484
+ })
485
+
486
+ /** `null` (thin sample) renders as "not enough data", not as a claim. */
487
+ function verdictOf(variation: MarketingVariationResult) {
488
+ if (variation.significant === null) {
489
+ return {
490
+ label: t('page.marketing.funnels.results.insufficient'),
491
+ color: 'neutral' as const,
492
+ }
493
+ }
494
+ return variation.significant
495
+ ? {
496
+ label: t('page.marketing.funnels.results.significant'),
497
+ color: 'success' as const,
498
+ }
499
+ : {
500
+ label: t('page.marketing.funnels.results.not_significant'),
501
+ color: 'neutral' as const,
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Past one challenger the banner only judges the best arm, so every other
507
+ * one gets its own line under the funnel — the mirrored figure has no column
508
+ * left to carry them.
509
+ */
510
+ const challengers = computed(() => {
511
+ const experiment = results.value?.experiment
512
+ if (!experiment || !multiArm.value || !armsMode.value) {
513
+ return []
514
+ }
515
+ return experiment.variations.flatMap((variation, index) =>
516
+ variation.key === experiment.control
517
+ ? []
518
+ : [{
519
+ key: variation.key,
520
+ label: t('page.marketing.funnels.results.arm_variation', {
521
+ letter: armLetter(index),
522
+ }),
523
+ lift: variation.uplift === null ? null : signedPercent(variation.uplift),
524
+ liftPositive: (variation.uplift ?? 0) >= 0,
525
+ verdict: verdictOf(variation),
526
+ }],
527
+ )
528
+ })
529
+
530
+ /** draft → running ⇄ stopped; the backend enforces, this only offers. The
531
+ * write itself belongs to the parent — it owns the list the row lives in. */
532
+ const nextStatus = computed<MarketingExperimentStatus | null>(() => {
533
+ if (status.value === 'running') {
534
+ return 'stopped'
535
+ }
536
+ return status.value === null ? null : 'running'
537
+ })
538
+
539
+ /**
540
+ * Stopping and resuming both ask first, for opposite reasons: the stop puts
541
+ * every visitor back on the control — a change to the live site, not just to
542
+ * the pane — while the resume reopens a reading that was final. Starting a
543
+ * draft asks nothing: it has nothing to undo.
544
+ */
545
+ function confirmTransition(target: MarketingExperimentStatus): Promise<boolean> {
546
+ if (target === 'stopped') {
547
+ return confirm({
548
+ title: t('page.marketing.funnels.stop.title'),
549
+ description: t('page.marketing.funnels.stop.description'),
550
+ confirmLabel: t('page.marketing.funnels.stop.confirm'),
551
+ confirmColor: 'warning',
552
+ })
553
+ }
554
+ if (status.value === 'stopped') {
555
+ return confirm({
556
+ title: t('page.marketing.funnels.resume.title'),
557
+ description: t('page.marketing.funnels.resume.description'),
558
+ confirmLabel: t('page.marketing.funnels.resume.confirm'),
559
+ })
560
+ }
561
+ return Promise.resolve(true)
562
+ }
563
+
564
+ async function changeStatus(): Promise<void> {
565
+ const target = nextStatus.value
566
+ if (!target || !(await confirmTransition(target))) {
567
+ return
568
+ }
569
+ emit('statusChanged', target)
570
+ }
571
+
572
+ const deleting = ref(false)
573
+
574
+ async function deleteFunnel(): Promise<void> {
575
+ const confirmed = await confirm({
576
+ title: t('page.marketing.funnels.delete.title'),
577
+ description: t('page.marketing.funnels.delete.description', {
578
+ name: props.funnelName,
579
+ }),
580
+ confirmLabel: t('page.marketing.funnels.delete.confirm'),
581
+ confirmColor: 'error',
582
+ })
583
+ if (!confirmed) {
584
+ return
585
+ }
586
+ deleting.value = true
587
+ try {
588
+ await api.deleteFunnel(props.funnelId)
589
+ toast.add({
590
+ color: 'success',
591
+ title: t('page.marketing.funnels.delete.done'),
592
+ })
593
+ emit('deleted', props.funnelId)
594
+ }
595
+ catch {
596
+ toast.add({
597
+ color: 'error',
598
+ title: t('page.marketing.funnels.delete.error'),
599
+ })
600
+ }
601
+ finally {
602
+ deleting.value = false
603
+ }
604
+ }
605
+
606
+ /** Destructive, and one gesture away from Edit: it lives behind the overflow
607
+ * menu rather than as another button in the header. */
608
+ const menuItems = computed<DropdownMenuItem[][]>(() => [[
609
+ {
610
+ label: t('page.marketing.funnels.delete.button'),
611
+ icon: 'i-ph-trash',
612
+ color: 'error',
613
+ onSelect: () => {
614
+ void deleteFunnel()
615
+ },
616
+ },
617
+ ]])
618
+ </script>
619
+
620
+ <template>
621
+ <div class="flex flex-col gap-4 p-4">
622
+ <div class="flex flex-wrap items-start justify-between gap-3">
623
+ <div class="min-w-0">
624
+ <div class="flex items-center gap-2">
625
+ <p class="truncate font-medium text-highlighted">
626
+ {{ funnelName }}
627
+ </p>
628
+ <UBadge
629
+ v-if="status"
630
+ :color="statusMeta[status].color"
631
+ variant="subtle"
632
+ size="sm"
633
+ >
634
+ <span class="size-1.5 rounded-full bg-current" aria-hidden="true" />
635
+ {{ statusMeta[status].label }}
636
+ </UBadge>
637
+ </div>
638
+ <p
639
+ class="mt-1 truncate font-mono text-xs text-muted"
640
+ :title="armsMode ? t('page.marketing.funnels.locked_hint') : undefined"
641
+ >
642
+ {{ metaLine }}
643
+ </p>
644
+ </div>
645
+ <div class="flex items-center gap-2">
646
+ <UButton
647
+ v-if="nextStatus === 'running'"
648
+ :icon="status === 'stopped' ? 'i-ph-arrow-counter-clockwise' : 'i-ph-play'"
649
+ color="success"
650
+ variant="subtle"
651
+ size="sm"
652
+ :label="status === 'stopped'
653
+ ? t('page.marketing.funnels.resume.button')
654
+ : t('page.marketing.funnels.start')"
655
+ @click="changeStatus"
656
+ />
657
+ <UButton
658
+ v-else-if="nextStatus === 'stopped'"
659
+ icon="i-ph-stop"
660
+ color="warning"
661
+ variant="subtle"
662
+ size="sm"
663
+ :label="t('page.marketing.funnels.stop.button')"
664
+ @click="changeStatus"
665
+ />
666
+ <UButton
667
+ icon="i-ph-pencil-simple"
668
+ color="neutral"
669
+ variant="subtle"
670
+ size="sm"
671
+ :label="t('page.marketing.funnels.edit')"
672
+ @click="emit('edit')"
673
+ />
674
+ <UDropdownMenu :items="menuItems">
675
+ <UButton
676
+ icon="i-ph-dots-three"
677
+ color="neutral"
678
+ variant="subtle"
679
+ size="sm"
680
+ :loading="deleting"
681
+ :aria-label="t('page.marketing.funnels.more_actions')"
682
+ />
683
+ </UDropdownMenu>
684
+ </div>
685
+ </div>
686
+
687
+ <!-- Definition summary, drafts only: running arms read their keys and
688
+ split off the meta line and the bars instead. -->
689
+ <div v-if="status === 'draft'" class="flex flex-wrap items-center gap-2">
690
+ <UBadge
691
+ v-for="(variation, index) in variations"
692
+ :key="variation.key"
693
+ :label="`${variation.key} · ${weightPercent(variation)}%`"
694
+ :color="index === 0 ? 'neutral' : 'primary'"
695
+ variant="subtle"
696
+ size="sm"
697
+ class="font-mono"
698
+ />
699
+ </div>
700
+
701
+ <!-- The verdict banner: the goal read as one two-tone bar, control
702
+ against the best challenger, with the numbers a decision needs.
703
+ Above the funnel — it is the answer, the funnel is the context. -->
704
+ <div
705
+ v-if="!failed && !loading && verdict"
706
+ class="mx-auto w-full max-w-3xl border-b border-default pb-4"
707
+ >
708
+ <div class="flex items-baseline gap-3 font-mono text-xs text-muted">
709
+ <span class="min-w-0 truncate" :title="verdict.control.key">
710
+ {{ verdict.control.label }}
711
+ <span class="font-semibold text-warning">{{ verdict.control.rate }}</span>
712
+ </span>
713
+ <span class="flex-1" />
714
+ <span class="min-w-0 truncate" :title="verdict.best.key">
715
+ <span class="font-semibold text-success">{{ verdict.best.rate }}</span>
716
+ {{ verdict.best.label }}
717
+ </span>
718
+ </div>
719
+ <div class="mt-1.5 flex h-3 overflow-hidden rounded-full bg-elevated">
720
+ <div
721
+ class="h-full bg-warning transition-all"
722
+ :style="{ width: `${verdict.controlWidth}%` }"
723
+ />
724
+ <div
725
+ class="h-full bg-success transition-all"
726
+ :style="{ width: `${verdict.bestWidth}%` }"
727
+ />
728
+ </div>
729
+ <div class="mt-3 flex flex-wrap gap-x-8 gap-y-2">
730
+ <div>
731
+ <p class="text-xs text-muted">
732
+ {{ t('page.marketing.funnels.results.verdict.lift') }}
733
+ </p>
734
+ <p
735
+ class="mt-0.5 text-sm font-medium"
736
+ :class="verdict.liftLabel === null
737
+ ? 'text-muted'
738
+ : verdict.significant
739
+ ? (verdict.liftPositive ? 'text-success' : 'text-error')
740
+ : 'text-warning'"
741
+ >
742
+ {{ verdict.liftLabel ?? '—' }}
743
+ </p>
744
+ </div>
745
+ <div>
746
+ <p class="text-xs text-muted">
747
+ {{ t('page.marketing.funnels.results.verdict.confidence') }}
748
+ </p>
749
+ <p
750
+ class="mt-0.5 text-sm font-medium"
751
+ :class="verdict.significant ? 'text-highlighted' : 'text-warning'"
752
+ >
753
+ {{ verdict.confidence }} ({{ verdict.pLabel }})
754
+ </p>
755
+ </div>
756
+ <div>
757
+ <p class="text-xs text-muted">
758
+ {{ t('page.marketing.funnels.results.verdict.sessions') }}
759
+ </p>
760
+ <p class="mt-0.5 text-sm font-medium text-highlighted">
761
+ {{ totalExposed }}
762
+ </p>
763
+ </div>
764
+ </div>
765
+ </div>
766
+
767
+ <div v-if="failed" class="flex flex-col items-start gap-3">
768
+ <p class="text-sm text-error">
769
+ {{ t('page.marketing.funnels.results_error') }}
770
+ </p>
771
+ <UButton
772
+ icon="i-ph-arrow-clockwise"
773
+ :label="t('page.marketing.funnels.retry')"
774
+ color="neutral"
775
+ variant="subtle"
776
+ size="sm"
777
+ @click="loadResults"
778
+ />
779
+ </div>
780
+ <USkeleton v-else-if="loading" class="h-64 w-full" />
781
+ <template v-else-if="results">
782
+ <UAlert
783
+ v-if="results.truncated"
784
+ color="warning"
785
+ variant="subtle"
786
+ icon="i-ph-hourglass-medium"
787
+ :title="t('page.marketing.funnels.results.truncated.title')"
788
+ :description="t('page.marketing.funnels.results.truncated.description')"
789
+ />
790
+ <UAlert
791
+ v-if="armsMode && results.experiment?.srm.mismatch"
792
+ color="warning"
793
+ variant="subtle"
794
+ icon="i-ph-scales"
795
+ :title="t('page.marketing.funnels.results.srm_warning.title')"
796
+ :description="t('page.marketing.funnels.results.srm_warning.description')"
797
+ />
798
+ <UAlert
799
+ v-if="armsMode && totalExposed === 0"
800
+ icon="i-ph-flask"
801
+ :title="t('page.marketing.funnels.results.empty.title')"
802
+ :description="t('page.marketing.funnels.results.empty.description')"
803
+ />
804
+ <UAlert
805
+ v-else-if="!armsMode && results.computation.totalSessions === 0"
806
+ icon="i-ph-funnel"
807
+ :title="t('page.marketing.funnels.no_sessions.title')"
808
+ :description="t('page.marketing.funnels.no_sessions.description')"
809
+ />
810
+ <!-- No verdict anywhere: bars would dress noise as signal, so the
811
+ read stays raw counts until the sample can answer. -->
812
+ <template v-else-if="armsMode && insufficient && results.experiment">
813
+ <UAlert
814
+ icon="i-ph-hourglass"
815
+ :title="t('page.marketing.funnels.results.insufficient_data.title')"
816
+ :description="t('page.marketing.funnels.results.insufficient_data.description')"
817
+ />
818
+ <div
819
+ class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-2"
820
+ >
821
+ <template v-for="(variation, index) in results.experiment.variations" :key="variation.key">
822
+ <span
823
+ class="text-right font-mono text-xs text-highlighted"
824
+ :title="variation.key"
825
+ >
826
+ {{ armLetter(index) }}
827
+ </span>
828
+ <p class="text-sm text-muted">
829
+ {{
830
+ t(
831
+ 'page.marketing.funnels.results.exposed',
832
+ { count: variation.exposedSessions },
833
+ variation.exposedSessions,
834
+ )
835
+ }}
836
+ ·
837
+ {{
838
+ t(
839
+ 'page.marketing.funnels.results.reached_goal',
840
+ { count: goalSessions(variation) },
841
+ goalSessions(variation),
842
+ )
843
+ }}
844
+ </p>
845
+ </template>
846
+ </div>
847
+ </template>
848
+ <!-- The figure: each bar is sized against its own column's base, so
849
+ widths compare across columns AND down the steps, and the taper
850
+ between two bars carries what the step costs. Two arms face each
851
+ other around the step they share; column order is CSS-only, the
852
+ DOM stays step-then-arms. -->
853
+ <div v-else-if="figure" class="mx-auto w-full max-w-3xl">
854
+ <div
855
+ class="flex items-center gap-2.5 border-b border-default pb-2.5 font-mono text-[10px] tracking-wide"
856
+ >
857
+ <div class="w-44 shrink-0" :style="{ order: figure.stepOrder }" />
858
+ <div
859
+ v-for="arm in figure.arms"
860
+ :key="arm.key"
861
+ class="flex min-w-0 flex-1 items-center gap-2"
862
+ :class="arm.reversed ? 'flex-row-reverse' : ''"
863
+ :style="{ order: arm.order }"
864
+ >
865
+ <span
866
+ class="w-12 shrink-0 text-dimmed"
867
+ :class="arm.reversed ? 'text-left' : 'text-right'"
868
+ >
869
+ {{ t('page.marketing.funnels.retained') }}
870
+ </span>
871
+ <span
872
+ class="min-w-0 flex-1 truncate text-center"
873
+ :class="ARM_CLASSES[arm.color].text"
874
+ :title="arm.key"
875
+ >
876
+ {{ arm.label }}
877
+ </span>
878
+ </div>
879
+ </div>
880
+
881
+ <div
882
+ v-for="(row, index) in figure.rows"
883
+ :key="index"
884
+ class="flex items-start gap-2.5"
885
+ :class="index === 0 ? 'mt-4' : ''"
886
+ >
887
+ <div class="w-44 shrink-0" :style="{ order: figure.stepOrder }">
888
+ <div class="flex h-12 flex-col items-center justify-center gap-0.5">
889
+ <span class="flex max-w-full items-center gap-1">
890
+ <span
891
+ class="truncate font-mono text-xs font-medium"
892
+ :class="row.isGoal ? 'text-highlighted' : 'text-toned'"
893
+ :title="row.step.value"
894
+ >
895
+ {{ row.step.value }}
896
+ </span>
897
+ <!-- A url step and a tracked page are matched on the same
898
+ key, so the step value addresses its heatmap with no
899
+ extra data. -->
900
+ <UButton
901
+ v-if="row.step.kind === 'url'"
902
+ icon="i-ph-cursor-click"
903
+ color="neutral"
904
+ variant="ghost"
905
+ size="xs"
906
+ class="shrink-0"
907
+ :to="pagesLink({
908
+ website: figure.websiteId,
909
+ path: row.step.value,
910
+ period,
911
+ })"
912
+ :title="t('page.marketing.funnels.see_heatmap')"
913
+ />
914
+ </span>
915
+ <span class="text-[10px] text-dimmed">{{ row.kindLabel }}</span>
916
+ </div>
917
+ <!-- Between two steps: the loss in sessions when one column can
918
+ own it, a bare connector between mirrored arms. -->
919
+ <div
920
+ v-if="!row.isGoal"
921
+ class="flex h-8 items-center justify-center font-mono text-[10px] text-dimmed"
922
+ >
923
+ <span v-if="row.lostLabel">{{ row.lostLabel }}</span>
924
+ <span v-else class="h-full w-px bg-accented" aria-hidden="true" />
925
+ </div>
926
+ </div>
927
+ <div
928
+ v-for="cell in row.cells"
929
+ :key="cell.key"
930
+ class="flex min-w-0 flex-1 items-start gap-2"
931
+ :class="cell.reversed ? 'flex-row-reverse' : ''"
932
+ :style="{ order: cell.order }"
933
+ >
934
+ <span
935
+ class="flex h-12 w-12 shrink-0 items-center font-mono text-[11px] text-muted"
936
+ :class="cell.reversed ? 'justify-start' : 'justify-end'"
937
+ >
938
+ {{ cell.retained }}
939
+ </span>
940
+ <div class="min-w-0 flex-1">
941
+ <!-- The count rides inside the fill (min-w-fit keeps it
942
+ there), so the ink inverts with the bar instead of
943
+ drowning on the pane. -->
944
+ <div
945
+ class="relative z-10 mx-auto flex h-12 min-w-fit items-center justify-center rounded-md px-2 transition-all"
946
+ :class="ARM_CLASSES[cell.color].bar"
947
+ :style="{ width: `${cell.width}%` }"
948
+ >
949
+ <span class="font-mono text-base font-semibold text-inverted">
950
+ {{ cell.sessions }}
951
+ </span>
952
+ </div>
953
+ <div v-if="cell.taper" class="relative -my-1 h-10">
954
+ <div
955
+ class="absolute inset-0"
956
+ :class="ARM_CLASSES[cell.color].taper"
957
+ :style="{ clipPath: cell.taper }"
958
+ aria-hidden="true"
959
+ />
960
+ <div
961
+ class="absolute inset-0 flex items-center justify-center whitespace-nowrap font-mono text-[10px] text-muted"
962
+ >
963
+ {{ cell.drop }}
964
+ </div>
965
+ </div>
966
+ </div>
967
+ </div>
968
+ </div>
969
+
970
+ <div
971
+ v-if="!figure.single"
972
+ class="mt-4 flex items-center gap-2.5 border-t border-default pt-3 font-mono text-[11px]"
973
+ >
974
+ <div class="w-44 shrink-0" :style="{ order: figure.stepOrder }" />
975
+ <div
976
+ v-for="arm in figure.arms"
977
+ :key="arm.key"
978
+ class="flex min-w-0 flex-1 items-center gap-2"
979
+ :class="arm.reversed ? 'flex-row-reverse' : ''"
980
+ :style="{ order: arm.order }"
981
+ >
982
+ <span class="w-12 shrink-0" aria-hidden="true" />
983
+ <span
984
+ class="min-w-0 flex-1 text-center leading-tight"
985
+ :class="ARM_CLASSES[arm.color].text"
986
+ >
987
+ {{ t('page.marketing.funnels.results.end_to_end', { value: arm.endToEnd }) }}
988
+ </span>
989
+ </div>
990
+ </div>
991
+
992
+ <!-- Past one challenger the banner judges only the best arm. -->
993
+ <div
994
+ v-if="challengers.length > 1"
995
+ class="mt-4 flex flex-col gap-2 border-t border-default pt-3"
996
+ >
997
+ <div
998
+ v-for="challenger in challengers"
999
+ :key="challenger.key"
1000
+ class="flex items-center gap-3"
1001
+ >
1002
+ <span
1003
+ class="w-28 shrink-0 truncate font-mono text-xs text-success"
1004
+ :title="challenger.key"
1005
+ >
1006
+ {{ challenger.label }}
1007
+ </span>
1008
+ <span
1009
+ v-if="challenger.lift"
1010
+ class="w-16 shrink-0 text-xs font-medium"
1011
+ :class="challenger.liftPositive ? 'text-success' : 'text-error'"
1012
+ >
1013
+ {{ challenger.lift }}
1014
+ </span>
1015
+ <UBadge
1016
+ :label="challenger.verdict.label"
1017
+ :color="challenger.verdict.color"
1018
+ variant="subtle"
1019
+ size="sm"
1020
+ />
1021
+ </div>
1022
+ </div>
1023
+ </div>
1024
+ </template>
1025
+ </div>
1026
+ </template>