@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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { computed, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
|
|
3
|
+
import type {
|
|
4
|
+
ContextWebsite,
|
|
5
|
+
MarketingContextPayload,
|
|
6
|
+
} from './useMarketingContext'
|
|
7
|
+
import { useMarketingApi } from './useMarketingApi'
|
|
8
|
+
import { useMarketingContextRead } from './useMarketingReads'
|
|
9
|
+
|
|
10
|
+
/** The website the page reads, shared by the toolbar's picker and the gate. */
|
|
11
|
+
export interface ContextWebsiteState {
|
|
12
|
+
context: Ref<MarketingContextPayload | null | undefined>
|
|
13
|
+
/**
|
|
14
|
+
* The context could not be read, and none is shown: a failed re-read (an
|
|
15
|
+
* arrival, a retry) keeps what is shown.
|
|
16
|
+
*/
|
|
17
|
+
failed: ComputedRef<boolean>
|
|
18
|
+
websites: ComputedRef<ContextWebsite[]>
|
|
19
|
+
/** The URL's website, while it is one of the tenant's. */
|
|
20
|
+
linkedId: ComputedRef<string | null>
|
|
21
|
+
selectedId: ComputedRef<string | null>
|
|
22
|
+
selected: ComputedRef<ContextWebsite | null>
|
|
23
|
+
/** A pick is being saved. */
|
|
24
|
+
switching: Ref<boolean>
|
|
25
|
+
select: (id: string) => Promise<void>
|
|
26
|
+
/** The websites and their last events again; a failure keeps them as is. */
|
|
27
|
+
reload: () => Promise<void>
|
|
28
|
+
/** Resolves once the context was read. */
|
|
29
|
+
ready: PromiseLike<unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The page's website: the URL's `?website=` while it names one of the
|
|
34
|
+
* tenant's, else the one saved server-side. The toolbar's website picker and
|
|
35
|
+
* the context gate both call it; they share the context read.
|
|
36
|
+
*/
|
|
37
|
+
export function useContextWebsite(): ContextWebsiteState {
|
|
38
|
+
const { t } = useI18n()
|
|
39
|
+
const api = useMarketingApi()
|
|
40
|
+
const toast = useToast()
|
|
41
|
+
const route = useDmsRoute()
|
|
42
|
+
const router = useDmsRouter()
|
|
43
|
+
|
|
44
|
+
const contextRead = useMarketingContextRead()
|
|
45
|
+
const context = contextRead.data
|
|
46
|
+
const failed = computed(
|
|
47
|
+
() =>
|
|
48
|
+
!!contextRead.error.value &&
|
|
49
|
+
contextRead.status.value !== 'pending' &&
|
|
50
|
+
!context.value,
|
|
51
|
+
)
|
|
52
|
+
const switching = useDmsState('marketing-context-switching', () => false)
|
|
53
|
+
|
|
54
|
+
const websites = computed(() => context.value?.websites ?? [])
|
|
55
|
+
const linkedId = computed(() => {
|
|
56
|
+
const id = route.query.website
|
|
57
|
+
return typeof id === 'string' &&
|
|
58
|
+
websites.value.some((site) => site.id === id)
|
|
59
|
+
? id
|
|
60
|
+
: null
|
|
61
|
+
})
|
|
62
|
+
const selectedId = computed(
|
|
63
|
+
() => linkedId.value ?? context.value?.selectedId ?? null,
|
|
64
|
+
)
|
|
65
|
+
const selected = computed<ContextWebsite | null>(
|
|
66
|
+
() => websites.value.find((site) => site.id === selectedId.value) ?? null,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const reload = (): Promise<void> => contextRead.refresh()
|
|
70
|
+
|
|
71
|
+
async function select(id: string): Promise<void> {
|
|
72
|
+
if (id === selectedId.value) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
switching.value = true
|
|
76
|
+
try {
|
|
77
|
+
// Saved first, as the website of the pages opened without one; the
|
|
78
|
+
// page follows the URL once it names it. A pick is a history entry, so
|
|
79
|
+
// Back takes it back.
|
|
80
|
+
context.value = await api.selectContextWebsite(id)
|
|
81
|
+
await router.push({ query: { ...route.query, website: id } })
|
|
82
|
+
} catch {
|
|
83
|
+
toast.add({
|
|
84
|
+
color: 'error',
|
|
85
|
+
title: t('page.marketing.context.switch_error'),
|
|
86
|
+
})
|
|
87
|
+
await reload()
|
|
88
|
+
} finally {
|
|
89
|
+
switching.value = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
context,
|
|
95
|
+
failed,
|
|
96
|
+
websites,
|
|
97
|
+
linkedId,
|
|
98
|
+
selectedId,
|
|
99
|
+
selected,
|
|
100
|
+
switching,
|
|
101
|
+
select,
|
|
102
|
+
reload,
|
|
103
|
+
ready: contextRead,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
interface UseCopyDraftOptions<T> {
|
|
4
|
+
/** The form's current values. */
|
|
5
|
+
values: () => T
|
|
6
|
+
/** Whether they differ from the form's baseline (`useFormDirty`). */
|
|
7
|
+
dirty: Ref<boolean> | ComputedRef<boolean>
|
|
8
|
+
/** Makes the current values the baseline (`useFormDirty`). */
|
|
9
|
+
markClean: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The unsaved state of a form that can open on a copy (a Duplicate). The copy
|
|
14
|
+
* is a change to save, so the save bar shows it from the start; but an
|
|
15
|
+
* untouched copy holds no work to lose: leaving it asks nothing, and closing
|
|
16
|
+
* the tab or a scripted navigation is never held by a "leave page?" prompt.
|
|
17
|
+
* Once the user edits it, it is guarded like any other change.
|
|
18
|
+
*/
|
|
19
|
+
export function useCopyDraft<T>(options: UseCopyDraftOptions<T>) {
|
|
20
|
+
/** The values the copy opened on; null outside a copy. */
|
|
21
|
+
const copied = ref<string | null>(null)
|
|
22
|
+
const snapshot = () => JSON.stringify(options.values())
|
|
23
|
+
|
|
24
|
+
/** Work leaving would lose: what the leave guard watches. */
|
|
25
|
+
const unsaved = computed(() =>
|
|
26
|
+
copied.value === null ? options.dirty.value : snapshot() !== copied.value,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
/** The copy is applied to the form: what is on screen is the copy. */
|
|
30
|
+
function open(): void {
|
|
31
|
+
copied.value = snapshot()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ends the copy on what is on screen: the form is clean and nothing holds
|
|
35
|
+
* the leave. Throwing it away (with any edit to it) and saving it both end it. */
|
|
36
|
+
function settle(): void {
|
|
37
|
+
copied.value = null
|
|
38
|
+
options.markClean()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
isCopy: computed(() => copied.value !== null),
|
|
43
|
+
unsaved,
|
|
44
|
+
open,
|
|
45
|
+
settle,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -24,7 +24,10 @@ export interface MarketingRejectedSource {
|
|
|
24
24
|
lastAt: number
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* One card of the Websites page: the computed fields a row of the websites
|
|
29
|
+
* table (GET /api/marketing/tables/websites/list) carries.
|
|
30
|
+
*/
|
|
28
31
|
export interface MarketingWebsiteCard extends ContextWebsite {
|
|
29
32
|
extraDomains: string[]
|
|
30
33
|
snapshotsEnabled: boolean
|
|
@@ -37,9 +40,14 @@ export interface MarketingWebsiteCard extends ContextWebsite {
|
|
|
37
40
|
snapshots: number
|
|
38
41
|
sampleRate: number
|
|
39
42
|
rejected: MarketingRejectedSource[]
|
|
43
|
+
/** The site `dashboardTracking` provisions: it cannot be deleted. */
|
|
44
|
+
dashboard: boolean
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* What deleting a website, or purging its data, discards
|
|
49
|
+
* (GET /websites/:id/deletion).
|
|
50
|
+
*/
|
|
43
51
|
export interface MarketingWebsiteDeletionImpact {
|
|
44
52
|
/** Every stored day, not a window. */
|
|
45
53
|
sessions: number
|
|
@@ -48,6 +56,24 @@ export interface MarketingWebsiteDeletionImpact {
|
|
|
48
56
|
since: number | null
|
|
49
57
|
funnels: number
|
|
50
58
|
snapshots: number
|
|
59
|
+
/** Clicks and scrolls, every period: what the heatmaps are drawn from. */
|
|
60
|
+
heatmapEvents: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What a purge deletes: everything collected, or the heatmaps alone. */
|
|
64
|
+
export type MarketingPurgeScope = 'all' | 'heatmaps'
|
|
65
|
+
|
|
66
|
+
/** Rows a purge deleted (POST /websites/:id/purge), 0 for what it keeps. */
|
|
67
|
+
export interface MarketingWebsitePurge {
|
|
68
|
+
purged: string
|
|
69
|
+
scope: MarketingPurgeScope
|
|
70
|
+
deleted: {
|
|
71
|
+
events: number
|
|
72
|
+
sessions: number
|
|
73
|
+
days: number
|
|
74
|
+
snapshots: number
|
|
75
|
+
imports: number
|
|
76
|
+
}
|
|
51
77
|
}
|
|
52
78
|
|
|
53
79
|
export interface MarketingConnectionStatus {
|
|
@@ -123,11 +149,18 @@ export interface MarketingPageSnapshot {
|
|
|
123
149
|
exact: boolean
|
|
124
150
|
capturedAt: number
|
|
125
151
|
origin: string
|
|
152
|
+
/** Origin and path captured — never a query string or fragment; absent
|
|
153
|
+
* from an older backend. */
|
|
154
|
+
url?: string
|
|
126
155
|
viewport: { width: number; height: number }
|
|
127
156
|
document: { width: number; height: number }
|
|
128
157
|
colorScheme?: 'light' | 'dark'
|
|
129
158
|
bytes: number
|
|
159
|
+
/** The upload cap in force; absent from an older backend. */
|
|
160
|
+
maxBytes?: number
|
|
130
161
|
html: string | null
|
|
162
|
+
/** Text masked by the backend: a capture of another workspace. */
|
|
163
|
+
masked?: boolean
|
|
131
164
|
}
|
|
132
165
|
|
|
133
166
|
export interface MarketingPageSnapshotResponse {
|
|
@@ -135,6 +168,30 @@ export interface MarketingPageSnapshotResponse {
|
|
|
135
168
|
snapshot: MarketingPageSnapshot | null
|
|
136
169
|
}
|
|
137
170
|
|
|
171
|
+
/** A workspace the dashboard's heatmaps can be read for (platform owners). */
|
|
172
|
+
export interface MarketingWorkspace {
|
|
173
|
+
id: string
|
|
174
|
+
name: string
|
|
175
|
+
/** The viewer's own workspace. */
|
|
176
|
+
current: boolean
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface MarketingWorkspaces {
|
|
180
|
+
/** Empty: no selector (not a platform owner, a customer website, or a
|
|
181
|
+
* single workspace). */
|
|
182
|
+
workspaces: MarketingWorkspace[]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** What was captured of one path, every layout together. */
|
|
186
|
+
export interface MarketingPageSnapshotSummary {
|
|
187
|
+
/** The newest capture with a document, else the newest oversize report. */
|
|
188
|
+
capturedAt: number
|
|
189
|
+
/** Layouts holding a document, widest first. */
|
|
190
|
+
layouts: MarketingSnapshotLayout[]
|
|
191
|
+
/** Only oversize reports: the page is too heavy to capture. */
|
|
192
|
+
tooHeavy: boolean
|
|
193
|
+
}
|
|
194
|
+
|
|
138
195
|
/** One observed path of a tracked site, over the requested window. */
|
|
139
196
|
export interface MarketingPageEntry {
|
|
140
197
|
path: string
|
|
@@ -142,6 +199,9 @@ export interface MarketingPageEntry {
|
|
|
142
199
|
clicks: number
|
|
143
200
|
/** Page loads sampled for clicks and scrolls, as counted. */
|
|
144
201
|
sampled: number
|
|
202
|
+
/** Null for no capture; absent while the site has snapshots off (or from
|
|
203
|
+
* an older backend). */
|
|
204
|
+
snapshot?: MarketingPageSnapshotSummary | null
|
|
145
205
|
}
|
|
146
206
|
|
|
147
207
|
/**
|
|
@@ -171,6 +231,8 @@ export interface MarketingPages {
|
|
|
171
231
|
sampleRate: number | null
|
|
172
232
|
/** Global collection switch; off means nothing new is being recorded. */
|
|
173
233
|
trackerEnabled: boolean
|
|
234
|
+
/** The site's snapshot option; absent from an older backend. */
|
|
235
|
+
snapshotsEnabled?: boolean
|
|
174
236
|
}
|
|
175
237
|
|
|
176
238
|
export interface MarketingFunnelStep {
|
|
@@ -344,6 +406,8 @@ export interface MarketingFunnelResults {
|
|
|
344
406
|
/** The same funnel over the comparison window; null on a split, without
|
|
345
407
|
* one, or when raw events do not cover it. */
|
|
346
408
|
previous?: MarketingFunnelComputation | null
|
|
409
|
+
/** What the period is compared with; null without a comparison window. */
|
|
410
|
+
comparison?: 'previous-period' | 'previous-year' | null
|
|
347
411
|
coverage?: MarketingFunnelCoverage
|
|
348
412
|
}
|
|
349
413
|
|
|
@@ -399,6 +463,24 @@ export interface MarketingDashboardTracking {
|
|
|
399
463
|
|
|
400
464
|
// --- Facade -----------------------------------------------------------------
|
|
401
465
|
|
|
466
|
+
type MarketingApi = ReturnType<typeof useMarketingApi>
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Writes a funnel definition, creating it when no id is given, and answers
|
|
470
|
+
* the id of the definition written.
|
|
471
|
+
*/
|
|
472
|
+
export async function saveFunnel(
|
|
473
|
+
api: Pick<MarketingApi, 'createFunnel' | 'updateFunnel'>,
|
|
474
|
+
input: MarketingFunnelInput,
|
|
475
|
+
funnelId?: string,
|
|
476
|
+
): Promise<string> {
|
|
477
|
+
if (!funnelId) {
|
|
478
|
+
return (await api.createFunnel(input))._id
|
|
479
|
+
}
|
|
480
|
+
await api.updateFunnel(funnelId, input)
|
|
481
|
+
return funnelId
|
|
482
|
+
}
|
|
483
|
+
|
|
402
484
|
export function useMarketingApi() {
|
|
403
485
|
const { $authFetch } = useAuthFetch()
|
|
404
486
|
|
|
@@ -427,6 +509,12 @@ export function useMarketingApi() {
|
|
|
427
509
|
{ method: 'DELETE' },
|
|
428
510
|
),
|
|
429
511
|
|
|
512
|
+
purgeWebsite: (website: string, scope: MarketingPurgeScope) =>
|
|
513
|
+
$authFetch<MarketingWebsitePurge>(
|
|
514
|
+
`/api/marketing/websites/${encodeURIComponent(website)}/purge`,
|
|
515
|
+
{ method: 'POST', body: { scope } },
|
|
516
|
+
),
|
|
517
|
+
|
|
430
518
|
previewFunnel: (
|
|
431
519
|
website: string,
|
|
432
520
|
steps: MarketingFunnelStep[],
|
|
@@ -454,9 +542,9 @@ export function useMarketingApi() {
|
|
|
454
542
|
|
|
455
543
|
// Definitions CRUD over the funnels data-api routes; the list is read by
|
|
456
544
|
// `useWebsiteFunnels`, one definition by `useFunnelRead`. The insert
|
|
457
|
-
// answers the
|
|
545
|
+
// answers the created row, its `_id` included (interface-data-api 0.3).
|
|
458
546
|
createFunnel: (input: MarketingFunnelInput) =>
|
|
459
|
-
$authFetch<
|
|
547
|
+
$authFetch<MarketingFunnelListItem>('/api/marketing/tables/funnels/new', {
|
|
460
548
|
method: 'POST',
|
|
461
549
|
body: input,
|
|
462
550
|
}),
|
|
@@ -14,8 +14,8 @@ import type {
|
|
|
14
14
|
MarketingScrollDepth,
|
|
15
15
|
MarketingSnapshotLayout,
|
|
16
16
|
MarketingWebsite,
|
|
17
|
-
MarketingWebsiteCard,
|
|
18
17
|
MarketingWebsiteDeletionImpact,
|
|
18
|
+
MarketingWorkspaces,
|
|
19
19
|
} from './useMarketingApi'
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -26,8 +26,9 @@ import type {
|
|
|
26
26
|
* over the one on screen). The page preview and the funnel builder draw in
|
|
27
27
|
* the browser only (`server: false`); a dialog or the builder reads what it
|
|
28
28
|
* shows when it asks (`ReadOnDemand`). The page's refreshes
|
|
29
|
-
* (`refreshPageBlocks()`)
|
|
30
|
-
* bar's, which reads
|
|
29
|
+
* (`refreshPageBlocks()`) and the events of its realtime topics read every
|
|
30
|
+
* one of them again but the context bar's, which reads through its own
|
|
31
|
+
* live refresh. Writes go through `useMarketingApi`.
|
|
31
32
|
*
|
|
32
33
|
* A read bound to the period waits for it: the server knows it only once the
|
|
33
34
|
* browser's time zone is known, and the browser once the context bar's
|
|
@@ -47,34 +48,17 @@ function periodRead(
|
|
|
47
48
|
return state ? (appendPeriodToUrl(url, state) ?? url) : null
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
/** How the context bar reads the context: on its own clock. */
|
|
51
|
-
export interface ContextReadOptions {
|
|
52
|
-
/**
|
|
53
|
-
* Reads again every this many milliseconds while the caller shows the
|
|
54
|
-
* data; a ref or a getter changes it, and `0` or `null` stops it.
|
|
55
|
-
*/
|
|
56
|
-
refreshInterval?: MaybeRefOrGetter<number | null | undefined>
|
|
57
|
-
}
|
|
58
|
-
|
|
59
51
|
/**
|
|
60
52
|
* The tenant's websites as the context bar lists them, and the saved pick:
|
|
61
|
-
* read again on the bar's own triggers (
|
|
62
|
-
* with the page's blocks.
|
|
53
|
+
* read again on the bar's own triggers (the page's live gate, its refresh
|
|
54
|
+
* button), not with the page's blocks.
|
|
63
55
|
*/
|
|
64
|
-
export function useMarketingContextRead(
|
|
65
|
-
options: ContextReadOptions = {},
|
|
66
|
-
): ApiDataResult<MarketingContextPayload> {
|
|
56
|
+
export function useMarketingContextRead(): ApiDataResult<MarketingContextPayload> {
|
|
67
57
|
return useApiData<MarketingContextPayload>('/api/marketing/context', {
|
|
68
58
|
refreshOnPageRefresh: false,
|
|
69
|
-
refreshInterval: options.refreshInterval,
|
|
70
59
|
})
|
|
71
60
|
}
|
|
72
61
|
|
|
73
|
-
/** The Websites page's cards. */
|
|
74
|
-
export function useWebsiteCards(): ApiDataResult<MarketingWebsiteCard[]> {
|
|
75
|
-
return useApiData<MarketingWebsiteCard[]>('/api/marketing/websites/summary')
|
|
76
|
-
}
|
|
77
|
-
|
|
78
62
|
/** The tenant's websites, with their capture options. */
|
|
79
63
|
export function useWebsites(): ApiDataResult<MarketingWebsite[]> {
|
|
80
64
|
return useApiData<MarketingWebsite[]>('/api/marketing/websites')
|
|
@@ -104,6 +88,9 @@ export interface PageInventoryRequest {
|
|
|
104
88
|
period: MaybeRefOrGetter<MarketingPeriod>
|
|
105
89
|
search: MaybeRefOrGetter<string>
|
|
106
90
|
sort: MaybeRefOrGetter<MarketingPagesSort>
|
|
91
|
+
/** Another workspace's dashboard, or `all` (see useDashboardWorkspaces);
|
|
92
|
+
* empty for the website's own data. */
|
|
93
|
+
workspace?: MaybeRefOrGetter<string>
|
|
107
94
|
}
|
|
108
95
|
|
|
109
96
|
/** The paths a website's visitors loaded over the period. */
|
|
@@ -120,6 +107,7 @@ export function usePageInventory(
|
|
|
120
107
|
website: toValue(request.websiteId),
|
|
121
108
|
search: toValue(request.search),
|
|
122
109
|
sort: toValue(request.sort),
|
|
110
|
+
workspace: toValue(request.workspace) || undefined,
|
|
123
111
|
}),
|
|
124
112
|
},
|
|
125
113
|
)
|
|
@@ -171,6 +159,8 @@ export interface PagePreviewRequest {
|
|
|
171
159
|
websiteId: MaybeRefOrGetter<string | undefined>
|
|
172
160
|
path: MaybeRefOrGetter<string>
|
|
173
161
|
period: MaybeRefOrGetter<MarketingPeriod>
|
|
162
|
+
/** As PageInventoryRequest's. */
|
|
163
|
+
workspace?: MaybeRefOrGetter<string | undefined>
|
|
174
164
|
}
|
|
175
165
|
|
|
176
166
|
function previewRead<T>(
|
|
@@ -186,6 +176,7 @@ function previewRead<T>(
|
|
|
186
176
|
query: () => ({
|
|
187
177
|
website: toValue(request.websiteId),
|
|
188
178
|
path: toValue(request.path),
|
|
179
|
+
workspace: toValue(request.workspace) || undefined,
|
|
189
180
|
}),
|
|
190
181
|
server: false,
|
|
191
182
|
},
|
|
@@ -217,6 +208,8 @@ export interface PageSnapshotRequest {
|
|
|
217
208
|
* backend while the request stays the same, so the capture is read again.
|
|
218
209
|
*/
|
|
219
210
|
maskText: MaybeRefOrGetter<boolean>
|
|
211
|
+
/** As PageInventoryRequest's: the backend masks such a capture. */
|
|
212
|
+
workspace?: MaybeRefOrGetter<string | undefined>
|
|
220
213
|
}
|
|
221
214
|
|
|
222
215
|
/** A page's capture in a layout, drawn in the browser. */
|
|
@@ -235,6 +228,7 @@ export function usePageSnapshot(
|
|
|
235
228
|
website: toValue(request.websiteId),
|
|
236
229
|
path: toValue(request.path),
|
|
237
230
|
layout: toValue(request.layout),
|
|
231
|
+
workspace: toValue(request.workspace) || undefined,
|
|
238
232
|
}),
|
|
239
233
|
watch: [() => toValue(request.maskText)],
|
|
240
234
|
server: false,
|
|
@@ -242,6 +236,21 @@ export function usePageSnapshot(
|
|
|
242
236
|
)
|
|
243
237
|
}
|
|
244
238
|
|
|
239
|
+
/**
|
|
240
|
+
* The workspaces a platform owner can read the dashboard's heatmaps of, from
|
|
241
|
+
* the dashboard's own website; an empty list for anyone else, on any other
|
|
242
|
+
* website, and on a single-workspace install. Read with the page: the pages
|
|
243
|
+
* panel shows its selector from the first paint.
|
|
244
|
+
*/
|
|
245
|
+
export function useDashboardWorkspaces(
|
|
246
|
+
websiteId: MaybeRefOrGetter<string>,
|
|
247
|
+
): ApiDataResult<MarketingWorkspaces> {
|
|
248
|
+
return useApiData<MarketingWorkspaces>(
|
|
249
|
+
() => (toValue(websiteId) ? '/api/marketing/stats/workspaces' : null),
|
|
250
|
+
{ query: () => ({ website: toValue(websiteId) }) },
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
245
254
|
/** The pages and events a website's visitors sent, for the funnel builder. */
|
|
246
255
|
export function useFunnelSuggestions(
|
|
247
256
|
websiteId: MaybeRefOrGetter<string>,
|
|
@@ -71,8 +71,31 @@ export function funnelBuilderLink(
|
|
|
71
71
|
})
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
/** A setting of the Websites page's settings modal a link can open on. */
|
|
75
|
+
export type WebsiteSetting = 'snapshots'
|
|
76
|
+
|
|
77
|
+
const WEBSITES_PATH = `${MODULE_BASE}/websites`
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The Websites page; with `record`, the settings modal of that site open
|
|
81
|
+
* (the table's deep link), scrolled to `setting` when given — where a
|
|
82
|
+
* setting shown elsewhere (the heatmap preview's snapshot state) links to.
|
|
83
|
+
*/
|
|
84
|
+
export function websitesLink(
|
|
85
|
+
record?: string | null,
|
|
86
|
+
setting?: WebsiteSetting,
|
|
87
|
+
): string {
|
|
88
|
+
return withQuery(WEBSITES_PATH, {
|
|
89
|
+
record,
|
|
90
|
+
setting: record ? setting : null,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The setting a Websites page URL scrolls the open modal to, if any. */
|
|
95
|
+
export function websiteSettingOf(
|
|
96
|
+
query: Record<string, unknown>,
|
|
97
|
+
): WebsiteSetting | undefined {
|
|
98
|
+
return query.setting === 'snapshots' ? 'snapshots' : undefined
|
|
76
99
|
}
|
|
77
100
|
|
|
78
101
|
/** The install guide: a new website, or the one `website` names. */
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { watch } from 'vue'
|
|
2
|
+
import { useDmsRoute } from '#dms/frontend-module'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ends the page's breadcrumb with the name of the record it shows ("… ›
|
|
6
|
+
* Marketing › Checkout funnel") through the DMS's `usePageRecordLabel`, which
|
|
7
|
+
* a form's `labelKey` uses on stock record pages. A page drawing its own
|
|
8
|
+
* record (a custom block) names it the same way; while the record is loading
|
|
9
|
+
* or missing, the page's own name stays.
|
|
10
|
+
*/
|
|
11
|
+
export function useRecordCrumb(name: () => string | null | undefined): void {
|
|
12
|
+
const route = useDmsRoute()
|
|
13
|
+
const { setLabel } = usePageRecordLabel()
|
|
14
|
+
watch(name, (label) => setLabel(route.path, label || undefined), {
|
|
15
|
+
immediate: true,
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
|
2
|
+
import { unrollSnapshotScrollRoot } from '../utils/snapshotScrollRoot'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Geometry of a page snapshot rendered in a same-origin, script-less frame.
|
|
@@ -10,6 +11,10 @@ import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
|
|
10
11
|
* from a reading taken through it runs away, every `100vh` section growing
|
|
11
12
|
* one tick at a time, exactly as it would with a live page. Once published,
|
|
12
13
|
* later growth is ignored until `reset()`.
|
|
14
|
+
*
|
|
15
|
+
* A capture of an app shell scrolling an inner panel is unrolled first (see
|
|
16
|
+
* snapshotScrollRoot): the reading is then the whole content, not the
|
|
17
|
+
* visitor's window.
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
/** Fallback document height when nothing measured it. */
|
|
@@ -112,6 +117,7 @@ export function useSnapshotFrame(frame: Ref<HTMLIFrameElement | null>) {
|
|
|
112
117
|
if (!doc || closed) {
|
|
113
118
|
return
|
|
114
119
|
}
|
|
120
|
+
unrollSnapshotScrollRoot(doc)
|
|
115
121
|
void (doc.fonts?.ready ?? Promise.resolve()).then(() => {
|
|
116
122
|
if (closed) {
|
|
117
123
|
return
|
|
@@ -184,6 +190,26 @@ export function useSnapshotFrame(frame: Ref<HTMLIFrameElement | null>) {
|
|
|
184
190
|
return boxes
|
|
185
191
|
}
|
|
186
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The anchors whose element the snapshot does not have at all — no
|
|
195
|
+
* `nth` match, or a selector this document cannot parse. An element that
|
|
196
|
+
* is there but not drawn at this width (a phone-only menu in a desktop
|
|
197
|
+
* layout) is not missing: measureAnchors gives it no box, this keeps it.
|
|
198
|
+
*/
|
|
199
|
+
function missingAnchors(anchors: AnchorRequest[]): AnchorRequest[] {
|
|
200
|
+
const doc = frameDocument()
|
|
201
|
+
if (!doc) {
|
|
202
|
+
return []
|
|
203
|
+
}
|
|
204
|
+
return anchors.filter((anchor) => {
|
|
205
|
+
try {
|
|
206
|
+
return !doc.querySelectorAll(anchor.selector)[anchor.nth]
|
|
207
|
+
} catch {
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
187
213
|
onBeforeUnmount(() => {
|
|
188
214
|
watchedFrame?.removeEventListener('load', onFrameLoad)
|
|
189
215
|
watchedFrame = null
|
|
@@ -201,5 +227,13 @@ export function useSnapshotFrame(frame: Ref<HTMLIFrameElement | null>) {
|
|
|
201
227
|
return measuredHeight.value !== null ? 'measured' : 'pending'
|
|
202
228
|
})
|
|
203
229
|
|
|
204
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
measuredWidth,
|
|
232
|
+
measuredHeight,
|
|
233
|
+
tier,
|
|
234
|
+
reset,
|
|
235
|
+
close,
|
|
236
|
+
measureAnchors,
|
|
237
|
+
missingAnchors,
|
|
238
|
+
}
|
|
205
239
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many anchored clicks aim at elements the snapshot no longer has: the
|
|
3
|
+
* sign the page changed since it was captured. Such a click is drawn at
|
|
4
|
+
* its document fraction instead of on its element (see PagePreview), which
|
|
5
|
+
* is still a position, not a loss — so the warning waits for a share and a
|
|
6
|
+
* count that say more than one removed button.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Share of the anchored clicks past which the warning shows. */
|
|
10
|
+
export const DRIFT_MIN_SHARE = 0.1
|
|
11
|
+
/** And the fewest clicks it takes: two clicks out of ten are not a trend. */
|
|
12
|
+
export const DRIFT_MIN_CLICKS = 5
|
|
13
|
+
|
|
14
|
+
interface DriftPoint {
|
|
15
|
+
weight: number
|
|
16
|
+
anchor?: { selector: string; nth: number }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AnchorDrift {
|
|
20
|
+
/** Clicks carrying an element anchor — the ones that can drift. */
|
|
21
|
+
anchored: number
|
|
22
|
+
/** Of those, the clicks whose element is not in the snapshot. */
|
|
23
|
+
missing: number
|
|
24
|
+
/** missing / anchored, 0 without anchored clicks. */
|
|
25
|
+
share: number
|
|
26
|
+
/** Past both thresholds: worth a warning. */
|
|
27
|
+
significant: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `isMissing` answers whether an anchor's element is absent from the
|
|
32
|
+
* snapshot document — absent, not merely hidden at the layout on screen: a
|
|
33
|
+
* phone menu clicked by phone visitors is in a desktop capture, just not
|
|
34
|
+
* drawn, and is no sign of change.
|
|
35
|
+
*/
|
|
36
|
+
export function anchorDrift(
|
|
37
|
+
points: DriftPoint[],
|
|
38
|
+
isMissing: (anchor: { selector: string; nth: number }) => boolean,
|
|
39
|
+
): AnchorDrift {
|
|
40
|
+
let anchored = 0
|
|
41
|
+
let missing = 0
|
|
42
|
+
for (const point of points) {
|
|
43
|
+
if (!point.anchor) {
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
anchored += point.weight
|
|
47
|
+
if (isMissing(point.anchor)) {
|
|
48
|
+
missing += point.weight
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const share = anchored > 0 ? missing / anchored : 0
|
|
52
|
+
return {
|
|
53
|
+
anchored,
|
|
54
|
+
missing,
|
|
55
|
+
share,
|
|
56
|
+
significant: missing >= DRIFT_MIN_CLICKS && share >= DRIFT_MIN_SHARE,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pages whose drift warning was dismissed: for the rest of the session (the
|
|
5
|
+
* tab's lifetime), per website and path, so switching pages and coming back
|
|
6
|
+
* does not bring it back. A reload does: the drift is still there.
|
|
7
|
+
*/
|
|
8
|
+
const dismissed = ref(new Set<string>())
|
|
9
|
+
|
|
10
|
+
function key(websiteId: string, path: string): string {
|
|
11
|
+
return `${websiteId}\u0000${path}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function driftDismissed(websiteId: string, path: string): boolean {
|
|
15
|
+
return dismissed.value.has(key(websiteId, path))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function dismissDrift(websiteId: string, path: string): void {
|
|
19
|
+
dismissed.value = new Set(dismissed.value).add(key(websiteId, path))
|
|
20
|
+
}
|