@antelopejs/dms-marketing 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/dist/config.d.ts +3 -0
  2. package/dist/config.js +11 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/data-api/websites.d.ts +10 -4
  5. package/dist/data-api/websites.js +79 -3
  6. package/dist/data-api/websites.js.map +1 -1
  7. package/dist/db/models/page_snapshots.model.d.ts +2 -0
  8. package/dist/db/models/page_snapshots.model.js +6 -0
  9. package/dist/db/models/page_snapshots.model.js.map +1 -1
  10. package/dist/db/models/websites.model.d.ts +1 -0
  11. package/dist/db/models/websites.model.js +9 -0
  12. package/dist/db/models/websites.model.js.map +1 -1
  13. package/dist/db/tables/page_snapshots.table.d.ts +1 -0
  14. package/dist/db/tables/page_snapshots.table.js +4 -0
  15. package/dist/db/tables/page_snapshots.table.js.map +1 -1
  16. package/dist/db/tables/websites.table.d.ts +2 -0
  17. package/dist/db/tables/websites.table.js +4 -0
  18. package/dist/db/tables/websites.table.js.map +1 -1
  19. package/dist/frontend-module.d.ts +6 -0
  20. package/dist/frontend-module.js +46 -0
  21. package/dist/frontend-module.js.map +1 -0
  22. package/dist/index.js +3 -11
  23. package/dist/index.js.map +1 -1
  24. package/dist/pages/acquisition/page.d.ts +1 -0
  25. package/dist/pages/acquisition/page.js +9 -4
  26. package/dist/pages/acquisition/page.js.map +1 -1
  27. package/dist/pages/blocks.d.ts +11 -2
  28. package/dist/pages/blocks.js +43 -5
  29. package/dist/pages/blocks.js.map +1 -1
  30. package/dist/pages/funnel/page.d.ts +1 -0
  31. package/dist/pages/funnel/page.js +1 -0
  32. package/dist/pages/funnel/page.js.map +1 -1
  33. package/dist/pages/funnels/page.d.ts +1 -0
  34. package/dist/pages/funnels/page.js +4 -2
  35. package/dist/pages/funnels/page.js.map +1 -1
  36. package/dist/pages/overview/page.d.ts +1 -0
  37. package/dist/pages/overview/page.js +5 -6
  38. package/dist/pages/overview/page.js.map +1 -1
  39. package/dist/pages/pages/page.d.ts +1 -0
  40. package/dist/pages/pages/page.js +1 -0
  41. package/dist/pages/pages/page.js.map +1 -1
  42. package/dist/pages/settings/form.js +1 -0
  43. package/dist/pages/settings/form.js.map +1 -1
  44. package/dist/pages/websites/page.d.ts +2 -3
  45. package/dist/pages/websites/page.js +63 -5
  46. package/dist/pages/websites/page.js.map +1 -1
  47. package/dist/routes/dashboard-tracking.js +5 -1
  48. package/dist/routes/dashboard-tracking.js.map +1 -1
  49. package/dist/routes/funnels.d.ts +2 -0
  50. package/dist/routes/funnels.js +6 -5
  51. package/dist/routes/funnels.js.map +1 -1
  52. package/dist/routes/public-endpoints.js +1 -1
  53. package/dist/routes/public-endpoints.js.map +1 -1
  54. package/dist/routes/snapshot.d.ts +1 -1
  55. package/dist/routes/snapshot.js +30 -13
  56. package/dist/routes/snapshot.js.map +1 -1
  57. package/dist/routes/stats.d.ts +10 -4
  58. package/dist/routes/stats.js +77 -23
  59. package/dist/routes/stats.js.map +1 -1
  60. package/dist/routes/websites.d.ts +5 -1
  61. package/dist/routes/websites.js +31 -13
  62. package/dist/routes/websites.js.map +1 -1
  63. package/dist/services/acquisition-report.d.ts +1 -0
  64. package/dist/services/acquisition-report.js +1 -0
  65. package/dist/services/acquisition-report.js.map +1 -1
  66. package/dist/services/collect.js +7 -2
  67. package/dist/services/collect.js.map +1 -1
  68. package/dist/services/dashboard-tracking.d.ts +7 -14
  69. package/dist/services/dashboard-tracking.js +42 -26
  70. package/dist/services/dashboard-tracking.js.map +1 -1
  71. package/dist/services/funnels-table.d.ts +3 -2
  72. package/dist/services/funnels-table.js +9 -9
  73. package/dist/services/funnels-table.js.map +1 -1
  74. package/dist/services/heatmap.d.ts +6 -0
  75. package/dist/services/heatmap.js +82 -17
  76. package/dist/services/heatmap.js.map +1 -1
  77. package/dist/services/index.d.ts +1 -0
  78. package/dist/services/index.js +1 -0
  79. package/dist/services/index.js.map +1 -1
  80. package/dist/services/prune.js +6 -1
  81. package/dist/services/prune.js.map +1 -1
  82. package/dist/services/realtime.d.ts +3 -0
  83. package/dist/services/realtime.js +49 -0
  84. package/dist/services/realtime.js.map +1 -0
  85. package/dist/services/request-body.d.ts +1 -0
  86. package/dist/services/request-body.js +18 -0
  87. package/dist/services/request-body.js.map +1 -1
  88. package/dist/services/sessionize.d.ts +1 -0
  89. package/dist/services/sessionize.js +9 -0
  90. package/dist/services/sessionize.js.map +1 -1
  91. package/dist/services/snapshots.d.ts +28 -1
  92. package/dist/services/snapshots.js +147 -26
  93. package/dist/services/snapshots.js.map +1 -1
  94. package/dist/services/tenant-website.d.ts +11 -0
  95. package/dist/services/tenant-website.js +27 -5
  96. package/dist/services/tenant-website.js.map +1 -1
  97. package/dist/services/website-report.d.ts +5 -0
  98. package/dist/services/website-report.js +13 -1
  99. package/dist/services/website-report.js.map +1 -1
  100. package/dist/services/workspace-scope.d.ts +31 -0
  101. package/dist/services/workspace-scope.js +72 -0
  102. package/dist/services/workspace-scope.js.map +1 -0
  103. package/dist/types/constants.d.ts +7 -0
  104. package/dist/types/constants.js +8 -1
  105. package/dist/types/constants.js.map +1 -1
  106. package/dist/types/events.d.ts +25 -25
  107. package/dist/types/snapshots.d.ts +29 -29
  108. package/dist/types/snapshots.js +1 -1
  109. package/dist/types/snapshots.js.map +1 -1
  110. package/dist/utils/funnel-steps-type.d.ts +13 -13
  111. package/frontend-vue/app/components/ChannelsCard.vue +19 -2
  112. package/frontend-vue/app/components/Context.vue +50 -376
  113. package/frontend-vue/app/components/FunnelBuilder.vue +28 -22
  114. package/frontend-vue/app/components/FunnelFigure.vue +15 -2
  115. package/frontend-vue/app/components/FunnelReport.vue +3 -0
  116. package/frontend-vue/app/components/FunnelTemplates.vue +15 -13
  117. package/frontend-vue/app/components/InstallGuide.vue +1 -1
  118. package/frontend-vue/app/components/PagePreview.vue +330 -74
  119. package/frontend-vue/app/components/PagesExplorer.vue +230 -85
  120. package/frontend-vue/app/components/TopListTabs.vue +51 -18
  121. package/frontend-vue/app/components/WebsiteCard.vue +394 -0
  122. package/frontend-vue/app/components/WebsiteCreate.vue +159 -0
  123. package/frontend-vue/app/components/WebsitePicker.vue +210 -0
  124. package/frontend-vue/app/components/WebsiteSettings.vue +539 -0
  125. package/frontend-vue/app/components/WorkspaceScope.vue +119 -0
  126. package/frontend-vue/app/composables/useContextWebsite.ts +105 -0
  127. package/frontend-vue/app/composables/useCopyDraft.ts +47 -0
  128. package/frontend-vue/app/composables/useMarketingApi.ts +92 -4
  129. package/frontend-vue/app/composables/useMarketingReads.ts +32 -23
  130. package/frontend-vue/app/composables/useMarketingRoutes.ts +25 -2
  131. package/frontend-vue/app/composables/useRecordCrumb.ts +17 -0
  132. package/frontend-vue/app/composables/useSnapshotFrame.ts +35 -1
  133. package/frontend-vue/app/utils/anchor-drift.ts +58 -0
  134. package/frontend-vue/app/utils/drift-dismissals.ts +20 -0
  135. package/frontend-vue/app/utils/fill-height.ts +141 -0
  136. package/frontend-vue/app/utils/format.ts +21 -2
  137. package/frontend-vue/app/utils/funnel-draft.ts +22 -0
  138. package/frontend-vue/app/utils/heatmap.ts +17 -0
  139. package/frontend-vue/app/utils/page-snapshot.ts +75 -0
  140. package/frontend-vue/app/utils/snapshot-options.ts +148 -0
  141. package/frontend-vue/app/utils/snapshotScrollRoot.ts +93 -0
  142. package/frontend-vue/app/utils/website-card.ts +93 -0
  143. package/frontend-vue/app/utils/website-purge.ts +95 -0
  144. package/frontend-vue/app/utils/website-settings.ts +121 -0
  145. package/frontend-vue/app/utils/website-state.ts +93 -0
  146. package/frontend-vue/app/utils/workspace-scope.ts +51 -0
  147. package/frontend-vue/i18n/locales/marketing-en-GB.json +112 -36
  148. package/frontend-vue/i18n/locales/marketing-fr-FR.json +112 -36
  149. package/frontend-vue/package.json +1 -1
  150. package/frontend-vue/tests/anchor-drift.test.ts +59 -0
  151. package/frontend-vue/tests/build-flags.test.ts +38 -0
  152. package/frontend-vue/tests/context-bar.test.ts +226 -0
  153. package/frontend-vue/tests/copy-draft.test.ts +64 -0
  154. package/frontend-vue/tests/drift-dismissals.test.ts +12 -0
  155. package/frontend-vue/tests/fill-height.test.ts +110 -0
  156. package/frontend-vue/tests/format.test.ts +14 -0
  157. package/frontend-vue/tests/funnel-draft.test.ts +37 -0
  158. package/frontend-vue/tests/heatmap.test.ts +25 -0
  159. package/frontend-vue/tests/install-guide.test.ts +25 -0
  160. package/frontend-vue/tests/marketing-reads.test.ts +20 -6
  161. package/frontend-vue/tests/page-snapshot.test.ts +62 -0
  162. package/frontend-vue/tests/record-crumb.test.ts +43 -0
  163. package/frontend-vue/tests/registration.test.ts +4 -1
  164. package/frontend-vue/tests/routes.test.ts +21 -0
  165. package/frontend-vue/tests/save-funnel.test.ts +47 -0
  166. package/frontend-vue/tests/server-parity.test.ts +3 -0
  167. package/frontend-vue/tests/snapshot-options.test.ts +148 -0
  168. package/frontend-vue/tests/snapshot-scroll-root.test.ts +84 -0
  169. package/frontend-vue/tests/stubs/frontend-module.ts +5 -1
  170. package/frontend-vue/tests/top-list-tabs.test.ts +158 -0
  171. package/frontend-vue/tests/website-card.test.ts +119 -0
  172. package/frontend-vue/tests/website-purge.test.ts +70 -0
  173. package/frontend-vue/tests/website-settings.test.ts +133 -0
  174. package/frontend-vue/tests/website-state.test.ts +96 -0
  175. package/frontend-vue/tests/workspace-scope-control.test.ts +113 -0
  176. package/frontend-vue/tests/workspace-scope.test.ts +55 -0
  177. package/package.json +5 -4
  178. package/skills/dms-marketing/SKILL.md +47 -6
  179. package/static/snapshot.js +539 -48
  180. package/static/tracker.js +154 -5
  181. package/frontend-vue/app/components/WebsitesGrid.vue +0 -812
