@antelopejs/dms-marketing 0.6.1 → 0.7.1

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 (202) hide show
  1. package/README.md +2 -2
  2. package/dist/config.d.ts +3 -0
  3. package/dist/config.js +11 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/data-api/websites.d.ts +10 -4
  6. package/dist/data-api/websites.js +79 -3
  7. package/dist/data-api/websites.js.map +1 -1
  8. package/dist/db/models/funnels.model.d.ts +0 -1
  9. package/dist/db/models/funnels.model.js +0 -18
  10. package/dist/db/models/funnels.model.js.map +1 -1
  11. package/dist/db/models/page_snapshots.model.d.ts +2 -1
  12. package/dist/db/models/page_snapshots.model.js +6 -14
  13. package/dist/db/models/page_snapshots.model.js.map +1 -1
  14. package/dist/db/models/websites.model.d.ts +1 -1
  15. package/dist/db/models/websites.model.js +9 -29
  16. package/dist/db/models/websites.model.js.map +1 -1
  17. package/dist/db/tables/funnels.table.d.ts +0 -1
  18. package/dist/db/tables/funnels.table.js +0 -4
  19. package/dist/db/tables/funnels.table.js.map +1 -1
  20. package/dist/db/tables/page_snapshots.table.d.ts +1 -1
  21. package/dist/db/tables/page_snapshots.table.js +4 -4
  22. package/dist/db/tables/page_snapshots.table.js.map +1 -1
  23. package/dist/db/tables/websites.table.d.ts +2 -1
  24. package/dist/db/tables/websites.table.js +4 -4
  25. package/dist/db/tables/websites.table.js.map +1 -1
  26. package/dist/frontend-module.d.ts +6 -0
  27. package/dist/frontend-module.js +46 -0
  28. package/dist/frontend-module.js.map +1 -0
  29. package/dist/implementations/dms-marketing/index.js +0 -5
  30. package/dist/implementations/dms-marketing/index.js.map +1 -1
  31. package/dist/index.js +3 -13
  32. package/dist/index.js.map +1 -1
  33. package/dist/pages/acquisition/page.d.ts +1 -0
  34. package/dist/pages/acquisition/page.js +9 -4
  35. package/dist/pages/acquisition/page.js.map +1 -1
  36. package/dist/pages/blocks.d.ts +11 -2
  37. package/dist/pages/blocks.js +43 -5
  38. package/dist/pages/blocks.js.map +1 -1
  39. package/dist/pages/funnel/page.d.ts +1 -0
  40. package/dist/pages/funnel/page.js +1 -0
  41. package/dist/pages/funnel/page.js.map +1 -1
  42. package/dist/pages/funnels/page.d.ts +1 -0
  43. package/dist/pages/funnels/page.js +4 -2
  44. package/dist/pages/funnels/page.js.map +1 -1
  45. package/dist/pages/overview/page.d.ts +1 -0
  46. package/dist/pages/overview/page.js +5 -6
  47. package/dist/pages/overview/page.js.map +1 -1
  48. package/dist/pages/pages/page.d.ts +1 -0
  49. package/dist/pages/pages/page.js +1 -0
  50. package/dist/pages/pages/page.js.map +1 -1
  51. package/dist/pages/settings/form.js +1 -0
  52. package/dist/pages/settings/form.js.map +1 -1
  53. package/dist/pages/websites/page.d.ts +2 -3
  54. package/dist/pages/websites/page.js +63 -5
  55. package/dist/pages/websites/page.js.map +1 -1
  56. package/dist/routes/dashboard-tracking.js +5 -1
  57. package/dist/routes/dashboard-tracking.js.map +1 -1
  58. package/dist/routes/funnels.d.ts +2 -0
  59. package/dist/routes/funnels.js +6 -5
  60. package/dist/routes/funnels.js.map +1 -1
  61. package/dist/routes/public-endpoints.js +1 -1
  62. package/dist/routes/public-endpoints.js.map +1 -1
  63. package/dist/routes/snapshot.d.ts +1 -1
  64. package/dist/routes/snapshot.js +30 -13
  65. package/dist/routes/snapshot.js.map +1 -1
  66. package/dist/routes/stats.d.ts +10 -4
  67. package/dist/routes/stats.js +77 -23
  68. package/dist/routes/stats.js.map +1 -1
  69. package/dist/routes/websites.d.ts +5 -1
  70. package/dist/routes/websites.js +31 -13
  71. package/dist/routes/websites.js.map +1 -1
  72. package/dist/services/acquisition-report.d.ts +1 -0
  73. package/dist/services/acquisition-report.js +1 -0
  74. package/dist/services/acquisition-report.js.map +1 -1
  75. package/dist/services/collect.js +7 -2
  76. package/dist/services/collect.js.map +1 -1
  77. package/dist/services/dashboard-tracking.d.ts +7 -14
  78. package/dist/services/dashboard-tracking.js +42 -26
  79. package/dist/services/dashboard-tracking.js.map +1 -1
  80. package/dist/services/declarations.d.ts +0 -2
  81. package/dist/services/declarations.js +0 -11
  82. package/dist/services/declarations.js.map +1 -1
  83. package/dist/services/declared-funnel.js +11 -99
  84. package/dist/services/declared-funnel.js.map +1 -1
  85. package/dist/services/declared-snapshot.js +8 -37
  86. package/dist/services/declared-snapshot.js.map +1 -1
  87. package/dist/services/declared-website.js +5 -46
  88. package/dist/services/declared-website.js.map +1 -1
  89. package/dist/services/funnels-table.d.ts +3 -2
  90. package/dist/services/funnels-table.js +9 -9
  91. package/dist/services/funnels-table.js.map +1 -1
  92. package/dist/services/heatmap.d.ts +6 -0
  93. package/dist/services/heatmap.js +82 -17
  94. package/dist/services/heatmap.js.map +1 -1
  95. package/dist/services/index.d.ts +1 -0
  96. package/dist/services/index.js +1 -0
  97. package/dist/services/index.js.map +1 -1
  98. package/dist/services/prune.js +6 -1
  99. package/dist/services/prune.js.map +1 -1
  100. package/dist/services/realtime.d.ts +3 -0
  101. package/dist/services/realtime.js +49 -0
  102. package/dist/services/realtime.js.map +1 -0
  103. package/dist/services/request-body.d.ts +1 -0
  104. package/dist/services/request-body.js +18 -0
  105. package/dist/services/request-body.js.map +1 -1
  106. package/dist/services/sessionize.d.ts +1 -0
  107. package/dist/services/sessionize.js +9 -0
  108. package/dist/services/sessionize.js.map +1 -1
  109. package/dist/services/snapshots.d.ts +29 -1
  110. package/dist/services/snapshots.js +151 -26
  111. package/dist/services/snapshots.js.map +1 -1
  112. package/dist/services/tenant-website.d.ts +11 -0
  113. package/dist/services/tenant-website.js +27 -5
  114. package/dist/services/tenant-website.js.map +1 -1
  115. package/dist/services/website-report.d.ts +5 -0
  116. package/dist/services/website-report.js +13 -1
  117. package/dist/services/website-report.js.map +1 -1
  118. package/dist/services/workspace-scope.d.ts +31 -0
  119. package/dist/services/workspace-scope.js +72 -0
  120. package/dist/services/workspace-scope.js.map +1 -0
  121. package/dist/types/constants.d.ts +7 -0
  122. package/dist/types/constants.js +8 -1
  123. package/dist/types/constants.js.map +1 -1
  124. package/dist/types/events.d.ts +25 -25
  125. package/dist/types/snapshots.d.ts +29 -29
  126. package/dist/types/snapshots.js +1 -1
  127. package/dist/types/snapshots.js.map +1 -1
  128. package/dist/utils/funnel-steps-type.d.ts +13 -13
  129. package/frontend-vue/app/components/ChannelsCard.vue +19 -2
  130. package/frontend-vue/app/components/Context.vue +50 -376
  131. package/frontend-vue/app/components/FunnelBuilder.vue +28 -22
  132. package/frontend-vue/app/components/FunnelFigure.vue +15 -2
  133. package/frontend-vue/app/components/FunnelReport.vue +3 -0
  134. package/frontend-vue/app/components/FunnelTemplates.vue +15 -13
  135. package/frontend-vue/app/components/InstallGuide.vue +1 -1
  136. package/frontend-vue/app/components/PagePreview.vue +330 -74
  137. package/frontend-vue/app/components/PagesExplorer.vue +230 -85
  138. package/frontend-vue/app/components/TopListTabs.vue +51 -18
  139. package/frontend-vue/app/components/WebsiteCard.vue +394 -0
  140. package/frontend-vue/app/components/WebsiteCreate.vue +159 -0
  141. package/frontend-vue/app/components/WebsitePicker.vue +210 -0
  142. package/frontend-vue/app/components/WebsiteSettings.vue +539 -0
  143. package/frontend-vue/app/components/WorkspaceScope.vue +119 -0
  144. package/frontend-vue/app/composables/useContextWebsite.ts +105 -0
  145. package/frontend-vue/app/composables/useCopyDraft.ts +47 -0
  146. package/frontend-vue/app/composables/useMarketingApi.ts +92 -4
  147. package/frontend-vue/app/composables/useMarketingReads.ts +32 -23
  148. package/frontend-vue/app/composables/useMarketingRoutes.ts +25 -2
  149. package/frontend-vue/app/composables/useRecordCrumb.ts +17 -0
  150. package/frontend-vue/app/composables/useSnapshotFrame.ts +35 -1
  151. package/frontend-vue/app/utils/anchor-drift.ts +58 -0
  152. package/frontend-vue/app/utils/drift-dismissals.ts +20 -0
  153. package/frontend-vue/app/utils/fill-height.ts +141 -0
  154. package/frontend-vue/app/utils/format.ts +21 -2
  155. package/frontend-vue/app/utils/funnel-draft.ts +22 -0
  156. package/frontend-vue/app/utils/heatmap.ts +17 -0
  157. package/frontend-vue/app/utils/page-snapshot.ts +75 -0
  158. package/frontend-vue/app/utils/snapshot-options.ts +148 -0
  159. package/frontend-vue/app/utils/snapshotScrollRoot.ts +93 -0
  160. package/frontend-vue/app/utils/website-card.ts +93 -0
  161. package/frontend-vue/app/utils/website-purge.ts +95 -0
  162. package/frontend-vue/app/utils/website-settings.ts +121 -0
  163. package/frontend-vue/app/utils/website-state.ts +93 -0
  164. package/frontend-vue/app/utils/workspace-scope.ts +51 -0
  165. package/frontend-vue/i18n/locales/marketing-en-GB.json +112 -36
  166. package/frontend-vue/i18n/locales/marketing-fr-FR.json +112 -36
  167. package/frontend-vue/package.json +1 -1
  168. package/frontend-vue/tests/anchor-drift.test.ts +59 -0
  169. package/frontend-vue/tests/build-flags.test.ts +38 -0
  170. package/frontend-vue/tests/context-bar.test.ts +226 -0
  171. package/frontend-vue/tests/copy-draft.test.ts +64 -0
  172. package/frontend-vue/tests/drift-dismissals.test.ts +12 -0
  173. package/frontend-vue/tests/fill-height.test.ts +110 -0
  174. package/frontend-vue/tests/format.test.ts +14 -0
  175. package/frontend-vue/tests/funnel-draft.test.ts +37 -0
  176. package/frontend-vue/tests/heatmap.test.ts +25 -0
  177. package/frontend-vue/tests/install-guide.test.ts +25 -0
  178. package/frontend-vue/tests/marketing-reads.test.ts +20 -6
  179. package/frontend-vue/tests/page-snapshot.test.ts +62 -0
  180. package/frontend-vue/tests/record-crumb.test.ts +43 -0
  181. package/frontend-vue/tests/registration.test.ts +4 -1
  182. package/frontend-vue/tests/routes.test.ts +21 -0
  183. package/frontend-vue/tests/save-funnel.test.ts +47 -0
  184. package/frontend-vue/tests/server-parity.test.ts +3 -0
  185. package/frontend-vue/tests/snapshot-options.test.ts +148 -0
  186. package/frontend-vue/tests/snapshot-scroll-root.test.ts +84 -0
  187. package/frontend-vue/tests/stubs/frontend-module.ts +5 -1
  188. package/frontend-vue/tests/top-list-tabs.test.ts +158 -0
  189. package/frontend-vue/tests/website-card.test.ts +119 -0
  190. package/frontend-vue/tests/website-purge.test.ts +70 -0
  191. package/frontend-vue/tests/website-settings.test.ts +133 -0
  192. package/frontend-vue/tests/website-state.test.ts +96 -0
  193. package/frontend-vue/tests/workspace-scope-control.test.ts +113 -0
  194. package/frontend-vue/tests/workspace-scope.test.ts +55 -0
  195. package/package.json +8 -7
  196. package/skills/dms-marketing/SKILL.md +54 -12
  197. package/static/snapshot.js +539 -48
  198. package/static/tracker.js +154 -5
  199. package/dist/services/readiness.d.ts +0 -2
  200. package/dist/services/readiness.js +0 -15
  201. package/dist/services/readiness.js.map +0 -1
  202. package/frontend-vue/app/components/WebsitesGrid.vue +0 -812
