@antelopejs/dms-marketing 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/README.md +18 -15
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.js +4 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/data-api/funnels.d.ts +2 -0
  6. package/dist/data-api/funnels.js +8 -1
  7. package/dist/data-api/funnels.js.map +1 -1
  8. package/dist/db/models/funnels.model.d.ts +2 -0
  9. package/dist/db/models/funnels.model.js +9 -0
  10. package/dist/db/models/funnels.model.js.map +1 -1
  11. package/dist/db/models/index.d.ts +1 -0
  12. package/dist/db/models/index.js +1 -0
  13. package/dist/db/models/index.js.map +1 -1
  14. package/dist/db/models/marketing_events.model.d.ts +22 -0
  15. package/dist/db/models/marketing_events.model.js +70 -5
  16. package/dist/db/models/marketing_events.model.js.map +1 -1
  17. package/dist/db/models/marketing_preferences.model.d.ts +25 -0
  18. package/dist/db/models/marketing_preferences.model.js +29 -0
  19. package/dist/db/models/marketing_preferences.model.js.map +1 -0
  20. package/dist/db/models/marketing_sessions.model.d.ts +2 -1
  21. package/dist/db/models/marketing_sessions.model.js +11 -1
  22. package/dist/db/models/marketing_sessions.model.js.map +1 -1
  23. package/dist/db/models/page_snapshots.model.d.ts +2 -1
  24. package/dist/db/models/page_snapshots.model.js +7 -7
  25. package/dist/db/models/page_snapshots.model.js.map +1 -1
  26. package/dist/db/models/website_statistics.model.d.ts +4 -1
  27. package/dist/db/models/website_statistics.model.js +15 -1
  28. package/dist/db/models/website_statistics.model.js.map +1 -1
  29. package/dist/db/tables/index.d.ts +1 -0
  30. package/dist/db/tables/index.js +1 -0
  31. package/dist/db/tables/index.js.map +1 -1
  32. package/dist/db/tables/marketing_preferences.table.d.ts +6 -0
  33. package/dist/db/tables/marketing_preferences.table.js +31 -0
  34. package/dist/db/tables/marketing_preferences.table.js.map +1 -0
  35. package/dist/hooks/index.js +8 -0
  36. package/dist/hooks/index.js.map +1 -1
  37. package/dist/index.js +18 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/pages/acquisition/page.d.ts +9 -0
  40. package/dist/pages/acquisition/page.js +181 -0
  41. package/dist/pages/acquisition/page.js.map +1 -0
  42. package/dist/pages/all.d.ts +5 -1
  43. package/dist/pages/all.js +5 -1
  44. package/dist/pages/all.js.map +1 -1
  45. package/dist/pages/blocks.d.ts +18 -0
  46. package/dist/pages/blocks.js +56 -0
  47. package/dist/pages/blocks.js.map +1 -0
  48. package/dist/pages/funnel/page.d.ts +9 -0
  49. package/dist/pages/funnel/page.js +36 -0
  50. package/dist/pages/funnel/page.js.map +1 -0
  51. package/dist/pages/funnel-builder/page.d.ts +9 -0
  52. package/dist/pages/funnel-builder/page.js +29 -0
  53. package/dist/pages/funnel-builder/page.js.map +1 -0
  54. package/dist/pages/funnels/page.d.ts +1 -1
  55. package/dist/pages/funnels/page.js +200 -3
  56. package/dist/pages/funnels/page.js.map +1 -1
  57. package/dist/pages/index.d.ts +1 -0
  58. package/dist/pages/index.js +1 -0
  59. package/dist/pages/index.js.map +1 -1
  60. package/dist/pages/{campaigns → install}/page.d.ts +3 -3
  61. package/dist/pages/{campaigns → install}/page.js +14 -12
  62. package/dist/pages/install/page.js.map +1 -0
  63. package/dist/pages/module.d.ts +3 -0
  64. package/dist/pages/module.js +30 -3
  65. package/dist/pages/module.js.map +1 -1
  66. package/dist/pages/overview/page.d.ts +1 -1
  67. package/dist/pages/overview/page.js +122 -3
  68. package/dist/pages/overview/page.js.map +1 -1
  69. package/dist/pages/pages/page.d.ts +1 -1
  70. package/dist/pages/pages/page.js +9 -4
  71. package/dist/pages/pages/page.js.map +1 -1
  72. package/dist/pages/quick-actions.d.ts +1 -0
  73. package/dist/pages/quick-actions.js +25 -0
  74. package/dist/pages/quick-actions.js.map +1 -0
  75. package/dist/pages/settings/form.d.ts +2 -1
  76. package/dist/pages/settings/form.js +62 -20
  77. package/dist/pages/settings/form.js.map +1 -1
  78. package/dist/pages/settings/page.d.ts +4 -1
  79. package/dist/pages/settings/page.js +50 -4
  80. package/dist/pages/settings/page.js.map +1 -1
  81. package/dist/pages/websites/page.d.ts +11 -0
  82. package/dist/pages/websites/page.js +62 -0
  83. package/dist/pages/websites/page.js.map +1 -0
  84. package/dist/routes/blocks.d.ts +50 -0
  85. package/dist/routes/blocks.js +269 -0
  86. package/dist/routes/blocks.js.map +1 -0
  87. package/dist/routes/collect.d.ts +1 -2
  88. package/dist/routes/collect.js +19 -12
  89. package/dist/routes/collect.js.map +1 -1
  90. package/dist/routes/context.d.ts +8 -0
  91. package/dist/routes/context.js +54 -0
  92. package/dist/routes/context.js.map +1 -0
  93. package/dist/routes/experiments.js +2 -1
  94. package/dist/routes/experiments.js.map +1 -1
  95. package/dist/routes/funnels.d.ts +21 -3
  96. package/dist/routes/funnels.js +150 -7
  97. package/dist/routes/funnels.js.map +1 -1
  98. package/dist/routes/index.d.ts +2 -0
  99. package/dist/routes/index.js +2 -0
  100. package/dist/routes/index.js.map +1 -1
  101. package/dist/routes/settings.d.ts +6 -1
  102. package/dist/routes/settings.js +65 -4
  103. package/dist/routes/settings.js.map +1 -1
  104. package/dist/routes/snapshot.d.ts +1 -1
  105. package/dist/routes/snapshot.js +24 -25
  106. package/dist/routes/snapshot.js.map +1 -1
  107. package/dist/routes/stats.d.ts +14 -23
  108. package/dist/routes/stats.js +73 -97
  109. package/dist/routes/stats.js.map +1 -1
  110. package/dist/routes/tracker.js +6 -4
  111. package/dist/routes/tracker.js.map +1 -1
  112. package/dist/routes/websites.d.ts +4 -0
  113. package/dist/routes/websites.js +111 -12
  114. package/dist/routes/websites.js.map +1 -1
  115. package/dist/services/acquisition-report.d.ts +62 -0
  116. package/dist/services/acquisition-report.js +181 -0
  117. package/dist/services/acquisition-report.js.map +1 -0
  118. package/dist/services/acquisition.d.ts +9 -3
  119. package/dist/services/acquisition.js +35 -5
  120. package/dist/services/acquisition.js.map +1 -1
  121. package/dist/services/catalog.d.ts +3 -0
  122. package/dist/services/catalog.js +17 -0
  123. package/dist/services/catalog.js.map +1 -0
  124. package/dist/services/collect.d.ts +1 -2
  125. package/dist/services/collect.js +49 -24
  126. package/dist/services/collect.js.map +1 -1
  127. package/dist/services/composed.d.ts +9 -0
  128. package/dist/services/composed.js +62 -0
  129. package/dist/services/composed.js.map +1 -0
  130. package/dist/services/context.d.ts +20 -0
  131. package/dist/services/context.js +76 -0
  132. package/dist/services/context.js.map +1 -0
  133. package/dist/services/dashboard.d.ts +74 -0
  134. package/dist/services/dashboard.js +343 -0
  135. package/dist/services/dashboard.js.map +1 -0
  136. package/dist/services/experiments/results.d.ts +12 -1
  137. package/dist/services/experiments/results.js +19 -2
  138. package/dist/services/experiments/results.js.map +1 -1
  139. package/dist/services/experiments/stats.d.ts +7 -1
  140. package/dist/services/experiments/stats.js +43 -5
  141. package/dist/services/experiments/stats.js.map +1 -1
  142. package/dist/services/funnel-report.d.ts +64 -0
  143. package/dist/services/funnel-report.js +280 -0
  144. package/dist/services/funnel-report.js.map +1 -0
  145. package/dist/services/funnels/compute.d.ts +2 -0
  146. package/dist/services/funnels/compute.js +25 -13
  147. package/dist/services/funnels/compute.js.map +1 -1
  148. package/dist/services/funnels/results.js +4 -1
  149. package/dist/services/funnels/results.js.map +1 -1
  150. package/dist/services/funnels/step-path.d.ts +4 -0
  151. package/dist/services/funnels/step-path.js +47 -0
  152. package/dist/services/funnels/step-path.js.map +1 -0
  153. package/dist/services/funnels-table.d.ts +31 -0
  154. package/dist/services/funnels-table.js +230 -0
  155. package/dist/services/funnels-table.js.map +1 -0
  156. package/dist/services/heatmap.d.ts +15 -3
  157. package/dist/services/heatmap.js +54 -11
  158. package/dist/services/heatmap.js.map +1 -1
  159. package/dist/services/hourly-traffic.d.ts +4 -0
  160. package/dist/services/hourly-traffic.js +35 -0
  161. package/dist/services/hourly-traffic.js.map +1 -0
  162. package/dist/services/origin-guard.d.ts +9 -1
  163. package/dist/services/origin-guard.js +46 -1
  164. package/dist/services/origin-guard.js.map +1 -1
  165. package/dist/services/period.d.ts +26 -1
  166. package/dist/services/period.js +99 -9
  167. package/dist/services/period.js.map +1 -1
  168. package/dist/services/prune.d.ts +6 -0
  169. package/dist/services/prune.js +35 -0
  170. package/dist/services/prune.js.map +1 -1
  171. package/dist/services/request-body.d.ts +1 -2
  172. package/dist/services/request-body.js +10 -36
  173. package/dist/services/request-body.js.map +1 -1
  174. package/dist/services/request-meta.d.ts +0 -1
  175. package/dist/services/request-meta.js +0 -11
  176. package/dist/services/request-meta.js.map +1 -1
  177. package/dist/services/rollups.d.ts +25 -0
  178. package/dist/services/rollups.js +102 -0
  179. package/dist/services/rollups.js.map +1 -0
  180. package/dist/services/sessionize.d.ts +5 -1
  181. package/dist/services/sessionize.js +52 -10
  182. package/dist/services/sessionize.js.map +1 -1
  183. package/dist/services/settings.d.ts +41 -2
  184. package/dist/services/settings.js +219 -6
  185. package/dist/services/settings.js.map +1 -1
  186. package/dist/services/snapshots.d.ts +3 -2
  187. package/dist/services/snapshots.js +110 -9
  188. package/dist/services/snapshots.js.map +1 -1
  189. package/dist/services/tenant-website.js +1 -0
  190. package/dist/services/tenant-website.js.map +1 -1
  191. package/dist/services/url.js +12 -4
  192. package/dist/services/url.js.map +1 -1
  193. package/dist/services/website-hosts.d.ts +15 -0
  194. package/dist/services/website-hosts.js +67 -0
  195. package/dist/services/website-hosts.js.map +1 -0
  196. package/dist/services/website-report.d.ts +40 -0
  197. package/dist/services/website-report.js +84 -0
  198. package/dist/services/website-report.js.map +1 -0
  199. package/dist/types/constants.d.ts +14 -2
  200. package/dist/types/constants.js +17 -6
  201. package/dist/types/constants.js.map +1 -1
  202. package/dist/types/events.d.ts +221 -68
  203. package/dist/types/events.js +60 -11
  204. package/dist/types/events.js.map +1 -1
  205. package/dist/types/snapshots.d.ts +6 -0
  206. package/dist/types/snapshots.js +2 -0
  207. package/dist/types/snapshots.js.map +1 -1
  208. package/dist/types/statistics.d.ts +2 -1
  209. package/dist/types/statistics.js +9 -2
  210. package/dist/types/statistics.js.map +1 -1
  211. package/dist/utils/experiment-type.js +1 -1
  212. package/dist/utils/experiment-type.js.map +1 -1
  213. package/dist/utils/funnel-steps-type.d.ts +21 -3
  214. package/dist/utils/funnel-steps-type.js +7 -2
  215. package/dist/utils/funnel-steps-type.js.map +1 -1
  216. package/frontend-vue/app/components/ChannelsCard.vue +265 -0
  217. package/frontend-vue/app/components/Context.vue +479 -0
  218. package/frontend-vue/app/components/ExperimentInput.vue +3 -1
  219. package/frontend-vue/app/components/ExperimentReport.vue +736 -0
  220. package/frontend-vue/app/components/FirstRun.vue +60 -0
  221. package/frontend-vue/app/components/FunnelBuilder.vue +1595 -0
  222. package/frontend-vue/app/components/FunnelFigure.vue +640 -0
  223. package/frontend-vue/app/components/FunnelReport.vue +532 -0
  224. package/frontend-vue/app/components/FunnelStepsInput.vue +3 -1
  225. package/frontend-vue/app/components/FunnelTemplates.vue +161 -0
  226. package/frontend-vue/app/components/HeatmapCanvas.vue +11 -4
  227. package/frontend-vue/app/components/InstallGuide.vue +753 -0
  228. package/frontend-vue/app/components/PagePreview.vue +889 -416
  229. package/frontend-vue/app/components/PagesExplorer.vue +552 -0
  230. package/frontend-vue/app/components/ScrollDepthOverlay.vue +48 -20
  231. package/frontend-vue/app/components/TopListTabs.vue +317 -0
  232. package/frontend-vue/app/components/TrackerSnippet.vue +50 -16
  233. package/frontend-vue/app/components/VariationsInput.vue +15 -5
  234. package/frontend-vue/app/components/WebsitesGrid.vue +799 -0
  235. package/frontend-vue/app/composables/useContextLink.ts +16 -0
  236. package/frontend-vue/app/composables/useExperiment.ts +1 -2
  237. package/frontend-vue/app/composables/useFunnelSteps.ts +1 -2
  238. package/frontend-vue/app/composables/useLatestRequest.ts +2 -4
  239. package/frontend-vue/app/composables/useMarketingApi.ts +298 -133
  240. package/frontend-vue/app/composables/useMarketingContext.ts +55 -0
  241. package/frontend-vue/app/composables/useMarketingRoutes.ts +80 -27
  242. package/frontend-vue/app/composables/useSnapshotFrame.ts +26 -4
  243. package/frontend-vue/app/constants.ts +22 -0
  244. package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +2 -1
  245. package/frontend-vue/app/utils/channels.ts +37 -0
  246. package/frontend-vue/app/utils/format.ts +132 -0
  247. package/frontend-vue/app/utils/funnel-draft.ts +264 -0
  248. package/frontend-vue/app/utils/heatmap.ts +82 -0
  249. package/frontend-vue/app/utils/heatmapPalette.ts +10 -0
  250. package/frontend-vue/app/utils/host-conflict.ts +48 -0
  251. package/frontend-vue/app/utils/sample-size.ts +17 -0
  252. package/frontend-vue/app/utils/snapshotViewportUnits.ts +15 -5
  253. package/frontend-vue/app/utils/snippet.ts +53 -0
  254. package/frontend-vue/app/utils/step-path.ts +32 -0
  255. package/frontend-vue/dms.frontend.ts +4 -3
  256. package/frontend-vue/i18n/locales/marketing-en-GB.json +1083 -316
  257. package/frontend-vue/i18n/locales/marketing-fr-FR.json +1087 -320
  258. package/frontend-vue/package.json +1 -1
  259. package/frontend-vue/tests/drafts.test.ts +3 -3
  260. package/frontend-vue/tests/format.test.ts +73 -0
  261. package/frontend-vue/tests/funnel-draft.test.ts +194 -0
  262. package/frontend-vue/tests/heatmap.test.ts +32 -0
  263. package/frontend-vue/tests/i18n.test.ts +87 -4
  264. package/frontend-vue/tests/latest-request.test.ts +6 -3
  265. package/frontend-vue/tests/locale-typography.test.ts +115 -0
  266. package/frontend-vue/tests/registration.test.ts +21 -11
  267. package/frontend-vue/tests/routes.test.ts +67 -7
  268. package/frontend-vue/tests/server-parity.test.ts +77 -0
  269. package/frontend-vue/tests/stubs/frontend-module.ts +18 -3
  270. package/package.json +9 -7
  271. package/static/snapshot.js +46 -16
  272. package/static/tracker.js +139 -48
  273. package/dist/pages/campaigns/page.js.map +0 -1
  274. package/frontend-vue/app/components/CampaignsView.vue +0 -330
  275. package/frontend-vue/app/components/FunnelDetail.vue +0 -1026
  276. package/frontend-vue/app/components/FunnelsView.vue +0 -493
  277. package/frontend-vue/app/components/NoWebsiteState.vue +0 -18
  278. package/frontend-vue/app/components/OverviewView.vue +0 -493
  279. package/frontend-vue/app/components/PagesView.vue +0 -391
  280. package/frontend-vue/app/components/TextKpiCard.vue +0 -28
  281. package/frontend-vue/app/components/TopListTabsCard.vue +0 -162
  282. package/frontend-vue/app/composables/useChildId.ts +0 -13
  283. package/frontend-vue/app/composables/useMarketingPeriod.ts +0 -65
  284. package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +0 -69
  285. package/frontend-vue/app/composables/useMarketingWebsites.ts +0 -80
  286. package/frontend-vue/app/utils/dmsComponent.ts +0 -18
  287. package/frontend-vue/tests/period.test.ts +0 -35