@@ -1,32 +1,23 @@
1
1
  <script setup lang="ts">
2
- import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
2
+ import { provide, watch } from 'vue'
3
3
  import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
4
- import {
5
- MARKETING_CONTEXT_KEY,
6
- type ContextWebsite,
7
- } from '../composables/useMarketingContext'
8
- import { useMarketingApi } from '../composables/useMarketingApi'
9
- import { useMarketingContextRead } from '../composables/useMarketingReads'
10
- import { useMarketingNow } from '../composables/useMarketingNow'
4
+ import { MARKETING_CONTEXT_KEY } from '../composables/useMarketingContext'
5
+ import { useContextWebsite } from '../composables/useContextWebsite'
11
6
  import { installLink } from '../composables/useMarketingRoutes'
12
- import { formatRelativeTime, initials, utcDayStart } from '../utils/format'
13
- import { MAX_PERIOD_DAYS, MS_PER_DAY, PERIOD_SCOPE } from '../constants'
14
7
 
15
8
  /**
16
- * The context bar of every analytics page and the gate in front of its blocks.
9
+ * The gate in front of the blocks of every analytics page. The page's
10
+ * toolbar (`marketingToolbar()`, declared by the page) picks the website and
11
+ * the period; this gate renders the blocks once the URL names the website.
17
12
  *
18
13
  * The page URL is the context: a reload, a copied link or Back opens on what
19
14
  * it names, and each tab keeps its own. The period is the DMS
20
- * `PeriodSelector` in the bar, publishing under `periodScope`, the scope every
21
- * block of the page binds to: it keeps the period in the URL
22
- * (`?<scope>.period=…&<scope>.compare=…`, `<scope>.from`/`.to` for a custom
23
- * range) and the user's last choice. The website is this bar's own
24
- * (`?website=`): the blocks read it through a `{{query.website}}` token, so a
25
- * switch reads them again.
26
- *
27
- * What the URL leaves out comes from the reader's last explicit pick: the
28
- * selector's remembered period and the website saved server-side. Only a pick
29
- * in the bar writes the website, so opening a link never moves another tab.
15
+ * `PeriodSelector` of the toolbar, publishing under the scope every block of
16
+ * the page binds to. The website is the module's own (`?website=`): the
17
+ * blocks read it through a `{{query.website}}` token, so a switch reads them
18
+ * again. What the URL leaves out comes from the reader's last explicit pick:
19
+ * the website saved server-side. Only a pick writes the website, so opening a
20
+ * link never moves another tab.
30
21
  *
31
22
  * Its children render once the URL names one of the tenant's websites; with
32
23
  * no website at all the first-run hero takes their place, and with
@@ -35,152 +26,49 @@ import { MAX_PERIOD_DAYS, MS_PER_DAY, PERIOD_SCOPE } from '../constants'
35
26
  * URL naming no website (or one the tenant does not have) is redirected to
36
27
  * the one saved, and the page renders on it.
37
28
  */