@@ -1,15 +1,25 @@
1
1
  <script setup lang="ts">
2
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
- import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
4
2
  import {
5
- useMarketingApi,
6
- type MarketingPageEntry,
7
- type MarketingPages,
8
- type MarketingPagesSort,
9
- type MarketingWebsite,
3
+ computed,
4
+ nextTick,
5
+ onBeforeUnmount,
6
+ onMounted,
7
+ ref,
8
+ watch,
9
+ } from 'vue'
10
+ import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
11
+ // Imported rather than resolved from the registry: a registered component is
12
+ // a lazy chunk of its own, fetched only once a page is selected, so the
13
+ // preview would land a beat after the list, its column empty meanwhile.
14
+ import DmsMarketingPagePreview from './PagePreview.vue'
15
+ import type {
16
+ MarketingPageEntry,
17
+ MarketingPages,
18
+ MarketingPagesSort,
19
+ MarketingWebsite,
10
20
  } from '../composables/useMarketingApi'
11
21
  import {
12
- useHeatmapCountRead,
22
+ useDashboardWorkspaces,
13
23
  usePageInventory,
14
24
  useWebsiteFunnels,
15
25
  useWebsites,
@@ -19,11 +29,16 @@ import { installLink } from '../composables/useMarketingRoutes'
19
29
  import { PERIOD_SCOPE } from '../constants'
20
30
  import { parseFunnelStepsValue } from '../composables/useFunnelSteps'
21
31
  import { formatNumber } from '../utils/format'
32
+ import { pageSnapshotBadge } from '../utils/page-snapshot'
33
+ import { measureFillHeight } from '../utils/fill-height'
34
+ import { useMarketingNow } from '../composables/useMarketingNow'
35
+ import { normalizePagePath, selectedPageRow } from '../utils/heatmap'
22
36
  import {
23
- heatmapDataLabel,
24
- heatmapResetImpact,
25
- normalizePagePath,
26
- } from '../utils/heatmap'
37
+ effectiveWorkspace,
38
+ rememberedWorkspace,
39
+ rememberWorkspace,
40
+ WORKSPACE_QUERY,
41
+ } from '../utils/workspace-scope'
27
42
 
