@antelopejs/dms-marketing 0.6.1 → 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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Height that lets an element fill what is left of its scroll viewport: the
|
|
3
|
+
* viewport's height, minus where the element starts and minus whatever the
|
|
4
|
+
* page lays out after it (a note, the shell's bottom padding), so the page
|
|
5
|
+
* itself has nothing left to scroll. All in the scroll content's
|
|
6
|
+
* coordinates, so the answer does not move as the page scrolls.
|
|
7
|
+
*
|
|
8
|
+
* What follows the element is measured from the layout itself, never from
|
|
9
|
+
* the scroll content's height: once the content is shorter than the
|
|
10
|
+
* viewport, `scrollHeight` is the viewport's height, the empty space below
|
|
11
|
+
* the element would read as content after it, and the element could never
|
|
12
|
+
* grow with the window.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Below this the panes stop shrinking and the page scrolls instead. */
|
|
16
|
+
export const MIN_FILL_HEIGHT = 480
|
|
17
|
+
|
|
18
|
+
export interface FillGeometry {
|
|
19
|
+
/** Height of the scroll viewport (the window, or the shell's panel). */
|
|
20
|
+
viewHeight: number
|
|
21
|
+
/** Where the element starts in the scroll content. */
|
|
22
|
+
top: number
|
|
23
|
+
/** What the content lays out after the element (see trailingExtent). */
|
|
24
|
+
trailing: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fillHeight(geometry: FillGeometry): number {
|
|
28
|
+
return Math.max(
|
|
29
|
+
Math.floor(geometry.viewHeight - geometry.top - geometry.trailing),
|
|
30
|
+
MIN_FILL_HEIGHT,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One step from the element up to the scroll root: a box and its parent. */
|
|
35
|
+
export interface TrailingLevel {
|
|
36
|
+
/** Bottom of the box's border box. */
|
|
37
|
+
bottom: number
|
|
38
|
+
/** The box's bottom margin, what it adds when nothing follows it. */
|
|
39
|
+
marginBottom: number
|
|
40
|
+
/** Bottoms (margin included) of the in-flow siblings after the box. */
|
|
41
|
+
following: number[]
|
|
42
|
+
/** The parent's bottom padding and border. */
|
|
43
|
+
closing: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What the content lays out after the element: at each level, how far the
|
|
48
|
+
* box's following siblings reach past its own bottom, and the parent's
|
|
49
|
+
* closing padding. Each level is counted from the box's actual bottom, so a
|
|
50
|
+
* box stretched to the viewport (`min-height: 100%`) adds nothing for its
|
|
51
|
+
* empty space, and the sum does not depend on the viewport's height.
|
|
52
|
+
*/
|
|
53
|
+
export function trailingExtent(levels: TrailingLevel[]): number {
|
|
54
|
+
let trailing = 0
|
|
55
|
+
for (const level of levels) {
|
|
56
|
+
trailing +=
|
|
57
|
+
level.following.length > 0
|
|
58
|
+
? Math.max(Math.max(...level.following) - level.bottom, 0)
|
|
59
|
+
: level.marginBottom
|
|
60
|
+
trailing += level.closing
|
|
61
|
+
}
|
|
62
|
+
return trailing
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SCROLLING = new Set(['auto', 'scroll', 'overlay'])
|
|
66
|
+
|
|
67
|
+
/** The element's scroll viewport: an app shell scrolls a panel, a plain
|
|
68
|
+
* page the document. */
|
|
69
|
+
function scrollViewport(element: HTMLElement): HTMLElement | null {
|
|
70
|
+
for (let node = element.parentElement; node; node = node.parentElement) {
|
|
71
|
+
if (SCROLLING.has(getComputedStyle(node).overflowY)) {
|
|
72
|
+
return node
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function px(value: string): number {
|
|
79
|
+
return Number.parseFloat(value) || 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inFlow(style: CSSStyleDeclaration): boolean {
|
|
83
|
+
return (
|
|
84
|
+
style.display !== 'none' &&
|
|
85
|
+
style.position !== 'absolute' &&
|
|
86
|
+
style.position !== 'fixed'
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The levels from the element up to the scroll root (the viewport, or the
|
|
91
|
+
* document element), read off the page. */
|
|
92
|
+
function trailingLevels(element: Element, root: Element): TrailingLevel[] {
|
|
93
|
+
const levels: TrailingLevel[] = []
|
|
94
|
+
for (let node = element; node !== root && node.parentElement;) {
|
|
95
|
+
const parent: Element = node.parentElement
|
|
96
|
+
const following: number[] = []
|
|
97
|
+
for (let next = node.nextElementSibling; next;) {
|
|
98
|
+
const style = getComputedStyle(next)
|
|
99
|
+
if (inFlow(style)) {
|
|
100
|
+
following.push(
|
|
101
|
+
next.getBoundingClientRect().bottom + px(style.marginBottom),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
next = next.nextElementSibling
|
|
105
|
+
}
|
|
106
|
+
const parentStyle = getComputedStyle(parent)
|
|
107
|
+
levels.push({
|
|
108
|
+
bottom: node.getBoundingClientRect().bottom,
|
|
109
|
+
marginBottom: px(getComputedStyle(node).marginBottom),
|
|
110
|
+
following,
|
|
111
|
+
// A scroll viewport's border is outside its clientHeight.
|
|
112
|
+
closing:
|
|
113
|
+
px(parentStyle.paddingBottom) +
|
|
114
|
+
(parent === root && root !== document.documentElement
|
|
115
|
+
? 0
|
|
116
|
+
: px(parentStyle.borderBottomWidth)),
|
|
117
|
+
})
|
|
118
|
+
node = parent
|
|
119
|
+
}
|
|
120
|
+
return levels
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** fillHeight for an element on screen. */
|
|
124
|
+
export function measureFillHeight(element: HTMLElement): number {
|
|
125
|
+
const box = element.getBoundingClientRect()
|
|
126
|
+
const viewport = scrollViewport(element)
|
|
127
|
+
if (!viewport) {
|
|
128
|
+
const root = document.documentElement
|
|
129
|
+
return fillHeight({
|
|
130
|
+
viewHeight: window.innerHeight,
|
|
131
|
+
top: box.top + window.scrollY,
|
|
132
|
+
trailing: trailingExtent(trailingLevels(element, root)),
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
const origin = viewport.getBoundingClientRect().top - viewport.scrollTop
|
|
136
|
+
return fillHeight({
|
|
137
|
+
viewHeight: viewport.clientHeight,
|
|
138
|
+
top: box.top - origin,
|
|
139
|
+
trailing: trailingExtent(trailingLevels(element, viewport)),
|
|
140
|
+
})
|
|
141
|
+
}
|
|
@@ -75,14 +75,33 @@ const RELATIVE_STEPS: [Intl.RelativeTimeFormatUnit, number][] = [
|
|
|
75
75
|
['year', Number.POSITIVE_INFINITY],
|
|
76
76
|
]
|
|
77
77
|
|
|
78
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Under this age an event reads "now": the server stamps events on its clock,
|
|
80
|
+
* which may run a few seconds ahead of the reader's.
|
|
81
|
+
*/
|
|
82
|
+
export const JUST_NOW_MS = 5 * MS_PER_SECOND
|
|
83
|
+
|
|
84
|
+
/** Whether an event at `at` reads "just now", including one stamped ahead. */
|
|
85
|
+
export function isJustNow(at: number, now: number): boolean {
|
|
86
|
+
return now - at < JUST_NOW_MS
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* "12 seconds ago", "4 minutes ago" — the age of the last event; "12 sec ago"
|
|
91
|
+
* with the `short` style. Never in the future: an age under `JUST_NOW_MS`, or
|
|
92
|
+
* a timestamp ahead of `now`, reads "now".
|
|
93
|
+
*/
|
|
79
94
|
export function formatRelativeTime(
|
|
80
95
|
at: number,
|
|
81
96
|
now: number,
|
|
82
97
|
locale: string,
|
|
98
|
+
style: Intl.RelativeTimeFormatStyle = 'long',
|
|
83
99
|
): string {
|
|
100
|
+
const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style })
|
|
101
|
+
if (isJustNow(at, now)) {
|
|
102
|
+
return format.format(0, 'second')
|
|
103
|
+
}
|
|
84
104
|
let value = Math.round((at - now) / MS_PER_SECOND)
|
|
85
|
-
const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
|
|
86
105
|
for (const [unit, size] of RELATIVE_STEPS) {
|
|
87
106
|
if (Math.abs(value) < size) {
|
|
88
107
|
return format.format(value, unit)
|
|
@@ -15,6 +15,16 @@ export const MIN_VARIATIONS = 2
|
|
|
15
15
|
export const MAX_VARIATIONS = 8
|
|
16
16
|
export const MAX_EXPERIMENT_KEY_LENGTH = 64
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The windows the builder offers as one click, in hours. A funnel is matched
|
|
20
|
+
* within one session, which ends after 30 minutes without activity: a window
|
|
21
|
+
* of days would change nothing (FB-11). Each preset's label states the hours
|
|
22
|
+
* it saves, so the form shows what the funnel stores.
|
|
23
|
+
*/
|
|
24
|
+
export const WINDOW_PRESET_HOURS = [1, 24] as const
|
|
25
|
+
/** A new funnel's window, as the backend defaults it. */
|
|
26
|
+
export const DEFAULT_WINDOW_HOURS = 24
|
|
27
|
+
|
|
18
28
|
/** The slug page code passes to `dmsMarketing.variation(…)`, both for the
|
|
19
29
|
* experiment and its arms: it starts with a letter or a digit. */
|
|
20
30
|
const EXPERIMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
|
|
@@ -100,6 +110,18 @@ export function templateSteps(raw: unknown): StepValue[] {
|
|
|
100
110
|
.slice(0, MAX_STEPS)
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
/**
|
|
114
|
+
* A stored funnel as the builder opens it: `?split=1` (the report's "Split
|
|
115
|
+
* into A/B test") turns its split on; a funnel that already carries one keeps
|
|
116
|
+
* it either way.
|
|
117
|
+
*/
|
|
118
|
+
export function withRequestedSplit(
|
|
119
|
+
draft: FunnelDraftValues,
|
|
120
|
+
raw: unknown,
|
|
121
|
+
): FunnelDraftValues {
|
|
122
|
+
return raw === '1' ? { ...draft, split: true } : draft
|
|
123
|
+
}
|
|
124
|
+
|
|
103
125
|
/** Whether a window is an hour count the server stores as is. */
|
|
104
126
|
export function isWindowHours(hours: number | string): hours is number {
|
|
105
127
|
return (
|
|
@@ -27,6 +27,23 @@ export function normalizePagePath(typed: string): string | undefined {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The selected path's row in the list just read, else the row it `kept`
|
|
32
|
+
* while a filter hides it. The caller drops what it kept when the list is
|
|
33
|
+
* read for another scope (workspace, days): there the path has its own
|
|
34
|
+
* figures, or none.
|
|
35
|
+
*/
|
|
36
|
+
export function selectedPageRow<Row extends { path: string }>(
|
|
37
|
+
list: readonly Row[],
|
|
38
|
+
path: string | undefined,
|
|
39
|
+
kept: Row | undefined,
|
|
40
|
+
): Row | undefined {
|
|
41
|
+
return (
|
|
42
|
+
list.find((page) => page.path === path) ??
|
|
43
|
+
(kept?.path === path ? kept : undefined)
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
/** What a heatmap reset deletes or deleted. */
|
|
31
48
|
export interface HeatmapDataCounts {
|
|
32
49
|
events: number
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MarketingPageSnapshotSummary,
|
|
3
|
+
MarketingSnapshotLayout,
|
|
4
|
+
} from '../composables/useMarketingApi'
|
|
5
|
+
import { formatRelativeTime, formatShortDate } from './format'
|
|
6
|
+
|
|
7
|
+
/** The `t` of useI18n, as far as these helpers call it. */
|
|
8
|
+
type Translate = (
|
|
9
|
+
key: string,
|
|
10
|
+
named?: Record<string, unknown>,
|
|
11
|
+
plural?: number,
|
|
12
|
+
) => string
|
|
13
|
+
|
|
14
|
+
const PREFIX = 'page.marketing.pages.row_snapshot'
|
|
15
|
+
|
|
16
|
+
/** What a row of the pages list says of its path's snapshot. */
|
|
17
|
+
export interface PageSnapshotBadge {
|
|
18
|
+
icon: string
|
|
19
|
+
label: string
|
|
20
|
+
/** The full sentence, for the tooltip and assistive technology. */
|
|
21
|
+
title: string
|
|
22
|
+
/** Muted when there is nothing to show. */
|
|
23
|
+
muted: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function layoutNames(layouts: MarketingSnapshotLayout[], t: Translate): string {
|
|
27
|
+
return layouts
|
|
28
|
+
.map((layout) =>
|
|
29
|
+
t(`page.marketing.pages.preview.layout_${layout}`).toLowerCase(),
|
|
30
|
+
)
|
|
31
|
+
.join(', ')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The row's snapshot mark: its age, or that the page has none or is too
|
|
36
|
+
* heavy. `undefined` is a site with snapshots off — the rows say nothing,
|
|
37
|
+
* the preview's strip says why.
|
|
38
|
+
*/
|
|
39
|
+
export function pageSnapshotBadge(
|
|
40
|
+
snapshot: MarketingPageSnapshotSummary | null | undefined,
|
|
41
|
+
now: number,
|
|
42
|
+
t: Translate,
|
|
43
|
+
locale: string,
|
|
44
|
+
): PageSnapshotBadge | null {
|
|
45
|
+
if (snapshot === undefined) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
if (snapshot === null) {
|
|
49
|
+
return {
|
|
50
|
+
icon: 'i-ph-camera-slash',
|
|
51
|
+
label: t(`${PREFIX}.none`),
|
|
52
|
+
title: t(`${PREFIX}.none_title`),
|
|
53
|
+
muted: true,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const date = formatShortDate(snapshot.capturedAt, locale)
|
|
57
|
+
if (snapshot.tooHeavy) {
|
|
58
|
+
return {
|
|
59
|
+
icon: 'i-ph-file-x',
|
|
60
|
+
label: t(`${PREFIX}.too_heavy`),
|
|
61
|
+
title: t(`${PREFIX}.too_heavy_title`, { date }),
|
|
62
|
+
muted: true,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
icon: 'i-ph-camera',
|
|
67
|
+
label: formatRelativeTime(snapshot.capturedAt, now, locale, 'short'),
|
|
68
|
+
title: t(
|
|
69
|
+
`${PREFIX}.title`,
|
|
70
|
+
{ date, layouts: layoutNames(snapshot.layouts, t) },
|
|
71
|
+
snapshot.layouts.length,
|
|
72
|
+
),
|
|
73
|
+
muted: false,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changing a site's snapshot options deletes its snapshots on the server
|
|
3
|
+
* (`PUT /api/marketing/websites/:id`): off means forgotten, and a mask
|
|
4
|
+
* setting only holds for captures taken under it. Every place offering the
|
|
5
|
+
* options asks first, with the same words.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The `t` of useI18n, as far as these helpers call it. */
|
|
9
|
+
type Translate = (
|
|
10
|
+
key: string,
|
|
11
|
+
named?: Record<string, unknown>,
|
|
12
|
+
plural?: number,
|
|
13
|
+
) => string
|
|
14
|
+
|
|
15
|
+
interface SnapshotOptions {
|
|
16
|
+
snapshotsEnabled: boolean
|
|
17
|
+
snapshotMaskText?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type SnapshotPatch = Partial<SnapshotOptions>
|
|
21
|
+
|
|
22
|
+
/** Whether saving `patch` changes an option the server discards on. */
|
|
23
|
+
export function changesSnapshotOptions(
|
|
24
|
+
website: SnapshotOptions,
|
|
25
|
+
patch: Partial<SnapshotOptions>,
|
|
26
|
+
): boolean {
|
|
27
|
+
return (
|
|
28
|
+
(patch.snapshotsEnabled !== undefined &&
|
|
29
|
+
patch.snapshotsEnabled !== website.snapshotsEnabled) ||
|
|
30
|
+
(patch.snapshotMaskText !== undefined &&
|
|
31
|
+
patch.snapshotMaskText !== (website.snapshotMaskText ?? false))
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether saving `patch` asks first: any change of those options, holding
|
|
37
|
+
* snapshots or not — a site with none yet still changes what every later
|
|
38
|
+
* capture keeps, and the question must come wherever the option is
|
|
39
|
+
* toggled. Turning snapshots on for a site holding none is the exception:
|
|
40
|
+
* there is nothing to lose and nothing to warn about. `count` is the
|
|
41
|
+
* site's snapshots, null when it could not be read.
|
|
42
|
+
*/
|
|
43
|
+
export function asksBeforeSnapshotChange(
|
|
44
|
+
website: SnapshotOptions,
|
|
45
|
+
patch: Partial<SnapshotOptions>,
|
|
46
|
+
count: number | null,
|
|
47
|
+
): boolean {
|
|
48
|
+
if (!changesSnapshotOptions(website, patch)) {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
const enabling = patch.snapshotsEnabled === true && !website.snapshotsEnabled
|
|
52
|
+
return !(enabling && count === 0)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What confirming a snapshot option change needs from its page. */
|
|
56
|
+
export interface SnapshotChangeSteps {
|
|
57
|
+
/** The site's snapshots; null when they could not be counted. */
|
|
58
|
+
count: () => Promise<number | null>
|
|
59
|
+
/** Resolves true when the person goes ahead. */
|
|
60
|
+
confirm: (
|
|
61
|
+
options: ReturnType<typeof snapshotDiscardConfirmation>,
|
|
62
|
+
) => Promise<boolean>
|
|
63
|
+
t: Translate
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether `patch` may be saved: counted, then asked when
|
|
68
|
+
* asksBeforeSnapshotChange says so. Nothing is saved on a refusal, so a
|
|
69
|
+
* control bound to the website keeps showing what the website holds.
|
|
70
|
+
*/
|
|
71
|
+
export async function confirmSnapshotChange(
|
|
72
|
+
website: SnapshotOptions,
|
|
73
|
+
patch: SnapshotPatch,
|
|
74
|
+
steps: SnapshotChangeSteps,
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
if (!changesSnapshotOptions(website, patch)) {
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
const count = await steps.count().catch(() => null)
|
|
80
|
+
if (!asksBeforeSnapshotChange(website, patch, count)) {
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
return steps.confirm(snapshotDiscardConfirmation(steps.t, count))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The two option checkboxes of the heatmap preview's menu. Bound to the
|
|
88
|
+
* website, never to a local copy: a declined change leaves them as they
|
|
89
|
+
* were, and a saved one shows once the website comes back updated.
|
|
90
|
+
*/
|
|
91
|
+
export function snapshotOptionItems(
|
|
92
|
+
website: SnapshotOptions | null,
|
|
93
|
+
change: (patch: SnapshotPatch) => void,
|
|
94
|
+
t: Translate,
|
|
95
|
+
) {
|
|
96
|
+
const enabled = website?.snapshotsEnabled ?? false
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
type: 'checkbox' as const,
|
|
100
|
+
label: t('page.marketing.pages.preview.snapshots_enabled'),
|
|
101
|
+
checked: enabled,
|
|
102
|
+
onUpdateChecked: (value: boolean): void =>
|
|
103
|
+
change({ snapshotsEnabled: value }),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
type: 'checkbox' as const,
|
|
107
|
+
label: t('page.marketing.pages.preview.mask_text'),
|
|
108
|
+
checked: website?.snapshotMaskText === true,
|
|
109
|
+
disabled: !enabled,
|
|
110
|
+
onUpdateChecked: (value: boolean): void =>
|
|
111
|
+
change({ snapshotMaskText: value }),
|
|
112
|
+
},
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The confirmation asked before such a change; `count` is the site's
|
|
118
|
+
* snapshots, null when it could not be read — the change still discards
|
|
119
|
+
* whatever there is, so the question is asked without the number — and 0
|
|
120
|
+
* when there are none to discard, the question then being the setting.
|
|
121
|
+
*/
|
|
122
|
+
export function snapshotDiscardConfirmation(
|
|
123
|
+
t: Translate,
|
|
124
|
+
count: number | null,
|
|
125
|
+
) {
|
|
126
|
+
const prefix = 'page.marketing.websites.discard_confirm'
|
|
127
|
+
if (count === 0) {
|
|
128
|
+
return {
|
|
129
|
+
title: t(`${prefix}.title_none`),
|
|
130
|
+
description: t(`${prefix}.description_none`),
|
|
131
|
+
confirmLabel: t(`${prefix}.confirm_none`),
|
|
132
|
+
cancelLabel: t(`${prefix}.cancel_none`),
|
|
133
|
+
color: 'warning' as const,
|
|
134
|
+
icon: 'i-ph-camera',
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
title: t(`${prefix}.title`),
|
|
139
|
+
description:
|
|
140
|
+
count === null
|
|
141
|
+
? t(`${prefix}.description_unknown`)
|
|
142
|
+
: t(`${prefix}.description`, { count }, count),
|
|
143
|
+
confirmLabel: t(`${prefix}.confirm`),
|
|
144
|
+
cancelLabel: t(`${prefix}.cancel`),
|
|
145
|
+
color: 'warning' as const,
|
|
146
|
+
icon: 'i-ph-warning',
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inner scroll container of a snapshot, unrolled.
|
|
3
|
+
*
|
|
4
|
+
* An app shell keeps its document still and scrolls a panel instead; the
|
|
5
|
+
* capture marks that panel `data-marketing-scroll-root` (see
|
|
6
|
+
* static/snapshot.js). Rendered as captured, the panel stays clipped at the
|
|
7
|
+
* visitor's window height and everything below the fold is out of reach —
|
|
8
|
+
* the frame does not scroll, and no click below the first screen has
|
|
9
|
+
* anything under it. Unrolled, the panel and every ancestor that pinned its
|
|
10
|
+
* height let it grow: the document becomes as tall as the content, which is
|
|
11
|
+
* what the tracker's click fractions and scroll depths are measured against.
|
|
12
|
+
*
|
|
13
|
+
* Only the vertical axis is released. A horizontal clip stays (as `clip`,
|
|
14
|
+
* which unlike `hidden` does not turn the box back into a scroll container),
|
|
15
|
+
* and only a column flexbox has its items' basis touched — in a row, the
|
|
16
|
+
* basis is a width.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const SCROLL_ROOT_ATTRIBUTE = 'data-marketing-scroll-root'
|
|
20
|
+
|
|
21
|
+
/** The few computed properties the unrolling reads. */
|
|
22
|
+
export interface UnrollStyle {
|
|
23
|
+
position: string
|
|
24
|
+
overflowX: string
|
|
25
|
+
display: string
|
|
26
|
+
flexDirection: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The part of an element the unrolling touches. */
|
|
30
|
+
export interface UnrollElement {
|
|
31
|
+
parentElement: UnrollElement | null
|
|
32
|
+
style: { setProperty(name: string, value: string, priority: string): void }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function important(element: UnrollElement, name: string, value: string): void {
|
|
36
|
+
element.style.setProperty(name, value, 'important')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isColumnFlex(style: UnrollStyle | null): boolean {
|
|
40
|
+
return (
|
|
41
|
+
!!style &&
|
|
42
|
+
(style.display === 'flex' || style.display === 'inline-flex') &&
|
|
43
|
+
style.flexDirection.startsWith('column')
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Releases `root` and its ancestors; `computed` reads an element's style as
|
|
49
|
+
* the page laid it out.
|
|
50
|
+
*/
|
|
51
|
+
export function unrollScrollRoot<E extends UnrollElement>(
|
|
52
|
+
root: E,
|
|
53
|
+
computed: (element: E) => UnrollStyle,
|
|
54
|
+
): void {
|
|
55
|
+
for (
|
|
56
|
+
let element: E | null = root;
|
|
57
|
+
element;
|
|
58
|
+
element = element.parentElement as E | null
|
|
59
|
+
) {
|
|
60
|
+
const style = computed(element)
|
|
61
|
+
const parent = element.parentElement as E | null
|
|
62
|
+
important(element, 'height', 'auto')
|
|
63
|
+
important(element, 'max-height', 'none')
|
|
64
|
+
important(element, 'overflow-y', 'visible')
|
|
65
|
+
if (style.overflowX !== 'visible') {
|
|
66
|
+
important(element, 'overflow-x', 'clip')
|
|
67
|
+
}
|
|
68
|
+
// A fixed box adds nothing to the document's scroll height, and one
|
|
69
|
+
// pinned at both ends (`inset: 0`) keeps the window's height.
|
|
70
|
+
if (style.position === 'fixed' || style.position === 'absolute') {
|
|
71
|
+
important(element, 'position', 'absolute')
|
|
72
|
+
important(element, 'bottom', 'auto')
|
|
73
|
+
}
|
|
74
|
+
if (parent && isColumnFlex(computed(parent))) {
|
|
75
|
+
important(element, 'flex-basis', 'auto')
|
|
76
|
+
important(element, 'flex-shrink', '0')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Unrolls the marked scroll root of a rendered snapshot, if it has one —
|
|
82
|
+
* a capture taken where the document scrolls has none and is left as is. */
|
|
83
|
+
export function unrollSnapshotScrollRoot(doc: Document): boolean {
|
|
84
|
+
const view = doc.defaultView
|
|
85
|
+
const root = doc.querySelector<HTMLElement>(`[${SCROLL_ROOT_ATTRIBUTE}]`)
|
|
86
|
+
if (!view || !root) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
unrollScrollRoot<HTMLElement>(root, (element) =>
|
|
90
|
+
view.getComputedStyle(element),
|
|
91
|
+
)
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Websites page's cards: a row of the websites table, as its list route
|
|
3
|
+
* answers it (the stored fields and, merged in, the card the backend
|
|
4
|
+
* computed), read into the card the card component and the settings modal
|
|
5
|
+
* draw. Pure, so the mapping is testable without the table.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
MarketingRejectedSource,
|
|
10
|
+
MarketingWebsiteCard,
|
|
11
|
+
} from '../composables/useMarketingApi'
|
|
12
|
+
import type { WebsiteState } from '../composables/useMarketingContext'
|
|
13
|
+
|
|
14
|
+
/** A row of the websites table, whatever it carries. */
|
|
15
|
+
export type WebsiteRow = Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
const WEBSITE_STATES: readonly WebsiteState[] = [
|
|
18
|
+
'live',
|
|
19
|
+
'imported',
|
|
20
|
+
'waiting',
|
|
21
|
+
'paused',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
function text(value: unknown): string {
|
|
25
|
+
return typeof value === 'string' ? value : ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function count(value: unknown): number {
|
|
29
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function epoch(value: unknown): number | null {
|
|
33
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
34
|
+
return value
|
|
35
|
+
}
|
|
36
|
+
if (typeof value === 'string' && value) {
|
|
37
|
+
const parsed = Date.parse(value)
|
|
38
|
+
return Number.isNaN(parsed) ? null : parsed
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hosts(value: unknown): string[] {
|
|
44
|
+
return Array.isArray(value)
|
|
45
|
+
? value.filter((host): host is string => typeof host === 'string')
|
|
46
|
+
: []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function refused(value: unknown): MarketingRejectedSource[] {
|
|
50
|
+
return Array.isArray(value)
|
|
51
|
+
? value.filter(
|
|
52
|
+
(source): source is MarketingRejectedSource =>
|
|
53
|
+
typeof source === 'object' &&
|
|
54
|
+
source !== null &&
|
|
55
|
+
typeof (source as MarketingRejectedSource).hostname === 'string',
|
|
56
|
+
)
|
|
57
|
+
: []
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The state the row's card shows. The backend sends it with the card; a row
|
|
62
|
+
* read without it (its card missing) falls back on the stored switch, and
|
|
63
|
+
* waits for its first pageview otherwise.
|
|
64
|
+
*/
|
|
65
|
+
function stateOf(row: WebsiteRow): WebsiteState {
|
|
66
|
+
const state = row.state as WebsiteState
|
|
67
|
+
if (WEBSITE_STATES.includes(state)) {
|
|
68
|
+
return state
|
|
69
|
+
}
|
|
70
|
+
return row.trackingEnabled === false ? 'paused' : 'waiting'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The card of a websites table row, every field defaulted. */
|
|
74
|
+
export function websiteCardOf(row: WebsiteRow): MarketingWebsiteCard {
|
|
75
|
+
return {
|
|
76
|
+
id: text(row._id) || text(row.id),
|
|
77
|
+
name: text(row.name),
|
|
78
|
+
domain: text(row.domain),
|
|
79
|
+
state: stateOf(row),
|
|
80
|
+
lastActivityAt: epoch(row.lastActivityAt),
|
|
81
|
+
extraDomains: hosts(row.extraDomains),
|
|
82
|
+
snapshotsEnabled: row.snapshotsEnabled === true,
|
|
83
|
+
snapshotMaskText: row.snapshotMaskText === true,
|
|
84
|
+
createdAt: epoch(row.createdAt) ?? 0,
|
|
85
|
+
sessions: count(row.sessions),
|
|
86
|
+
pages: count(row.pages),
|
|
87
|
+
funnels: count(row.funnels),
|
|
88
|
+
snapshots: count(row.snapshots),
|
|
89
|
+
sampleRate: count(row.sampleRate),
|
|
90
|
+
rejected: refused(row.rejected),
|
|
91
|
+
dashboard: row.dashboard === true,
|
|
92
|
+
}
|
|
93
|
+
}
|