38
- const props = withDefaults(
39
- defineProps<{
40
- periodScope?: string
41
- waitingGuide?: boolean
42
- componentId?: string
43
- pageId?: string
44
- }>(),
45
- {
46
- periodScope: PERIOD_SCOPE,
47
- waitingGuide: false,
48
- componentId: undefined,
49
- pageId: undefined,
50
- },
51
- )
52
-
53
- const PRESETS = [
54
- 'today',
55
- 'last-7-days',
56
- 'last-30-days',
57
- 'last-90-days',
58
- 'custom',
59
- ]
60
- const COMPARISONS = ['previous-period', 'previous-year', 'none']
61
- const CLOCK_TICK_MS = 15_000
62
- /**
63
- * How often the blocks of a period reaching today and the websites' last
64
- * events are read again (the pages' `live` option caps the blocks alike).
65
- */
66
- const LIVE_REFRESH_MS = 60_000
67
- /** A website is live while its last event is this recent. */
68
- const LIVE_WINDOW_MS = 30 * 60 * 1000
29
+ // The gate comes first under the page toolbar. The DMS page stack sets its
30
+ // items 24px apart (space-y-6); `-mt-2` keeps the 16px the analytics pages
31
+ // had when the bar was drawn in this gate (gap-4).
32
+ const props = withDefaults(defineProps<{ waitingGuide?: boolean }>(), {
33
+ waitingGuide: false,
34
+ })
69
35
 
