@antelopejs/dms-marketing 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/dist/config.d.ts +3 -0
  2. package/dist/config.js +11 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/data-api/websites.d.ts +10 -4
  5. package/dist/data-api/websites.js +79 -3
  6. package/dist/data-api/websites.js.map +1 -1
  7. package/dist/db/models/page_snapshots.model.d.ts +2 -0
  8. package/dist/db/models/page_snapshots.model.js +6 -0
  9. package/dist/db/models/page_snapshots.model.js.map +1 -1
  10. package/dist/db/models/websites.model.d.ts +1 -0
  11. package/dist/db/models/websites.model.js +9 -0
  12. package/dist/db/models/websites.model.js.map +1 -1
  13. package/dist/db/tables/page_snapshots.table.d.ts +1 -0
  14. package/dist/db/tables/page_snapshots.table.js +4 -0
  15. package/dist/db/tables/page_snapshots.table.js.map +1 -1
  16. package/dist/db/tables/websites.table.d.ts +2 -0
  17. package/dist/db/tables/websites.table.js +4 -0
  18. package/dist/db/tables/websites.table.js.map +1 -1
  19. package/dist/frontend-module.d.ts +6 -0
  20. package/dist/frontend-module.js +46 -0
  21. package/dist/frontend-module.js.map +1 -0
  22. package/dist/index.js +3 -11
  23. package/dist/index.js.map +1 -1
  24. package/dist/pages/acquisition/page.d.ts +1 -0
  25. package/dist/pages/acquisition/page.js +9 -4
  26. package/dist/pages/acquisition/page.js.map +1 -1
  27. package/dist/pages/blocks.d.ts +11 -2
  28. package/dist/pages/blocks.js +43 -5
  29. package/dist/pages/blocks.js.map +1 -1
  30. package/dist/pages/funnel/page.d.ts +1 -0
  31. package/dist/pages/funnel/page.js +1 -0
  32. package/dist/pages/funnel/page.js.map +1 -1
  33. package/dist/pages/funnels/page.d.ts +1 -0
  34. package/dist/pages/funnels/page.js +4 -2
  35. package/dist/pages/funnels/page.js.map +1 -1
  36. package/dist/pages/overview/page.d.ts +1 -0
  37. package/dist/pages/overview/page.js +5 -6
  38. package/dist/pages/overview/page.js.map +1 -1
  39. package/dist/pages/pages/page.d.ts +1 -0
  40. package/dist/pages/pages/page.js +1 -0
  41. package/dist/pages/pages/page.js.map +1 -1
  42. package/dist/pages/settings/form.js +1 -0
  43. package/dist/pages/settings/form.js.map +1 -1
  44. package/dist/pages/websites/page.d.ts +2 -3
  45. package/dist/pages/websites/page.js +63 -5
  46. package/dist/pages/websites/page.js.map +1 -1
  47. package/dist/routes/dashboard-tracking.js +5 -1
  48. package/dist/routes/dashboard-tracking.js.map +1 -1
  49. package/dist/routes/funnels.d.ts +2 -0
  50. package/dist/routes/funnels.js +6 -5
  51. package/dist/routes/funnels.js.map +1 -1
  52. package/dist/routes/public-endpoints.js +1 -1
  53. package/dist/routes/public-endpoints.js.map +1 -1
  54. package/dist/routes/snapshot.d.ts +1 -1
  55. package/dist/routes/snapshot.js +30 -13
  56. package/dist/routes/snapshot.js.map +1 -1
  57. package/dist/routes/stats.d.ts +10 -4
  58. package/dist/routes/stats.js +77 -23
  59. package/dist/routes/stats.js.map +1 -1
  60. package/dist/routes/websites.d.ts +5 -1
  61. package/dist/routes/websites.js +31 -13
  62. package/dist/routes/websites.js.map +1 -1
  63. package/dist/services/acquisition-report.d.ts +1 -0
  64. package/dist/services/acquisition-report.js +1 -0
  65. package/dist/services/acquisition-report.js.map +1 -1
  66. package/dist/services/collect.js +7 -2
  67. package/dist/services/collect.js.map +1 -1
  68. package/dist/services/dashboard-tracking.d.ts +7 -14
  69. package/dist/services/dashboard-tracking.js +42 -26
  70. package/dist/services/dashboard-tracking.js.map +1 -1
  71. package/dist/services/funnels-table.d.ts +3 -2
  72. package/dist/services/funnels-table.js +9 -9
  73. package/dist/services/funnels-table.js.map +1 -1
  74. package/dist/services/heatmap.d.ts +6 -0
  75. package/dist/services/heatmap.js +82 -17
  76. package/dist/services/heatmap.js.map +1 -1
  77. package/dist/services/index.d.ts +1 -0
  78. package/dist/services/index.js +1 -0
  79. package/dist/services/index.js.map +1 -1
  80. package/dist/services/prune.js +6 -1
  81. package/dist/services/prune.js.map +1 -1
  82. package/dist/services/realtime.d.ts +3 -0
  83. package/dist/services/realtime.js +49 -0
  84. package/dist/services/realtime.js.map +1 -0
  85. package/dist/services/request-body.d.ts +1 -0
  86. package/dist/services/request-body.js +18 -0
  87. package/dist/services/request-body.js.map +1 -1
  88. package/dist/services/sessionize.d.ts +1 -0
  89. package/dist/services/sessionize.js +9 -0
  90. package/dist/services/sessionize.js.map +1 -1
  91. package/dist/services/snapshots.d.ts +28 -1
  92. package/dist/services/snapshots.js +147 -26
  93. package/dist/services/snapshots.js.map +1 -1
  94. package/dist/services/tenant-website.d.ts +11 -0
  95. package/dist/services/tenant-website.js +27 -5
  96. package/dist/services/tenant-website.js.map +1 -1
  97. package/dist/services/website-report.d.ts +5 -0
  98. package/dist/services/website-report.js +13 -1
  99. package/dist/services/website-report.js.map +1 -1
  100. package/dist/services/workspace-scope.d.ts +31 -0
  101. package/dist/services/workspace-scope.js +72 -0
  102. package/dist/services/workspace-scope.js.map +1 -0
  103. package/dist/types/constants.d.ts +7 -0
  104. package/dist/types/constants.js +8 -1
  105. package/dist/types/constants.js.map +1 -1
  106. package/dist/types/events.d.ts +25 -25
  107. package/dist/types/snapshots.d.ts +29 -29
  108. package/dist/types/snapshots.js +1 -1
  109. package/dist/types/snapshots.js.map +1 -1
  110. package/dist/utils/funnel-steps-type.d.ts +13 -13
  111. package/frontend-vue/app/components/ChannelsCard.vue +19 -2
  112. package/frontend-vue/app/components/Context.vue +50 -376
  113. package/frontend-vue/app/components/FunnelBuilder.vue +28 -22
  114. package/frontend-vue/app/components/FunnelFigure.vue +15 -2
  115. package/frontend-vue/app/components/FunnelReport.vue +3 -0
  116. package/frontend-vue/app/components/FunnelTemplates.vue +15 -13
  117. package/frontend-vue/app/components/InstallGuide.vue +1 -1
  118. package/frontend-vue/app/components/PagePreview.vue +330 -74
  119. package/frontend-vue/app/components/PagesExplorer.vue +230 -85
  120. package/frontend-vue/app/components/TopListTabs.vue +51 -18
  121. package/frontend-vue/app/components/WebsiteCard.vue +394 -0
  122. package/frontend-vue/app/components/WebsiteCreate.vue +159 -0
  123. package/frontend-vue/app/components/WebsitePicker.vue +210 -0
  124. package/frontend-vue/app/components/WebsiteSettings.vue +539 -0
  125. package/frontend-vue/app/components/WorkspaceScope.vue +119 -0
  126. package/frontend-vue/app/composables/useContextWebsite.ts +105 -0
  127. package/frontend-vue/app/composables/useCopyDraft.ts +47 -0
  128. package/frontend-vue/app/composables/useMarketingApi.ts +92 -4
  129. package/frontend-vue/app/composables/useMarketingReads.ts +32 -23
  130. package/frontend-vue/app/composables/useMarketingRoutes.ts +25 -2
  131. package/frontend-vue/app/composables/useRecordCrumb.ts +17 -0
  132. package/frontend-vue/app/composables/useSnapshotFrame.ts +35 -1
  133. package/frontend-vue/app/utils/anchor-drift.ts +58 -0
  134. package/frontend-vue/app/utils/drift-dismissals.ts +20 -0
  135. package/frontend-vue/app/utils/fill-height.ts +141 -0
  136. package/frontend-vue/app/utils/format.ts +21 -2
  137. package/frontend-vue/app/utils/funnel-draft.ts +22 -0
  138. package/frontend-vue/app/utils/heatmap.ts +17 -0
  139. package/frontend-vue/app/utils/page-snapshot.ts +75 -0
  140. package/frontend-vue/app/utils/snapshot-options.ts +148 -0
  141. package/frontend-vue/app/utils/snapshotScrollRoot.ts +93 -0
  142. package/frontend-vue/app/utils/website-card.ts +93 -0
  143. package/frontend-vue/app/utils/website-purge.ts +95 -0
  144. package/frontend-vue/app/utils/website-settings.ts +121 -0
  145. package/frontend-vue/app/utils/website-state.ts +93 -0
  146. package/frontend-vue/app/utils/workspace-scope.ts +51 -0
  147. package/frontend-vue/i18n/locales/marketing-en-GB.json +112 -36
  148. package/frontend-vue/i18n/locales/marketing-fr-FR.json +112 -36
  149. package/frontend-vue/package.json +1 -1
  150. package/frontend-vue/tests/anchor-drift.test.ts +59 -0
  151. package/frontend-vue/tests/build-flags.test.ts +38 -0
  152. package/frontend-vue/tests/context-bar.test.ts +226 -0
  153. package/frontend-vue/tests/copy-draft.test.ts +64 -0
  154. package/frontend-vue/tests/drift-dismissals.test.ts +12 -0
  155. package/frontend-vue/tests/fill-height.test.ts +110 -0
  156. package/frontend-vue/tests/format.test.ts +14 -0
  157. package/frontend-vue/tests/funnel-draft.test.ts +37 -0
  158. package/frontend-vue/tests/heatmap.test.ts +25 -0
  159. package/frontend-vue/tests/install-guide.test.ts +25 -0
  160. package/frontend-vue/tests/marketing-reads.test.ts +20 -6
  161. package/frontend-vue/tests/page-snapshot.test.ts +62 -0
  162. package/frontend-vue/tests/record-crumb.test.ts +43 -0
  163. package/frontend-vue/tests/registration.test.ts +4 -1
  164. package/frontend-vue/tests/routes.test.ts +21 -0
  165. package/frontend-vue/tests/save-funnel.test.ts +47 -0
  166. package/frontend-vue/tests/server-parity.test.ts +3 -0
  167. package/frontend-vue/tests/snapshot-options.test.ts +148 -0
  168. package/frontend-vue/tests/snapshot-scroll-root.test.ts +84 -0
  169. package/frontend-vue/tests/stubs/frontend-module.ts +5 -1
  170. package/frontend-vue/tests/top-list-tabs.test.ts +158 -0
  171. package/frontend-vue/tests/website-card.test.ts +119 -0
  172. package/frontend-vue/tests/website-purge.test.ts +70 -0
  173. package/frontend-vue/tests/website-settings.test.ts +133 -0
  174. package/frontend-vue/tests/website-state.test.ts +96 -0
  175. package/frontend-vue/tests/workspace-scope-control.test.ts +113 -0
  176. package/frontend-vue/tests/workspace-scope.test.ts +55 -0
  177. package/package.json +5 -4
  178. package/skills/dms-marketing/SKILL.md +47 -6
  179. package/static/snapshot.js +539 -48
  180. package/static/tracker.js +154 -5
  181. package/frontend-vue/app/components/WebsitesGrid.vue +0 -812
@@ -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
- /** One card of the Websites page (GET /api/marketing/websites/summary). */
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
- /** What deleting a website discards (GET /websites/:id/deletion). */
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 generated keys, an array — not the row.
545
+ // answers the created row, its `_id` included (interface-data-api 0.3).
458
546
  createFunnel: (input: MarketingFunnelInput) =>
459
- $authFetch<string[]>('/api/marketing/tables/funnels/new', {
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()`) read every one of them again but the context
30
- * bar's, which reads on its own clock. Writes go through `useMarketingApi`.
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 (its clock, its refresh button), not
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
- export function websitesLink(): string {
75
- return `${MODULE_BASE}/websites`
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 { measuredWidth, measuredHeight, tier, reset, close, measureAnchors }
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
+ }