28
43
  /**
29
44
  * Pages & heatmaps: the paths visitors loaded on the selected website, with
@@ -44,6 +59,8 @@ const props = withDefaults(
44
59
  )
45
60
 
46
61
  const SEARCH_DEBOUNCE_MS = 300
62
+ /** The snapshot ages of the rows move on by the minute. */
63
+ const NOW_TICK_MS = 60_000
47
64
  const SKELETON_ROWS = 8
48
65
  /** Where the list and the preview sit side by side (Tailwind `xl`). */
49
66
  const SIDE_BY_SIDE_QUERY = '(min-width: 1280px)'
@@ -51,10 +68,7 @@ const SIDE_BY_SIDE_QUERY = '(min-width: 1280px)'
51
68
  const { t, locale } = useI18n()
52
69
  const route = useDmsRoute()
53
70
  const router = useDmsRouter()
54
- const api = useMarketingApi()
55
71
  const context = useMarketingContext()
56
- const toast = useToast()
57
- const { confirm } = useConfirm()
58
72
 
59
73
  const websiteId = computed(() => context.selected.value?.id ?? '')
60
74
  const period = usePeriodScope(props.periodScope)
@@ -85,25 +99,101 @@ const website = computed(
85
99
  null,
86
100
  )
87
101
 
88
- /** A site as its update answered it, in place: the list is not read again. */
102
+ /**
103
+ * A site as its update answered it, in place: the sites are not read again.
104
+ * The pages are, when its snapshot options changed — saving them deleted the
105
+ * site's snapshots, which the rows show.
106
+ */
89
107
  function putWebsite(saved: MarketingWebsite): void {
90
108
  const sites = websitesRead.data.value
109
+ const previous = sites?.find((site) => site._id === saved._id)
91
110
  if (sites) {
92
111
  websitesRead.data.value = sites.map((site) =>
93
112
  site._id === saved._id ? saved : site,
94
113
  )
95
114
  }
115
+ if (
116
+ previous &&
117
+ (previous.snapshotsEnabled !== saved.snapshotsEnabled ||
118
+ previous.snapshotMaskText !== saved.snapshotMaskText)
119
+ ) {
120
+ void loadPages()
121
+ }
122
+ }
123
+
124
+ // --- Workspace scope ----------------------------------------------------------
125
+
126
+ // A platform owner on the dashboard's own website reads it for another
127
+ // workspace, or all of them (see utils/workspace-scope). Everyone else gets
128
+ // an empty list from the backend, which also refuses the scope itself.
129
+ const workspacesRead = useDashboardWorkspaces(websiteId)
130
+ const workspaceOptions = computed(() =>
131
+ workspacesRead.error.value
132
+ ? []
133
+ : (workspacesRead.data.value?.workspaces ?? []),
134
+ )
135
+ const queryWorkspace = () => {
136
+ const value = route.query[WORKSPACE_QUERY]
137
+ return typeof value === 'string' ? value : ''
138
+ }
139
+ /** What the link asks for, else this browser's last pick (once mounted:
140
+ * the storage is the browser's). */
141
+ const wantedWorkspace = ref(queryWorkspace())
142
+ /** The scope the reads use: the own workspace until the selector offers
143
+ * the wanted one. */
144
+ const workspace = computed(() =>
145
+ effectiveWorkspace(wantedWorkspace.value, workspaceOptions.value),
146
+ )
147
+ /** Another workspace's data: read-only, backdrops masked by the backend. */
148
+ const foreign = computed(() => workspace.value !== '')
149
+ /** A wanted scope waits for the selector's answer, so a link to one never
150
+ * reads the own workspace first, nor fails for a viewer it is not for. */
151
+ const scopeReady = computed(
152
+ () =>
153
+ !wantedWorkspace.value ||
154
+ workspacesRead.status.value === 'success' ||
155
+ workspacesRead.status.value === 'error',
156
+ )
157
+ const scopedWebsiteId = computed(() =>
158
+ scopeReady.value ? websiteId.value : '',
159
+ )
160
+
161
+ function selectWorkspace(value: string): void {
162
+ wantedWorkspace.value = value
163
+ rememberWorkspace(value)
164
+ void router.replace({
165
+ query: { ...route.query, [WORKSPACE_QUERY]: value || undefined },
166
+ })
96
167
  }
