@antelopejs/dms-marketing 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/README.md +18 -15
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.js +4 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/data-api/funnels.d.ts +2 -0
  6. package/dist/data-api/funnels.js +8 -1
  7. package/dist/data-api/funnels.js.map +1 -1
  8. package/dist/db/models/funnels.model.d.ts +2 -0
  9. package/dist/db/models/funnels.model.js +9 -0
  10. package/dist/db/models/funnels.model.js.map +1 -1
  11. package/dist/db/models/index.d.ts +1 -0
  12. package/dist/db/models/index.js +1 -0
  13. package/dist/db/models/index.js.map +1 -1
  14. package/dist/db/models/marketing_events.model.d.ts +22 -0
  15. package/dist/db/models/marketing_events.model.js +70 -5
  16. package/dist/db/models/marketing_events.model.js.map +1 -1
  17. package/dist/db/models/marketing_preferences.model.d.ts +25 -0
  18. package/dist/db/models/marketing_preferences.model.js +29 -0
  19. package/dist/db/models/marketing_preferences.model.js.map +1 -0
  20. package/dist/db/models/marketing_sessions.model.d.ts +2 -1
  21. package/dist/db/models/marketing_sessions.model.js +11 -1
  22. package/dist/db/models/marketing_sessions.model.js.map +1 -1
  23. package/dist/db/models/page_snapshots.model.d.ts +2 -1
  24. package/dist/db/models/page_snapshots.model.js +7 -7
  25. package/dist/db/models/page_snapshots.model.js.map +1 -1
  26. package/dist/db/models/website_statistics.model.d.ts +4 -1
  27. package/dist/db/models/website_statistics.model.js +15 -1
  28. package/dist/db/models/website_statistics.model.js.map +1 -1
  29. package/dist/db/tables/index.d.ts +1 -0
  30. package/dist/db/tables/index.js +1 -0
  31. package/dist/db/tables/index.js.map +1 -1
  32. package/dist/db/tables/marketing_preferences.table.d.ts +6 -0
  33. package/dist/db/tables/marketing_preferences.table.js +31 -0
  34. package/dist/db/tables/marketing_preferences.table.js.map +1 -0
  35. package/dist/hooks/index.js +8 -0
  36. package/dist/hooks/index.js.map +1 -1
  37. package/dist/index.js +18 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/pages/acquisition/page.d.ts +9 -0
  40. package/dist/pages/acquisition/page.js +181 -0
  41. package/dist/pages/acquisition/page.js.map +1 -0
  42. package/dist/pages/all.d.ts +5 -1
  43. package/dist/pages/all.js +5 -1
  44. package/dist/pages/all.js.map +1 -1
  45. package/dist/pages/blocks.d.ts +18 -0
  46. package/dist/pages/blocks.js +56 -0
  47. package/dist/pages/blocks.js.map +1 -0
  48. package/dist/pages/funnel/page.d.ts +9 -0
  49. package/dist/pages/funnel/page.js +36 -0
  50. package/dist/pages/funnel/page.js.map +1 -0
  51. package/dist/pages/funnel-builder/page.d.ts +9 -0
  52. package/dist/pages/funnel-builder/page.js +29 -0
  53. package/dist/pages/funnel-builder/page.js.map +1 -0
  54. package/dist/pages/funnels/page.d.ts +1 -1
  55. package/dist/pages/funnels/page.js +200 -3
  56. package/dist/pages/funnels/page.js.map +1 -1
  57. package/dist/pages/index.d.ts +1 -0
  58. package/dist/pages/index.js +1 -0
  59. package/dist/pages/index.js.map +1 -1
  60. package/dist/pages/{campaigns → install}/page.d.ts +3 -3
  61. package/dist/pages/{campaigns → install}/page.js +14 -12
  62. package/dist/pages/install/page.js.map +1 -0
  63. package/dist/pages/module.d.ts +3 -0
  64. package/dist/pages/module.js +30 -3
  65. package/dist/pages/module.js.map +1 -1
  66. package/dist/pages/overview/page.d.ts +1 -1
  67. package/dist/pages/overview/page.js +122 -3
  68. package/dist/pages/overview/page.js.map +1 -1
  69. package/dist/pages/pages/page.d.ts +1 -1
  70. package/dist/pages/pages/page.js +9 -4
  71. package/dist/pages/pages/page.js.map +1 -1
  72. package/dist/pages/quick-actions.d.ts +1 -0
  73. package/dist/pages/quick-actions.js +25 -0
  74. package/dist/pages/quick-actions.js.map +1 -0
  75. package/dist/pages/settings/form.d.ts +2 -1
  76. package/dist/pages/settings/form.js +62 -20
  77. package/dist/pages/settings/form.js.map +1 -1
  78. package/dist/pages/settings/page.d.ts +4 -1
  79. package/dist/pages/settings/page.js +50 -4
  80. package/dist/pages/settings/page.js.map +1 -1
  81. package/dist/pages/websites/page.d.ts +11 -0
  82. package/dist/pages/websites/page.js +62 -0
  83. package/dist/pages/websites/page.js.map +1 -0
  84. package/dist/routes/blocks.d.ts +50 -0
  85. package/dist/routes/blocks.js +269 -0
  86. package/dist/routes/blocks.js.map +1 -0
  87. package/dist/routes/collect.d.ts +1 -2
  88. package/dist/routes/collect.js +19 -12
  89. package/dist/routes/collect.js.map +1 -1
  90. package/dist/routes/context.d.ts +8 -0
  91. package/dist/routes/context.js +54 -0
  92. package/dist/routes/context.js.map +1 -0
  93. package/dist/routes/experiments.js +2 -1
  94. package/dist/routes/experiments.js.map +1 -1
  95. package/dist/routes/funnels.d.ts +21 -3
  96. package/dist/routes/funnels.js +150 -7
  97. package/dist/routes/funnels.js.map +1 -1
  98. package/dist/routes/index.d.ts +2 -0
  99. package/dist/routes/index.js +2 -0
  100. package/dist/routes/index.js.map +1 -1
  101. package/dist/routes/settings.d.ts +6 -1
  102. package/dist/routes/settings.js +65 -4
  103. package/dist/routes/settings.js.map +1 -1
  104. package/dist/routes/snapshot.d.ts +1 -1
  105. package/dist/routes/snapshot.js +24 -25
  106. package/dist/routes/snapshot.js.map +1 -1
  107. package/dist/routes/stats.d.ts +14 -23
  108. package/dist/routes/stats.js +73 -97
  109. package/dist/routes/stats.js.map +1 -1
  110. package/dist/routes/tracker.js +6 -4
  111. package/dist/routes/tracker.js.map +1 -1
  112. package/dist/routes/websites.d.ts +4 -0
  113. package/dist/routes/websites.js +111 -12
  114. package/dist/routes/websites.js.map +1 -1
  115. package/dist/services/acquisition-report.d.ts +62 -0
  116. package/dist/services/acquisition-report.js +181 -0
  117. package/dist/services/acquisition-report.js.map +1 -0
  118. package/dist/services/acquisition.d.ts +9 -3
  119. package/dist/services/acquisition.js +35 -5
  120. package/dist/services/acquisition.js.map +1 -1
  121. package/dist/services/catalog.d.ts +3 -0
  122. package/dist/services/catalog.js +17 -0
  123. package/dist/services/catalog.js.map +1 -0
  124. package/dist/services/collect.d.ts +1 -2
  125. package/dist/services/collect.js +49 -24
  126. package/dist/services/collect.js.map +1 -1
  127. package/dist/services/composed.d.ts +9 -0
  128. package/dist/services/composed.js +62 -0
  129. package/dist/services/composed.js.map +1 -0
  130. package/dist/services/context.d.ts +20 -0
  131. package/dist/services/context.js +76 -0
  132. package/dist/services/context.js.map +1 -0
  133. package/dist/services/dashboard.d.ts +74 -0
  134. package/dist/services/dashboard.js +343 -0
  135. package/dist/services/dashboard.js.map +1 -0
  136. package/dist/services/experiments/results.d.ts +12 -1
  137. package/dist/services/experiments/results.js +19 -2
  138. package/dist/services/experiments/results.js.map +1 -1
  139. package/dist/services/experiments/stats.d.ts +7 -1
  140. package/dist/services/experiments/stats.js +43 -5
  141. package/dist/services/experiments/stats.js.map +1 -1
  142. package/dist/services/funnel-report.d.ts +64 -0
  143. package/dist/services/funnel-report.js +280 -0
  144. package/dist/services/funnel-report.js.map +1 -0
  145. package/dist/services/funnels/compute.d.ts +2 -0
  146. package/dist/services/funnels/compute.js +25 -13
  147. package/dist/services/funnels/compute.js.map +1 -1
  148. package/dist/services/funnels/results.js +4 -1
  149. package/dist/services/funnels/results.js.map +1 -1
  150. package/dist/services/funnels/step-path.d.ts +4 -0
  151. package/dist/services/funnels/step-path.js +47 -0
  152. package/dist/services/funnels/step-path.js.map +1 -0
  153. package/dist/services/funnels-table.d.ts +31 -0
  154. package/dist/services/funnels-table.js +230 -0
  155. package/dist/services/funnels-table.js.map +1 -0
  156. package/dist/services/heatmap.d.ts +15 -3
  157. package/dist/services/heatmap.js +54 -11
  158. package/dist/services/heatmap.js.map +1 -1
  159. package/dist/services/hourly-traffic.d.ts +4 -0
  160. package/dist/services/hourly-traffic.js +35 -0
  161. package/dist/services/hourly-traffic.js.map +1 -0
  162. package/dist/services/origin-guard.d.ts +9 -1
  163. package/dist/services/origin-guard.js +46 -1
  164. package/dist/services/origin-guard.js.map +1 -1
  165. package/dist/services/period.d.ts +26 -1
  166. package/dist/services/period.js +99 -9
  167. package/dist/services/period.js.map +1 -1
  168. package/dist/services/prune.d.ts +6 -0
  169. package/dist/services/prune.js +35 -0
  170. package/dist/services/prune.js.map +1 -1
  171. package/dist/services/request-body.d.ts +1 -2
  172. package/dist/services/request-body.js +10 -36
  173. package/dist/services/request-body.js.map +1 -1
  174. package/dist/services/request-meta.d.ts +0 -1
  175. package/dist/services/request-meta.js +0 -11
  176. package/dist/services/request-meta.js.map +1 -1
  177. package/dist/services/rollups.d.ts +25 -0
  178. package/dist/services/rollups.js +102 -0
  179. package/dist/services/rollups.js.map +1 -0
  180. package/dist/services/sessionize.d.ts +5 -1
  181. package/dist/services/sessionize.js +52 -10
  182. package/dist/services/sessionize.js.map +1 -1
  183. package/dist/services/settings.d.ts +41 -2
  184. package/dist/services/settings.js +219 -6
  185. package/dist/services/settings.js.map +1 -1
  186. package/dist/services/snapshots.d.ts +3 -2
  187. package/dist/services/snapshots.js +110 -9
  188. package/dist/services/snapshots.js.map +1 -1
  189. package/dist/services/tenant-website.js +1 -0
  190. package/dist/services/tenant-website.js.map +1 -1
  191. package/dist/services/url.js +12 -4
  192. package/dist/services/url.js.map +1 -1
  193. package/dist/services/website-hosts.d.ts +15 -0
  194. package/dist/services/website-hosts.js +67 -0
  195. package/dist/services/website-hosts.js.map +1 -0
  196. package/dist/services/website-report.d.ts +40 -0
  197. package/dist/services/website-report.js +84 -0
  198. package/dist/services/website-report.js.map +1 -0
  199. package/dist/types/constants.d.ts +14 -2
  200. package/dist/types/constants.js +17 -6
  201. package/dist/types/constants.js.map +1 -1
  202. package/dist/types/events.d.ts +221 -68
  203. package/dist/types/events.js +60 -11
  204. package/dist/types/events.js.map +1 -1
  205. package/dist/types/snapshots.d.ts +6 -0
  206. package/dist/types/snapshots.js +2 -0
  207. package/dist/types/snapshots.js.map +1 -1
  208. package/dist/types/statistics.d.ts +2 -1
  209. package/dist/types/statistics.js +9 -2
  210. package/dist/types/statistics.js.map +1 -1
  211. package/dist/utils/experiment-type.js +1 -1
  212. package/dist/utils/experiment-type.js.map +1 -1
  213. package/dist/utils/funnel-steps-type.d.ts +21 -3
  214. package/dist/utils/funnel-steps-type.js +7 -2
  215. package/dist/utils/funnel-steps-type.js.map +1 -1
  216. package/frontend-vue/app/components/ChannelsCard.vue +265 -0
  217. package/frontend-vue/app/components/Context.vue +479 -0
  218. package/frontend-vue/app/components/ExperimentInput.vue +3 -1
  219. package/frontend-vue/app/components/ExperimentReport.vue +736 -0
  220. package/frontend-vue/app/components/FirstRun.vue +60 -0
  221. package/frontend-vue/app/components/FunnelBuilder.vue +1595 -0
  222. package/frontend-vue/app/components/FunnelFigure.vue +640 -0
  223. package/frontend-vue/app/components/FunnelReport.vue +532 -0
  224. package/frontend-vue/app/components/FunnelStepsInput.vue +3 -1
  225. package/frontend-vue/app/components/FunnelTemplates.vue +161 -0
  226. package/frontend-vue/app/components/HeatmapCanvas.vue +11 -4
  227. package/frontend-vue/app/components/InstallGuide.vue +753 -0
  228. package/frontend-vue/app/components/PagePreview.vue +889 -416
  229. package/frontend-vue/app/components/PagesExplorer.vue +552 -0
  230. package/frontend-vue/app/components/ScrollDepthOverlay.vue +48 -20
  231. package/frontend-vue/app/components/TopListTabs.vue +317 -0
  232. package/frontend-vue/app/components/TrackerSnippet.vue +50 -16
  233. package/frontend-vue/app/components/VariationsInput.vue +15 -5
  234. package/frontend-vue/app/components/WebsitesGrid.vue +799 -0
  235. package/frontend-vue/app/composables/useContextLink.ts +16 -0
  236. package/frontend-vue/app/composables/useExperiment.ts +1 -2
  237. package/frontend-vue/app/composables/useFunnelSteps.ts +1 -2
  238. package/frontend-vue/app/composables/useLatestRequest.ts +2 -4
  239. package/frontend-vue/app/composables/useMarketingApi.ts +298 -133
  240. package/frontend-vue/app/composables/useMarketingContext.ts +55 -0
  241. package/frontend-vue/app/composables/useMarketingRoutes.ts +80 -27
  242. package/frontend-vue/app/composables/useSnapshotFrame.ts +26 -4
  243. package/frontend-vue/app/constants.ts +22 -0
  244. package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +2 -1
  245. package/frontend-vue/app/utils/channels.ts +37 -0
  246. package/frontend-vue/app/utils/format.ts +132 -0
  247. package/frontend-vue/app/utils/funnel-draft.ts +264 -0
  248. package/frontend-vue/app/utils/heatmap.ts +82 -0
  249. package/frontend-vue/app/utils/heatmapPalette.ts +10 -0
  250. package/frontend-vue/app/utils/host-conflict.ts +48 -0
  251. package/frontend-vue/app/utils/sample-size.ts +17 -0
  252. package/frontend-vue/app/utils/snapshotViewportUnits.ts +15 -5
  253. package/frontend-vue/app/utils/snippet.ts +53 -0
  254. package/frontend-vue/app/utils/step-path.ts +32 -0
  255. package/frontend-vue/dms.frontend.ts +4 -3
  256. package/frontend-vue/i18n/locales/marketing-en-GB.json +1083 -316
  257. package/frontend-vue/i18n/locales/marketing-fr-FR.json +1087 -320
  258. package/frontend-vue/package.json +3 -0
  259. package/frontend-vue/tests/drafts.test.ts +3 -3
  260. package/frontend-vue/tests/format.test.ts +73 -0
  261. package/frontend-vue/tests/funnel-draft.test.ts +194 -0
  262. package/frontend-vue/tests/heatmap.test.ts +32 -0
  263. package/frontend-vue/tests/i18n.test.ts +87 -4
  264. package/frontend-vue/tests/latest-request.test.ts +6 -3
  265. package/frontend-vue/tests/locale-typography.test.ts +115 -0
  266. package/frontend-vue/tests/registration.test.ts +21 -11
  267. package/frontend-vue/tests/routes.test.ts +67 -7
  268. package/frontend-vue/tests/server-parity.test.ts +77 -0
  269. package/frontend-vue/tests/stubs/frontend-module.ts +18 -3
  270. package/package.json +9 -7
  271. package/static/snapshot.js +46 -16
  272. package/static/tracker.js +139 -48
  273. package/dist/pages/campaigns/page.js.map +0 -1
  274. package/frontend-vue/app/components/CampaignsView.vue +0 -330
  275. package/frontend-vue/app/components/FunnelDetail.vue +0 -1026
  276. package/frontend-vue/app/components/FunnelsView.vue +0 -493
  277. package/frontend-vue/app/components/NoWebsiteState.vue +0 -18
  278. package/frontend-vue/app/components/OverviewView.vue +0 -493
  279. package/frontend-vue/app/components/PagesView.vue +0 -391
  280. package/frontend-vue/app/components/TextKpiCard.vue +0 -28
  281. package/frontend-vue/app/components/TopListTabsCard.vue +0 -162
  282. package/frontend-vue/app/composables/useChildId.ts +0 -13
  283. package/frontend-vue/app/composables/useMarketingPeriod.ts +0 -65
  284. package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +0 -69
  285. package/frontend-vue/app/composables/useMarketingWebsites.ts +0 -80
  286. package/frontend-vue/app/utils/dmsComponent.ts +0 -18
  287. package/frontend-vue/tests/period.test.ts +0 -35
