@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.
Files changed (181) hide show
  1. package/dist/config.d.ts +3 -0
  2. package/dist/config.js +11 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/data-api/websites.d.ts +10 -4
  5. package/dist/data-api/websites.js +79 -3
  6. package/dist/data-api/websites.js.map +1 -1
  7. package/dist/db/models/page_snapshots.model.d.ts +2 -0
  8. package/dist/db/models/page_snapshots.model.js +6 -0
  9. package/dist/db/models/page_snapshots.model.js.map +1 -1
  10. package/dist/db/models/websites.model.d.ts +1 -0
  11. package/dist/db/models/websites.model.js +9 -0
  12. package/dist/db/models/websites.model.js.map +1 -1
  13. package/dist/db/tables/page_snapshots.table.d.ts +1 -0
  14. package/dist/db/tables/page_snapshots.table.js +4 -0
  15. package/dist/db/tables/page_snapshots.table.js.map +1 -1
  16. package/dist/db/tables/websites.table.d.ts +2 -0
  17. package/dist/db/tables/websites.table.js +4 -0
  18. package/dist/db/tables/websites.table.js.map +1 -1
  19. package/dist/frontend-module.d.ts +6 -0
  20. package/dist/frontend-module.js +46 -0
  21. package/dist/frontend-module.js.map +1 -0
  22. package/dist/index.js +3 -11
  23. package/dist/index.js.map +1 -1
  24. package/dist/pages/acquisition/page.d.ts +1 -0
  25. package/dist/pages/acquisition/page.js +9 -4
  26. package/dist/pages/acquisition/page.js.map +1 -1
  27. package/dist/pages/blocks.d.ts +11 -2
  28. package/dist/pages/blocks.js +43 -5
  29. package/dist/pages/blocks.js.map +1 -1
  30. package/dist/pages/funnel/page.d.ts +1 -0
  31. package/dist/pages/funnel/page.js +1 -0
  32. package/dist/pages/funnel/page.js.map +1 -1
  33. package/dist/pages/funnels/page.d.ts +1 -0
  34. package/dist/pages/funnels/page.js +4 -2
  35. package/dist/pages/funnels/page.js.map +1 -1
  36. package/dist/pages/overview/page.d.ts +1 -0
  37. package/dist/pages/overview/page.js +5 -6
  38. package/dist/pages/overview/page.js.map +1 -1
  39. package/dist/pages/pages/page.d.ts +1 -0
  40. package/dist/pages/pages/page.js +1 -0
  41. package/dist/pages/pages/page.js.map +1 -1
  42. package/dist/pages/settings/form.js +1 -0
  43. package/dist/pages/settings/form.js.map +1 -1
  44. package/dist/pages/websites/page.d.ts +2 -3
  45. package/dist/pages/websites/page.js +63 -5
  46. package/dist/pages/websites/page.js.map +1 -1
  47. package/dist/routes/dashboard-tracking.js +5 -1
  48. package/dist/routes/dashboard-tracking.js.map +1 -1
  49. package/dist/routes/funnels.d.ts +2 -0
  50. package/dist/routes/funnels.js +6 -5
  51. package/dist/routes/funnels.js.map +1 -1
  52. package/dist/routes/public-endpoints.js +1 -1
  53. package/dist/routes/public-endpoints.js.map +1 -1
  54. package/dist/routes/snapshot.d.ts +1 -1
  55. package/dist/routes/snapshot.js +30 -13
  56. package/dist/routes/snapshot.js.map +1 -1
  57. package/dist/routes/stats.d.ts +10 -4
  58. package/dist/routes/stats.js +77 -23
  59. package/dist/routes/stats.js.map +1 -1
  60. package/dist/routes/websites.d.ts +5 -1
  61. package/dist/routes/websites.js +31 -13
  62. package/dist/routes/websites.js.map +1 -1
  63. package/dist/services/acquisition-report.d.ts +1 -0
  64. package/dist/services/acquisition-report.js +1 -0
  65. package/dist/services/acquisition-report.js.map +1 -1
  66. package/dist/services/collect.js +7 -2
  67. package/dist/services/collect.js.map +1 -1
  68. package/dist/services/dashboard-tracking.d.ts +7 -14
  69. package/dist/services/dashboard-tracking.js +42 -26
  70. package/dist/services/dashboard-tracking.js.map +1 -1
  71. package/dist/services/funnels-table.d.ts +3 -2
  72. package/dist/services/funnels-table.js +9 -9
  73. package/dist/services/funnels-table.js.map +1 -1
  74. package/dist/services/heatmap.d.ts +6 -0
  75. package/dist/services/heatmap.js +82 -17
  76. package/dist/services/heatmap.js.map +1 -1
  77. package/dist/services/index.d.ts +1 -0
  78. package/dist/services/index.js +1 -0
  79. package/dist/services/index.js.map +1 -1
  80. package/dist/services/prune.js +6 -1
  81. package/dist/services/prune.js.map +1 -1
  82. package/dist/services/realtime.d.ts +3 -0
  83. package/dist/services/realtime.js +49 -0
  84. package/dist/services/realtime.js.map +1 -0
  85. package/dist/services/request-body.d.ts +1 -0
  86. package/dist/services/request-body.js +18 -0
  87. package/dist/services/request-body.js.map +1 -1
  88. package/dist/services/sessionize.d.ts +1 -0
  89. package/dist/services/sessionize.js +9 -0
  90. package/dist/services/sessionize.js.map +1 -1
  91. package/dist/services/snapshots.d.ts +28 -1
  92. package/dist/services/snapshots.js +147 -26
  93. package/dist/services/snapshots.js.map +1 -1
  94. package/dist/services/tenant-website.d.ts +11 -0
  95. package/dist/services/tenant-website.js +27 -5
  96. package/dist/services/tenant-website.js.map +1 -1
  97. package/dist/services/website-report.d.ts +5 -0
  98. package/dist/services/website-report.js +13 -1
  99. package/dist/services/website-report.js.map +1 -1
  100. package/dist/services/workspace-scope.d.ts +31 -0
  101. package/dist/services/workspace-scope.js +72 -0
  102. package/dist/services/workspace-scope.js.map +1 -0
  103. package/dist/types/constants.d.ts +7 -0
  104. package/dist/types/constants.js +8 -1
  105. package/dist/types/constants.js.map +1 -1
  106. package/dist/types/events.d.ts +25 -25
  107. package/dist/types/snapshots.d.ts +29 -29
  108. package/dist/types/snapshots.js +1 -1
  109. package/dist/types/snapshots.js.map +1 -1
  110. package/dist/utils/funnel-steps-type.d.ts +13 -13
  111. package/frontend-vue/app/components/ChannelsCard.vue +19 -2
  112. package/frontend-vue/app/components/Context.vue +50 -376
  113. package/frontend-vue/app/components/FunnelBuilder.vue +28 -22
  114. package/frontend-vue/app/components/FunnelFigure.vue +15 -2
  115. package/frontend-vue/app/components/FunnelReport.vue +3 -0
  116. package/frontend-vue/app/components/FunnelTemplates.vue +15 -13
  117. package/frontend-vue/app/components/InstallGuide.vue +1 -1
  118. package/frontend-vue/app/components/PagePreview.vue +330 -74
  119. package/frontend-vue/app/components/PagesExplorer.vue +230 -85
  120. package/frontend-vue/app/components/TopListTabs.vue +51 -18
  121. package/frontend-vue/app/components/WebsiteCard.vue +394 -0
  122. package/frontend-vue/app/components/WebsiteCreate.vue +159 -0
  123. package/frontend-vue/app/components/WebsitePicker.vue +210 -0
  124. package/frontend-vue/app/components/WebsiteSettings.vue +539 -0
  125. package/frontend-vue/app/components/WorkspaceScope.vue +119 -0
  126. package/frontend-vue/app/composables/useContextWebsite.ts +105 -0
  127. package/frontend-vue/app/composables/useCopyDraft.ts +47 -0
  128. package/frontend-vue/app/composables/useMarketingApi.ts +92 -4
  129. package/frontend-vue/app/composables/useMarketingReads.ts +32 -23
  130. package/frontend-vue/app/composables/useMarketingRoutes.ts +25 -2
  131. package/frontend-vue/app/composables/useRecordCrumb.ts +17 -0
  132. package/frontend-vue/app/composables/useSnapshotFrame.ts +35 -1
  133. package/frontend-vue/app/utils/anchor-drift.ts +58 -0
  134. package/frontend-vue/app/utils/drift-dismissals.ts +20 -0
  135. package/frontend-vue/app/utils/fill-height.ts +141 -0
  136. package/frontend-vue/app/utils/format.ts +21 -2
  137. package/frontend-vue/app/utils/funnel-draft.ts +22 -0
  138. package/frontend-vue/app/utils/heatmap.ts +17 -0
  139. package/frontend-vue/app/utils/page-snapshot.ts +75 -0
  140. package/frontend-vue/app/utils/snapshot-options.ts +148 -0
  141. package/frontend-vue/app/utils/snapshotScrollRoot.ts +93 -0
  142. package/frontend-vue/app/utils/website-card.ts +93 -0
  143. package/frontend-vue/app/utils/website-purge.ts +95 -0
  144. package/frontend-vue/app/utils/website-settings.ts +121 -0
  145. package/frontend-vue/app/utils/website-state.ts +93 -0
  146. package/frontend-vue/app/utils/workspace-scope.ts +51 -0
  147. package/frontend-vue/i18n/locales/marketing-en-GB.json +112 -36
  148. package/frontend-vue/i18n/locales/marketing-fr-FR.json +112 -36
  149. package/frontend-vue/package.json +1 -1
  150. package/frontend-vue/tests/anchor-drift.test.ts +59 -0
  151. package/frontend-vue/tests/build-flags.test.ts +38 -0
  152. package/frontend-vue/tests/context-bar.test.ts +226 -0
  153. package/frontend-vue/tests/copy-draft.test.ts +64 -0
  154. package/frontend-vue/tests/drift-dismissals.test.ts +12 -0
  155. package/frontend-vue/tests/fill-height.test.ts +110 -0
  156. package/frontend-vue/tests/format.test.ts +14 -0
  157. package/frontend-vue/tests/funnel-draft.test.ts +37 -0
  158. package/frontend-vue/tests/heatmap.test.ts +25 -0
  159. package/frontend-vue/tests/install-guide.test.ts +25 -0
  160. package/frontend-vue/tests/marketing-reads.test.ts +20 -6
  161. package/frontend-vue/tests/page-snapshot.test.ts +62 -0
  162. package/frontend-vue/tests/record-crumb.test.ts +43 -0
  163. package/frontend-vue/tests/registration.test.ts +4 -1
  164. package/frontend-vue/tests/routes.test.ts +21 -0
  165. package/frontend-vue/tests/save-funnel.test.ts +47 -0
  166. package/frontend-vue/tests/server-parity.test.ts +3 -0
  167. package/frontend-vue/tests/snapshot-options.test.ts +148 -0
  168. package/frontend-vue/tests/snapshot-scroll-root.test.ts +84 -0
  169. package/frontend-vue/tests/stubs/frontend-module.ts +5 -1
  170. package/frontend-vue/tests/top-list-tabs.test.ts +158 -0
  171. package/frontend-vue/tests/website-card.test.ts +119 -0
  172. package/frontend-vue/tests/website-purge.test.ts +70 -0
  173. package/frontend-vue/tests/website-settings.test.ts +133 -0
  174. package/frontend-vue/tests/website-state.test.ts +96 -0
  175. package/frontend-vue/tests/workspace-scope-control.test.ts +113 -0
  176. package/frontend-vue/tests/workspace-scope.test.ts +55 -0
  177. package/package.json +5 -4
  178. package/skills/dms-marketing/SKILL.md +47 -6
  179. package/static/snapshot.js +539 -48
  180. package/static/tracker.js +154 -5
  181. package/frontend-vue/app/components/WebsitesGrid.vue +0 -812
@@ -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
- /** "12 seconds ago", "4 minutes ago" — the age of the last event. */
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
+ }