@@ -0,0 +1,640 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { useI18n } from '#dms/frontend-module'
4
+ import type { MarketingFunnelResults } from '../composables/useMarketingApi'
5
+ import {
6
+ funnelBuilderLink,
7
+ overviewLink,
8
+ pagesLink,
9
+ } from '../composables/useMarketingRoutes'
10
+ import { useContextLink } from '../composables/useContextLink'
11
+ import {
12
+ formatNumber,
13
+ formatShortDate,
14
+ formatSignedDelta,
15
+ } from '../utils/format'
16
+ import { fractionDigits } from '../constants'
17
+
18
+ /**
19
+ * The report of a plain funnel: KPIs (entered, completed, end to end, the
20
+ * biggest loss), the sessions at each step with what each transition loses,
21
+ * where to look next and the definition. It says when the numbers cannot be
22
+ * read as they look: a site that sent nothing, a step it never sent, days or
23
+ * a comparison the raw events do not cover.
24
+ */
25
+ const props = defineProps<{
26
+ results: MarketingFunnelResults
27
+ loading: boolean
28
+ websiteName: string
29
+ }>()
30
+
31
+ type Basis = 'entered' | 'previous'
32
+
33
+ const MIN_BAR_PERCENT = 6
34
+ const PERCENT = 100
35
+ const MS_PER_HOUR = 3_600_000
36
+
37
+ const { t, locale } = useI18n()
38
+
39
+ const basis = ref<Basis>('entered')
40
+
41
+ /** In the page's own context: the funnel's website and the period. */
42
+ const inContext = useContextLink()
43
+
44
+ const steps = computed(() => props.results.computation.steps)
45
+ const previous = computed(() => props.results.previous?.steps ?? null)
46
+ const entered = computed(() => steps.value[0]?.sessions ?? 0)
47
+ const completed = computed(() => steps.value.at(-1)?.sessions ?? 0)
48
+ const endToEnd = computed(() =>
49
+ entered.value > 0 ? (completed.value / entered.value) * PERCENT : null,
50
+ )
51
+
52
+ /** The site sent no page view nor event at all over the period. */
53
+ const noTraffic = computed(() => props.results.computation.events === 0)
54
+
55
+ /** Steps no event of the period matched, while the site sent others; a
56
+ * read cut at its cap concludes nothing. */
57
+ const unseen = computed(() =>
58
+ noTraffic.value || props.results.truncated
59
+ ? []
60
+ : steps.value
61
+ .filter((row) => row.matches === 0)
62
+ .map((row) => row.step.value),
63
+ )
64
+
65
+ const coverage = computed(() => props.results.coverage ?? null)
66
+
67
+ const previousEntered = computed(() => previous.value?.[0]?.sessions)
68
+ const previousCompleted = computed(() => previous.value?.at(-1)?.sessions)
69
+ const previousEndToEnd = computed(() =>
70
+ previousEntered.value
71
+ ? ((previousCompleted.value ?? 0) / previousEntered.value) * PERCENT
72
+ : null,
73
+ )
74
+
75
+ function change(
76
+ current: number,
77
+ before: number | undefined | null,
78
+ ): number | null {
79
+ return before ? ((current - before) / before) * PERCENT : null
80
+ }
81
+
82
+ interface Transition {
83
+ index: number
84
+ lost: number
85
+ rate: number
86
+ }
87
+
88
+ const transitions = computed<Transition[]>(() =>
89
+ steps.value.slice(0, -1).map((step, index) => {
90
+ const next = steps.value[index + 1]?.sessions ?? 0
91
+ const lost = Math.max(0, step.sessions - next)
92
+ return {
93
+ index,
94
+ lost,
95
+ rate: step.sessions > 0 ? (lost / step.sessions) * PERCENT : 0,
96
+ }
97
+ }),
98
+ )
99
+
100
+ /** The transition losing the largest share, among those losing anyone —
101
+ * a loss before a step the site never sends is a tracking gap, not one. */
102
+ const worst = computed(() =>
103
+ transitions.value.reduce<Transition | null>(
104
+ (best, transition) =>
105
+ transition.lost > 0 &&
106
+ !unseen.value.includes(stepLabel(transition.index + 1)) &&
107
+ (!best || transition.rate > best.rate)
108
+ ? transition
109
+ : best,
110
+ null,
111
+ ),
112
+ )
113
+
114
+ function stepLabel(index: number): string {
115
+ return steps.value[index]?.step.value ?? ''
116
+ }
117
+
118
+ const kpis = computed(() => [
119
+ {
120
+ id: 'entered',
121
+ icon: 'i-ph-sign-in',
122
+ eyebrow: t('page.marketing.funnel.kpis.entered'),
123
+ value: formatNumber(entered.value, locale.value),
124
+ ...trend(change(entered.value, previousEntered.value), 'percent'),
125
+ },
126
+ {
127
+ id: 'completed',
128
+ icon: 'i-ph-flag-checkered',
129
+ eyebrow: t('page.marketing.funnel.kpis.completed'),
130
+ value: formatNumber(completed.value, locale.value),
131
+ ...trend(change(completed.value, previousCompleted.value), 'percent'),
132
+ },
133
+ {
134
+ id: 'end-to-end',
135
+ icon: 'i-ph-percent',
136
+ eyebrow: t('page.marketing.funnel.kpis.end_to_end'),
137
+ value:
138
+ endToEnd.value === null
139
+ ? '—'
140
+ : String(
141
+ formatPercentage(
142
+ endToEnd.value / 100,
143
+ locale.value,
144
+ fractionDigits(1),
145
+ ),
146
+ ),
147
+ ...trend(
148
+ endToEnd.value !== null && previousEndToEnd.value !== null
149
+ ? endToEnd.value - previousEndToEnd.value
150
+ : null,
151
+ 'points',
152
+ ),
153
+ },
154
+ {
155
+ id: 'biggest-loss',
156
+ icon: 'i-ph-arrow-fat-down',
157
+ tone: worst.value ? ('error' as const) : undefined,
158
+ eyebrow: t('page.marketing.funnel.kpis.biggest_loss'),
159
+ value: worst.value ? stepLabel(worst.value.index) : '—',
160
+ detail: worst.value
161
+ ? t('page.marketing.funnel.lost_rate', {
162
+ rate: formatPercentage(
163
+ worst.value.rate / 100,
164
+ locale.value,
165
+ fractionDigits(1),
166
+ ),
167
+ next: stepLabel(worst.value.index + 1),
168
+ })
169
+ : undefined,
170
+ detailTone: worst.value ? ('error' as const) : undefined,
171
+ },
172
+ ])
173
+
174
+ function trend(delta: number | null, unit: 'percent' | 'points') {
175
+ if (delta === null) {
176
+ return {}
177
+ }
178
+ return {
179
+ detail: `${formatSignedDelta(delta, unit, locale.value)} · ${t('page.marketing.context.vs_previous_short')}`,
180
+ detailTone:
181
+ Math.abs(delta) < 0.05
182
+ ? ('neutral' as const)
183
+ : delta > 0
184
+ ? ('success' as const)
185
+ : ('error' as const),
186
+ }
187
+ }
188
+
189
+ function baseOf(index: number): number {
190
+ return basis.value === 'entered' || index === 0
191
+ ? entered.value
192
+ : (steps.value[index - 1]?.sessions ?? 0)
193
+ }
194
+
195
+ function barPercent(sessions: number, index: number): number {
196
+ const base = baseOf(index)
197
+ return base > 0
198
+ ? Math.max((sessions / base) * PERCENT, sessions > 0 ? MIN_BAR_PERCENT : 0)
199
+ : 0
200
+ }
201
+
202
+ function shareLabel(sessions: number, index: number): string {
203
+ const base = baseOf(index)
204
+ return base > 0
205
+ ? String(formatPercentage(sessions / base, locale.value, fractionDigits(1)))
206
+ : '—'
207
+ }
208
+
209
+ /** What the share under a step is a share of; the goal's "converted" only
210
+ * when it is a share of everyone who entered. */
211
+ function shareCaption(index: number): string {
212
+ if (index === 0 || basis.value === 'entered') {
213
+ return t(
214
+ index === goalIndex.value && index > 0
215
+ ? 'page.marketing.funnel.converted'
216
+ : 'page.marketing.funnel.of_entered',
217
+ )
218
+ }
219
+ return t('page.marketing.funnel.of_previous')
220
+ }
221
+
222
+ const basisItems = computed(() => [
223
+ { value: 'entered', label: t('page.marketing.funnel.basis.entered') },
224
+ { value: 'previous', label: t('page.marketing.funnel.basis.previous') },
225
+ ])
226
+
227
+ const goalIndex = computed(() => steps.value.length - 1)
228
+
229
+ /** The first step's loss, as a split worth testing — unless it is the worst
230
+ * one already named, or the funnel already carries a split. */
231
+ const firstDrop = computed(() => {
232
+ const first = transitions.value[0]
233
+ return first &&
234
+ first.lost > 0 &&
235
+ !props.results.funnel.experiment &&
236
+ worst.value?.index !== 0
237
+ ? first
238
+ : null
239
+ })
240
+
241
+ const isDraft = computed(
242
+ () => props.results.funnel.experiment?.status === 'draft',
243
+ )
244
+
245
+ const definition = computed(() => props.results.funnel)
246
+ const windowHours = computed(() =>
247
+ Math.round(definition.value.conversionWindowMs / MS_PER_HOUR),
248
+ )
249
+
250
+ const coverageDate = computed(() =>
251
+ coverage.value?.since
252
+ ? formatShortDate(coverage.value.since, locale.value)
253
+ : '',
254
+ )
255
+ </script>
256
+
257
+ <template>
258
+ <div class="flex flex-col gap-4">
259
+ <DmsBanner
260
+ v-if="isDraft"
261
+ tone="info"
262
+ size="sm"
263
+ icon="i-ph-lock-simple-open"
264
+ :title="t('page.marketing.funnel.draft_title')"
265
+ :description="t('page.marketing.funnel.draft_description')"
266
+ />
267
+ <DmsBanner
268
+ v-if="results.truncated"
269
+ tone="warning"
270
+ size="sm"
271
+ icon="i-ph-hourglass"
272
+ :title="t('page.marketing.funnel.truncated_title')"
273
+ :description="t('page.marketing.funnel.truncated_description')"
274
+ />
275
+ <DmsBanner
276
+ v-if="
277
+ coverage && coverage.since && (coverage.period || coverage.previous)
278
+ "
279
+ tone="info"
280
+ size="sm"
281
+ icon="i-ph-clock-counter-clockwise"
282
+ :title="
283
+ coverage.period
284
+ ? t('page.marketing.funnels.coverage.period_title', {
285
+ date: coverageDate,
286
+ })
287
+ : t('page.marketing.funnels.coverage.previous_title')
288
+ "
289
+ :description="
290
+ t('page.marketing.funnels.coverage.description', {
291
+ date: coverageDate,
292
+ })
293
+ "
294
+ />
295
+ <DmsBanner
296
+ v-for="step in unseen"
297
+ :key="step"
298
+ tone="warning"
299
+ size="sm"
300
+ icon="i-ph-warning"
301
+ :title="
302
+ t('page.marketing.funnel.unseen.title', {
303
+ step,
304
+ website: websiteName,
305
+ })
306
+ "
307
+ :description="t('page.marketing.funnel.unseen.description')"
308
+ />
309
+
310
+ <DmsStatGroup :items="kpis" layout="cards" :columns="4" />
311
+
312
+ <DmsEmptyState
313
+ v-if="noTraffic"
314
+ icon="i-ph-wifi-slash"
315
+ hatched
316
+ class="dms-card"
317
+ :title="
318
+ t('page.marketing.funnel.no_traffic.title', { website: websiteName })
319
+ "
320
+ :description="t('page.marketing.funnel.no_traffic.description')"
321
+ >
322
+ <template #actions>
323
+ <UButton
324
+ color="neutral"
325
+ variant="outline"
326
+ size="sm"
327
+ icon="i-ph-chart-line"
328
+ :label="t('page.marketing.funnel.no_traffic.overview')"
329
+ :to="inContext(overviewLink())"
330
+ />
331
+ </template>
332
+ </DmsEmptyState>
333
+
334
+ <DmsEmptyState
335
+ v-else-if="entered === 0"
336
+ icon="i-ph-funnel"
337
+ hatched
338
+ class="dms-card"
339
+ :title="t('page.marketing.funnel.empty.title')"
340
+ :description="
341
+ t('page.marketing.funnel.empty.description', { step: stepLabel(0) })
342
+ "
343
+ >
344
+ <template #actions>
345
+ <UButton
346
+ color="neutral"
347
+ variant="outline"
348
+ size="sm"
349
+ icon="i-ph-pencil-simple"
350
+ :label="t('page.marketing.funnel.edit_steps')"
351
+ :to="inContext(funnelBuilderLink(definition._id))"
352
+ />
353
+ </template>
354
+ </DmsEmptyState>
355
+
356
+ <section
357
+ v-else
358
+ class="dms-card overflow-hidden p-0"
359
+ :class="loading ? 'opacity-70' : ''"
360
+ >
361
+ <header
362
+ class="flex flex-wrap items-center justify-between gap-3 border-b border-default px-4 py-3"
363
+ >
364
+ <DmsEyebrow :label="t('page.marketing.funnel.figure_title')" />
365
+ <DmsSegmented v-model="basis" :items="basisItems" size="xs" />
366
+ </header>
367
+ <ol class="flex flex-col px-4 py-3">
368
+ <template v-for="(row, index) in steps" :key="index">
369
+ <!-- Phone: number, step and share on a line, the bar under them.
370
+ From md: one row, the bar between the step and the share. -->
371
+ <li
372
+ class="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1.5 py-2 md:grid-cols-[2rem_minmax(10rem,16rem)_minmax(0,1fr)_7rem] md:gap-x-4"
373
+ >
374
+ <span
375
+ class="grid size-7 place-items-center rounded-md font-mono text-xs"
376
+ :class="
377
+ index === goalIndex
378
+ ? 'bg-primary text-inverted'
379
+ : 'bg-elevated text-toned'
380
+ "
381
+ >
382
+ <UIcon
383
+ v-if="index === goalIndex"
384
+ name="i-ph-flag-checkered"
385
+ class="size-3.5"
386
+ />
387
+ <template v-else>{{ index + 1 }}</template>
388
+ </span>
389
+ <span class="min-w-0">
390
+ <span
391
+ class="block truncate font-mono text-[13px] text-highlighted"
392
+ :title="row.step.value"
393
+ >
394
+ {{ row.step.value }}
395
+ </span>
396
+ <span
397
+ class="flex flex-wrap items-center gap-x-1 text-xs text-dimmed"
398
+ >
399
+ <UIcon
400
+ :name="
401
+ row.step.kind === 'url' ? 'i-ph-browser' : 'i-ph-lightning'
402
+ "
403
+ class="size-3"
404
+ />
405
+ {{ t(`page.marketing.funnel.kind.${row.step.kind}`) }}
406
+ <template v-if="index === goalIndex">
407
+ · {{ t('page.marketing.funnel.goal') }}
408
+ </template>
409
+ <template v-if="row.step.kind === 'url'">
410
+ ·
411
+ <DmsAutoLink
412
+ :to="inContext(pagesLink(row.step.value))"
413
+ class="text-primary hover:underline"
414
+ >
415
+ {{ t('page.marketing.funnel.see_clicks') }}
416
+ </DmsAutoLink>
417
+ </template>
418
+ <span
419
+ v-if="unseen.includes(row.step.value)"
420
+ class="inline-flex items-center gap-1 text-warning"
421
+ >
422
+ ·
423
+ <UIcon name="i-ph-warning" class="size-3" />
424
+ {{ t('page.marketing.funnel.unseen.badge') }}
425
+ </span>
426
+ </span>
427
+ </span>
428
+ <span
429
+ class="col-span-3 col-start-1 row-start-2 h-6 overflow-hidden rounded-md bg-elevated/60 md:col-span-1 md:col-start-3 md:row-start-1 md:h-8"
430
+ >
431
+ <span
432
+ class="block h-full rounded-md bg-gradient-to-r from-primary/40 to-primary transition-[width] duration-500"
433
+ :style="{ width: `${barPercent(row.sessions, index)}%` }"
434
+ />
435
+ </span>
436
+ <span class="col-start-3 row-start-1 text-right md:col-start-4">
437
+ <span
438
+ class="block font-mono text-sm font-semibold tabular-nums text-highlighted"
439
+ >
440
+ {{ shareLabel(row.sessions, index) }}
441
+ </span>
442
+ <span
443
+ class="block font-mono text-[10px] whitespace-nowrap text-dimmed"
444
+ >
445
+ {{
446
+ t(
447
+ 'page.marketing.funnel.sessions_count',
448
+ { count: formatNumber(row.sessions, locale) },
449
+ row.sessions,
450
+ )
451
+ }}
452
+ </span>
453
+ <span
454
+ class="block font-mono text-[10px] whitespace-nowrap text-dimmed"
455
+ >
456
+ {{ shareCaption(index) }}
457
+ </span>
458
+ </span>
459
+ </li>
460
+ <li
461
+ v-if="transitions[index]"
462
+ class="grid grid-cols-[2rem_minmax(0,1fr)] gap-x-3 md:grid-cols-[2rem_minmax(10rem,16rem)_minmax(0,1fr)_7rem] md:gap-x-4"
463
+ >
464
+ <span class="mx-auto h-6 w-px bg-(--ui-border)" />
465
+ <span class="hidden md:block" />
466
+ <span class="min-w-0">
467
+ <span
468
+ class="inline-flex max-w-full flex-wrap items-center gap-x-1.5 rounded border px-2 py-0.5 font-mono text-[11px]"
469
+ :class="
470
+ transitions[index]!.lost > 0
471
+ ? 'border-error/30 bg-error/10 text-error'
472
+ : 'border-default text-dimmed'
473
+ "
474
+ >
475
+ <UIcon name="i-ph-arrow-down" class="size-3" />
476
+ {{
477
+ t(
478
+ 'page.marketing.funnel.lost',
479
+ {
480
+ count: formatNumber(transitions[index]!.lost, locale),
481
+ rate: formatPercentage(
482
+ transitions[index]!.rate / 100,
483
+ locale,
484
+ fractionDigits(1),
485
+ ),
486
+ },
487
+ transitions[index]!.lost,
488
+ )
489
+ }}
490
+ <template v-if="worst && worst.index === index">
491
+ · {{ t('page.marketing.funnel.worst_step') }}
492
+ </template>
493
+ </span>
494
+ </span>
495
+ </li>
496
+ </template>
497
+ </ol>
498
+ <footer
499
+ class="flex items-start gap-1.5 border-t border-default px-4 py-2.5 text-xs text-dimmed"
500
+ >
501
+ <UIcon name="i-ph-info" class="mt-0.5 size-3.5 shrink-0" />
502
+ {{ t('page.marketing.funnel.figure_note', { hours: windowHours }) }}
503
+ </footer>
504
+ </section>
505
+
506
+ <div class="grid gap-4 lg:grid-cols-2">
507
+ <section v-if="!noTraffic" class="dms-card p-0">
508
+ <header class="border-b border-default px-4 py-3">
509
+ <DmsEyebrow :label="t('page.marketing.funnel.next.title')" />
510
+ </header>
511
+ <ul class="divide-y divide-default">
512
+ <li v-if="worst" class="flex items-start gap-3 px-4 py-3">
513
+ <DmsIconWell icon="i-ph-browser" tone="error" size="sm" />
514
+ <span class="min-w-0 flex-1">
515
+ <span class="block text-sm font-medium text-highlighted">
516
+ {{
517
+ t('page.marketing.funnel.next.worst', {
518
+ rate: formatPercentage(
519
+ worst.rate / 100,
520
+ locale,
521
+ fractionDigits(1),
522
+ ),
523
+ step: stepLabel(worst.index),
524
+ })
525
+ }}
526
+ </span>
527
+ <span class="block text-xs text-muted">
528
+ {{ t('page.marketing.funnel.next.worst_hint') }}
529
+ </span>
530
+ </span>
531
+ <DmsAutoLink
532
+ v-if="steps[worst.index]?.step.kind === 'url'"
533
+ :to="inContext(pagesLink(stepLabel(worst.index)))"
534
+ class="font-mono text-xs text-primary hover:underline"
535
+ >
536
+ {{ t('page.marketing.funnel.next.heatmap') }}
537
+ </DmsAutoLink>
538
+ </li>
539
+ <li v-if="firstDrop" class="flex items-start gap-3 px-4 py-3">
540
+ <DmsIconWell icon="i-ph-flask" tone="warning" size="sm" />
541
+ <span class="min-w-0 flex-1">
542
+ <span class="block text-sm font-medium text-highlighted">
543
+ {{
544
+ t('page.marketing.funnel.next.first', {
545
+ rate: formatPercentage(
546
+ firstDrop.rate / 100,
547
+ locale,
548
+ fractionDigits(1),
549
+ ),
550
+ step: stepLabel(1),
551
+ })
552
+ }}
553
+ </span>
554
+ <span class="block text-xs text-muted">
555
+ {{
556
+ t('page.marketing.funnel.next.first_hint', {
557
+ step: stepLabel(0),
558
+ })
559
+ }}
560
+ </span>
561
+ </span>
562
+ <DmsAutoLink
563
+ :to="
564
+ inContext(funnelBuilderLink(definition._id, { split: true }))
565
+ "
566
+ class="font-mono text-xs text-primary hover:underline"
567
+ >
568
+ {{ t('page.marketing.funnel.next.split') }}
569
+ </DmsAutoLink>
570
+ </li>
571
+ <li v-if="!worst" class="px-4 py-6 text-center text-sm text-muted">
572
+ {{
573
+ t(
574
+ unseen.length > 0
575
+ ? 'page.marketing.funnel.next.unseen'
576
+ : entered === 0
577
+ ? 'page.marketing.funnel.next.nobody'
578
+ : 'page.marketing.funnel.next.none',
579
+ )
580
+ }}
581
+ </li>
582
+ </ul>
583
+ </section>
584
+
585
+ <section class="dms-card p-0" :class="noTraffic ? 'lg:col-span-2' : ''">
586
+ <header
587
+ class="flex items-center justify-between border-b border-default px-4 py-3"
588
+ >
589
+ <DmsEyebrow :label="t('page.marketing.funnel.definition.title')" />
590
+ <DmsAutoLink
591
+ :to="inContext(funnelBuilderLink(definition._id))"
592
+ class="text-xs font-medium text-primary hover:underline"
593
+ >
594
+ {{ t('page.marketing.common.edit') }}
595
+ </DmsAutoLink>
596
+ </header>
597
+ <dl
598
+ class="grid grid-cols-[6rem_minmax(0,1fr)] gap-x-4 gap-y-3 px-4 py-3 text-sm sm:grid-cols-[8rem_minmax(0,1fr)]"
599
+ >
600
+ <dt class="text-muted">
601
+ {{ t('page.marketing.funnel.definition.website') }}
602
+ </dt>
603
+ <dd class="text-highlighted">{{ websiteName || '—' }}</dd>
604
+ <dt class="text-muted">
605
+ {{ t('page.marketing.funnel.definition.steps') }}
606
+ </dt>
607
+ <dd class="flex flex-wrap items-center gap-1">
608
+ <template v-for="(step, index) in definition.steps" :key="index">
609
+ <UIcon
610
+ v-if="index > 0"
611
+ name="i-ph-caret-right"
612
+ class="size-3 text-dimmed"
613
+ />
614
+ <span
615
+ class="max-w-full truncate rounded border px-1.5 py-0.5 font-mono text-[11px]"
616
+ :class="
617
+ step.kind === 'custom'
618
+ ? 'border-primary/40 text-primary'
619
+ : 'border-default text-toned'
620
+ "
621
+ >
622
+ {{ step.value }}
623
+ </span>
624
+ </template>
625
+ </dd>
626
+ <dt class="text-muted">
627
+ {{ t('page.marketing.funnel.definition.window') }}
628
+ </dt>
629
+ <dd class="text-highlighted">
630
+ {{
631
+ t('page.marketing.funnel.definition.window_value', {
632
+ hours: windowHours,
633
+ })
634
+ }}
635
+ </dd>
636
+ </dl>
637
+ </section>
638
+ </div>
639
+ </div>
640
+ </template>