97
168
 
169
+ // Back and Forward move the scope with the URL.
170
+ watch(queryWorkspace, (value) => {
171
+ wantedWorkspace.value = value || rememberedWorkspace()
172
+ })
173
+
174
+ // A remembered scope lands in the URL too: a copied link carries it.
175
+ watch(workspace, (value) => {
176
+ if (value && queryWorkspace() !== value) {
177
+ void router.replace({ query: { ...route.query, [WORKSPACE_QUERY]: value } })
178
+ }
179
+ })
180
+
98
181
  const inventoryRead = usePageInventory({
99
- websiteId,
182
+ websiteId: scopedWebsiteId,
100
183
  period: pagesPeriod,
101
184
  search: searchedTerm,
102
185
  sort,
186
+ workspace,
103
187
  })
104
188
  const inventory = computed(() =>
105
189
  inventoryRead.error.value ? null : inventoryRead.data.value,
106
190
  )
191
+
192
+ // The pages panel holds the workspace selector from the first paint: a
193
+ // server render, or a navigation, reads the workspaces before it draws, so
194
+ // the panel never grows under the reader (the inventory reads alongside).
195
+ await workspacesRead
196
+
107
197
  const loading = computed(() => inventoryRead.status.value === 'pending')
108
198
  /** A list is on screen (the previous one while another loads), or failed. */
