@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,154 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { useI18n } from '#dms/frontend-module'
4
+ import {
5
+ parseFunnelStepsValue,
6
+ type FunnelStepDraft,
7
+ } from '../composables/useFunnelSteps'
8
+
9
+ /**
10
+ * Form input for the marketing_funnel_steps DataType: an ordered list of
11
+ * {kind, value} steps, emitted as the array itself so the client-side zod
12
+ * schema and the write path both see the real type (a JSON string would be
13
+ * persisted raw). Bounds mirror the backend MIN/MAX_FUNNEL_STEPS.
14
+ */
15
+ interface StepKindMeta {
16
+ label: string
17
+ placeholder: string
18
+ }
19
+
20
+ const MIN_STEPS = 1
21
+ const MAX_STEPS = 10
22
+
23
+ const props = defineProps<{
24
+ modelValue?: FunnelStepDraft[] | string | null
25
+ initialValue?: FunnelStepDraft[] | string | null
26
+ disabled?: boolean
27
+ }>()
28
+
29
+ const emit = defineEmits<{ 'update:modelValue': [value: FunnelStepDraft[]] }>()
30
+
31
+ const { t } = useI18n()
32
+
33
+ // Computed so labels follow live locale switches (t() at setup would freeze).
34
+ const stepKindMeta = computed<Record<FunnelStepDraft['kind'], StepKindMeta>>(
35
+ () => ({
36
+ url: {
37
+ label: t('page.marketing.funnels.step_kind.url'),
38
+ placeholder: t('page.marketing.funnels.form.url_placeholder'),
39
+ },
40
+ custom: {
41
+ label: t('page.marketing.funnels.step_kind.custom'),
42
+ placeholder: t('page.marketing.funnels.form.event_placeholder'),
43
+ },
44
+ }),
45
+ )
46
+
47
+ const stepKindItems = computed(() =>
48
+ Object.entries(stepKindMeta.value).map(([value, meta]) => ({
49
+ label: meta.label,
50
+ value,
51
+ })),
52
+ )
53
+
54
+ function emptyStep(): FunnelStepDraft {
55
+ return { kind: 'url', value: '' }
56
+ }
57
+
58
+ function parseValue(
59
+ raw: FunnelStepDraft[] | string | null | undefined,
60
+ ): FunnelStepDraft[] {
61
+ // Copies, not references: the drafts are mutated by the inputs below and
62
+ // must never write through to the prop value.
63
+ const steps = parseFunnelStepsValue(raw).map(step => ({ ...step }))
64
+ while (steps.length < MIN_STEPS) {
65
+ steps.push(emptyStep())
66
+ }
67
+ return steps
68
+ }
69
+
70
+ const steps = ref<FunnelStepDraft[]>(
71
+ parseValue(props.modelValue ?? props.initialValue),
72
+ )
73
+
74
+ // Values are emitted untrimmed — trimming here would eat spaces mid-typing
75
+ // through the v-model echo; the DataType schema trims server-side.
76
+ function onEdit(): void {
77
+ emit(
78
+ 'update:modelValue',
79
+ steps.value.map((step) => ({ kind: step.kind, value: step.value })),
80
+ )
81
+ }
82
+
83
+ function addStep(): void {
84
+ if (steps.value.length >= MAX_STEPS) {
85
+ return
86
+ }
87
+ steps.value.push(emptyStep())
88
+ onEdit()
89
+ }
90
+
91
+ function removeStep(index: number): void {
92
+ if (steps.value.length <= MIN_STEPS) {
93
+ return
94
+ }
95
+ steps.value.splice(index, 1)
96
+ onEdit()
97
+ }
98
+
99
+ watch(
100
+ () => props.modelValue,
101
+ (value) => {
102
+ const parsed = parseValue(value)
103
+ if (JSON.stringify(parsed) !== JSON.stringify(steps.value)) {
104
+ steps.value = parsed
105
+ }
106
+ },
107
+ )
108
+ </script>
109
+
110
+ <template>
111
+ <div class="flex flex-col gap-2">
112
+ <div
113
+ v-for="(step, index) in steps"
114
+ :key="index"
115
+ class="flex items-center gap-2"
116
+ >
117
+ <USelect
118
+ v-model="step.kind"
119
+ :items="stepKindItems"
120
+ :disabled="props.disabled"
121
+ class="w-32"
122
+ size="sm"
123
+ @update:model-value="onEdit"
124
+ />
125
+ <UInput
126
+ v-model="step.value"
127
+ :placeholder="stepKindMeta[step.kind].placeholder"
128
+ :disabled="props.disabled"
129
+ class="flex-1"
130
+ size="sm"
131
+ @update:model-value="onEdit"
132
+ />
133
+ <UButton
134
+ v-if="!props.disabled"
135
+ icon="i-ph-x"
136
+ color="neutral"
137
+ variant="ghost"
138
+ size="xs"
139
+ :disabled="steps.length <= MIN_STEPS"
140
+ @click="removeStep(index)"
141
+ />
142
+ </div>
143
+ <UButton
144
+ v-if="!props.disabled && steps.length < MAX_STEPS"
145
+ icon="i-ph-plus"
146
+ :label="t('page.marketing.funnels.form.add_step')"
147
+ color="neutral"
148
+ variant="subtle"
149
+ size="sm"
150
+ class="self-start"
151
+ @click="addStep"
152
+ />
153
+ </div>
154
+ </template>
@@ -0,0 +1,493 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import {
4
+ useDmsRoute,
5
+ useDmsRouter,
6
+ useI18n,
7
+ } from '#dms/frontend-module'
8
+ import { useChildId } from '../composables/useChildId'
9
+ import { parseExperimentValue } from '../composables/useExperiment'
10
+ import { parseFunnelStepsValue } from '../composables/useFunnelSteps'
11
+ import { useLatestRequest } from '../composables/useLatestRequest'
12
+ import {
13
+ useMarketingApi,
14
+ type MarketingExperimentStatus,
15
+ type MarketingFunnelListItem,
16
+ } from '../composables/useMarketingApi'
17
+ import {
18
+ MARKETING_PERIOD_PRESETS,
19
+ ndToPreset,
20
+ useMarketingPeriod,
21
+ } from '../composables/useMarketingPeriod'
22
+ import { funnelsLink } from '../composables/useMarketingRoutes'
23
+ import { useMarketingWebsites } from '../composables/useMarketingWebsites'
24
+ import { dmsComponent } from '../utils/dmsComponent'
25
+
26
+ /**
27
+ * Conversion funnels of a site, plain or split into A/B arms: the
28
+ * definitions list on the left, the figure of the selected one on the right —
29
+ * the tracked-pages layout, for the same reason: picking what to look at is
30
+ * a single-selection gesture, and it, the period and the URL state belong to
31
+ * one surface.
32
+ *
33
+ * Definitions CRUD goes through the funnels data-api — create/edit open the
34
+ * generic DmsForm in a modal, delete and the lifecycle transitions sit in the
35
+ * detail pane; the selection lives in the URL so a funnel can be linked to.
36
+ */
37
+ const PERIOD_SCOPE = 'dms-marketing-funnels'
38
+
39
+ // Declared, not inherited as attributes: the DMS page renderer hands every
40
+ // component its identity, and an undeclared prop lands on the root element.
41
+ const props = withDefaults(
42
+ defineProps<{ componentId?: string, pageId?: string }>(),
43
+ { componentId: 'marketing-funnels', pageId: 'marketing' },
44
+ )
45
+
46
+ const childId = useChildId(() => props.componentId)
47
+
48
+ const api = useMarketingApi()
49
+ const { t } = useI18n()
50
+ const route = useDmsRoute()
51
+ const router = useDmsRouter()
52
+ const modal = useModal()
53
+ const toast = useToast()
54
+
55
+ const queryParam = (key: string): string => {
56
+ const value = route.query[key]
57
+ return typeof value === 'string' ? value : ''
58
+ }
59
+
60
+ // Read once: the selector seeds its preset at init and never reactively, so a
61
+ // deep link's window has to be resolved before it renders.
62
+ const initialPreset = ndToPreset(queryParam('period'))
63
+
64
+ const {
65
+ websites,
66
+ items: websiteItems,
67
+ selectedId: selectedWebsiteId,
68
+ loading: loadingWebsites,
69
+ failed: websitesFailed,
70
+ load: loadWebsites,
71
+ create: createWebsite,
72
+ } = useMarketingWebsites(queryParam('website'))
73
+
74
+ // `undefined`, not null: this is what DmsMasterDetail's model is typed on.
75
+ const selectedFunnelId = ref<string | undefined>(
76
+ queryParam('funnel') || undefined,
77
+ )
78
+
79
+ const period = useMarketingPeriod(PERIOD_SCOPE)
80
+
81
+ /** Bumped after an edit so the detail recomputes an unchanged selection. */
82
+ const refreshToken = ref(0)
83
+
84
+ const {
85
+ data: funnelList,
86
+ loading: loadingFunnels,
87
+ failed: funnelsFailed,
88
+ run: loadFunnels,
89
+ } = useLatestRequest(() =>
90
+ // Server-side there is no session to authenticate with: the fetch belongs
91
+ // to the client, like every other read here.
92
+ selectedWebsiteId.value && !import.meta.env.SSR
93
+ ? api.listFunnels(selectedWebsiteId.value)
94
+ : null,
95
+ )
96
+
97
+ const funnels = computed(() => funnelList.value?.results ?? [])
98
+
99
+ const selectedFunnel = computed(
100
+ () => funnels.value.find(funnel => funnel._id === selectedFunnelId.value) ?? null,
101
+ )
102
+
103
+ const selectedExperiment = computed(() =>
104
+ parseExperimentValue(selectedFunnel.value?.experiment),
105
+ )
106
+
107
+ // Two failures, two flags: the list fetch clears its own error on every
108
+ // attempt, and must not clear the one that says the site list never loaded.
109
+ const failed = computed(() => websitesFailed.value || funnelsFailed.value)
110
+
111
+ // Land on something rather than on an empty pane. A linked funnel wins, but
112
+ // only while the list confirms it exists: a deleted or foreign-site id from
113
+ // the URL would pin the detail pane on an error no retry can clear. Watching
114
+ // the answer rather than writing from the fetch keeps superseded ones out:
115
+ // only the newest ever reaches here.
116
+ watch(funnelList, (data) => {
117
+ if (!data) {
118
+ return
119
+ }
120
+ const selected = selectedFunnelId.value
121
+ if (selected && data.results.some(funnel => funnel._id === selected)) {
122
+ return
123
+ }
124
+ selectedFunnelId.value = data.results[0]?._id
125
+ })
126
+
127
+ /**
128
+ * Retry entry point. Both fetches, in order, because either can be the one
129
+ * that failed: on a deep link the list runs even when the site list did not,
130
+ * and reloading the site list alone leaves the selection unchanged, so the
131
+ * watcher never fires and the funnels are never retried.
132
+ */
133
+ async function reload() {
134
+ if (websites.value.length === 0) {
135
+ await loadWebsites()
136
+ }
137
+ await loadFunnels()
138
+ }
139
+
140
+ onMounted(loadWebsites)
141
+
142
+ // The watcher owns every list fetch, including the first: definitions do not
143
+ // depend on the period, only on the site. Switching site invalidates the
144
+ // selection — only a real site-to-site change may clear it: the immediate run
145
+ // hands the watcher an undefined previous, and the site list resolving
146
+ // undefined → id is not a switch either. Both carry a linked funnel that must
147
+ // survive.
148
+ watch(selectedWebsiteId, (websiteId, previousWebsiteId) => {
149
+ if (previousWebsiteId !== undefined && websiteId !== previousWebsiteId) {
150
+ selectedFunnelId.value = undefined
151
+ }
152
+ void loadFunnels()
153
+ }, { immediate: true })
154
+
155
+ // State lives in the URL so a funnel can be linked to. Query-only navigation
156
+ // does not remount the page, so this is free.
157
+ watch([selectedWebsiteId, selectedFunnelId, period], () => {
158
+ void router.replace(
159
+ funnelsLink({
160
+ website: selectedWebsiteId.value,
161
+ funnel: selectedFunnelId.value,
162
+ period: period.value,
163
+ }),
164
+ )
165
+ })
166
+
167
+ const statusLabels = computed<Record<MarketingExperimentStatus, string>>(() => ({
168
+ draft: t('page.marketing.funnels.status.draft'),
169
+ running: t('page.marketing.funnels.status.running'),
170
+ stopped: t('page.marketing.funnels.status.stopped'),
171
+ }))
172
+
173
+ const STATUS_ICONS: Record<MarketingExperimentStatus, string> = {
174
+ draft: 'i-ph-pencil-simple-line',
175
+ running: 'i-ph-play',
176
+ stopped: 'i-ph-stop',
177
+ }
178
+
179
+ const listItems = computed(() =>
180
+ funnels.value.map((funnel) => {
181
+ const experiment = parseExperimentValue(funnel.experiment)
182
+ const stepCount = parseFunnelStepsValue(funnel.steps).length
183
+ const stepsLabel = t(
184
+ 'page.marketing.funnels.steps_count',
185
+ { count: stepCount },
186
+ stepCount,
187
+ )
188
+ if (!experiment) {
189
+ return {
190
+ value: funnel._id,
191
+ label: funnel.name,
192
+ sublabel: stepsLabel,
193
+ icon: 'i-ph-funnel',
194
+ }
195
+ }
196
+ const status = experiment.status ?? 'draft'
197
+ const armCount = experiment.variations.length
198
+ return {
199
+ value: funnel._id,
200
+ label: funnel.name,
201
+ sublabel: `${statusLabels.value[status]} · ${t(
202
+ 'page.marketing.funnels.variations_count',
203
+ { count: armCount },
204
+ armCount,
205
+ )}`,
206
+ icon: STATUS_ICONS[status],
207
+ }
208
+ }),
209
+ )
210
+
211
+ // --- Definitions CRUD -------------------------------------------------------
212
+
213
+ const FUNNELS_TABLE_BASE = '/api/marketing/tables/funnels'
214
+
215
+ /**
216
+ * The writable field set of a funnel definition, declared for the generic
217
+ * DmsForm so create/edit look like every other DMS form. The website is not
218
+ * a field: it rides in submitDefaults — the submit only carries declared
219
+ * fields plus those, so the expanded relation a fetch may hydrate never
220
+ * reaches the write path. The experiment's status is never edited here: new
221
+ * rows start in draft server-side, transitions go through the detail pane.
222
+ *
223
+ * The JSON schema is not optional garnish: without one the form validates
224
+ * against an EMPTY zod object, and the parsed submit drops every field. Fine
225
+ * validation (step shapes, key slug, arm bounds) stays with the data-api.
226
+ */
227
+ const FUNNEL_FORM_SCHEMA = {
228
+ type: 'object',
229
+ properties: {
230
+ name: { type: 'string', minLength: 1, maxLength: 100 },
231
+ steps: { type: 'array' },
232
+ conversionWindowHours: { type: 'number', minimum: 1, maximum: 720 },
233
+ experiment: { type: ['object', 'null'] },
234
+ },
235
+ required: ['name', 'steps'],
236
+ }
237
+
238
+ const FUNNEL_FORM_FIELDS = [
239
+ {
240
+ id: 'name',
241
+ label: '$page.marketing.funnels.column.name',
242
+ required: true,
243
+ component: {
244
+ componentName: 'dms-input-text',
245
+ options: { placeholder: '$page.marketing.funnels.form.name' },
246
+ },
247
+ },
248
+ {
249
+ id: 'steps',
250
+ label: '$page.marketing.funnels.form.steps',
251
+ description: '$page.marketing.funnels.form.steps_description',
252
+ required: true,
253
+ component: { componentName: 'DmsMarketingFunnelStepsInput' },
254
+ },
255
+ {
256
+ id: 'conversionWindowHours',
257
+ label: '$page.marketing.funnels.form.window',
258
+ defaultValue: 24,
259
+ component: {
260
+ componentName: 'dms-input-number',
261
+ options: { min: 1, max: 720 },
262
+ },
263
+ },
264
+ {
265
+ id: 'experiment',
266
+ label: '$page.marketing.funnels.form.experiment',
267
+ description: '$page.marketing.funnels.form.experiment_description',
268
+ component: { componentName: 'DmsMarketingExperimentInput' },
269
+ },
270
+ ]
271
+
272
+ interface FunnelFormTarget {
273
+ title: string
274
+ websiteId: string
275
+ fetchUrl?: string
276
+ submitUrl: string
277
+ submitUrlMethod: 'POST' | 'PUT'
278
+ /** The saved row id, from the submit response. */
279
+ resolveId: (response: unknown) => string
280
+ }
281
+
282
+ /** The generic form closes through onSuccessCallback; a plain dismiss
283
+ * resolves the container result with undefined. */
284
+ function openFunnelForm(target: FunnelFormTarget): Promise<{ _id: string } | undefined> {
285
+ const instance = modal.open<{ _id: string } | undefined>({
286
+ title: target.title,
287
+ size: 'lg',
288
+ component: dmsComponent('dms-form'),
289
+ componentOptions: {
290
+ fields: FUNNEL_FORM_FIELDS,
291
+ schema: FUNNEL_FORM_SCHEMA,
292
+ fetchUrl: target.fetchUrl,
293
+ fetchUrlMethod: 'GET',
294
+ submitUrl: target.submitUrl,
295
+ submitUrlMethod: target.submitUrlMethod,
296
+ submitDefaults: { websiteId: target.websiteId },
297
+ onSuccessCallback: (response?: unknown) => {
298
+ instance.close({ _id: target.resolveId(response) })
299
+ },
300
+ },
301
+ })
302
+ return instance.result
303
+ }
304
+
305
+ async function openCreate(): Promise<void> {
306
+ const websiteId = selectedWebsiteId.value
307
+ if (!websiteId) {
308
+ return
309
+ }
310
+ const saved = await openFunnelForm({
311
+ title: t('page.marketing.funnels.create_title'),
312
+ websiteId,
313
+ submitUrl: `${FUNNELS_TABLE_BASE}/new`,
314
+ submitUrlMethod: 'POST',
315
+ // The insert answers the generated keys, an array — not the row.
316
+ resolveId: response => (Array.isArray(response) ? String(response[0] ?? '') : ''),
317
+ })
318
+ if (!saved) {
319
+ return
320
+ }
321
+ await loadFunnels()
322
+ // A missing id (unexpected insert answer) keeps the current selection
323
+ // rather than blanking the pane.
324
+ if (saved._id) {
325
+ selectedFunnelId.value = saved._id
326
+ }
327
+ }
328
+
329
+ /** The list hands the website relation expanded; the write path wants the
330
+ * bare id again. */
331
+ function bareWebsiteId(funnel: MarketingFunnelListItem): string {
332
+ return typeof funnel.websiteId === 'object' ? funnel.websiteId._id : funnel.websiteId
333
+ }
334
+
335
+ async function openEdit(): Promise<void> {
336
+ const funnel = selectedFunnel.value
337
+ if (!funnel) {
338
+ return
339
+ }
340
+ const saved = await openFunnelForm({
341
+ title: t('page.marketing.funnels.edit_title'),
342
+ websiteId: bareWebsiteId(funnel),
343
+ fetchUrl: `${FUNNELS_TABLE_BASE}/get?id=${encodeURIComponent(funnel._id)}`,
344
+ submitUrl: `${FUNNELS_TABLE_BASE}/edit?id=${encodeURIComponent(funnel._id)}`,
345
+ submitUrlMethod: 'PUT',
346
+ resolveId: () => funnel._id,
347
+ })
348
+ if (!saved) {
349
+ return
350
+ }
351
+ await loadFunnels()
352
+ refreshToken.value++
353
+ }
354
+
355
+ /** Lifecycle transition from the detail pane: a full edit write, because the
356
+ * data-api's mandatory fields all travel on every edit. */
357
+ async function changeStatus(target: MarketingExperimentStatus): Promise<void> {
358
+ const funnel = selectedFunnel.value
359
+ const experiment = selectedExperiment.value
360
+ if (!funnel || !experiment) {
361
+ return
362
+ }
363
+ try {
364
+ await api.updateFunnel(funnel._id, {
365
+ websiteId: bareWebsiteId(funnel),
366
+ name: funnel.name,
367
+ steps: parseFunnelStepsValue(funnel.steps),
368
+ conversionWindowHours: funnel.conversionWindowHours,
369
+ experiment: { ...experiment, status: target },
370
+ })
371
+ toast.add({
372
+ color: 'success',
373
+ title: t('page.marketing.funnels.status_updated'),
374
+ })
375
+ }
376
+ catch {
377
+ toast.add({
378
+ color: 'error',
379
+ title: t('page.marketing.funnels.status_error'),
380
+ })
381
+ }
382
+ await loadFunnels()
383
+ refreshToken.value++
384
+ }
385
+
386
+ async function onDeleted(): Promise<void> {
387
+ // Clearing first lets the list watcher land on the first survivor once the
388
+ // reload answers.
389
+ selectedFunnelId.value = undefined
390
+ await loadFunnels()
391
+ }
392
+ </script>
393
+
394
+ <template>
395
+ <div class="space-y-6 p-6">
396
+ <div class="flex flex-wrap items-center justify-between gap-4">
397
+ <div>
398
+ <h1 class="text-xl font-semibold">{{ t('page.marketing.funnels.title') }}</h1>
399
+ <p class="text-sm text-muted">{{ t('page.marketing.funnels.subtitle') }}</p>
400
+ </div>
401
+ <div class="flex flex-wrap items-center gap-3">
402
+ <USelect
403
+ v-if="websites.length > 1"
404
+ v-model="selectedWebsiteId"
405
+ :items="websiteItems"
406
+ class="w-56"
407
+ />
408
+ <DmsPeriodSelector
409
+ :id="PERIOD_SCOPE"
410
+ :component-id="childId('period')"
411
+ :page-id="props.pageId"
412
+ :default-preset="initialPreset"
413
+ :presets="MARKETING_PERIOD_PRESETS"
414
+ :comparisons="['none']"
415
+ :show-range-label="false"
416
+ />
417
+ <UButton
418
+ v-if="selectedWebsiteId"
419
+ icon="i-ph-plus"
420
+ size="sm"
421
+ :label="t('page.marketing.funnels.new')"
422
+ @click="openCreate"
423
+ />
424
+ </div>
425
+ </div>
426
+
427
+ <div v-if="failed" class="flex flex-col items-start gap-3">
428
+ <UAlert
429
+ color="error"
430
+ variant="subtle"
431
+ icon="i-ph-warning-circle"
432
+ :title="t('page.marketing.funnels.error')"
433
+ :description="t('page.marketing.funnels.error_hint')"
434
+ />
435
+ <UButton
436
+ icon="i-ph-arrow-clockwise"
437
+ :label="t('page.marketing.funnels.retry')"
438
+ color="neutral"
439
+ variant="subtle"
440
+ size="sm"
441
+ @click="reload"
442
+ />
443
+ </div>
444
+
445
+ <USkeleton v-else-if="loadingWebsites" class="h-96 w-full" />
446
+
447
+ <DmsMarketingNoWebsiteState
448
+ v-else-if="websites.length === 0"
449
+ icon="i-ph-globe"
450
+ :title="t('page.marketing.funnels.no_website.title')"
451
+ :description="t('page.marketing.funnels.no_website.description')"
452
+ @create="createWebsite"
453
+ />
454
+
455
+ <div
456
+ v-else-if="!loadingFunnels && funnels.length === 0"
457
+ class="flex flex-col items-start gap-3"
458
+ >
459
+ <UAlert
460
+ icon="i-ph-funnel"
461
+ :title="t('page.marketing.funnels.empty.title')"
462
+ :description="t('page.marketing.funnels.empty.description')"
463
+ />
464
+ <UButton
465
+ icon="i-ph-plus"
466
+ :label="t('page.marketing.funnels.new')"
467
+ @click="openCreate"
468
+ />
469
+ </div>
470
+
471
+ <DmsMasterDetail
472
+ v-else
473
+ v-model="selectedFunnelId"
474
+ :items="listItems"
475
+ :list-label="t('page.marketing.funnels.list')"
476
+ >
477
+ <DmsMarketingFunnelDetail
478
+ v-if="selectedFunnelId && selectedFunnel"
479
+ :funnel-id="selectedFunnelId"
480
+ :funnel-name="selectedFunnel.name"
481
+ :experiment="selectedExperiment"
482
+ :period="period"
483
+ :refresh-token="refreshToken"
484
+ @edit="openEdit"
485
+ @deleted="onDeleted"
486
+ @status-changed="changeStatus"
487
+ />
488
+ <div v-else class="p-8 text-center text-sm text-muted">
489
+ {{ t('page.marketing.funnels.select_hint') }}
490
+ </div>
491
+ </DmsMasterDetail>
492
+ </div>
493
+ </template>