@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.
- package/dist/config.d.ts +3 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/data-api/websites.d.ts +10 -4
- package/dist/data-api/websites.js +79 -3
- package/dist/data-api/websites.js.map +1 -1
- package/dist/db/models/page_snapshots.model.d.ts +2 -0
- package/dist/db/models/page_snapshots.model.js +6 -0
- package/dist/db/models/page_snapshots.model.js.map +1 -1
- package/dist/db/models/websites.model.d.ts +1 -0
- package/dist/db/models/websites.model.js +9 -0
- package/dist/db/models/websites.model.js.map +1 -1
- package/dist/db/tables/page_snapshots.table.d.ts +1 -0
- package/dist/db/tables/page_snapshots.table.js +4 -0
- package/dist/db/tables/page_snapshots.table.js.map +1 -1
- package/dist/db/tables/websites.table.d.ts +2 -0
- package/dist/db/tables/websites.table.js +4 -0
- package/dist/db/tables/websites.table.js.map +1 -1
- package/dist/frontend-module.d.ts +6 -0
- package/dist/frontend-module.js +46 -0
- package/dist/frontend-module.js.map +1 -0
- package/dist/index.js +3 -11
- package/dist/index.js.map +1 -1
- package/dist/pages/acquisition/page.d.ts +1 -0
- package/dist/pages/acquisition/page.js +9 -4
- package/dist/pages/acquisition/page.js.map +1 -1
- package/dist/pages/blocks.d.ts +11 -2
- package/dist/pages/blocks.js +43 -5
- package/dist/pages/blocks.js.map +1 -1
- package/dist/pages/funnel/page.d.ts +1 -0
- package/dist/pages/funnel/page.js +1 -0
- package/dist/pages/funnel/page.js.map +1 -1
- package/dist/pages/funnels/page.d.ts +1 -0
- package/dist/pages/funnels/page.js +4 -2
- package/dist/pages/funnels/page.js.map +1 -1
- package/dist/pages/overview/page.d.ts +1 -0
- package/dist/pages/overview/page.js +5 -6
- package/dist/pages/overview/page.js.map +1 -1
- package/dist/pages/pages/page.d.ts +1 -0
- package/dist/pages/pages/page.js +1 -0
- package/dist/pages/pages/page.js.map +1 -1
- package/dist/pages/settings/form.js +1 -0
- package/dist/pages/settings/form.js.map +1 -1
- package/dist/pages/websites/page.d.ts +2 -3
- package/dist/pages/websites/page.js +63 -5
- package/dist/pages/websites/page.js.map +1 -1
- package/dist/routes/dashboard-tracking.js +5 -1
- package/dist/routes/dashboard-tracking.js.map +1 -1
- package/dist/routes/funnels.d.ts +2 -0
- package/dist/routes/funnels.js +6 -5
- package/dist/routes/funnels.js.map +1 -1
- package/dist/routes/public-endpoints.js +1 -1
- package/dist/routes/public-endpoints.js.map +1 -1
- package/dist/routes/snapshot.d.ts +1 -1
- package/dist/routes/snapshot.js +30 -13
- package/dist/routes/snapshot.js.map +1 -1
- package/dist/routes/stats.d.ts +10 -4
- package/dist/routes/stats.js +77 -23
- package/dist/routes/stats.js.map +1 -1
- package/dist/routes/websites.d.ts +5 -1
- package/dist/routes/websites.js +31 -13
- package/dist/routes/websites.js.map +1 -1
- package/dist/services/acquisition-report.d.ts +1 -0
- package/dist/services/acquisition-report.js +1 -0
- package/dist/services/acquisition-report.js.map +1 -1
- package/dist/services/collect.js +7 -2
- package/dist/services/collect.js.map +1 -1
- package/dist/services/dashboard-tracking.d.ts +7 -14
- package/dist/services/dashboard-tracking.js +42 -26
- package/dist/services/dashboard-tracking.js.map +1 -1
- package/dist/services/funnels-table.d.ts +3 -2
- package/dist/services/funnels-table.js +9 -9
- package/dist/services/funnels-table.js.map +1 -1
- package/dist/services/heatmap.d.ts +6 -0
- package/dist/services/heatmap.js +82 -17
- package/dist/services/heatmap.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/prune.js +6 -1
- package/dist/services/prune.js.map +1 -1
- package/dist/services/realtime.d.ts +3 -0
- package/dist/services/realtime.js +49 -0
- package/dist/services/realtime.js.map +1 -0
- package/dist/services/request-body.d.ts +1 -0
- package/dist/services/request-body.js +18 -0
- package/dist/services/request-body.js.map +1 -1
- package/dist/services/sessionize.d.ts +1 -0
- package/dist/services/sessionize.js +9 -0
- package/dist/services/sessionize.js.map +1 -1
- package/dist/services/snapshots.d.ts +28 -1
- package/dist/services/snapshots.js +147 -26
- package/dist/services/snapshots.js.map +1 -1
- package/dist/services/tenant-website.d.ts +11 -0
- package/dist/services/tenant-website.js +27 -5
- package/dist/services/tenant-website.js.map +1 -1
- package/dist/services/website-report.d.ts +5 -0
- package/dist/services/website-report.js +13 -1
- package/dist/services/website-report.js.map +1 -1
- package/dist/services/workspace-scope.d.ts +31 -0
- package/dist/services/workspace-scope.js +72 -0
- package/dist/services/workspace-scope.js.map +1 -0
- package/dist/types/constants.d.ts +7 -0
- package/dist/types/constants.js +8 -1
- package/dist/types/constants.js.map +1 -1
- package/dist/types/events.d.ts +25 -25
- package/dist/types/snapshots.d.ts +29 -29
- package/dist/types/snapshots.js +1 -1
- package/dist/types/snapshots.js.map +1 -1
- package/dist/utils/funnel-steps-type.d.ts +13 -13
- package/frontend-vue/app/components/ChannelsCard.vue +19 -2
- package/frontend-vue/app/components/Context.vue +50 -376
- package/frontend-vue/app/components/FunnelBuilder.vue +28 -22
- package/frontend-vue/app/components/FunnelFigure.vue +15 -2
- package/frontend-vue/app/components/FunnelReport.vue +3 -0
- package/frontend-vue/app/components/FunnelTemplates.vue +15 -13
- package/frontend-vue/app/components/InstallGuide.vue +1 -1
- package/frontend-vue/app/components/PagePreview.vue +330 -74
- package/frontend-vue/app/components/PagesExplorer.vue +230 -85
- package/frontend-vue/app/components/TopListTabs.vue +51 -18
- package/frontend-vue/app/components/WebsiteCard.vue +394 -0
- package/frontend-vue/app/components/WebsiteCreate.vue +159 -0
- package/frontend-vue/app/components/WebsitePicker.vue +210 -0
- package/frontend-vue/app/components/WebsiteSettings.vue +539 -0
- package/frontend-vue/app/components/WorkspaceScope.vue +119 -0
- package/frontend-vue/app/composables/useContextWebsite.ts +105 -0
- package/frontend-vue/app/composables/useCopyDraft.ts +47 -0
- package/frontend-vue/app/composables/useMarketingApi.ts +92 -4
- package/frontend-vue/app/composables/useMarketingReads.ts +32 -23
- package/frontend-vue/app/composables/useMarketingRoutes.ts +25 -2
- package/frontend-vue/app/composables/useRecordCrumb.ts +17 -0
- package/frontend-vue/app/composables/useSnapshotFrame.ts +35 -1
- package/frontend-vue/app/utils/anchor-drift.ts +58 -0
- package/frontend-vue/app/utils/drift-dismissals.ts +20 -0
- package/frontend-vue/app/utils/fill-height.ts +141 -0
- package/frontend-vue/app/utils/format.ts +21 -2
- package/frontend-vue/app/utils/funnel-draft.ts +22 -0
- package/frontend-vue/app/utils/heatmap.ts +17 -0
- package/frontend-vue/app/utils/page-snapshot.ts +75 -0
- package/frontend-vue/app/utils/snapshot-options.ts +148 -0
- package/frontend-vue/app/utils/snapshotScrollRoot.ts +93 -0
- package/frontend-vue/app/utils/website-card.ts +93 -0
- package/frontend-vue/app/utils/website-purge.ts +95 -0
- package/frontend-vue/app/utils/website-settings.ts +121 -0
- package/frontend-vue/app/utils/website-state.ts +93 -0
- package/frontend-vue/app/utils/workspace-scope.ts +51 -0
- package/frontend-vue/i18n/locales/marketing-en-GB.json +112 -36
- package/frontend-vue/i18n/locales/marketing-fr-FR.json +112 -36
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/anchor-drift.test.ts +59 -0
- package/frontend-vue/tests/build-flags.test.ts +38 -0
- package/frontend-vue/tests/context-bar.test.ts +226 -0
- package/frontend-vue/tests/copy-draft.test.ts +64 -0
- package/frontend-vue/tests/drift-dismissals.test.ts +12 -0
- package/frontend-vue/tests/fill-height.test.ts +110 -0
- package/frontend-vue/tests/format.test.ts +14 -0
- package/frontend-vue/tests/funnel-draft.test.ts +37 -0
- package/frontend-vue/tests/heatmap.test.ts +25 -0
- package/frontend-vue/tests/install-guide.test.ts +25 -0
- package/frontend-vue/tests/marketing-reads.test.ts +20 -6
- package/frontend-vue/tests/page-snapshot.test.ts +62 -0
- package/frontend-vue/tests/record-crumb.test.ts +43 -0
- package/frontend-vue/tests/registration.test.ts +4 -1
- package/frontend-vue/tests/routes.test.ts +21 -0
- package/frontend-vue/tests/save-funnel.test.ts +47 -0
- package/frontend-vue/tests/server-parity.test.ts +3 -0
- package/frontend-vue/tests/snapshot-options.test.ts +148 -0
- package/frontend-vue/tests/snapshot-scroll-root.test.ts +84 -0
- package/frontend-vue/tests/stubs/frontend-module.ts +5 -1
- package/frontend-vue/tests/top-list-tabs.test.ts +158 -0
- package/frontend-vue/tests/website-card.test.ts +119 -0
- package/frontend-vue/tests/website-purge.test.ts +70 -0
- package/frontend-vue/tests/website-settings.test.ts +133 -0
- package/frontend-vue/tests/website-state.test.ts +96 -0
- package/frontend-vue/tests/workspace-scope-control.test.ts +113 -0
- package/frontend-vue/tests/workspace-scope.test.ts +55 -0
- package/package.json +5 -4
- package/skills/dms-marketing/SKILL.md +47 -6
- package/static/snapshot.js +539 -48
- package/static/tracker.js +154 -5
- package/frontend-vue/app/components/WebsitesGrid.vue +0 -812
|
@@ -1,32 +1,23 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { provide, watch } from 'vue'
|
|
3
3
|
import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
|
|
4
|
-
import {
|
|
5
|
-
|
|
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
|
|
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`
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
71
|
-
const api = useMarketingApi()
|
|
72
|
-
const toast = useToast()
|
|
36
|
+
const { t } = useI18n()
|
|
73
37
|
const route = useDmsRoute()
|
|
74
38
|
const router = useDmsRouter()
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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="
|
|
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, `?
|
|
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>(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
726
|
-
|
|
727
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
)
|