70
- const { t, locale } = useI18n()
71
- const api = useMarketingApi()
72
- const toast = useToast()
36
+ const { t } = useI18n()
73
37
  const route = useDmsRoute()
74
38
  const router = useDmsRouter()
75
-
76
- const presetLabels = computed(() =>
77
- Object.fromEntries(
78
- PRESETS.map((preset) => [
79
- preset,
80
- t(`page.marketing.context.presets.${preset}`),
81
- ]),
82
- ),
83
- )
84
-
85
- const comparisonLabels = computed(() =>
86
- Object.fromEntries(
87
- COMPARISONS.map((comparison) => [
88
- comparison,
89
- t(`page.marketing.context.comparisons.${comparison}`),
90
- ]),
91
- ),
92
- )
93
-
94
- // The minute's activity of every website: read on the bar's own clock.
95
- const contextRead = useMarketingContextRead({
96
- refreshInterval: LIVE_REFRESH_MS,
97
- })
98
- const context = contextRead.data
99
- /**
100
- * The context could not be read, and none is shown: a failed re-read (the
101
- * minute's activity, a retry) keeps the bar as it is.
102
- */
103
- const failed = computed(
104
- () =>
105
- !!contextRead.error.value &&
106
- contextRead.status.value !== 'pending' &&
107
- !context.value,
108
- )
109
- const switching = ref(false)
110
-
111
- const websites = computed(() => context.value?.websites ?? [])
112
- /** The URL's website, while it is one of the tenant's. */
113
- const linkedId = computed(() => {
114
- const id = route.query.website
115
- return typeof id === 'string' && websites.value.some((site) => site.id === id)
116
- ? id
117
- : null
118
- })
119
- const selectedId = computed(
120
- () => linkedId.value ?? context.value?.selectedId ?? null,
121
- )
122
- const selected = computed<ContextWebsite | null>(
123
- () => websites.value.find((site) => site.id === selectedId.value) ?? null,
124
- )
125
-
126
- /** The websites and their last events again; a failure keeps the bar as is. */
127
- const reload = (): Promise<void> => contextRead.refresh()
128
-
129
- async function select(id: string): Promise<void> {
130
- if (id === selectedId.value) {
131
- return
132
- }
133
- switching.value = true
134
- try {
135
- // Saved first, as the website of the pages opened without one; the bar
136
- // and the blocks follow the URL once it names it. A pick is a history
137
- // entry, so Back takes it back.
138
- context.value = await api.selectContextWebsite(id)
139
- await router.push({ query: { ...route.query, website: id } })
140
- } catch {
141
- toast.add({
142
- color: 'error',
143
- title: t('page.marketing.context.switch_error'),
144
- })
145
- await reload()
146
- } finally {
147
- switching.value = false
148
- }
149
- }
39
+ const {
40
+ context,
41
+ failed,
42
+ websites,
43
+ linkedId,
44
+ selectedId,
45
+ selected,
46
+ select,
47
+ reload,
48
+ ready,
49
+ } = useContextWebsite()
150
50
 
151
51
  // The URL names no website, or one the tenant does not have: fill in the
152
52
  // saved one, without a history entry (a server render redirects to it).
153
53
  // Watched with the query too, so a block rewriting the URL for its own state
154
54
  // (the heatmap path) keeps it. Set up once the context is read.
