@antelopejs/dms-marketing 0.3.3 → 0.4.1

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 (289) 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 -4
  36. package/dist/hooks/index.js.map +1 -1
  37. package/dist/index.js +18 -3
  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 -2
  259. package/frontend-vue/pnpm-lock.yaml +51 -19
  260. package/frontend-vue/tests/drafts.test.ts +3 -3
  261. package/frontend-vue/tests/format.test.ts +73 -0
  262. package/frontend-vue/tests/funnel-draft.test.ts +194 -0
  263. package/frontend-vue/tests/heatmap.test.ts +32 -0
  264. package/frontend-vue/tests/i18n.test.ts +87 -4
  265. package/frontend-vue/tests/latest-request.test.ts +6 -3
  266. package/frontend-vue/tests/locale-typography.test.ts +115 -0
  267. package/frontend-vue/tests/registration.test.ts +17 -11
  268. package/frontend-vue/tests/routes.test.ts +67 -7
  269. package/frontend-vue/tests/server-parity.test.ts +77 -0
  270. package/frontend-vue/tests/stubs/frontend-module.ts +18 -3
  271. package/frontend-vue/vitest.config.ts +3 -1
  272. package/package.json +10 -8
  273. package/static/snapshot.js +46 -16
  274. package/static/tracker.js +139 -48
  275. package/dist/pages/campaigns/page.js.map +0 -1
  276. package/frontend-vue/app/components/CampaignsView.vue +0 -330
  277. package/frontend-vue/app/components/FunnelDetail.vue +0 -1026
  278. package/frontend-vue/app/components/FunnelsView.vue +0 -493
  279. package/frontend-vue/app/components/NoWebsiteState.vue +0 -18
  280. package/frontend-vue/app/components/OverviewView.vue +0 -493
  281. package/frontend-vue/app/components/PagesView.vue +0 -391
  282. package/frontend-vue/app/components/TextKpiCard.vue +0 -28
  283. package/frontend-vue/app/components/TopListTabsCard.vue +0 -162
  284. package/frontend-vue/app/composables/useChildId.ts +0 -13
  285. package/frontend-vue/app/composables/useMarketingPeriod.ts +0 -65
  286. package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +0 -69
  287. package/frontend-vue/app/composables/useMarketingWebsites.ts +0 -80
  288. package/frontend-vue/app/utils/dmsComponent.ts +0 -18
  289. package/frontend-vue/tests/period.test.ts +0 -35
