@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,162 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import {
4
+ useDmsRoute,
5
+ useDmsRouter,
6
+ useI18n,
7
+ } from '#dms/frontend-module'
8
+
9
+ interface TopListTabItem {
10
+ id: string
11
+ title: string
12
+ value: number
13
+ to?: { path: string, query?: Record<string, string> }
14
+ }
15
+
16
+ interface TopListTab {
17
+ id: string
18
+ label: string
19
+ items: TopListTabItem[]
20
+ }
21
+
22
+ /**
23
+ * One overview card holding sibling top lists behind pill tabs. The rows
24
+ * mirror dms-ui's DmsTopListCard rendering, re-done locally because that
25
+ * card carries its own DmsCard chrome and cannot nest inside another card.
26
+ * A single tab renders as a plain list: no pill bar.
27
+ */
28
+ const props = defineProps<{
29
+ title: string
30
+ tabs: TopListTab[]
31
+ emptyLabel: string
32
+ /** URL query key persisting the active tab; omit for free state. */
33
+ stateKey?: string
34
+ footerLabel?: string
35
+ footerTo?: { path: string, query?: Record<string, string> }
36
+ }>()
37
+
38
+ const route = useDmsRoute()
39
+ const router = useDmsRouter()
40
+ const { locale } = useI18n()
41
+
42
+ const HIGHLIGHTED_RANKS = 3
43
+
44
+ const tabItems = computed(() =>
45
+ props.tabs.map(tab => ({ label: tab.label, value: tab.id })))
46
+
47
+ function initialTab(): string {
48
+ const wanted = props.stateKey ? route.query[props.stateKey] : undefined
49
+ return typeof wanted === 'string' && props.tabs.some(tab => tab.id === wanted)
50
+ ? wanted
51
+ : (props.tabs[0]?.id ?? '')
52
+ }
53
+
54
+ const activeId = ref(initialTab())
55
+
56
+ // Conditional tabs (countries follows geoipEnabled, false until the fetch
57
+ // settles) can be missing at mount and appear later: adopt the tab the URL
58
+ // still asks for once it exists. The activeId watcher keeps the query in
59
+ // step with every manual pick, so re-reading it can never override one.
60
+ // The fallback covers the reverse: the active tab disappearing.
61
+ watch(() => props.tabs, (tabs) => {
62
+ const wanted = props.stateKey ? route.query[props.stateKey] : undefined
63
+ if (typeof wanted === 'string' && tabs.some(tab => tab.id === wanted)) {
64
+ activeId.value = wanted
65
+ return
66
+ }
67
+ if (!tabs.some(tab => tab.id === activeId.value)) {
68
+ activeId.value = tabs[0]?.id ?? ''
69
+ }
70
+ })
71
+
72
+ // Replace, not push: paging through tabs must not stack history entries.
73
+ // The default tab keeps the URL bare so a copied link stays minimal.
74
+ watch(activeId, (id) => {
75
+ const key = props.stateKey
76
+ if (!key) {
77
+ return
78
+ }
79
+ const query = Object.fromEntries(
80
+ Object.entries(route.query).filter(([name]) => name !== key),
81
+ )
82
+ if (id !== props.tabs[0]?.id) {
83
+ query[key] = id
84
+ }
85
+ void router.replace({ query })
86
+ })
87
+
88
+ const activeTab = computed(() =>
89
+ props.tabs.find(tab => tab.id === activeId.value) ?? props.tabs[0])
90
+
91
+ function rankClass(index: number): string {
92
+ return index < HIGHLIGHTED_RANKS ? 'font-semibold text-primary' : 'text-muted'
93
+ }
94
+
95
+ function formatCount(value: number): string {
96
+ return value.toLocaleString(locale.value)
97
+ }
98
+ </script>
99
+
100
+ <template>
101
+ <!-- The grid stretches the cards of a row; the growing body soaks up the
102
+ slack so every footer anchors to the bottom edge. -->
103
+ <UCard class="flex flex-col" :ui="{ body: 'grow' }">
104
+ <template #header>
105
+ <div class="flex flex-wrap items-center justify-between gap-3">
106
+ <p class="font-medium">{{ title }}</p>
107
+ <UTabs
108
+ v-if="tabs.length > 1"
109
+ v-model="activeId"
110
+ :items="tabItems"
111
+ :content="false"
112
+ size="xs"
113
+ />
114
+ </div>
115
+ </template>
116
+
117
+ <!-- min-h holds the height of a full list — 8 rows of 2.5rem plus 7
118
+ divide-y pixels — so switching to a shorter tab never jumps. -->
119
+ <ul
120
+ v-if="activeTab && activeTab.items.length > 0"
121
+ class="grid min-h-[calc(20rem+7px)] content-start gap-x-3 divide-y divide-default [grid-template-columns:1.75rem_minmax(0,1fr)_auto]"
122
+ >
123
+ <li
124
+ v-for="(item, index) in activeTab.items"
125
+ :key="item.id"
126
+ class="relative col-[1/-1] grid grid-cols-subgrid items-center py-2.5 transition-colors hover:bg-elevated/60"
127
+ :class="item.to ? 'cursor-pointer' : ''"
128
+ >
129
+ <DmsLink
130
+ v-if="item.to"
131
+ :to="item.to"
132
+ class="absolute inset-0 z-10"
133
+ :aria-label="item.title"
134
+ />
135
+ <span
136
+ class="text-center font-mono text-xs tabular-nums"
137
+ :class="rankClass(index)"
138
+ >
139
+ {{ String(index + 1).padStart(2, '0') }}
140
+ </span>
141
+ <p class="min-w-0 truncate text-sm font-medium">{{ item.title }}</p>
142
+ <span class="justify-self-end whitespace-nowrap text-xs tabular-nums text-muted">
143
+ {{ formatCount(item.value) }}
144
+ </span>
145
+ </li>
146
+ </ul>
147
+
148
+ <div v-else class="flex min-h-[calc(20rem+7px)] items-center justify-center">
149
+ <p class="text-sm text-muted">{{ emptyLabel }}</p>
150
+ </div>
151
+
152
+ <template v-if="footerLabel && footerTo" #footer>
153
+ <DmsLink
154
+ :to="footerTo"
155
+ class="inline-flex items-center gap-1 text-sm text-muted transition-colors hover:text-highlighted"
156
+ >
157
+ {{ footerLabel }}
158
+ <UIcon name="i-ph-arrow-right" class="size-4" />
159
+ </DmsLink>
160
+ </template>
161
+ </UCard>
162
+ </template>
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useDmsRuntimeConfig, useI18n } from '#dms/frontend-module'
4
+ import type { MarketingWebsite } from '../composables/useMarketingApi'
5
+
6
+ const props = defineProps<{ website: MarketingWebsite }>()
7
+ const { t } = useI18n()
8
+ const config = useDmsRuntimeConfig()
9
+
10
+ const backendOrigin = computed(() =>
11
+ String((config.public as { dms?: { baseURL?: string } }).dms?.baseURL ?? ''),
12
+ )
13
+
14
+ const CLOSING_TAG = '</' + 'script>'
15
+
16
+ const snippet = computed(() =>
17
+ `<script defer src="${backendOrigin.value}/api/marketing/tracker.js" data-website-id="${props.website._id}">${CLOSING_TAG}`,
18
+ )
19
+ </script>
20
+
21
+ <template>
22
+ <div class="space-y-4">
23
+ <p class="text-sm text-muted">
24
+ {{ t('page.marketing.websites.snippet.description', { name: website.name }) }}
25
+ </p>
26
+ <div class="flex items-start gap-2 rounded-lg bg-elevated p-3">
27
+ <pre class="min-w-0 flex-1 overflow-x-auto text-xs"><code>{{ snippet }}</code></pre>
28
+ <DmsCopyButton :value="snippet" />
29
+ </div>
30
+ </div>
31
+ </template>
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { useI18n } from '#dms/frontend-module'
4
+ import {
5
+ parseExperimentVariationsValue,
6
+ type ExperimentVariationDraft,
7
+ } from '../composables/useExperiment'
8
+
9
+ /**
10
+ * The arms of an experiment, inside DmsMarketingExperimentInput: a list of
11
+ * {key, weight} rows emitted as the array itself. Bounds mirror the backend
12
+ * MIN/MAX_EXPERIMENT_VARIATIONS; the first arm is the control.
13
+ */
14
+ const MIN_VARIATIONS = 2
15
+ const MAX_VARIATIONS = 8
16
+ const DEFAULT_WEIGHT = 1
17
+
18
+ const props = defineProps<{
19
+ modelValue?: ExperimentVariationDraft[] | string | null
20
+ initialValue?: ExperimentVariationDraft[] | string | null
21
+ disabled?: boolean
22
+ }>()
23
+
24
+ const emit = defineEmits<{
25
+ 'update:modelValue': [value: ExperimentVariationDraft[]]
26
+ }>()
27
+
28
+ const { t } = useI18n()
29
+
30
+ function emptyVariation(): ExperimentVariationDraft {
31
+ return { key: '', weight: DEFAULT_WEIGHT }
32
+ }
33
+
34
+ function parseValue(
35
+ raw: ExperimentVariationDraft[] | string | null | undefined,
36
+ ): ExperimentVariationDraft[] {
37
+ // Copies, not references: the drafts are mutated by the inputs below and
38
+ // must never write through to the prop value.
39
+ const variations = parseExperimentVariationsValue(raw).map(variation => ({
40
+ ...variation,
41
+ }))
42
+ while (variations.length < MIN_VARIATIONS) {
43
+ variations.push(emptyVariation())
44
+ }
45
+ return variations
46
+ }
47
+
48
+ const variations = ref<ExperimentVariationDraft[]>(
49
+ parseValue(props.modelValue ?? props.initialValue),
50
+ )
51
+
52
+ const totalWeight = computed(() =>
53
+ variations.value.reduce(
54
+ (sum, variation) => sum + Math.max(variation.weight || 0, 0),
55
+ 0,
56
+ ),
57
+ )
58
+
59
+ function sharePercent(variation: ExperimentVariationDraft): number {
60
+ if (totalWeight.value <= 0) {
61
+ return Math.round(100 / variations.value.length)
62
+ }
63
+ return Math.round(
64
+ (Math.max(variation.weight || 0, 0) / totalWeight.value) * 100,
65
+ )
66
+ }
67
+
68
+ // Values are emitted untrimmed — trimming here would eat characters
69
+ // mid-typing through the v-model echo; the DataType schema trims server-side.
70
+ function onEdit(): void {
71
+ emit(
72
+ 'update:modelValue',
73
+ variations.value.map(variation => ({
74
+ key: variation.key,
75
+ weight: variation.weight,
76
+ })),
77
+ )
78
+ }
79
+
80
+ function addVariation(): void {
81
+ if (variations.value.length >= MAX_VARIATIONS) {
82
+ return
83
+ }
84
+ variations.value.push(emptyVariation())
85
+ onEdit()
86
+ }
87
+
88
+ function removeVariation(index: number): void {
89
+ if (variations.value.length <= MIN_VARIATIONS) {
90
+ return
91
+ }
92
+ variations.value.splice(index, 1)
93
+ onEdit()
94
+ }
95
+
96
+ watch(
97
+ () => props.modelValue,
98
+ (value) => {
99
+ const parsed = parseValue(value)
100
+ if (JSON.stringify(parsed) !== JSON.stringify(variations.value)) {
101
+ variations.value = parsed
102
+ }
103
+ },
104
+ )
105
+ </script>
106
+
107
+ <template>
108
+ <div class="flex flex-col gap-2">
109
+ <div
110
+ v-for="(variation, index) in variations"
111
+ :key="index"
112
+ class="flex items-center gap-2"
113
+ >
114
+ <UBadge
115
+ v-if="index === 0"
116
+ :label="t('page.marketing.funnels.results.control')"
117
+ color="neutral"
118
+ variant="subtle"
119
+ size="sm"
120
+ class="w-16 justify-center shrink-0"
121
+ />
122
+ <span v-else class="w-16 shrink-0" />
123
+ <UInput
124
+ v-model="variation.key"
125
+ :placeholder="t('page.marketing.funnels.form.variation_key')"
126
+ :disabled="props.disabled"
127
+ class="flex-1 font-mono"
128
+ size="sm"
129
+ @update:model-value="onEdit"
130
+ />
131
+ <UInput
132
+ v-model.number="variation.weight"
133
+ type="number"
134
+ min="0"
135
+ :disabled="props.disabled"
136
+ class="w-20"
137
+ size="sm"
138
+ @update:model-value="onEdit"
139
+ />
140
+ <span class="w-10 shrink-0 text-right text-xs text-muted">
141
+ {{ sharePercent(variation) }}%
142
+ </span>
143
+ <UButton
144
+ v-if="!props.disabled"
145
+ icon="i-ph-x"
146
+ color="neutral"
147
+ variant="ghost"
148
+ size="xs"
149
+ :disabled="variations.length <= MIN_VARIATIONS"
150
+ @click="removeVariation(index)"
151
+ />
152
+ </div>
153
+ <UButton
154
+ v-if="!props.disabled && variations.length < MAX_VARIATIONS"
155
+ icon="i-ph-plus"
156
+ :label="t('page.marketing.funnels.form.add_variation')"
157
+ color="neutral"
158
+ variant="subtle"
159
+ size="sm"
160
+ class="self-start"
161
+ @click="addVariation"
162
+ />
163
+ </div>
164
+ </template>
@@ -0,0 +1,13 @@
1
+ import { toValue, type MaybeRefOrGetter } from 'vue'
2
+
3
+ /**
4
+ * Derives one stable id per child from the id the DMS page renderer hands the
5
+ * component. dms-ui primitives key their events and their persisted state on
6
+ * it, so two children sharing an id share that state; the `${parent}-child`
7
+ * shape is the one DmsRecursiveComponent uses.
8
+ */
9
+ export function useChildId(
10
+ componentId: MaybeRefOrGetter<string | undefined>,
11
+ ): (suffix: string) => string {
12
+ return (suffix: string) => `${toValue(componentId)}-${suffix}`
13
+ }
@@ -0,0 +1,59 @@
1
+ import type {
2
+ MarketingExperimentRun,
3
+ MarketingExperimentStatus,
4
+ } from './useMarketingApi'
5
+
6
+ /**
7
+ * The A/B facet of a funnel as it travels through the form layer.
8
+ *
9
+ * The value reaches components either as the real object or as a JSON string —
10
+ * the input and the list display both have to read the two shapes, so the
11
+ * parse lives once here. Invalid input answers null (or an empty list), never
12
+ * a throw: this runs while rendering a cell.
13
+ */
14
+ export interface ExperimentVariationDraft {
15
+ key: string
16
+ weight: number
17
+ }
18
+
19
+ export interface ExperimentDraft {
20
+ key: string
21
+ status?: MarketingExperimentStatus
22
+ variations: ExperimentVariationDraft[]
23
+ /** Read-only here: the pane reads how many runs the split has had, the
24
+ * form carries them back untouched and the backend re-derives them. */
25
+ runs: MarketingExperimentRun[]
26
+ }
27
+
28
+ function parseJsonValue(raw: unknown): unknown {
29
+ if (typeof raw !== 'string') {
30
+ return raw
31
+ }
32
+ try {
33
+ return raw ? JSON.parse(raw) : null
34
+ }
35
+ catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ export function parseExperimentVariationsValue(
41
+ raw: unknown,
42
+ ): ExperimentVariationDraft[] {
43
+ const parsed = parseJsonValue(raw)
44
+ return Array.isArray(parsed) ? (parsed as ExperimentVariationDraft[]) : []
45
+ }
46
+
47
+ export function parseExperimentValue(raw: unknown): ExperimentDraft | null {
48
+ const parsed = parseJsonValue(raw)
49
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
50
+ return null
51
+ }
52
+ const candidate = parsed as Partial<ExperimentDraft>
53
+ return {
54
+ key: typeof candidate.key === 'string' ? candidate.key : '',
55
+ status: candidate.status,
56
+ variations: parseExperimentVariationsValue(candidate.variations),
57
+ runs: Array.isArray(candidate.runs) ? candidate.runs : [],
58
+ }
59
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Funnel steps as they travel through the form layer.
3
+ *
4
+ * The value reaches components either as the real array or as a JSON string —
5
+ * the input and the table display both have to read the two shapes, so the
6
+ * parse lives once here. Invalid input answers an empty list, never a throw:
7
+ * this runs while rendering a cell.
8
+ */
9
+ export interface FunnelStepDraft {
10
+ kind: 'url' | 'custom'
11
+ value: string
12
+ }
13
+
14
+ export function parseFunnelStepsValue(raw: unknown): FunnelStepDraft[] {
15
+ let parsed: unknown = raw
16
+ if (typeof raw === 'string') {
17
+ try {
18
+ parsed = raw ? JSON.parse(raw) : []
19
+ }
20
+ catch {
21
+ parsed = []
22
+ }
23
+ }
24
+ return Array.isArray(parsed) ? (parsed as FunnelStepDraft[]) : []
25
+ }
@@ -0,0 +1,90 @@
1
+ import { ref, type Ref } from 'vue'
2
+
3
+ /**
4
+ * A read whose answers may land out of order, resolved so that only the
5
+ * newest one is ever shown.
6
+ *
7
+ * Every surface of this module refetches on a selection, a period and
8
+ * sometimes a search box, and those fetches are not ordered: a slow answer for
9
+ * the site, page or funnel the user just left would otherwise repaint over the
10
+ * one on screen. The rule is the same everywhere — stamp each run, and let a
11
+ * run write only while it is still the latest — which is exactly what this
12
+ * owns instead of leaving it to be re-derived per component.
13
+ *
14
+ * `fetcher` returns `null` when there is nothing to ask for (no selection yet,
15
+ * or server-side, where there is no session to authenticate with). That is a
16
+ * settled empty state, not a failure and not a load: it clears the data and
17
+ * leaves nothing spinning.
18
+ */
19
+ export interface LatestRequest<T> {
20
+ /** Newest answer, or null before the first one and after any failure. */
21
+ data: Ref<T | null>
22
+ loading: Ref<boolean>
23
+ failed: Ref<boolean>
24
+ /** Cause of the last failure, for callers that surface a message. */
25
+ error: Ref<unknown>
26
+ /**
27
+ * False until a run has settled. This is what tells "not asked yet" from
28
+ * "asked, and there is nothing" — a component that renders an empty state
29
+ * on the former flashes it before its first fetch resolves.
30
+ */
31
+ settled: Ref<boolean>
32
+ run: () => Promise<void>
33
+ }
34
+
35
+ export function useLatestRequest<T>(
36
+ fetcher: () => Promise<T> | null,
37
+ ): LatestRequest<T> {
38
+ const data = ref(null) as Ref<T | null>
39
+ const loading = ref(false)
40
+ const failed = ref(false)
41
+ const error = ref<unknown>(null)
42
+ const settled = ref(false)
43
+
44
+ let latest = 0
45
+
46
+ async function run(): Promise<void> {
47
+ const request = ++latest
48
+ // Cleared before the guard below, so a retry with nothing to fetch cannot
49
+ // leave a stale error on screen.
50
+ failed.value = false
51
+ error.value = null
52
+
53
+ // Called synchronously: the fetcher reads the selection as it is now, not
54
+ // as it will be once the previous request resolves.
55
+ const pending = fetcher()
56
+ if (!pending) {
57
+ data.value = null
58
+ loading.value = false
59
+ settled.value = true
60
+ return
61
+ }
62
+
63
+ loading.value = true
64
+ try {
65
+ const result = await pending
66
+ if (request !== latest) {
67
+ return
68
+ }
69
+ data.value = result
70
+ }
71
+ catch (cause) {
72
+ if (request !== latest) {
73
+ return
74
+ }
75
+ failed.value = true
76
+ error.value = cause
77
+ data.value = null
78
+ }
79
+ finally {
80
+ // A superseded run owns none of the shared state — not even the flag
81
+ // that says the newest one is still in flight.
82
+ if (request === latest) {
83
+ loading.value = false
84
+ settled.value = true
85
+ }
86
+ }
87
+ }
88
+
89
+ return { data, loading, failed, error, settled, run }
90
+ }