109
199
  const settled = computed(
@@ -118,7 +208,6 @@ const failed = computed(
118
208
  const loadPages = (): Promise<void> => inventoryRead.refresh()
119
209
 
120
210
  const funnelsRead = useWebsiteFunnels(websiteId)
121
- const countSiteHeatmap = useHeatmapCountRead(websiteId)
122
211
  const funnels = computed(() =>
123
212
  funnelsRead.error.value ? [] : (funnelsRead.data.value ?? []),
124
213
  )
@@ -155,6 +244,16 @@ const sampleRateLabel = computed(() => {
155
244
  const trackerEnabled = computed(() => inventory.value?.trackerEnabled ?? true)
156
245
  const waiting = computed(() => context.selected.value?.state === 'waiting')
157
246
 
247
+ const now = useMarketingNow(NOW_TICK_MS)
248
+
249
+ /** The listed pages, each with what its row says of its snapshot. */
250
+ const rows = computed(() =>
251
+ pages.value.map((page) => ({
252
+ ...page,
253
+ badge: pageSnapshotBadge(page.snapshot, now.value, t, locale.value),
254
+ })),
255
+ )
256
+
158
257
  function metricOf(page: { pageviews: number; clicks: number }): number {
159
258
  return sort.value === 'clicks' ? page.clicks : page.pageviews
160
259
  }
@@ -191,6 +290,55 @@ watch(inventory, selectFirst)
191
290
 
192
291
  const previewWrapper = ref<HTMLElement | null>(null)
193
292
 
293
+ /**
294
+ * Side by side, the list and the preview fill the window below the context
295
+ * bar, each scrolling inside itself: the page does not scroll, and the
296
+ * preview's toolbar and legend stay in sight however long the page or the
297
+ * list. Stacked (below xl), nothing is pinned: null.
298
+ */
299
+ const panes = ref<HTMLElement | null>(null)
300
+ const paneHeight = ref<number | null>(null)
301
+ let paneFrame = 0
302
+
303
+ function measurePanes(): void {
304
+ cancelAnimationFrame(paneFrame)
305
+ paneFrame = requestAnimationFrame(() => {
306
+ const element = panes.value
307
+ paneHeight.value =
308
+ element && window.matchMedia(SIDE_BY_SIDE_QUERY).matches
309
+ ? measureFillHeight(element)
310
+ : null
311
+ })
312
+ }
313
+
314
+ // What sits above or below the panes (a banner, the sampling note) moves
315
+ // them: the body's size says so on a page that scrolls, this block's own on
316
+ // a shell that scrolls a panel. Their own height leaves the answer as is,
317
+ // so the observer settles at once.
318
+ let paneObserver: ResizeObserver | null = null
319
+ onMounted(() => {
320
+ measurePanes()
321
+ window.addEventListener('resize', measurePanes)
322
+ paneObserver = new ResizeObserver(measurePanes)
323
+ paneObserver.observe(document.body)
324
+ const block = panes.value?.parentElement
325
+ if (block) {
326
+ paneObserver.observe(block)
327
+ }
328
+ })
329
+ onBeforeUnmount(() => {
330
+ cancelAnimationFrame(paneFrame)
331
+ window.removeEventListener('resize', measurePanes)
332
+ paneObserver?.disconnect()
333
+ })
334
+
335
+ // The panes come and go with the waiting state.
336
+ watch(panes, (element) => element && measurePanes())
337
+
338
+ const paneStyle = computed(() =>
339
+ paneHeight.value === null ? undefined : { height: `${paneHeight.value}px` },
340
+ )
341
+
194
342
  /** Stacked under the list (below xl), the preview is out of sight: bring it in. */
195
343
  async function pick(path: string): Promise<void> {
196
344
  selectPath(path, 'push')
@@ -216,10 +364,17 @@ function inspectTyped(): void {
216
364
  */
217
365
  const selectedPage = ref<MarketingPageEntry>()
218
366
  watch([pages, selectedPath], ([list, path]) => {
219
- const found = list.find((page) => page.path === path)
220
- if (found || selectedPage.value?.path !== path) {
221
- selectedPage.value = found
222
- }
367
+ selectedPage.value = selectedPageRow(list, path, selectedPage.value)
368
+ })
369
+
370
+ /** What the list is read for, the filter aside: a change is another read. */
371
+ const readScope = computed(() =>
372
+ JSON.stringify([workspace.value, pagesPeriod.value]),
373
+ )
374
+ // Another workspace or other days: the row read before says nothing of
375
+ // them, the header waits for the path's row in the new list (or none).
376
+ watch(readScope, () => {
377
+ selectedPage.value = undefined
223
378
  })
224
379
 
225
380
  /** Funnels with a page step on the selected path, for the "Used in" card. */
@@ -232,11 +387,17 @@ const usedIn = computed(() =>
232
387
  )
233
388
 
234
389
  // The preview draws in the browser only (frames, canvases): a server render
235
- // holds its place.
236
- const mounted = ref(false)
390
+ // holds its place, until the browser hydrating it is mounted. A client
391
+ // navigation draws it with the list, from the first render: mounted later,
392
+ // it would come in after the list, and its skeleton after none.
393
+ const mounted = ref(!import.meta.env.SSR && !isDmsHydrating())
394
+ if (mounted.value) {
395
+ selectFirst(inventory.value)
396
+ }
237
397
  onMounted(() => {
238
398
  mounted.value = true
239
399
  selectFirst(inventory.value)
400
+ wantedWorkspace.value ||= rememberedWorkspace()
240
401
  })
241
402
 
242
403
  const countLabel = computed(() => {
@@ -251,53 +412,6 @@ const sortItems = computed(() => [
251
412
  { value: 'views', label: t('page.marketing.pages.views') },
252
413
  { value: 'clicks', label: t('page.marketing.pages.overlay.clicks') },
253
414
  ])
254
-
255
- async function resetSite(): Promise<void> {
256
- const site = website.value
257
- if (!site) {
258
- return
259
- }
260
- const counts = await countSiteHeatmap().catch(() => null)
261
- await confirm({
262
- title: t('page.marketing.pages.reset.site_title', { name: site.name }),
263
- description: t('page.marketing.pages.reset.site_description'),
264
- color: 'error',
265
- icon: 'i-ph-eraser',
266
- confirmLabel:
267
- counts && counts.events + counts.snapshots > 0
268
- ? t('page.marketing.pages.reset.confirm_count', {
269
- what: heatmapDataLabel(counts, t, locale.value),
270
- })
271
- : t('page.marketing.pages.reset.confirm'),
272
- confirmText: site.domain,
273
- // A hostname is not case-sensitive: neither is the typed check.
274
- confirmTextCaseInsensitive: true,
275
- impact: heatmapResetImpact(counts, t, locale.value),
276
- onConfirm: async () => {
277
- const deleted = await api.resetHeatmap(site._id)
278
- toast.add({
279
- color: 'success',
280
- title: t('page.marketing.pages.reset.site_done', { name: site.name }),
281
- description: t('page.marketing.pages.reset.done', {
282
- what: heatmapDataLabel(deleted, t, locale.value),
283
- }),
284
- })
285
- // The dialog closes once the pages show the emptied counts.
286
- await refreshPageBlocks()
287
- },
288
- })
289
- }
290
-
291
- const moreItems = computed(() => [
292
- [
293
- {
294
- label: t('page.marketing.pages.reset.site_button'),
295
- icon: 'i-ph-eraser',
296
- color: 'error' as const,
297
- onSelect: () => void resetSite(),
298
- },
299
- ],
300
- ])
301
415
  </script>
302
416
 
303
417
  <template>
@@ -336,10 +450,27 @@ const moreItems = computed(() => [
336
450
  :description="t('page.marketing.pages.tracking_off.description')"
337
451
  />
338
452
 
339
- <div class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]">
453
+ <div
454
+ ref="panes"
455
+ class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]"
456
+ >
340
457
  <section
341
- class="dms-card flex min-w-0 flex-col overflow-hidden p-0 xl:sticky xl:top-4"
458
+ class="dms-card flex min-w-0 flex-col overflow-hidden p-0"
459
+ :class="paneHeight === null ? 'xl:sticky xl:top-4' : ''"
460
+ :style="paneStyle"
342
461
  >
462
+ <!-- Whose dashboard the list reads, above its search: read with
463
+ the page, so the row is there from the first paint. -->
464
+ <div
465
+ v-if="workspaceOptions.length > 1"
466
+ class="border-b border-default p-3"
467
+ >
468
+ <DmsMarketingWorkspaceScope
469
+ :workspaces="workspaceOptions"
470
+ :model-value="workspace"
471
+ @update:model-value="selectWorkspace"
472
+ />
473
+ </div>
343
474
  <div class="flex items-center gap-2 border-b border-default p-3">
344
475
  <UInput
345
476
  v-model="search"
@@ -349,15 +480,6 @@ const moreItems = computed(() => [
349
480
  :placeholder="t('page.marketing.pages.search_placeholder')"
350
481
  @keyup.enter="inspectTyped"
351
482
  />
352
- <UDropdownMenu :items="moreItems" :content="{ align: 'end' }">
353
- <UButton
354
- icon="i-ph-dots-three"
355
- size="sm"
356
- color="neutral"
357
- variant="ghost"
358
- :aria-label="t('page.marketing.pages.more_list')"
359
- />
360
- </UDropdownMenu>
361
483
  </div>
362
484
  <div class="flex items-center justify-between gap-2 px-4 py-2">
363
485
  <DmsEyebrow :label="countLabel" />
@@ -429,10 +551,15 @@ const moreItems = computed(() => [
429
551
  />
430
552
  <ul
431
553
  v-else
432
- class="flex max-h-[45vh] flex-col gap-1 overflow-y-auto p-2 xl:max-h-[70vh]"
433
- :class="loading ? 'opacity-60' : ''"
554
+ class="flex flex-col gap-1 overflow-y-auto p-2"
555
+ :class="[
556
+ loading ? 'opacity-60' : '',
557
+ paneHeight === null
558
+ ? 'max-h-[45vh] xl:max-h-[70vh]'
559
+ : 'min-h-0 flex-1',
560
+ ]"
434
561
  >
435
- <li v-for="page in pages" :key="page.path">
562
+ <li v-for="page in rows" :key="page.path">
436
563
  <button
437
564
  type="button"
438
565
  class="flex w-full flex-col gap-1 rounded-lg border px-3 py-2 text-left transition-colors"
@@ -488,6 +615,20 @@ const moreItems = computed(() => [
488
615
  }}
489
616
  </template>
490
617
  </span>
618
+ <span
619
+ v-if="page.badge"
620
+ class="flex shrink-0 items-center gap-1 text-[11px]"
621
+ :class="page.badge.muted ? 'text-dimmed' : 'text-muted'"
622
+ :title="page.badge.title"
623
+ >
624
+ <UIcon
625
+ :name="page.badge.icon"
626
+ class="size-3"
627
+ :class="page.badge.muted ? '' : 'text-success'"
628
+ />
629
+ <span class="sr-only">{{ page.badge.title }}</span>
630
+ <span aria-hidden="true">{{ page.badge.label }}</span>
631
+ </span>
491
632
  <span
492
633
  class="h-1 w-14 overflow-hidden rounded-full bg-elevated"
493
634
  aria-hidden="true"
@@ -525,6 +666,7 @@ const moreItems = computed(() => [
525
666
  <USkeleton
526
667
  v-if="!mounted || !selectedPath || !website"
527
668
  class="h-[32rem] rounded-xl"
669
+ :style="paneStyle"
528
670
  aria-hidden="true"
529
671
  />
530
672
  <DmsMarketingPagePreview
@@ -536,6 +678,9 @@ const moreItems = computed(() => [
536
678
  :tracker-enabled="trackerEnabled"
537
679
  :pageviews="selectedPage?.pageviews"
538
680
  :used-in="usedIn"
681
+ :height="paneHeight"
682
+ :workspace="workspace"
683
+ :workspace-snapshots="inventory?.snapshotsEnabled ?? false"
539
684
  @website-updated="putWebsite"
540
685
  />
541
686
  </div>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
  import { useI18n } from '#dms/frontend-module'
4
4
  import { useContextLink } from '../composables/useContextLink'
5
5
  import { displayName, formatNumber, percentChangeTrend } from '../utils/format'
@@ -60,7 +60,10 @@ const props = withDefaults(
60
60
  )
61
61
 
62
62
  const HIGHLIGHTED_RANKS = 3
63
- const SKELETON_ROWS = 6
63
+ /** The rows a tab lists at most (the tops route's limit): the skeleton's. */
64
+ const SKELETON_ROWS = 8
65
+ /** The tabs the overview groups hold at most: the skeleton's. */
66
+ const SKELETON_TABS = 3
64
67
  const RANK_DIGITS = 2
65
68
 
66
69
  const { t, te, locale } = useI18n()
@@ -73,21 +76,27 @@ const { data, isLoading, error, refresh } = useChartFetch<TopTabsResponse>({
73
76
  })
74
77
 
75
78
  const tabs = computed(() => data.value?.tabs ?? [])
76
- const activeId = ref<string>('')
77
-
78
- watch(
79
- tabs,
80
- (list) => {
81
- if (!list.some((tab) => tab.id === activeId.value)) {
82
- activeId.value = list[0]?.id ?? ''
83
- }
79
+ /** The picked tab; the first one until a pick names one the data has. */
80
+ const pickedId = ref<string>('')
81
+ /**
82
+ * Derived rather than set by a watcher: a server render runs no watcher once
83
+ * its data lands, and would draw the empty state the browser then replaces.
84
+ */
85
+ const activeId = computed({
86
+ get: () =>
87
+ tabs.value.some((tab) => tab.id === pickedId.value)
88
+ ? pickedId.value
89
+ : (tabs.value[0]?.id ?? ''),
90
+ set: (id: string) => {
91
+ pickedId.value = id
84
92
  },
85
- { immediate: true },
86
- )
93
+ })
87
94
 
88
95
  const active = computed(
89
96
  () => tabs.value.find((tab) => tab.id === activeId.value) ?? null,
90
97
  )
98
+ /** Nothing to draw yet: the skeleton, the size of a full card. */
99
+ const pending = computed(() => !data.value && !error.value && isLoading.value)
91
100
  const maxShare = computed(() =>
92
101
  Math.max(1, ...(active.value?.items.map((item) => item.share) ?? [])),
93
102
  )
@@ -174,6 +183,16 @@ const TABS_UI = {
174
183
  class="w-full"
175
184
  />
176
185
  </div>
186
+ <!-- The tab strip's place while the tabs load, at its height. -->
187
+ <div
188
+ v-else-if="pending"
189
+ class="order-last -mx-1 flex min-w-0 basis-full gap-1 overflow-hidden sm:order-none sm:mx-0 sm:flex-1 sm:basis-0"
190
+ aria-hidden="true"
191
+ >
192
+ <span v-for="index in SKELETON_TABS" :key="index" class="px-2 py-3">
193
+ <USkeleton class="h-3.5 w-24" />
194
+ </span>
195
+ </div>
177
196
  <DmsEyebrow
178
197
  :label="processI18n(valueLabel)"
179
198
  class="ms-auto shrink-0 py-3"
@@ -204,19 +223,27 @@ const TABS_UI = {
204
223
  />
205
224
  </template>
206
225
  </DmsEmptyState>
226
+ <!-- Each row at a list row's size: rank, label over its share bar, count
227
+ and share. -->
207
228
  <ul
208
- v-else-if="isLoading && !data"
229
+ v-else-if="pending"
209
230
  class="divide-y divide-default"
210
231
  aria-hidden="true"
211
232
  >
212
233
  <li
213
234
  v-for="index in SKELETON_ROWS"
214
235
  :key="index"
215
- class="flex items-center gap-3 px-4 py-3"
236
+ class="grid grid-cols-[1.5rem_minmax(0,1fr)_auto_3rem] items-center gap-x-3 px-4 py-2"
216
237
  >
217
- <USkeleton class="h-3 w-5" />
218
- <USkeleton class="h-3 flex-1" />
219
- <USkeleton class="h-3 w-12" />
238
+ <USkeleton class="h-3 w-4" />
239
+ <span class="min-w-0">
240
+ <span class="flex h-[19.5px] items-center">
241
+ <USkeleton class="h-3 w-3/5" />
242
+ </span>
243
+ <USkeleton class="mt-1 h-0.5 w-2/5" />
244
+ </span>
245
+ <USkeleton class="h-3 w-8" />
246
+ <USkeleton class="ms-auto h-3 w-9" />
220
247
  </li>
221
248
  </ul>
222
249
  <p
@@ -230,7 +257,13 @@ const TABS_UI = {
230
257
  class="divide-y divide-default transition-opacity"
231
258
  :class="isLoading ? 'opacity-60' : ''"
232
259
  >
233
- <li v-for="(item, index) in active.items" :key="item.id">
260
+ <!-- A row's height, kept while its link component loads on a first
261
+ navigation (the divider adds a pixel to every row but the first). -->
262
+ <li
263
+ v-for="(item, index) in active.items"
264
+ :key="item.id"
265
+ class="min-h-[42.5px] first:min-h-[41.5px]"
266
+ >
234
267
  <component
235
268
  :is="item.to ? 'DmsAutoLink' : 'div'"
236
269
  :to="item.to ? inContext(item.to) : undefined"