@@ -0,0 +1,799 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
3
+ import { useDmsRouter, useI18n } from '#dms/frontend-module'
4
+ import {
5
+ useMarketingApi,
6
+ type MarketingWebsiteCard,
7
+ } from '../composables/useMarketingApi'
8
+ import { installLink, overviewLink } from '../composables/useMarketingRoutes'
9
+ import {
10
+ formatNumber,
11
+ formatRelativeTime,
12
+ formatShortDate,
13
+ initials,
14
+ } from '../utils/format'
15
+ import { allowHostConfirmation, hostConflictOf } from '../utils/host-conflict'
16
+ import {
17
+ MAX_WEBSITE_DOMAIN_LENGTH,
18
+ MAX_WEBSITE_NAME_LENGTH,
19
+ fractionDigits,
20
+ } from '../constants'
21
+
22
+ /**
23
+ * The Websites page: one card per site with its state, volumes, accepted
24
+ * hosts and capture options; a site still waiting for its first pageview
25
+ * leads to the install guide. Hosts the guard refused show on any card, with
26
+ * the fix. The pencil opens a drawer with the site's General, Capture and
27
+ * Danger zone tabs.
28
+ */
29
+ const props = withDefaults(
30
+ defineProps<{ fetchUrl?: string; componentId?: string; pageId?: string }>(),
31
+ { fetchUrl: undefined, componentId: undefined, pageId: undefined },
32
+ )
33
+
34
+ type DrawerTab = 'general' | 'capture' | 'danger'
35
+
36
+ const CLOCK_TICK_MS = 15_000
37
+
38
+ const { t, locale } = useI18n()
39
+ const router = useDmsRouter()
40
+ const api = useMarketingApi()
41
+ const toast = useToast()
42
+ // A field-tied error from the save shows under its field, the rest as a toast.
43
+ const fieldErrors = useFieldErrors({
44
+ fields: {
45
+ name: 'website-name',
46
+ domain: 'website-domain',
47
+ extraDomains: 'website-hosts',
48
+ },
49
+ })
50
+ const { confirm } = useConfirm()
51
+
52
+ void props
53
+
54
+ const cards = ref<MarketingWebsiteCard[] | null>(null)
55
+ const failed = ref(false)
56
+
57
+ async function load(): Promise<void> {
58
+ failed.value = false
59
+ try {
60
+ cards.value = await api.listWebsiteCards()
61
+ } catch {
62
+ failed.value = true
63
+ }
64
+ }
65
+
66
+ const now = ref(Date.now())
67
+ let clock: ReturnType<typeof setInterval> | null = null
68
+ onMounted(() => {
69
+ void load()
70
+ clock = setInterval(() => (now.value = Date.now()), CLOCK_TICK_MS)
71
+ })
72
+ onBeforeUnmount(() => clock && clearInterval(clock))
73
+
74
+ function stateLine(card: MarketingWebsiteCard): string {
75
+ if (card.state === 'live' && card.lastActivityAt) {
76
+ return t('page.marketing.context.live', {
77
+ ago: formatRelativeTime(card.lastActivityAt, now.value, locale.value),
78
+ })
79
+ }
80
+ return t(`page.marketing.context.state.${card.state}`)
81
+ }
82
+
83
+ const STATE_DOTS = {
84
+ live: 'bg-success',
85
+ waiting: 'bg-warning',
86
+ paused: 'bg-(--ui-text-dimmed)',
87
+ } as const
88
+
89
+ async function openOverview(card: MarketingWebsiteCard): Promise<void> {
90
+ await api.selectContextWebsite(card.id)
91
+ await router.push(overviewLink(card.id))
92
+ }
93
+
94
+ // --- Card actions ----------------------------------------------------------------
95
+
96
+ /** The card action running, as `<site id>:<what>`, for its spinner. */
97
+ const busy = ref<string | null>(null)
98
+
99
+ async function runCardAction(
100
+ key: string,
101
+ action: () => Promise<unknown>,
102
+ successTitle: string,
103
+ errorTitle: string,
104
+ ): Promise<void> {
105
+ busy.value = key
106
+ try {
107
+ await action()
108
+ toast.add({ color: 'success', title: successTitle })
109
+ await load()
110
+ } catch (error) {
111
+ const conflict = hostConflictOf(error)
112
+ if (conflict) {
113
+ toast.add({
114
+ color: 'error',
115
+ title: t(errorTitle),
116
+ description: t('page.marketing.errors.duplicate_host_named', {
117
+ ...conflict,
118
+ }),
119
+ })
120
+ } else {
121
+ fieldErrors.toastError(error, { toastTitle: errorTitle })
122
+ }
123
+ } finally {
124
+ busy.value = null
125
+ }
126
+ }
127
+
128
+ async function allowHost(card: MarketingWebsiteCard, hostname: string) {
129
+ if (!(await confirm(allowHostConfirmation(t, hostname)))) {
130
+ return
131
+ }
132
+ await runCardAction(
133
+ `${card.id}:${hostname}`,
134
+ () => api.allowHost(card.id, hostname),
135
+ t('page.marketing.install.allowed', { host: hostname }),
136
+ 'page.marketing.install.allow_error',
137
+ )
138
+ }
139
+
140
+ function resume(card: MarketingWebsiteCard) {
141
+ return runCardAction(
142
+ `${card.id}:resume`,
143
+ () => api.updateWebsite(card.id, { trackingEnabled: true }),
144
+ t('page.marketing.websites.resumed'),
145
+ 'page.marketing.websites.save_error',
146
+ )
147
+ }
148
+
149
+ // --- Drawer --------------------------------------------------------------------
150
+
151
+ const editing = ref<MarketingWebsiteCard | null>(null)
152
+ const drawerOpen = ref(false)
153
+ const tab = ref<DrawerTab>('general')
154
+ const draftName = ref('')
155
+ const draftDomain = ref('')
156
+ const draftHosts = ref<string[]>([])
157
+ const draftTracking = ref(true)
158
+ const draftSnapshots = ref(false)
159
+ const draftMask = ref(false)
160
+ const saving = ref(false)
161
+
162
+ function edit(
163
+ card: MarketingWebsiteCard,
164
+ initialTab: DrawerTab = 'general',
165
+ ): void {
166
+ fieldErrors.clear()
167
+ editing.value = card
168
+ tab.value = initialTab
169
+ draftName.value = card.name
170
+ draftDomain.value = card.domain
171
+ draftHosts.value = [...card.extraDomains]
172
+ draftTracking.value = card.state !== 'paused'
173
+ draftSnapshots.value = card.snapshotsEnabled
174
+ draftMask.value = card.snapshotMaskText
175
+ drawerOpen.value = true
176
+ }
177
+
178
+ // The content stays while the drawer slides out; it goes once it is gone.
179
+ function onDrawerClosed(): void {
180
+ editing.value = null
181
+ }
182
+
183
+ const tabItems = computed(() => [
184
+ { value: 'general', label: t('page.marketing.websites.drawer.general') },
185
+ { value: 'capture', label: t('page.marketing.websites.drawer.capture') },
186
+ { value: 'danger', label: t('page.marketing.websites.drawer.danger') },
187
+ ])
188
+
189
+ /** Turning snapshots off, or changing the mask, discards the captures. */
190
+ const discardsSnapshots = computed(
191
+ () =>
192
+ editing.value !== null &&
193
+ editing.value.snapshots > 0 &&
194
+ (draftSnapshots.value !== editing.value.snapshotsEnabled ||
195
+ draftMask.value !== editing.value.snapshotMaskText),
196
+ )
197
+
198
+ /** Empty required fields say so under themselves, on the General tab. */
199
+ async function requireFields(): Promise<boolean> {
200
+ const blank = (['domain', 'name'] as const).filter(
201
+ (field) => !{ name: draftName, domain: draftDomain }[field].value.trim(),
202
+ )
203
+ if (blank.length > 0) {
204
+ tab.value = 'general'
205
+ }
206
+ for (const field of blank) {
207
+ await fieldErrors.setError(field, t('dms.field_errors.required'))
208
+ }
209
+ return blank.length === 0
210
+ }
211
+
212
+ async function save(): Promise<void> {
213
+ const card = editing.value
214
+ if (!card || !(await requireFields())) {
215
+ return
216
+ }
217
+ saving.value = true
218
+ try {
219
+ await api.updateWebsite(card.id, {
220
+ name: draftName.value.trim(),
221
+ domain: draftDomain.value.trim(),
222
+ extraDomains: draftHosts.value.map((host) => host.trim()).filter(Boolean),
223
+ trackingEnabled: draftTracking.value,
224
+ snapshotsEnabled: draftSnapshots.value,
225
+ snapshotMaskText: draftMask.value,
226
+ })
227
+ toast.add({ color: 'success', title: t('page.marketing.websites.saved') })
228
+ drawerOpen.value = false
229
+ await load()
230
+ } catch (error) {
231
+ // A refused field lives on the General tab: show it there.
232
+ const conflict = hostConflictOf(error)
233
+ if (conflict) {
234
+ tab.value = 'general'
235
+ await fieldErrors.setError(
236
+ conflict.field === 'extraDomains' ? 'extraDomains' : 'domain',
237
+ t('page.marketing.errors.duplicate_host_named', { ...conflict }),
238
+ )
239
+ return
240
+ }
241
+ if (await fieldErrors.applyApiError(error)) {
242
+ tab.value = 'general'
243
+ return
244
+ }
245
+ fieldErrors.toastError(error, {
246
+ toastTitle: 'page.marketing.websites.save_error',
247
+ })
248
+ } finally {
249
+ saving.value = false
250
+ }
251
+ }
252
+
253
+ async function remove(card: MarketingWebsiteCard): Promise<void> {
254
+ let impact
255
+ try {
256
+ impact = await api.getWebsiteDeletionImpact(card.id)
257
+ } catch (error) {
258
+ fieldErrors.toastError(error, {
259
+ toastTitle: 'page.marketing.websites.delete.impact_error',
260
+ })
261
+ return
262
+ }
263
+ // Every stored day goes, so the counts read "since the oldest one".
264
+ const since =
265
+ impact.since === null ? null : formatShortDate(impact.since, locale.value)
266
+ const sinceLabel = (key: 'sessions' | 'pageviews') =>
267
+ since === null
268
+ ? t(`page.marketing.websites.delete.${key}`)
269
+ : t(`page.marketing.websites.delete.${key}_since`, { date: since })
270
+ await confirm({
271
+ title: t('page.marketing.websites.delete.title', { name: card.name }),
272
+ description: t('page.marketing.websites.delete.description'),
273
+ color: 'error',
274
+ icon: 'i-ph-trash',
275
+ confirmLabel: t('page.marketing.websites.delete.confirm'),
276
+ // A hostname is not case-sensitive: neither is the typed check.
277
+ confirmText: card.domain,
278
+ confirmTextCaseInsensitive: true,
279
+ impact: [
280
+ {
281
+ icon: 'i-ph-users',
282
+ label: sinceLabel('sessions'),
283
+ count: formatNumber(impact.sessions, locale.value),
284
+ },
285
+ {
286
+ icon: 'i-ph-eye',
287
+ label: sinceLabel('pageviews'),
288
+ count: formatNumber(impact.pageviews, locale.value),
289
+ },
290
+ {
291
+ icon: 'i-ph-funnel',
292
+ label: t('page.marketing.websites.delete.funnels'),
293
+ count: impact.funnels,
294
+ },
295
+ {
296
+ icon: 'i-ph-camera',
297
+ label: t('page.marketing.websites.delete.snapshots'),
298
+ count: impact.snapshots,
299
+ },
300
+ ],
301
+ onConfirm: async () => {
302
+ await api.deleteWebsite(card.id)
303
+ toast.add({
304
+ color: 'success',
305
+ title: t('page.marketing.websites.delete.done', { name: card.name }),
306
+ })
307
+ drawerOpen.value = false
308
+ await load()
309
+ },
310
+ })
311
+ }
312
+ </script>
313
+
314
+ <template>
315
+ <div class="flex flex-col gap-4">
316
+ <DmsEmptyState
317
+ v-if="failed"
318
+ variant="error"
319
+ class="dms-card"
320
+ :title="t('page.marketing.websites.error')"
321
+ >
322
+ <template #actions>
323
+ <UButton
324
+ size="sm"
325
+ color="neutral"
326
+ variant="outline"
327
+ icon="i-ph-arrow-clockwise"
328
+ :label="t('page.marketing.common.retry')"
329
+ @click="load"
330
+ />
331
+ </template>
332
+ </DmsEmptyState>
333
+
334
+ <div v-else-if="!cards" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
335
+ <USkeleton v-for="index in 3" :key="index" class="h-72 rounded-xl" />
336
+ </div>
337
+
338
+ <DmsMarketingFirstRun v-else-if="cards.length === 0" />
339
+
340
+ <template v-else>
341
+ <!-- The page's "collection paused" Banner block, when the module is paused. -->
342
+ <slot />
343
+
344
+ <div class="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
345
+ <article
346
+ v-for="card in cards"
347
+ :key="card.id"
348
+ class="flex flex-col overflow-hidden rounded-xl border bg-(--ui-bg)"
349
+ :class="
350
+ card.state === 'waiting'
351
+ ? 'border-dashed border-warning/60'
352
+ : 'dms-card p-0'
353
+ "
354
+ >
355
+ <header class="flex items-start gap-3 px-4 pt-4">
356
+ <span
357
+ class="grid size-10 shrink-0 place-items-center rounded-lg bg-primary/15 font-mono text-sm font-semibold text-(--ui-color-primary-800) dark:text-primary"
358
+ >
359
+ {{ initials(card.name) }}
360
+ </span>
361
+ <span class="min-w-0 flex-1">
362
+ <span class="block truncate font-semibold text-highlighted">
363
+ {{ card.name }}
364
+ </span>
365
+ <span class="block truncate font-mono text-xs text-dimmed">
366
+ {{ card.domain }}
367
+ </span>
368
+ </span>
369
+ <UButton
370
+ icon="i-ph-pencil-simple"
371
+ size="xs"
372
+ color="neutral"
373
+ variant="ghost"
374
+ :aria-label="
375
+ t('page.marketing.websites.edit_named', { name: card.name })
376
+ "
377
+ @click="edit(card)"
378
+ />
379
+ </header>
380
+ <p
381
+ class="flex items-center gap-2 px-4 pt-3 pb-3 font-mono text-xs text-muted"
382
+ >
383
+ <span class="size-2 rounded-full" :class="STATE_DOTS[card.state]" />
384
+ {{ stateLine(card) }}
385
+ </p>
386
+
387
+ <div
388
+ v-if="card.state === 'paused' || card.rejected.length > 0"
389
+ class="flex flex-col gap-2 px-4 pb-3"
390
+ >
391
+ <DmsBanner
392
+ v-if="card.state === 'paused'"
393
+ tone="info"
394
+ size="sm"
395
+ icon="i-ph-pause-circle"
396
+ :title="t('page.marketing.websites.paused_description')"
397
+ >
398
+ <template #actions>
399
+ <UButton
400
+ size="xs"
401
+ color="neutral"
402
+ variant="outline"
403
+ icon="i-ph-play"
404
+ :loading="busy === `${card.id}:resume`"
405
+ :label="t('page.marketing.websites.resume')"
406
+ @click="resume(card)"
407
+ />
408
+ </template>
409
+ </DmsBanner>
410
+ <DmsBanner
411
+ v-for="refused in card.rejected"
412
+ :key="refused.hostname"
413
+ tone="warning"
414
+ size="sm"
415
+ icon="i-ph-prohibit"
416
+ :title="
417
+ t(
418
+ 'page.marketing.websites.refused',
419
+ { count: refused.count, host: refused.hostname },
420
+ refused.count,
421
+ )
422
+ "
423
+ >
424
+ <template #actions>
425
+ <UButton
426
+ size="xs"
427
+ color="neutral"
428
+ variant="outline"
429
+ :loading="busy === `${card.id}:${refused.hostname}`"
430
+ :label="t('page.marketing.install.allow')"
431
+ @click="allowHost(card, refused.hostname)"
432
+ />
433
+ </template>
434
+ </DmsBanner>
435
+ </div>
436
+
437
+ <template v-if="card.state !== 'waiting'">
438
+ <div class="border-y border-default">
439
+ <!-- The window the three counts cover, whatever period the
440
+ other pages show. -->
441
+ <p
442
+ class="px-4 pt-1.5 text-right font-mono text-[10px] text-dimmed"
443
+ >
444
+ {{ t('page.marketing.websites.stats.window') }}
445
+ </p>
446
+ <dl class="grid grid-cols-3">
447
+ <div
448
+ v-for="stat in ['sessions', 'pages', 'funnels'] as const"
449
+ :key="stat"
450
+ class="border-default px-4 pt-0.5 pb-3 not-first:border-l"
451
+ >
452
+ <dt>
453
+ <DmsEyebrow
454
+ :label="t(`page.marketing.websites.stats.${stat}`)"
455
+ />
456
+ </dt>
457
+ <dd
458
+ class="mt-1 font-mono text-lg font-semibold tabular-nums text-highlighted"
459
+ >
460
+ {{ formatNumber(card[stat], locale) }}
461
+ </dd>
462
+ </div>
463
+ </dl>
464
+ </div>
465
+ <dl class="flex flex-col gap-2 px-4 py-3 text-sm">
466
+ <div class="flex items-start justify-between gap-3">
467
+ <dt
468
+ class="flex shrink-0 items-center gap-2 whitespace-nowrap text-muted"
469
+ >
470
+ <UIcon name="i-ph-link" class="size-3.5" />
471
+ {{ t('page.marketing.websites.also_accepts') }}
472
+ </dt>
473
+ <dd class="flex min-w-0 flex-wrap justify-end gap-1">
474
+ <span
475
+ v-for="host in card.extraDomains"
476
+ :key="host"
477
+ class="max-w-full truncate rounded bg-elevated px-1.5 py-0.5 font-mono text-[11px] text-toned"
478
+ >
479
+ {{ host }}
480
+ </span>
481
+ <span
482
+ v-if="card.extraDomains.length === 0"
483
+ class="text-xs text-dimmed"
484
+ >
485
+ {{ t('page.marketing.websites.subdomains_only') }}
486
+ </span>
487
+ </dd>
488
+ </div>
489
+ <div class="flex items-center justify-between gap-3">
490
+ <dt
491
+ class="flex shrink-0 items-center gap-2 whitespace-nowrap text-muted"
492
+ >
493
+ <UIcon name="i-ph-camera" class="size-3.5" />
494
+ {{ t('page.marketing.websites.snapshots') }}
495
+ </dt>
496
+ <dd>
497
+ <DmsStatusPill
498
+ size="sm"
499
+ :mono="false"
500
+ :tone="card.snapshotsEnabled ? 'success' : 'neutral'"
501
+ :label="
502
+ card.snapshotsEnabled
503
+ ? t(
504
+ card.snapshotMaskText
505
+ ? 'page.marketing.websites.snapshots_masked'
506
+ : 'page.marketing.websites.snapshots_on',
507
+ )
508
+ : t('page.marketing.websites.snapshots_off')
509
+ "
510
+ />
511
+ </dd>
512
+ </div>
513
+ <div class="flex items-center justify-between gap-3">
514
+ <dt
515
+ class="flex shrink-0 items-center gap-2 whitespace-nowrap text-muted"
516
+ >
517
+ <UIcon name="i-ph-cursor-click" class="size-3.5" />
518
+ {{ t('page.marketing.websites.sampling') }}
519
+ </dt>
520
+ <dd class="text-right font-mono text-xs text-highlighted">
521
+ {{
522
+ formatPercentage(card.sampleRate, locale, fractionDigits(0))
523
+ }}
524
+ ·
525
+ {{ t('page.marketing.websites.sampling_default') }}
526
+ </dd>
527
+ </div>
528
+ </dl>
529
+ <footer
530
+ class="mt-auto flex items-center justify-between gap-2 border-t border-default px-4 py-3"
531
+ >
532
+ <UButton
533
+ icon="i-ph-code"
534
+ size="sm"
535
+ color="neutral"
536
+ variant="outline"
537
+ :label="t('page.marketing.websites.snippet_button')"
538
+ :aria-label="
539
+ t('page.marketing.websites.snippet_named', {
540
+ name: card.name,
541
+ })
542
+ "
543
+ :to="installLink(card.id)"
544
+ />
545
+ <UButton
546
+ trailing-icon="i-ph-arrow-right"
547
+ size="sm"
548
+ color="neutral"
549
+ variant="ghost"
550
+ :label="t('page.marketing.websites.open_overview')"
551
+ :aria-label="
552
+ t('page.marketing.websites.open_overview_named', {
553
+ name: card.name,
554
+ })
555
+ "
556
+ @click="openOverview(card)"
557
+ />
558
+ </footer>
559
+ </template>
560
+
561
+ <template v-else>
562
+ <p class="px-4 text-sm text-muted">
563
+ {{
564
+ t(
565
+ card.rejected.length > 0
566
+ ? 'page.marketing.websites.waiting_refused'
567
+ : 'page.marketing.websites.waiting_description',
568
+ { date: formatShortDate(card.createdAt, locale) },
569
+ )
570
+ }}
571
+ </p>
572
+ <footer class="mt-auto px-4 py-4">
573
+ <UButton
574
+ icon="i-ph-pulse"
575
+ size="sm"
576
+ :label="t('page.marketing.websites.finish_install')"
577
+ :aria-label="
578
+ t('page.marketing.websites.finish_install_named', {
579
+ name: card.name,
580
+ })
581
+ "
582
+ :to="installLink(card.id)"
583
+ />
584
+ </footer>
585
+ </template>
586
+ </article>
587
+ </div>
588
+ </template>
589
+
590
+ <p
591
+ v-if="cards && cards.length > 0"
592
+ class="flex items-center gap-2 text-xs text-dimmed"
593
+ >
594
+ <UIcon name="i-ph-shield-check" class="size-4" />
595
+ {{ t('page.marketing.websites.privacy') }}
596
+ </p>
597
+
598
+ <USlideover
599
+ v-model:open="drawerOpen"
600
+ :ui="{ content: 'max-w-lg', title: 'min-w-0' }"
601
+ @after:leave="onDrawerClosed"
602
+ >
603
+ <template #title>
604
+ <span v-if="editing" class="flex min-w-0 flex-col gap-2">
605
+ <DmsEyebrow :label="t('page.marketing.websites.drawer.eyebrow')" />
606
+ <span class="flex min-w-0 items-center gap-3">
607
+ <span
608
+ class="grid size-9 shrink-0 place-items-center rounded-lg bg-primary/15 font-mono text-sm font-semibold text-(--ui-color-primary-800) dark:text-primary"
609
+ >
610
+ {{ initials(editing.name) }}
611
+ </span>
612
+ <span class="truncate text-xl font-semibold text-highlighted">
613
+ {{ editing.name }}
614
+ </span>
615
+ </span>
616
+ </span>
617
+ </template>
618
+ <template #description>
619
+ <span v-if="editing" class="font-mono text-xs break-all text-dimmed">
620
+ {{ editing.id }} ·
621
+ {{
622
+ t('page.marketing.websites.drawer.added', {
623
+ date: formatShortDate(editing.createdAt, locale),
624
+ })
625
+ }}
626
+ </span>
627
+ </template>
628
+ <template #body>
629
+ <div v-if="editing" class="flex flex-col gap-5">
630
+ <DmsSegmented
631
+ v-model="tab"
632
+ :items="tabItems"
633
+ size="sm"
634
+ class="self-start"
635
+ />
636
+ <template v-if="tab === 'general'">
637
+ <DmsFieldRow
638
+ layout="stack"
639
+ spacing="list"
640
+ :inset="false"
641
+ label="$page.marketing.websites.fields.name"
642
+ label-for="website-name"
643
+ required
644
+ >
645
+ <UInput
646
+ id="website-name"
647
+ v-model="draftName"
648
+ class="w-full"
649
+ :maxlength="MAX_WEBSITE_NAME_LENGTH"
650
+ v-bind="fieldErrors.aria('name')"
651
+ @update:model-value="fieldErrors.clear('name')"
652
+ />
653
+ <DmsFieldError
654
+ :id="fieldErrors.errorId('name')"
655
+ :message="fieldErrors.errors.name"
656
+ />
657
+ </DmsFieldRow>
658
+ <DmsFieldRow
659
+ layout="stack"
660
+ spacing="list"
661
+ :inset="false"
662
+ label="$page.marketing.websites.fields.domain"
663
+ description="$page.marketing.websites.fields.domain_hint"
664
+ label-for="website-domain"
665
+ required
666
+ >
667
+ <UInput
668
+ id="website-domain"
669
+ v-model="draftDomain"
670
+ class="w-full font-mono"
671
+ :maxlength="MAX_WEBSITE_DOMAIN_LENGTH"
672
+ v-bind="fieldErrors.aria('domain')"
673
+ @update:model-value="fieldErrors.clear('domain')"
674
+ />
675
+ <DmsFieldError
676
+ :id="fieldErrors.errorId('domain')"
677
+ :message="fieldErrors.errors.domain"
678
+ />
679
+ </DmsFieldRow>
680
+ <DmsFieldRow
681
+ layout="stack"
682
+ spacing="list"
683
+ :inset="false"
684
+ label="$page.marketing.websites.fields.hosts"
685
+ description="$page.marketing.websites.fields.hosts_hint"
686
+ label-for="website-hosts"
687
+ >
688
+ <UInputTags
689
+ id="website-hosts"
690
+ v-model="draftHosts"
691
+ class="w-full font-mono"
692
+ :placeholder="'staging.example.com'"
693
+ v-bind="fieldErrors.aria('extraDomains')"
694
+ @update:model-value="fieldErrors.clear('extraDomains')"
695
+ />
696
+ <DmsFieldError
697
+ :id="fieldErrors.errorId('extraDomains')"
698
+ :message="fieldErrors.errors.extraDomains"
699
+ />
700
+ </DmsFieldRow>
701
+ <DmsFieldRow
702
+ spacing="list"
703
+ :inset="false"
704
+ label="$page.marketing.websites.fields.tracking"
705
+ description="$page.marketing.websites.fields.tracking_hint"
706
+ label-for="website-tracking"
707
+ >
708
+ <USwitch id="website-tracking" v-model="draftTracking" />
709
+ </DmsFieldRow>
710
+ <DmsFormRequiredLegend />
711
+ </template>
712
+ <template v-else-if="tab === 'capture'">
713
+ <DmsFieldRow
714
+ spacing="list"
715
+ :inset="false"
716
+ label="$page.marketing.websites.fields.snapshots"
717
+ description="$page.marketing.websites.fields.snapshots_hint"
718
+ label-for="website-snapshots"
719
+ >
720
+ <USwitch id="website-snapshots" v-model="draftSnapshots" />
721
+ </DmsFieldRow>
722
+ <DmsFieldRow
723
+ spacing="list"
724
+ :inset="false"
725
+ label="$page.marketing.websites.fields.mask"
726
+ description="$page.marketing.websites.fields.mask_hint"
727
+ label-for="website-mask"
728
+ :disabled="!draftSnapshots"
729
+ >
730
+ <USwitch
731
+ id="website-mask"
732
+ v-model="draftMask"
733
+ :disabled="!draftSnapshots"
734
+ />
735
+ </DmsFieldRow>
736
+ <DmsBanner
737
+ v-if="discardsSnapshots"
738
+ tone="warning"
739
+ size="sm"
740
+ :title="
741
+ t(
742
+ 'page.marketing.websites.fields.discard',
743
+ { count: editing.snapshots },
744
+ editing.snapshots,
745
+ )
746
+ "
747
+ />
748
+ </template>
749
+ <template v-else>
750
+ <div
751
+ class="flex flex-col gap-3 rounded-lg border border-error/40 p-4"
752
+ >
753
+ <span class="text-sm font-semibold text-highlighted">
754
+ {{ t('page.marketing.websites.delete.section') }}
755
+ </span>
756
+ <span class="text-sm text-muted">
757
+ {{ t('page.marketing.websites.delete.section_description') }}
758
+ </span>
759
+ <UButton
760
+ class="self-start"
761
+ color="error"
762
+ variant="outline"
763
+ icon="i-ph-trash"
764
+ :label="t('page.marketing.websites.delete.action')"
765
+ @click="remove(editing)"
766
+ />
767
+ </div>
768
+ </template>
769
+ </div>
770
+ </template>
771
+ <template #footer>
772
+ <div
773
+ v-if="tab !== 'danger'"
774
+ class="flex w-full items-center justify-between"
775
+ >
776
+ <UButton
777
+ color="neutral"
778
+ variant="ghost"
779
+ :label="t('page.marketing.common.cancel')"
780
+ @click="drawerOpen = false"
781
+ />
782
+ <UButton
783
+ :loading="saving"
784
+ :label="t('page.marketing.common.save_changes')"
785
+ @click="save"
786
+ />
787
+ </div>
788
+ <UButton
789
+ v-else
790
+ class="ml-auto"
791
+ color="neutral"
792
+ variant="outline"
793
+ :label="t('page.marketing.websites.drawer.close')"
794
+ @click="drawerOpen = false"
795
+ />
796
+ </template>
797
+ </USlideover>
798
+ </div>
799
+ </template>