@@ -0,0 +1,736 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useI18n } from '#dms/frontend-module'
4
+ import type {
5
+ MarketingFunnelResults,
6
+ MarketingVariationResult,
7
+ } from '../composables/useMarketingApi'
8
+ import { pagesLink } from '../composables/useMarketingRoutes'
9
+ import { useContextLink } from '../composables/useContextLink'
10
+ import { formatNumber } from '../utils/format'
11
+ import {
12
+ localizedSnippetComments,
13
+ variationSnippet,
14
+ } from '../utils/funnel-draft'
15
+ import { fractionDigits } from '../constants'
16
+
17
+ /**
18
+ * The report of a started A/B test, read over its own runs: the verdict as a
19
+ * sentence with its next action, lift with its interval, p-value and sample
20
+ * size beside it; the warnings that void a reading above the numbers; each
21
+ * arm step by step; the frozen variations and the code that asks for one.
22
+ *
23
+ * A significant reading is only "ready to decide" past the decision rule (a
24
+ * full week served, a conversions floor in every arm): checked every day, a
25
+ * p-value crosses .05 by chance long before. Before that it is "too early".
26
+ *
27
+ * Arms keep an identity colour everywhere (A neutral, B primary, then the
28
+ * other tones): green and red only ever mean a verdict.
29
+ */
30
+ const props = defineProps<{
31
+ results: MarketingFunnelResults
32
+ loading: boolean
33
+ websiteName: string
34
+ }>()
35
+
36
+ const LETTER_A = 65
37
+ const PERCENT = 100
38
+ const MIN_OUTCOMES = 5
39
+ const SMALL_P = 0.001
40
+
41
+ // On a 15% tint the light theme's 700 text falls under 4.5:1: one shade darker
42
+ // there, the theme colour in dark.
43
+ const ARM_STYLES = [
44
+ { chip: 'bg-elevated text-toned', bar: 'bg-(--ui-text-dimmed)' },
45
+ {
46
+ chip: 'bg-primary/15 text-(--ui-color-primary-800) dark:text-primary',
47
+ bar: 'bg-primary',
48
+ },
49
+ {
50
+ chip: 'bg-warning/15 text-(--ui-color-warning-800) dark:text-warning',
51
+ bar: 'bg-warning',
52
+ },
53
+ {
54
+ chip: 'bg-info/15 text-(--ui-color-info-800) dark:text-info',
55
+ bar: 'bg-info',
56
+ },
57
+ ]
58
+
59
+ const { t, locale } = useI18n()
60
+ const inContext = useContextLink()
61
+
62
+ const experiment = computed(() => props.results.experiment ?? null)
63
+ const definition = computed(() => props.results.funnel.experiment!)
64
+ const arms = computed(() => experiment.value?.variations ?? [])
65
+ const stopped = computed(() => definition.value.status === 'stopped')
66
+ const decision = computed(() => experiment.value?.decision ?? null)
67
+
68
+ function letter(index: number): string {
69
+ return String.fromCharCode(LETTER_A + index)
70
+ }
71
+
72
+ function armStyle(index: number) {
73
+ return ARM_STYLES[index % ARM_STYLES.length]!
74
+ }
75
+
76
+ function goalSessions(arm: MarketingVariationResult): number {
77
+ return arm.computation.steps.at(-1)?.sessions ?? 0
78
+ }
79
+
80
+ const control = computed(() => arms.value[0] ?? null)
81
+
82
+ /** The server's reading: the challenger it names (best rate among those that
83
+ * got a p-value — the list names the same one) and its verdict. */
84
+ const verdict = computed(() => props.results.verdict ?? null)
85
+
86
+ const bestIndex = computed(() => verdict.value?.leader ?? -1)
87
+
88
+ const best = computed(() =>
89
+ bestIndex.value === -1 ? null : (arms.value[bestIndex.value] ?? null),
90
+ )
91
+
92
+ const totalExposed = computed(() =>
93
+ arms.value.reduce((sum, arm) => sum + arm.exposedSessions, 0),
94
+ )
95
+
96
+ /** Nobody was exposed since the start: the page does not ask for an arm. */
97
+ const noExposure = computed(
98
+ () => arms.value.length > 0 && totalExposed.value === 0,
99
+ )
100
+
101
+ const insufficient = computed(
102
+ () =>
103
+ !noExposure.value &&
104
+ !props.results.truncated &&
105
+ arms.value.length > 0 &&
106
+ arms.value.slice(1).every((arm) => arm.significant === null),
107
+ )
108
+
109
+ const srmMismatch = computed(() => experiment.value?.srm.mismatch === true)
110
+
111
+ /** The level each challenger is tested at, in percent (95, or more past two
112
+ * arms). */
113
+ const level = computed(() =>
114
+ formatNumber(
115
+ (1 - (experiment.value?.alpha ?? 0.05)) * PERCENT,
116
+ locale.value,
117
+ 1,
118
+ ).replace(/[.,]0$/, ''),
119
+ )
120
+
121
+ const verdictKind = computed(() => verdict.value?.kind ?? 'withheld')
122
+
123
+ const lift = computed(() => (best.value?.uplift ?? 0) * PERCENT)
124
+
125
+ function signed(value: number, digits = 1): string {
126
+ return `${value >= 0 ? '+' : '−'}${formatPercentage(Math.abs(value) / 100, locale.value, fractionDigits(digits))}`
127
+ }
128
+
129
+ const interval = computed(() => {
130
+ const arm = best.value
131
+ if (!arm || arm.upliftLow === null || arm.upliftHigh === null) {
132
+ return ''
133
+ }
134
+ return t('page.marketing.experiment.lift_interval', {
135
+ level: level.value,
136
+ low: signed(arm.upliftLow * PERCENT, 0),
137
+ high: signed(arm.upliftHigh * PERCENT, 0),
138
+ })
139
+ })
140
+
141
+ const pValueText = computed(() => {
142
+ const p = best.value?.pValue
143
+ if (p === null || p === undefined) {
144
+ return '—'
145
+ }
146
+ return p < SMALL_P
147
+ ? `< ${formatNumber(SMALL_P, locale.value, 3)}`
148
+ : formatNumber(p, locale.value, 3)
149
+ })
150
+
151
+ const goal = computed(() => props.results.funnel.steps.at(-1)?.value ?? '')
152
+
153
+ const headline = computed(() => {
154
+ const params = {
155
+ letter: letter(bestIndex.value),
156
+ lift: formatPercentage(
157
+ Math.abs(lift.value) / 100,
158
+ locale.value,
159
+ fractionDigits(1),
160
+ ),
161
+ }
162
+ return t(`page.marketing.experiment.verdict.${verdictKind.value}`, params)
163
+ })
164
+
165
+ const explanation = computed(() => {
166
+ if (!best.value || !control.value) {
167
+ return ''
168
+ }
169
+ const kind = verdictKind.value
170
+ const stoppedKind =
171
+ stopped.value && kind !== 'withheld' && kind !== 'early'
172
+ ? `${kind}_detail_stopped`
173
+ : `${kind}_detail`
174
+ const days = Math.floor(decision.value?.days ?? 0)
175
+ return t(`page.marketing.experiment.verdict.${stoppedKind}`, {
176
+ goal: goal.value,
177
+ best: formatPercentage(
178
+ best.value.conversionRate,
179
+ locale.value,
180
+ fractionDigits(2),
181
+ ),
182
+ control: formatPercentage(
183
+ control.value.conversionRate,
184
+ locale.value,
185
+ fractionDigits(2),
186
+ ),
187
+ letter: letter(bestIndex.value),
188
+ key: best.value.key,
189
+ experiment: definition.value.key,
190
+ days,
191
+ minDays: decision.value?.minDays ?? 0,
192
+ minConversions: decision.value?.minConversions ?? 0,
193
+ })
194
+ })
195
+
196
+ const maxRate = computed(() =>
197
+ Math.max(0.0001, ...arms.value.map((arm) => arm.conversionRate)),
198
+ )
199
+
200
+ const weights = computed(() => {
201
+ const total =
202
+ definition.value.variations.reduce(
203
+ (sum, arm) => sum + Math.max(arm.weight, 0),
204
+ 0,
205
+ ) || 1
206
+ return definition.value.variations.map(
207
+ (arm) => (Math.max(arm.weight, 0) / total) * PERCENT,
208
+ )
209
+ })
210
+
211
+ const srmLine = computed(() => {
212
+ const srm = experiment.value?.srm
213
+ if (!srm || totalExposed.value === 0) {
214
+ return ''
215
+ }
216
+ const observed = arms.value
217
+ .map((arm) =>
218
+ formatNumber(
219
+ (arm.exposedSessions / totalExposed.value) * PERCENT,
220
+ locale.value,
221
+ 1,
222
+ ),
223
+ )
224
+ .join(' / ')
225
+ const expected = weights.value
226
+ .map((weight) => formatNumber(weight, locale.value, 0))
227
+ .join(' / ')
228
+ return t(
229
+ srm.mismatch
230
+ ? 'page.marketing.experiment.srm_failed'
231
+ : 'page.marketing.experiment.srm_passed',
232
+ { observed, expected },
233
+ )
234
+ })
235
+
236
+ function stepShare(arm: MarketingVariationResult, stepIndex: number): number {
237
+ const sessions = arm.computation.steps[stepIndex]?.sessions ?? 0
238
+ return arm.exposedSessions > 0
239
+ ? (sessions / arm.exposedSessions) * PERCENT
240
+ : 0
241
+ }
242
+
243
+ function stepLift(
244
+ arm: MarketingVariationResult,
245
+ stepIndex: number,
246
+ ): number | null {
247
+ const base = control.value ? stepShare(control.value, stepIndex) : 0
248
+ return base > 0 ? ((stepShare(arm, stepIndex) - base) / base) * PERCENT : null
249
+ }
250
+
251
+ // The builder's snippet: no top-level await, comments in the reader's language.
252
+ const snippet = computed(() =>
253
+ variationSnippet(
254
+ definition.value.key,
255
+ definition.value.variations[0]?.key ?? 'control',
256
+ definition.value.variations.slice(1).map((arm) => arm.key),
257
+ localizedSnippetComments(t),
258
+ ),
259
+ )
260
+
261
+ const withheldCounts = computed(() =>
262
+ arms.value.map((arm, index) => ({
263
+ key: arm.key,
264
+ letter: letter(index),
265
+ exposed: arm.exposedSessions,
266
+ goal: goalSessions(arm),
267
+ })),
268
+ )
269
+ </script>
270
+
271
+ <template>
272
+ <div class="flex flex-col gap-4" :class="loading ? 'opacity-80' : ''">
273
+ <DmsBanner
274
+ v-if="srmMismatch"
275
+ tone="error"
276
+ icon="i-ph-scales"
277
+ :title="t('page.marketing.experiment.srm_title')"
278
+ :description="srmLine"
279
+ />
280
+ <DmsBanner
281
+ v-if="results.truncated"
282
+ tone="warning"
283
+ icon="i-ph-hourglass"
284
+ :title="t('page.marketing.experiment.truncated_title')"
285
+ :description="t('page.marketing.experiment.truncated_description')"
286
+ />
287
+
288
+ <section v-if="noExposure" class="dms-card flex flex-col gap-3 p-5">
289
+ <DmsStatusPill
290
+ :tone="stopped ? 'neutral' : 'warning'"
291
+ icon="i-ph-eye-slash"
292
+ :mono="false"
293
+ :label="t('page.marketing.experiment.no_exposure_badge')"
294
+ />
295
+ <h2 class="text-lg font-semibold text-highlighted">
296
+ {{
297
+ t('page.marketing.experiment.no_exposure_title', {
298
+ website: websiteName,
299
+ })
300
+ }}
301
+ </h2>
302
+ <p class="text-sm text-muted">
303
+ {{
304
+ t('page.marketing.experiment.no_exposure_description', {
305
+ key: definition.key,
306
+ })
307
+ }}
308
+ </p>
309
+ </section>
310
+
311
+ <section v-else-if="insufficient" class="dms-card flex flex-col gap-3 p-5">
312
+ <DmsStatusPill
313
+ tone="neutral"
314
+ icon="i-ph-hourglass"
315
+ :mono="false"
316
+ :label="t('page.marketing.experiment.insufficient_badge')"
317
+ />
318
+ <h2 class="text-lg font-semibold text-highlighted">
319
+ {{ t('page.marketing.experiment.insufficient_title') }}
320
+ </h2>
321
+ <p class="text-sm text-muted">
322
+ {{
323
+ t('page.marketing.experiment.insufficient_description', {
324
+ count: MIN_OUTCOMES,
325
+ })
326
+ }}
327
+ </p>
328
+ <ul class="flex flex-col gap-1.5">
329
+ <li
330
+ v-for="arm in withheldCounts"
331
+ :key="arm.key"
332
+ class="flex flex-wrap items-center gap-x-3 gap-y-0.5 font-mono text-sm"
333
+ >
334
+ <span
335
+ class="grid size-5 place-items-center rounded text-[10px] font-semibold"
336
+ :class="armStyle(arm.letter.charCodeAt(0) - LETTER_A).chip"
337
+ >
338
+ {{ arm.letter }}
339
+ </span>
340
+ <span class="w-28 truncate text-highlighted">{{ arm.key }}</span>
341
+ <span class="text-muted">
342
+ {{
343
+ t('page.marketing.experiment.raw_counts', {
344
+ exposed: formatNumber(arm.exposed, locale),
345
+ goal: formatNumber(arm.goal, locale),
346
+ })
347
+ }}
348
+ </span>
349
+ </li>
350
+ </ul>
351
+ </section>
352
+
353
+ <section
354
+ v-else
355
+ class="dms-card grid overflow-hidden p-0 lg:grid-cols-[minmax(0,1.6fr)_repeat(3,minmax(0,1fr))]"
356
+ >
357
+ <div
358
+ class="flex flex-col gap-3 p-5"
359
+ :class="
360
+ verdictKind === 'winner'
361
+ ? 'bg-success/5'
362
+ : verdictKind === 'loser'
363
+ ? 'bg-error/5'
364
+ : ''
365
+ "
366
+ >
367
+ <div class="flex flex-wrap items-center gap-2">
368
+ <DmsStatusPill
369
+ :tone="
370
+ verdictKind === 'winner'
371
+ ? 'success'
372
+ : verdictKind === 'loser'
373
+ ? 'error'
374
+ : 'neutral'
375
+ "
376
+ :icon="
377
+ verdictKind === 'flat' || verdictKind === 'withheld'
378
+ ? 'i-ph-minus-circle'
379
+ : 'i-ph-check-circle'
380
+ "
381
+ :mono="false"
382
+ :label="
383
+ t(
384
+ verdictKind === 'flat' || verdictKind === 'withheld'
385
+ ? 'page.marketing.experiment.not_significant'
386
+ : 'page.marketing.experiment.significant',
387
+ { level },
388
+ )
389
+ "
390
+ />
391
+ <DmsStatusPill
392
+ v-if="verdictKind === 'winner' && definition.status === 'running'"
393
+ tone="neutral"
394
+ :mono="false"
395
+ :label="t('page.marketing.experiment.ready')"
396
+ />
397
+ <DmsStatusPill
398
+ v-else-if="verdictKind === 'early'"
399
+ tone="info"
400
+ icon="i-ph-hourglass"
401
+ :mono="false"
402
+ :label="t('page.marketing.experiment.too_early')"
403
+ />
404
+ </div>
405
+ <h2 class="text-xl font-semibold text-balance text-highlighted">
406
+ {{ headline }}
407
+ </h2>
408
+ <p class="text-sm text-muted">{{ explanation }}</p>
409
+ <ul class="mt-1 flex flex-col gap-2">
410
+ <li
411
+ v-for="(arm, index) in arms"
412
+ :key="arm.key"
413
+ class="grid grid-cols-[1.25rem_minmax(0,6rem)_minmax(0,1fr)_4rem] items-center gap-2 sm:grid-cols-[1.25rem_7rem_minmax(0,1fr)_4rem] sm:gap-3"
414
+ >
415
+ <span
416
+ class="grid size-5 place-items-center rounded font-mono text-[10px] font-semibold"
417
+ :class="armStyle(index).chip"
418
+ >
419
+ {{ letter(index) }}
420
+ </span>
421
+ <span class="truncate font-mono text-[13px] text-highlighted">
422
+ {{ arm.key }}
423
+ </span>
424
+ <span class="h-2 overflow-hidden rounded-full bg-elevated">
425
+ <span
426
+ class="block h-full rounded-full"
427
+ :class="armStyle(index).bar"
428
+ :style="{ width: `${(arm.conversionRate / maxRate) * 100}%` }"
429
+ />
430
+ </span>
431
+ <span
432
+ class="text-right font-mono text-[13px] font-semibold tabular-nums text-highlighted"
433
+ >
434
+ {{
435
+ formatPercentage(arm.conversionRate, locale, fractionDigits(2))
436
+ }}
437
+ </span>
438
+ </li>
439
+ </ul>
440
+ </div>
441
+ <div
442
+ class="flex flex-col gap-1 border-t border-default p-5 lg:border-t-0 lg:border-l"
443
+ >
444
+ <DmsEyebrow :label="t('page.marketing.experiment.lift')" />
445
+ <span
446
+ class="text-3xl font-semibold tabular-nums"
447
+ :class="
448
+ verdictKind === 'winner'
449
+ ? 'text-success'
450
+ : verdictKind === 'loser'
451
+ ? 'text-error'
452
+ : 'text-highlighted'
453
+ "
454
+ >
455
+ {{ best ? signed(lift) : '—' }}
456
+ </span>
457
+ <span class="font-mono text-[11px] text-dimmed">
458
+ {{
459
+ t('page.marketing.experiment.lift_hint', {
460
+ letter: letter(bestIndex),
461
+ })
462
+ }}
463
+ </span>
464
+ <span v-if="interval" class="font-mono text-[11px] text-muted">
465
+ {{ interval }}
466
+ </span>
467
+ </div>
468
+ <div
469
+ class="flex flex-col gap-1 border-t border-default p-5 lg:border-t-0 lg:border-l"
470
+ >
471
+ <DmsEyebrow :label="t('page.marketing.experiment.p_value')" />
472
+ <span class="text-3xl font-semibold tabular-nums text-highlighted">
473
+ {{ pValueText }}
474
+ </span>
475
+ <span class="font-mono text-[11px] text-dimmed">
476
+ {{
477
+ t('page.marketing.experiment.p_value_hint', {
478
+ alpha: formatNumber(experiment?.alpha ?? 0.05, locale, 3).replace(
479
+ /0+$/,
480
+ '',
481
+ ),
482
+ })
483
+ }}
484
+ </span>
485
+ </div>
486
+ <div
487
+ class="flex flex-col gap-1 border-t border-default p-5 lg:border-t-0 lg:border-l"
488
+ >
489
+ <DmsEyebrow :label="t('page.marketing.experiment.exposed')" />
490
+ <span class="text-3xl font-semibold tabular-nums text-highlighted">
491
+ {{ formatNumber(totalExposed, locale) }}
492
+ </span>
493
+ <span class="font-mono text-[11px] text-dimmed">
494
+ {{
495
+ arms
496
+ .map(
497
+ (arm, index) =>
498
+ `${formatNumber(arm.exposedSessions, locale)} ${letter(index)}`,
499
+ )
500
+ .join(' · ')
501
+ }}
502
+ </span>
503
+ <span v-if="decision" class="font-mono text-[11px] text-muted">
504
+ {{
505
+ t(
506
+ 'page.marketing.experiment.days_served',
507
+ { days: Math.floor(decision.days) },
508
+ Math.floor(decision.days),
509
+ )
510
+ }}
511
+ </span>
512
+ </div>
513
+ <footer
514
+ v-if="srmLine && !srmMismatch"
515
+ class="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-default px-5 py-2.5 text-xs text-dimmed lg:col-span-4"
516
+ >
517
+ <span class="flex items-center gap-1.5">
518
+ <UIcon name="i-ph-scales" class="size-3.5" />
519
+ {{ srmLine }}
520
+ </span>
521
+ </footer>
522
+ </section>
523
+
524
+ <div class="grid gap-4 lg:grid-cols-[minmax(0,1.7fr)_minmax(0,1fr)]">
525
+ <section class="dms-card min-w-0 overflow-hidden p-0">
526
+ <header
527
+ class="flex items-center justify-between gap-3 border-b border-default px-4 py-3"
528
+ >
529
+ <DmsEyebrow :label="t('page.marketing.experiment.steps_title')" />
530
+ <span class="flex gap-1">
531
+ <span
532
+ v-for="(arm, index) in arms"
533
+ :key="arm.key"
534
+ class="grid size-5 place-items-center rounded font-mono text-[10px] font-semibold"
535
+ :class="armStyle(index).chip"
536
+ >
537
+ {{ letter(index) }}
538
+ </span>
539
+ </span>
540
+ </header>
541
+ <ol class="divide-y divide-default">
542
+ <!-- Phone: the step on its own line, the arms under it full width.
543
+ From md: the step beside its arms. -->
544
+ <li
545
+ v-for="(step, stepIndex) in results.funnel.steps"
546
+ :key="stepIndex"
547
+ class="grid grid-cols-[2rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2 px-4 py-3 md:grid-cols-[2rem_minmax(8rem,14rem)_minmax(0,1fr)] md:gap-x-4"
548
+ >
549
+ <span
550
+ class="grid size-7 place-items-center rounded-md font-mono text-xs"
551
+ :class="
552
+ stepIndex === results.funnel.steps.length - 1
553
+ ? 'bg-primary text-inverted'
554
+ : 'bg-elevated text-toned'
555
+ "
556
+ >
557
+ <UIcon
558
+ v-if="stepIndex === results.funnel.steps.length - 1"
559
+ name="i-ph-flag-checkered"
560
+ class="size-3.5"
561
+ />
562
+ <template v-else>{{ stepIndex + 1 }}</template>
563
+ </span>
564
+ <span class="min-w-0">
565
+ <span
566
+ class="block truncate font-mono text-[13px] text-highlighted"
567
+ :title="step.value"
568
+ >
569
+ {{ step.value }}
570
+ </span>
571
+ <span class="flex items-center gap-1 text-xs text-dimmed">
572
+ {{ t(`page.marketing.funnel.kind.${step.kind}`) }}
573
+ <template v-if="step.kind === 'url'">
574
+ ·
575
+ <DmsAutoLink
576
+ :to="inContext(pagesLink(step.value))"
577
+ class="text-primary hover:underline"
578
+ >
579
+ {{ t('page.marketing.funnel.see_clicks') }}
580
+ </DmsAutoLink>
581
+ </template>
582
+ </span>
583
+ </span>
584
+ <span class="col-span-2 flex flex-col gap-1.5 md:col-span-1">
585
+ <span
586
+ v-for="(arm, index) in arms"
587
+ :key="arm.key"
588
+ class="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-center gap-2"
589
+ >
590
+ <span
591
+ class="grid size-5 place-items-center rounded font-mono text-[10px] font-semibold"
592
+ :class="armStyle(index).chip"
593
+ >
594
+ {{ letter(index) }}
595
+ </span>
596
+ <span class="h-2 overflow-hidden rounded-full bg-elevated">
597
+ <span
598
+ class="block h-full rounded-full"
599
+ :class="armStyle(index).bar"
600
+ :style="{ width: `${stepShare(arm, stepIndex)}%` }"
601
+ />
602
+ </span>
603
+ <!-- Nobody exposed to the arm: no share to give, not 0 %. -->
604
+ <span
605
+ v-if="arm.exposedSessions === 0"
606
+ class="min-w-[7.5rem] text-right font-mono text-[11px] text-dimmed"
607
+ >
608
+ —
609
+ </span>
610
+ <span
611
+ v-else
612
+ class="min-w-[7.5rem] text-right font-mono text-[11px] whitespace-nowrap tabular-nums"
613
+ >
614
+ <span class="font-semibold text-highlighted">
615
+ {{
616
+ formatNumber(
617
+ arm.computation.steps[stepIndex]?.sessions ?? 0,
618
+ locale,
619
+ )
620
+ }}
621
+ </span>
622
+ <span class="ms-1 text-dimmed">
623
+ {{
624
+ formatPercentage(
625
+ stepShare(arm, stepIndex) / 100,
626
+ locale,
627
+ fractionDigits(stepShare(arm, stepIndex) < 10 ? 2 : 1),
628
+ )
629
+ }}
630
+ </span>
631
+ <span
632
+ v-if="
633
+ index > 0 &&
634
+ stepIndex > 0 &&
635
+ stepLift(arm, stepIndex) !== null
636
+ "
637
+ class="ms-1 text-toned"
638
+ >
639
+ {{ signed(stepLift(arm, stepIndex) ?? 0, 0) }}
640
+ </span>
641
+ </span>
642
+ </span>
643
+ </span>
644
+ </li>
645
+ </ol>
646
+ <footer
647
+ class="flex items-start gap-1.5 border-t border-default px-4 py-2.5 text-xs text-dimmed"
648
+ >
649
+ <UIcon name="i-ph-info" class="mt-0.5 size-3.5 shrink-0" />
650
+ {{ t('page.marketing.experiment.colours_note') }}
651
+ </footer>
652
+ </section>
653
+
654
+ <div class="flex min-w-0 flex-col gap-4">
655
+ <section class="dms-card p-0">
656
+ <header
657
+ class="flex items-center justify-between border-b border-default px-4 py-3"
658
+ >
659
+ <DmsEyebrow :label="t('page.marketing.experiment.variations')" />
660
+ <DmsStatusPill
661
+ size="sm"
662
+ icon="i-ph-lock-simple"
663
+ :mono="false"
664
+ :label="t('page.marketing.experiment.frozen')"
665
+ />
666
+ </header>
667
+ <ul class="flex flex-col gap-2 px-4 py-3">
668
+ <li
669
+ v-for="(arm, index) in definition.variations"
670
+ :key="arm.key"
671
+ class="flex items-center gap-3"
672
+ >
673
+ <span
674
+ class="grid size-5 place-items-center rounded font-mono text-[10px] font-semibold"
675
+ :class="armStyle(index).chip"
676
+ >
677
+ {{ letter(index) }}
678
+ </span>
679
+ <span class="truncate font-mono text-sm text-highlighted">
680
+ {{ arm.key }}
681
+ </span>
682
+ <span
683
+ v-if="index === 0"
684
+ class="font-mono text-[11px] text-dimmed"
685
+ >
686
+ {{ t('page.marketing.experiment.control_arm') }}
687
+ </span>
688
+ <span class="ms-auto font-mono text-sm text-muted">
689
+ {{
690
+ formatPercentage(
691
+ (weights[index] ?? 0) / 100,
692
+ locale,
693
+ fractionDigits(0),
694
+ )
695
+ }}
696
+ </span>
697
+ </li>
698
+ </ul>
699
+ </section>
700
+ <section class="dms-card p-0">
701
+ <header class="border-b border-default px-4 py-3">
702
+ <DmsEyebrow
703
+ :label="
704
+ t(
705
+ stopped
706
+ ? 'page.marketing.experiment.on_site_stopped'
707
+ : 'page.marketing.experiment.on_site',
708
+ )
709
+ "
710
+ />
711
+ </header>
712
+ <div class="flex flex-col gap-2 p-4">
713
+ <p v-if="stopped" class="text-xs text-muted">
714
+ {{
715
+ t('page.marketing.experiment.on_site_stopped_hint', {
716
+ key: definition.key,
717
+ })
718
+ }}
719
+ </p>
720
+ <div class="overflow-hidden rounded-lg border border-default">
721
+ <div
722
+ class="flex items-center justify-between border-b border-default px-3 py-1.5"
723
+ >
724
+ <DmsEyebrow label="JavaScript" />
725
+ <DmsCopyButton :value="snippet" />
726
+ </div>
727
+ <pre
728
+ class="px-3 py-2.5 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap text-toned"
729
+ >{{ snippet }}</pre>
730
+ </div>
731
+ </div>
732
+ </section>
733
+ </div>
734
+ </div>
735
+ </div>
736
+ </template>