155
- await contextRead
156
- watch(
157
- [selectedId, () => route.query],
158
- ([id]) => {
159
- if (id && route.query.website !== id) {
160
- void router.replace({ query: { ...route.query, website: id } })
161
- }
162
- },
163
- { immediate: true },
164
- )
165
-
166
- // --- Period --------------------------------------------------------------------
167
-
168
- const period = usePeriodScope(props.periodScope)
169
-
170
- // Read once the bar shows the selector: the scope registers with it, and a
171
- // read before has the DMS warn about a missing scope.
172
- const range = computed(() => (selected.value ? period.value?.range : null))
173
-
174
- /** A custom range longer than the routes read: only its last days are shown. */
175
- const cutHint = computed(() => {
176
- if (!range.value) {
177
- return ''
55
+ await ready
56
+ function linkSelected(id: string | null): Promise<unknown> | undefined {
57
+ if (id && route.query.website !== id) {
58
+ return router.replace({ query: { ...route.query, website: id } })
178
59
  }
179
- const { from, to } = range.value
180
- const days = Math.round((to.getTime() - from.getTime()) / MS_PER_DAY)
181
- return days > MAX_PERIOD_DAYS
182
- ? t('page.marketing.context.range_cut', { days: MAX_PERIOD_DAYS })
183
- : ''
60
+ }
61
+ // A navigation in the browser waits for the URL to name the website: the
62
+ // page then opens on its blocks, not on the gate's placeholder first. The
63
+ // blocks then set up while the page's suspense is still pending, so one that
64
+ // holds the navigation for its data (a fetched banner) is drawn with the page.
65
+ // Vite's SSR flag: the DMS builds with Vite alone, where Nuxt's client flag
66
+ // is undefined (see tests/build-flags.test.ts).
67
+ if (!import.meta.env.SSR) {
68
+ await linkSelected(selectedId.value)
69
+ }
70
+ watch([selectedId, () => route.query], ([id]) => void linkSelected(id), {
71
+ immediate: true,
184
72
  })
185
73
 
186
74
  provide(MARKETING_CONTEXT_KEY, {
@@ -189,226 +77,10 @@ provide(MARKETING_CONTEXT_KEY, {
189
77
  select,
190
78
  reload,
191
79
  })
192
-
193
- // --- Clock -------------------------------------------------------------------
194
- // A period reaching today follows the clock: its blocks read again every
195
- // minute, through the page's live gate. The selector itself moves a day
196
- // preset on at midnight.
197
-
198
- const now = useMarketingNow(CLOCK_TICK_MS)
199
-
200
- /** The period covers the present: its counts are still moving. */
201
- const reachesToday = computed(
202
- () => !!range.value && range.value.to.getTime() >= now.value,
203
- )
204
-
205
- /** Refresh: every block reads again, and the bar. */
206
- function refresh(): void {
207
- refreshPageBlocks()
208
- void reload()
209
- }
210
-
211
- /** A period reaching today: its blocks read again, through the live gate. */
212
- function liveRefresh(): void {
213
- if (reachesToday.value && linkedId.value) {
214
- refreshLivePageBlocks()
215
- }
216
- }
217
-
218
- let live: ReturnType<typeof setInterval> | null = null
219
- onMounted(() => {
220
- live = setInterval(liveRefresh, LIVE_REFRESH_MS)
221
- })
222
- onBeforeUnmount(() => {
223
- if (live) {
224
- clearInterval(live)
225
- }
226
- })
227
-
228
- // --- Bar ---------------------------------------------------------------------
229
-
230
- /** `live` only while events are recent; a quiet site is `idle`. */
231
- type DisplayState = ContextWebsite['state'] | 'idle'
232
-
233
- const STATE_TONES: Record<DisplayState, string> = {
234
- live: 'bg-success',
235
- idle: 'bg-(--ui-text-muted)',
236
- imported: 'bg-info',
237
- waiting: 'bg-warning',
238
- paused: 'bg-(--ui-text-dimmed)',
239
- }
240
-
241
- function displayState(site: ContextWebsite): DisplayState {
242
- if (
243
- site.state === 'live' &&
244
- (site.lastActivityAt === null ||
245
- now.value - site.lastActivityAt > LIVE_WINDOW_MS)
246
- ) {
247
- return 'idle'
248
- }
249
- return site.state
250
- }
251
-
252
- function stateLine(site: ContextWebsite): string {
253
- const state = displayState(site)
254
- if (
255
- (state === 'live' || state === 'idle' || state === 'imported') &&
256
- site.lastActivityAt
257
- ) {
258
- return t(`page.marketing.context.${state}`, {
259
- ago: formatRelativeTime(site.lastActivityAt, now.value, locale.value),
260
- })
261
- }
262
- return t(`page.marketing.context.state.${site.state}`)
263
- }
264
-
265
- const websiteItems = computed(() => [
266
- websites.value.map((site) => ({
267
- label: site.name,
268
- description: site.domain,
269
- type: 'checkbox' as const,
270
- checked: site.id === selectedId.value,
271
- slot: 'website' as const,
272
- site,
273
- onSelect: () => void select(site.id),
274
- })),
275
- [
276
- {
277
- label: t('page.marketing.context.add_website'),
278
- icon: 'i-ph-plus',
279
- to: installLink(),
280
- },
281
- ],
282
- ])
283
-
284
- /**
285
- * The statistics are kept per UTC day: where the reader's midnight is not
286
- * UTC's, the bar says when their days really start.
287
- */
288
- const utcHint = computed(() => {
289
- // The reader's zone: the browser's, which a server render reads from its
290
- // cookie.
291
- const time = utcDayStart(now.value, locale.value, readerTimeZone())
292
- return time ? t('page.marketing.context.utc_days', { time }) : ''
293
- })
294
80
  </script>
295
81
 
296
82
  <template>
297
- <div class="flex flex-col gap-4">
298
- <div
299
- class="dms-card flex flex-wrap items-center gap-x-4 gap-y-2 px-3 py-2"
300
- role="toolbar"
301
- :aria-label="t('page.marketing.context.label')"
302
- >
303
- <USkeleton v-if="!context && !failed" class="h-7 w-72" />
304
- <template v-else-if="selected">
305
- <UDropdownMenu :items="websiteItems" :content="{ align: 'start' }">
306
- <UButton
307
- color="neutral"
308
- variant="outline"
309
- size="sm"
310
- trailing-icon="i-ph-caret-up-down"
311
- :loading="switching"
312
- :aria-label="t('page.marketing.context.switch_website')"
313
- >
314
- <span
315
- class="grid size-5 place-items-center rounded bg-primary/15 font-mono text-[10px] font-semibold text-(--ui-color-primary-800) dark:text-primary"
316
- >
317
- {{ initials(selected.name) }}
318
- </span>
319
- <span class="font-semibold">{{ selected.name }}</span>
320
- </UButton>
321
- <template #website="{ item }">
322
- <span
323
- class="grid size-6 shrink-0 place-items-center rounded bg-primary/15 font-mono text-[10px] font-semibold text-(--ui-color-primary-800) dark:text-primary"
324
- >
325
- {{ initials(item.site.name) }}
326
- </span>
327
- <span class="min-w-0 flex-1 text-left">
328
- <span class="block truncate text-sm font-medium text-highlighted">
329
- {{ item.site.name }}
330
- </span>
331
- <span
332
- class="flex min-w-0 items-center gap-1.5 text-xs text-dimmed"
333
- >
334
- <span
335
- class="size-1.5 shrink-0 rounded-full"
336
- :class="STATE_TONES[displayState(item.site)]"
337
- aria-hidden="true"
338
- />
339
- <span class="truncate font-mono">{{ item.site.domain }}</span>
340
- <span class="shrink-0">· {{ stateLine(item.site) }}</span>
341
- </span>
342
- </span>
343
- <UIcon
344
- v-if="item.checked"
345
- name="i-ph-check"
346
- class="size-4 shrink-0 text-primary"
347
- />
348
- </template>
349
- </UDropdownMenu>
350
-
351
- <span class="flex items-center gap-2 font-mono text-xs text-muted">
352
- <span
353
- class="size-2 rounded-full"
354
- :class="STATE_TONES[displayState(selected)]"
355
- aria-hidden="true"
356
- />
357
- {{ stateLine(selected) }}
358
- </span>
359
-
360
- <span class="hidden h-5 w-px bg-(--ui-border) md:block" />
361
-
362
- <div class="flex flex-wrap items-center gap-2">
363
- <DmsPeriodSelector
364
- :id="periodScope"
365
- :component-id="`${componentId ?? 'marketing-context'}-period`"
366
- :page-id="pageId"
367
- variant="segmented"
368
- size="xs"
369
- align="left"
370
- default-preset="last-30-days"
371
- default-comparison="previous-period"
372
- :presets="PRESETS"
373
- :comparisons="COMPARISONS"
374
- :preset-labels="presetLabels"
375
- :comparison-labels="comparisonLabels"
376
- show-range-label
377
- />
378
- <span
379
- v-if="cutHint"
380
- class="flex items-center gap-1 text-xs text-warning"
381
- role="status"
382
- >
383
- <UIcon name="i-ph-warning" class="size-3.5 shrink-0" />
384
- {{ cutHint }}
385
- </span>
386
- <UTooltip v-if="utcHint" :text="utcHint">
387
- <span
388
- class="cursor-help rounded border border-default px-1 font-mono text-[10px] text-dimmed"
389
- tabindex="0"
390
- :aria-label="utcHint"
391
- >
392
- UTC
393
- </span>
394
- </UTooltip>
395
- </div>
396
-
397
- <UButton
398
- class="ms-auto"
399
- icon="i-ph-arrow-clockwise"
400
- color="neutral"
401
- variant="ghost"
402
- size="xs"
403
- :aria-label="t('page.marketing.context.refresh')"
404
- @click="refresh"
405
- />
406
- </template>
407
- <span v-else-if="!failed" class="text-sm text-muted">
408
- {{ t('page.marketing.context.no_website') }}
409
- </span>
410
- </div>
411
-
83
+ <div class="-mt-2 flex flex-col gap-4">
412
84
  <DmsEmptyState
413
85
  v-if="failed"
414
86
  variant="error"
@@ -431,7 +103,9 @@ const utcHint = computed(() => {
431
103
  <DmsMarketingFirstRun v-else-if="context && websites.length === 0" />
432
104
 
433
105
  <DmsEmptyState
434
- v-else-if="waitingGuide && linkedId && selected?.state === 'waiting'"
106
+ v-else-if="
107
+ props.waitingGuide && linkedId && selected?.state === 'waiting'
108
+ "
435
109
  icon="i-ph-plugs"
436
110
  :title="
437
111
  t('page.marketing.context.waiting_guide.title', { name: selected.name })
@@ -5,6 +5,7 @@ import { parseExperimentValue } from '../composables/useExperiment'
5
5
  import { parseFunnelStepsValue } from '../composables/useFunnelSteps'
6
6
  import { useLatestRequest } from '../composables/useLatestRequest'
7
7
  import {
8
+ saveFunnel,
8
9
  useMarketingApi,
9
10
  type MarketingExperiment,
10
11
  type MarketingFunnelPreview,
@@ -23,7 +24,9 @@ import {
23
24
  funnelsLink,
24
25
  } from '../composables/useMarketingRoutes'
25
26
  import { useContextLink } from '../composables/useContextLink'
27
+ import { useCopyDraft } from '../composables/useCopyDraft'
26
28
  import {
29
+ DEFAULT_WINDOW_HOURS,
27
30
  draftIssues,
28
31
  isWindowHours,
29
32
  MAX_EXPERIMENT_KEY_LENGTH,
@@ -37,6 +40,8 @@ import {
37
40
  templateSteps,
38
41
  localizedSnippetComments,
39
42
  variationSnippet,
43
+ WINDOW_PRESET_HOURS,
44
+ withRequestedSplit,
40
45
  type DraftField,
41
46
  type FunnelDraftValues,
42
47
  type StepValue,
@@ -46,8 +51,8 @@ import { normalizeFunnelStepPath } from '../utils/step-path'
46
51
  import { fractionDigits } from '../constants'
47
52
 
48
53
  /**
49
- * The funnel builder (`?id=` to edit, `?from=` to start from a copy of a
50
- * funnel, `?split=1` to open the A/B section, `?steps=` to start from a
54
+ * The funnel builder (`?id=` to edit, `?copy=` to start from a copy of a
55
+ * funnel, `?split=1` to open the A/B section switched on, `?steps=` to start from a
51
56
  * template). Steps are picked from the pages and events the site already
52
57
  * tracks, with their counts; the preview scores the draft over the last 30
53
58
  * days while it is edited. Follows the DMS form contract — unsaved changes
@@ -72,9 +77,6 @@ interface VariationDraft {
72
77
  weight: number | string
73
78
  }
74
79
 
75
- // A funnel is matched within one session, which ends after 30 minutes
76
- // without activity: a window of days would change nothing (FB-11).
77
- const WINDOW_PRESETS = [1, 24]
78
80
  const PREVIEW_DEBOUNCE_MS = 450
79
81
  const SUGGESTIONS_SHOWN = 6
80
82
  const MINIMUM_DETECTABLE_LIFT = 0.2
@@ -109,7 +111,7 @@ const uid = () => nextUid++
109
111
  const name = ref('')
110
112
  const websiteId = ref<string>('')
111
113
  const steps = ref<StepDraft[]>([{ uid: uid(), kind: 'url', value: '' }])
112
- const windowHours = ref<number | string>(24)
114
+ const windowHours = ref<number | string>(DEFAULT_WINDOW_HOURS)
113
115
  const splitEnabled = ref(route.query.split === '1')
114
116
  const experimentKey = ref('')
115
117
  const variations = ref<VariationDraft[]>([
@@ -153,7 +155,10 @@ function applyValues(next: FunnelDraftValues): void {
153
155
  }
154
156
 
155
157
  const { dirty, baseline, markClean } = useFormDirty(values)
156
- const { confirmLeave } = useUnsavedChanges({ dirty })
158
+ // A Duplicate opens dirty (the copy is a change to save), but an untouched
159
+ // copy holds nothing to lose: only edits to it hold the leave.
160
+ const copy = useCopyDraft({ values, dirty, markClean })
161
+ const { confirmLeave } = useUnsavedChanges({ dirty: copy.unsaved })
157
162
 
158
163
  // Errors land under their field: the client's checks on a save attempt, and
159
164
  // the server's — its codes name the field they belong to.
@@ -240,14 +245,11 @@ function objectiveOf(draft: FunnelDraftValues): string {
240
245
 
241
246
  async function loadExisting(): Promise<void> {
242
247
  const { draft, experiment } = await fetchDefinition()
243
- applyValues(draft)
248
+ applyValues(withRequestedSplit(draft, route.query.split))
244
249
  original.value = experiment
245
250
  savedObjective.value = objectiveOf(draft)
246
251
  }
247
252
 
248
- /** The copy a Duplicate opened on, what Discard returns to. */
249
- const copied = ref<FunnelDraftValues | null>(null)
250
-
251
253
  /** Duplicate: the copy is a new draft, unsaved until the user saves it — its
252
254
  * experiment key must change, keys being unique per site. */
253
255
  async function loadCopy(): Promise<void> {
@@ -260,7 +262,7 @@ async function loadCopy(): Promise<void> {
260
262
  ),
261
263
  experimentKey: '',
262
264
  })
263
- copied.value = values()
265
+ copy.open()
264
266
  }
265
267
 
266
268
  // The builder sits outside the context bar: it reads the website list itself,
@@ -575,7 +577,7 @@ function moveStep(index: number, offset: number): void {
575
577
  }
576
578
 
577
579
  const windowItems = computed(() =>
578
- WINDOW_PRESETS.map((hours) => ({
580
+ WINDOW_PRESET_HOURS.map((hours) => ({
579
581
  value: hours,
580
582
  label: t(`page.marketing.builder.window_presets.${hours}`),
581
583
  })),
@@ -722,12 +724,10 @@ async function save(): Promise<void> {
722
724
  conversionWindowHours: windowHours.value as number,
723
725
  experiment: experimentPayload(),
724
726
  }
725
- const id = isEdit ? funnelId : (await api.createFunnel(input))[0]
726
- if (isEdit) {
727
- await api.updateFunnel(funnelId, input)
728
- }
729
- // Clean before leaving, or the leave guard would ask.
730
- markClean()
727
+ const id = await saveFunnel(api, input, funnelId)
728
+ // Clean before leaving, or the leave guard would ask: a saved copy is
729
+ // no copy any more.
730
+ copy.settle()
731
731
  toast.add({
732
732
  color: 'success',
733
733
  title: t('page.marketing.builder.saved'),
@@ -742,13 +742,19 @@ async function save(): Promise<void> {
742
742
  }
743
743
  }
744
744
 
745
- /** Back to what the page opened on: the copy itself for a Duplicate, which
746
- * stays a change to save. */
745
+ /** Back to what the page opened on. A Duplicate opened on its copy, which
746
+ * stays a change to save: discarding it drops the copy and leaves, with
747
+ * nothing left to hold the navigation. */
747
748
  function discard(): void {
748
749
  clearStepError()
749
750
  clearVariationError()
750
751
  fieldErrors.clear()
751
- applyValues(copied.value ?? baseline.value)
752
+ if (copy.isCopy.value) {
753
+ copy.settle()
754
+ void router.push(backLink.value)
755
+ return
756
+ }
757
+ applyValues(baseline.value)
752
758
  }
753
759
 
754
760
  /** A link out of the builder, on the website and period it was opened from. */
@@ -171,12 +171,21 @@ const kpis = computed(() => [
171
171
  },
172
172
  ])
173
173
 
174
+ /** The changes are measured against the same days a year before. */
175
+ const isPreviousYear = computed(
176
+ () => props.results.comparison === 'previous-year',
177
+ )
178
+
174
179
  function trend(delta: number | null, unit: 'percent' | 'points') {
175
180
  if (delta === null) {
176
181
  return {}
177
182
  }
178
183
  return {
179
- detail: `${formatSignedDelta(delta, unit, locale.value)} · ${t('page.marketing.context.vs_previous_short')}`,
184
+ detail: `${formatSignedDelta(delta, unit, locale.value)} · ${t(
185
+ isPreviousYear.value
186
+ ? 'page.marketing.context.vs_previous_year_short'
187
+ : 'page.marketing.context.vs_previous_short',
188
+ )}`,
180
189
  detailTone:
181
190
  Math.abs(delta) < 0.05
182
191
  ? ('neutral' as const)
@@ -284,7 +293,11 @@ const coverageDate = computed(() =>
284
293
  ? t('page.marketing.funnels.coverage.period_title', {
285
294
  date: coverageDate,
286
295
  })
287
- : t('page.marketing.funnels.coverage.previous_title')
296
+ : t(
297
+ isPreviousYear
298
+ ? 'page.marketing.funnels.coverage.previous_year_title'
299
+ : 'page.marketing.funnels.coverage.previous_title',
300
+ )
288
301
  "
289
302
  :description="
290
303
  t('page.marketing.funnels.coverage.description', {
@@ -12,6 +12,7 @@ import {
12
12
  funnelsLink,
13
13
  } from '../composables/useMarketingRoutes'
14
14
  import { useContextLink } from '../composables/useContextLink'
15
+ import { useRecordCrumb } from '../composables/useRecordCrumb'
15
16
  import { formatShortDate } from '../utils/format'
16
17
  import { MS_PER_DAY, fractionDigits } from '../constants'
17
18
 
@@ -75,6 +76,8 @@ const status = computed<MarketingExperimentStatus | null>(
75
76
  () => experiment.value?.status ?? null,
76
77
  )
77
78
  const isSplit = computed(() => experiment.value !== null)
79
+ // The breadcrumb names the funnel, not only the page ("Funnel").
80
+ useRecordCrumb(() => funnel.value?.name)
78
81
  const notFound = computed(
79
82
  () => !current.value && apiErrorStatus(error.value) === HTTP_NOT_FOUND,
80
83
  )