@antelopejs/dms-marketing 0.4.0 → 0.5.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 (29) hide show
  1. package/dist/hooks/index.js +1 -5
  2. package/dist/hooks/index.js.map +1 -1
  3. package/dist/index.js +2 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/pages/funnels/page.js +5 -5
  6. package/dist/pages/funnels/page.js.map +1 -1
  7. package/dist/routes/public-endpoints.d.ts +2 -0
  8. package/dist/routes/public-endpoints.js +14 -0
  9. package/dist/routes/public-endpoints.js.map +1 -0
  10. package/frontend-vue/app/components/Context.vue +37 -61
  11. package/frontend-vue/app/components/FunnelBuilder.vue +22 -19
  12. package/frontend-vue/app/components/FunnelReport.vue +1 -1
  13. package/frontend-vue/app/components/FunnelTemplates.vue +16 -27
  14. package/frontend-vue/app/components/InstallGuide.vue +40 -57
  15. package/frontend-vue/app/components/PagePreview.vue +60 -76
  16. package/frontend-vue/app/components/PagesExplorer.vue +86 -80
  17. package/frontend-vue/app/components/WebsitesGrid.vue +32 -23
  18. package/frontend-vue/app/composables/useMarketingApi.ts +7 -120
  19. package/frontend-vue/app/composables/useMarketingContext.ts +1 -10
  20. package/frontend-vue/app/composables/useMarketingNow.ts +29 -0
  21. package/frontend-vue/app/composables/useMarketingReads.ts +314 -0
  22. package/frontend-vue/app/utils/format.ts +37 -1
  23. package/frontend-vue/package.json +1 -2
  24. package/frontend-vue/pnpm-lock.yaml +51 -19
  25. package/frontend-vue/tests/format.test.ts +21 -0
  26. package/frontend-vue/tests/marketing-reads.test.ts +252 -0
  27. package/frontend-vue/tests/registration.test.ts +0 -4
  28. package/frontend-vue/vitest.config.ts +3 -1
  29. package/package.json +3 -3
@@ -1,24 +1,19 @@
1
1
  <script setup lang="ts">
2
- import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
3
  import {
4
4
  useDmsRoute,
5
5
  useDmsRouter,
6
6
  useDmsRuntimeConfig,
7
7
  useI18n,
8
8
  } from '#dms/frontend-module'
9
- import {
10
- useMarketingApi,
11
- type MarketingConnectionStatus,
12
- } from '../composables/useMarketingApi'
9
+ import { useMarketingApi } from '../composables/useMarketingApi'
10
+ import { useWebsiteConnection } from '../composables/useMarketingReads'
13
11
  import {
14
12
  installLink,
15
13
  overviewLink,
16
14
  websitesLink,
17
15
  } from '../composables/useMarketingRoutes'
18
- import {
19
- allowHostConfirmation,
20
- hostConflictOf,
21
- } from '../utils/host-conflict'
16
+ import { allowHostConfirmation, hostConflictOf } from '../utils/host-conflict'
22
17
  import { trackerSnippet } from '../utils/snippet'
23
18
 
24
19
  /**
@@ -57,12 +52,20 @@ const fieldErrors = useFieldErrors({
57
52
  const websiteId = ref(
58
53
  typeof route.query.website === 'string' ? route.query.website : '',
59
54
  )
60
- const status = ref<MarketingConnectionStatus | null>(null)
61
- const failed = ref(false)
62
- /** The `?website=` names no site of the workspace (deleted, mistyped). */
63
- const missing = ref(false)
64
55
  /** Server time "Check again" waits from; null: since the site's creation. */
65
56
  const recheckSince = ref<number | null>(null)
57
+ /** The check's polling period: `POLL_MS` while it waits for a pageview, 0 (stopped) otherwise. */
58
+ const pollInterval = ref(0)
59
+ const connection = useWebsiteConnection(websiteId, recheckSince, {
60
+ refreshInterval: pollInterval,
61
+ })
62
+ const status = connection.data
63
+ /** The `?website=` names no site of the workspace (deleted, mistyped). */
64
+ const missing = computed(
65
+ () => apiErrorStatus(connection.error.value) === HTTP_NOT_FOUND,
66
+ )
67
+ /** The last check failed; the next one is on its way. */
68
+ const failed = computed(() => !!connection.error.value && !missing.value)
66
69
 
67
70
  // --- Step 1 ----------------------------------------------------------------------
68
71
 
@@ -95,7 +98,6 @@ async function createWebsite(): Promise<void> {
95
98
  await api.selectContextWebsite(created._id)
96
99
  websiteId.value = created._id
97
100
  await router.replace(installLink(created._id))
98
- await poll()
99
101
  } catch (error) {
100
102
  const conflict = hostConflictOf(error)
101
103
  if (conflict) {
@@ -115,47 +117,27 @@ async function createWebsite(): Promise<void> {
115
117
 
116
118
  // --- Step 3: connection check ---------------------------------------------------
117
119
 
118
- let timer: ReturnType<typeof setTimeout> | null = null
119
-
120
- function stopPolling(): void {
121
- if (timer) {
122
- clearTimeout(timer)
123
- timer = null
124
- }
125
- }
126
-
127
- async function poll(): Promise<void> {
128
- stopPolling()
129
- if (!websiteId.value) {
130
- return
131
- }
132
- try {
133
- status.value = await api.getConnection(
134
- websiteId.value,
135
- recheckSince.value ?? undefined,
136
- )
137
- failed.value = false
138
- } catch (error) {
139
- if (apiErrorStatus(error) === HTTP_NOT_FOUND) {
140
- missing.value = true
141
- status.value = null
142
- return
143
- }
144
- failed.value = true
145
- }
146
- if (!status.value?.visit) {
147
- timer = setTimeout(() => void poll(), POLL_MS)
148
- }
149
- }
120
+ /** No pageview yet, and the site is there: the check polls. */
121
+ const isWaiting = computed(
122
+ () => !!websiteId.value && !missing.value && !status.value?.visit,
123
+ )
124
+ // The check polls while it waits, and stops once a pageview arrived or the
125
+ // site is gone.
126
+ watch(
127
+ isWaiting,
128
+ (waiting) => {
129
+ pollInterval.value = waiting ? POLL_MS : 0
130
+ },
131
+ { immediate: true },
132
+ )
150
133
 
151
134
  /** Waits for a pageview sent from now on, to re-verify an installed tag. */
152
- async function checkAgain(): Promise<void> {
135
+ function checkAgain(): void {
153
136
  if (!status.value) {
154
137
  return
155
138
  }
156
139
  recheckSince.value = status.value.checkedAt
157
140
  status.value = { ...status.value, visit: null }
158
- await poll()
159
141
  }
160
142
 
161
143
  const apiOrigin = ref('')
@@ -164,9 +146,7 @@ onMounted(() => {
164
146
  apiOrigin.value = String(
165
147
  (config.public as { dms?: { baseURL?: string } }).dms?.baseURL ?? '',
166
148
  )
167
- void poll()
168
149
  })
169
- onBeforeUnmount(stopPolling)
170
150
 
171
151
  watch(
172
152
  () => route.query.website,
@@ -174,10 +154,8 @@ watch(
174
154
  const next = typeof value === 'string' ? value : ''
175
155
  if (next !== websiteId.value) {
176
156
  websiteId.value = next
177
- status.value = null
178
- missing.value = false
179
157
  recheckSince.value = null
180
- void poll()
158
+ connection.clear()
181
159
  }
182
160
  },
183
161
  )
@@ -201,7 +179,8 @@ async function allow(hostname: string): Promise<void> {
201
179
  color: 'success',
202
180
  title: t('page.marketing.install.allowed', { host: hostname }),
203
181
  })
204
- await poll()
182
+ // The refused host leaves the check before its button comes back.
183
+ await refreshPageBlocks()
205
184
  } catch (error) {
206
185
  const conflict = hostConflictOf(error)
207
186
  if (conflict) {
@@ -232,7 +211,7 @@ async function resumeTracking(): Promise<void> {
232
211
  color: 'success',
233
212
  title: t('page.marketing.websites.resumed'),
234
213
  })
235
- await poll()
214
+ await refreshPageBlocks()
236
215
  } catch (error) {
237
216
  fieldErrors.toastError(error, {
238
217
  toastTitle: 'page.marketing.websites.save_error',
@@ -638,7 +617,9 @@ const stepTwoState = computed(() =>
638
617
  />
639
618
  </span>
640
619
  <span class="min-w-0 flex-1">
641
- <span class="block font-semibold break-words text-highlighted">
620
+ <span
621
+ class="block font-semibold break-words text-highlighted"
622
+ >
642
623
  {{
643
624
  t(
644
625
  recheckSince === null
@@ -677,7 +658,9 @@ const stepTwoState = computed(() =>
677
658
  >
678
659
  <DmsIconWell icon="i-ph-prohibit" tone="error" />
679
660
  <span class="min-w-0 flex-1">
680
- <span class="block font-semibold break-words text-highlighted">
661
+ <span
662
+ class="block font-semibold break-words text-highlighted"
663
+ >
681
664
  {{
682
665
  t(
683
666
  'page.marketing.install.refused',
@@ -1,18 +1,21 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
3
3
  import { useI18n } from '#dms/frontend-module'
4
- import { useLatestRequest } from '../composables/useLatestRequest'
5
4
  import {
6
5
  useMarketingApi,
7
6
  type MarketingPeriod,
8
- type MarketingHeatmap,
9
- type MarketingPageSnapshotResponse,
10
- type MarketingScrollDepth,
11
7
  type MarketingSnapshotLayout,
12
8
  type MarketingWebsite,
13
9
  type MarketingWebsitePatch,
14
10
  type MarketingFunnelListItem,
15
11
  } from '../composables/useMarketingApi'
12
+ import {
13
+ useHeatmapCountRead,
14
+ usePageHeatmap,
15
+ usePageScrollDepth,
16
+ usePageSnapshot,
17
+ type PagePreviewRequest,
18
+ } from '../composables/useMarketingReads'
16
19
  import { funnelLink, settingsLink } from '../composables/useMarketingRoutes'
17
20
  import { formatNumber, formatShortDate } from '../utils/format'
18
21
  import {
@@ -77,17 +80,14 @@ const props = defineProps<{
77
80
  trackerEnabled: boolean
78
81
  /** Pageviews of this path over the window, for the sampling hint. */
79
82
  pageviews?: number
80
- /** Bumped by the parent when it resets clicks out from under this view. */
81
- refreshToken?: number
82
83
  /** Funnels with a page step on this path. */
83
84
  usedIn?: MarketingFunnelListItem[]
84
85
  }>()
85
86
 
86
- /** The site's snapshot options were changed here; the parent owns the row. */
87
+ /** The site's snapshot options were saved here, as the backend answered
88
+ * them; the parent owns the row. */
87
89
  const emit = defineEmits<{
88
- 'website-updated': []
89
- /** The page lost its clicks: the inventory counts them too. */
90
- 'heatmap-reset': []
90
+ 'website-updated': [website: MarketingWebsite]
91
91
  }>()
92
92
 
93
93
  interface LayoutPreset {
@@ -138,19 +138,20 @@ const { t, locale } = useI18n()
138
138
 
139
139
  // --- Heatmap ----------------------------------------------------------------
140
140
 
141
- // Path, site and period all retrigger a load and the answers can land out of
142
- // order: a slow answer for the page the user just left would otherwise repaint
143
- // over the one on screen — see useLatestRequest.
144
- const {
145
- data: heatmap,
146
- loading,
147
- failed,
148
- run: loadHeatmap,
149
- } = useLatestRequest<MarketingHeatmap>(() =>
150
- props.website && props.path && !import.meta.env.SSR
151
- ? api.getHeatmap(props.website._id, props.path, props.period)
152
- : null,
141
+ // Path, site and period all read again, and a newer read replaces one still
142
+ // in flight: a slow answer for the page the user just left never repaints
143
+ // over the one on screen. Drawn in the browser only.
144
+ const previewRequest: PagePreviewRequest = {
145
+ websiteId: () => props.website?._id,
146
+ path: () => props.path,
147
+ period: () => props.period,
148
+ }
149
+ const heatmapRead = usePageHeatmap(previewRequest)
150
+ const heatmap = computed(() =>
151
+ heatmapRead.error.value ? null : heatmapRead.data.value,
153
152
  )
153
+ const loading = computed(() => heatmapRead.pending.value)
154
+ const failed = computed(() => !!heatmapRead.error.value && !loading.value)
154
155
 
155
156
  const points = computed(() => heatmap.value?.points ?? [])
156
157
  const maxWeight = computed(() => heatmap.value?.maxWeight ?? 0)
@@ -163,30 +164,13 @@ const overlayMode = ref<'clicks' | 'scroll'>('clicks')
163
164
  // Read with the clicks, not when its overlay opens: every sampled view
164
165
  // reports one depth, so its count is the page's sampled views the header
165
166
  // and the empty states speak of.
166
- const {
167
- data: scrollDepth,
168
- loading: loadingScroll,
169
- failed: scrollFailed,
170
- run: loadScrollDepth,
171
- } = useLatestRequest<MarketingScrollDepth>(() =>
172
- props.website && props.path && !import.meta.env.SSR
173
- ? api.getScrollDepth(props.website._id, props.path, props.period)
174
- : null,
167
+ const scrollRead = usePageScrollDepth(previewRequest)
168
+ const scrollDepth = computed(() =>
169
+ scrollRead.error.value ? null : scrollRead.data.value,
175
170
  )
176
-
177
- watch(
178
- () => [
179
- props.website?._id,
180
- props.path,
181
- props.period?.range?.from.getTime(),
182
- props.period?.range?.to.getTime(),
183
- props.refreshToken,
184
- ],
185
- () => {
186
- void loadHeatmap()
187
- void loadScrollDepth()
188
- },
189
- { immediate: true },
171
+ const loadingScroll = computed(() => scrollRead.pending.value)
172
+ const scrollFailed = computed(
173
+ () => !!scrollRead.error.value && !loadingScroll.value,
190
174
  )
191
175
 
192
176
  const scrollViews = computed(() => scrollDepth.value?.views ?? 0)
@@ -221,8 +205,13 @@ const { confirm } = useConfirm()
221
205
  const toast = useToast()
222
206
  const resetting = ref(false)
223
207
 
208
+ const countPageHeatmap = useHeatmapCountRead(
209
+ () => props.website?._id ?? '',
210
+ () => props.path,
211
+ )
212
+
224
213
  /** Deletes the clicks, scrolls and snapshots of the page (every period),
225
- * counted by the server before asking, then refetches both overlays. */
214
+ * counted by the server before asking; the page's reads follow. */
226
215
  async function resetPageHeatmap() {
227
216
  const website = props.website
228
217
  if (!website) {
@@ -230,8 +219,7 @@ async function resetPageHeatmap() {
230
219
  }
231
220
  const path = props.path
232
221
  resetting.value = true
233
- const counts = await api
234
- .countHeatmap(website._id, path)
222
+ const counts = await countPageHeatmap()
235
223
  .catch(() => null)
236
224
  .finally(() => (resetting.value = false))
237
225
  await confirm({
@@ -258,8 +246,8 @@ async function resetPageHeatmap() {
258
246
  what: heatmapDataLabel(deleted, t, locale.value),
259
247
  }),
260
248
  })
261
- emit('heatmap-reset')
262
- await Promise.all([loadHeatmap(), loadScrollDepth(), loadSnapshot()])
249
+ // The preview shows the emptied page before the button comes back.
250
+ await refreshPageBlocks()
263
251
  } finally {
264
252
  resetting.value = false
265
253
  }
@@ -283,29 +271,26 @@ const snapshotsEnabled = computed(
283
271
 
284
272
  // Not fetched while the site has snapshots off: the option's banner is the
285
273
  // whole story then, and a leftover capture must not contradict it.
286
- const {
287
- data: snapshotResponse,
288
- settled: snapshotSettled,
289
- run: loadSnapshot,
290
- } = useLatestRequest<MarketingPageSnapshotResponse>(() =>
291
- props.website && snapshotsEnabled.value && props.path && !import.meta.env.SSR
292
- ? api.getSnapshot(props.website._id, props.path, layout.value)
293
- : null,
274
+ const snapshotRead = usePageSnapshot({
275
+ websiteId: () => props.website?._id,
276
+ path: () => props.path,
277
+ layout,
278
+ enabled: snapshotsEnabled,
279
+ })
280
+ const isSnapshotAsked = computed(
281
+ () => snapshotsEnabled.value && !!props.website?._id && !!props.path,
294
282
  )
295
-
296
- watch(
297
- () => [
298
- props.website?._id,
299
- snapshotsEnabled.value,
300
- props.path,
301
- layout.value,
302
- props.refreshToken,
303
- ],
304
- () => void loadSnapshot(),
305
- { immediate: true },
283
+ /** A capture was asked and answered, or there is none to ask for. */
284
+ const snapshotSettled = computed(
285
+ () =>
286
+ !isSnapshotAsked.value ||
287
+ snapshotRead.status.value === 'success' ||
288
+ snapshotRead.status.value === 'error',
306
289
  )
307
290
 
308
- const snapshot = computed(() => snapshotResponse.value?.snapshot ?? null)
291
+ const snapshot = computed(() =>
292
+ snapshotRead.error.value ? null : (snapshotRead.data.value?.snapshot ?? null),
293
+ )
309
294
 
310
295
  const backdrop = computed(() => snapshot.value?.html ?? null)
311
296
 
@@ -345,8 +330,7 @@ async function updateSnapshotOptions(
345
330
  }
346
331
  savingOptions.value = true
347
332
  try {
348
- await api.updateWebsite(website._id, patch)
349
- emit('website-updated')
333
+ emit('website-updated', await api.updateWebsite(website._id, patch))
350
334
  } catch {
351
335
  toast.add({
352
336
  color: 'error',
@@ -627,10 +611,10 @@ const activeLoading = computed(() =>
627
611
 
628
612
  function retryActive(): void {
629
613
  if (overlayMode.value === 'clicks') {
630
- void loadHeatmap()
614
+ void heatmapRead.refresh()
631
615
  return
632
616
  }
633
- void loadScrollDepth()
617
+ void scrollRead.refresh()
634
618
  }
635
619
 
636
620
  const truncatedState = computed(() => {
@@ -947,7 +931,7 @@ const moreItems = computed(() => [
947
931
  type: 'checkbox' as const,
948
932
  label: t('page.marketing.pages.preview.snapshots_enabled'),
949
933
  checked: snapshotsEnabled.value,
950
- onUpdateChecked: (value: boolean) =>
934
+ onUpdateChecked: (value: boolean): void =>
951
935
  void updateSnapshotOptions({ snapshotsEnabled: value }),
952
936
  },
953
937
  {
@@ -955,7 +939,7 @@ const moreItems = computed(() => [
955
939
  label: t('page.marketing.pages.preview.mask_text'),
956
940
  checked: props.website?.snapshotMaskText === true,
957
941
  disabled: !snapshotsEnabled.value,
958
- onUpdateChecked: (value: boolean) =>
942
+ onUpdateChecked: (value: boolean): void =>
959
943
  void updateSnapshotOptions({ snapshotMaskText: value }),
960
944
  },
961
945
  ],
@@ -1,15 +1,19 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
3
  import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
4
- import { useLatestRequest } from '../composables/useLatestRequest'
5
4
  import {
6
5
  useMarketingApi,
7
- type MarketingFunnelListItem,
8
- type MarketingPages,
9
6
  type MarketingPageEntry,
7
+ type MarketingPages,
10
8
  type MarketingPagesSort,
11
9
  type MarketingWebsite,
12
10
  } from '../composables/useMarketingApi'
11
+ import {
12
+ useHeatmapCountRead,
13
+ usePageInventory,
14
+ useWebsiteFunnels,
15
+ useWebsites,
16
+ } from '../composables/useMarketingReads'
13
17
  import { useMarketingContext } from '../composables/useMarketingContext'
14
18
  import { installLink } from '../composables/useMarketingRoutes'
15
19
  import { PERIOD_SCOPE } from '../constants'
@@ -55,78 +59,83 @@ const { confirm } = useConfirm()
55
59
  const websiteId = computed(() => context.selected.value?.id ?? '')
56
60
  const period = usePeriodScope(props.periodScope)
57
61
  /** The days read: a comparison change alone reads nothing again. */
58
- const periodDays = computed(() => {
59
- const range = period.value?.range
60
- return range ? `${range.from.toISOString()}|${range.to.toISOString()}` : ''
61
- })
62
+ const pagesPeriod = computed(() =>
63
+ period.value
64
+ ? {
65
+ ...period.value,
66
+ comparison: 'none' as const,
67
+ compareRange: null,
68
+ }
69
+ : null,
70
+ )
62
71
 
63
72
  const queryPath = () =>
64
73
  typeof route.query.path === 'string' ? route.query.path : ''
65
74
  const selectedPath = ref<string | undefined>(queryPath() || undefined)
66
75
  const search = ref('')
76
+ /** The search the list is read with, once the box holds still. */
77
+ const searchedTerm = ref('')
67
78
  const sort = ref<MarketingPagesSort>('views')
68
79
 
69
80
  // The full row: the context carries the id, the preview needs the snapshot options.
70
- const website = ref<MarketingWebsite | null>(null)
71
-
72
- async function loadWebsite(): Promise<void> {
73
- const list = await api.listWebsites()
74
- website.value = list.find((site) => site._id === websiteId.value) ?? null
75
- }
76
-
77
- const {
78
- data: inventory,
79
- loading,
80
- settled,
81
- failed,
82
- run: loadPages,
83
- } = useLatestRequest<MarketingPages>(() =>
84
- websiteId.value && !import.meta.env.SSR
85
- ? api.listPages(
86
- websiteId.value,
87
- period.value,
88
- search.value.trim(),
89
- sort.value,
90
- )
91
- : null,
81
+ const websitesRead = useWebsites()
82
+ const website = computed(
83
+ () =>
84
+ websitesRead.data.value?.find((site) => site._id === websiteId.value) ??
85
+ null,
92
86
  )
93
87
 
94
- const funnels = ref<MarketingFunnelListItem[]>([])
95
-
96
- async function loadFunnels(): Promise<void> {
97
- try {
98
- funnels.value = (await api.listFunnels(websiteId.value)).results
99
- } catch {
100
- funnels.value = []
88
+ /** A site as its update answered it, in place: the list is not read again. */
89
+ function putWebsite(saved: MarketingWebsite): void {
90
+ const sites = websitesRead.data.value
91
+ if (sites) {
92
+ websitesRead.data.value = sites.map((site) =>
93
+ site._id === saved._id ? saved : site,
94
+ )
101
95
  }
102
96
  }
103
97
 
104
- watch(
105
- [websiteId, context.refreshToken],
106
- ([id], previous) => {
107
- if (previous?.[0] !== undefined && id !== previous[0]) {
108
- selectedPath.value = undefined
109
- }
110
- if (id) {
111
- void loadWebsite()
112
- void loadFunnels()
113
- }
114
- },
115
- { immediate: true },
98
+ const inventoryRead = usePageInventory({
99
+ websiteId,
100
+ period: pagesPeriod,
101
+ search: searchedTerm,
102
+ sort,
103
+ })
104
+ const inventory = computed(() =>
105
+ inventoryRead.error.value ? null : inventoryRead.data.value,
116
106
  )
107
+ const loading = computed(() => inventoryRead.status.value === 'pending')
108
+ /** A list is on screen (the previous one while another loads), or failed. */
109
+ const settled = computed(
110
+ () =>
111
+ inventoryRead.data.value !== null ||
112
+ inventoryRead.status.value === 'success' ||
113
+ inventoryRead.status.value === 'error',
114
+ )
115
+ const failed = computed(
116
+ () => !!inventoryRead.error.value && inventoryRead.status.value !== 'pending',
117
+ )
118
+ const loadPages = (): Promise<void> => inventoryRead.refresh()
117
119
 
118
- watch(
119
- [websiteId, periodDays, context.refreshToken, sort],
120
- () => void loadPages(),
121
- { immediate: true },
120
+ const funnelsRead = useWebsiteFunnels(websiteId)
121
+ const countSiteHeatmap = useHeatmapCountRead(websiteId)
122
+ const funnels = computed(() =>
123
+ funnelsRead.error.value ? [] : (funnelsRead.data.value ?? []),
122
124
  )
123
125
 
126
+ watch(websiteId, () => {
127
+ selectedPath.value = undefined
128
+ })
129
+
124
130
  let searchTimer: ReturnType<typeof setTimeout> | null = null
125
- watch(search, () => {
131
+ watch(search, (value) => {
126
132
  if (searchTimer) {
127
133
  clearTimeout(searchTimer)
128
134
  }
129
- searchTimer = setTimeout(() => void loadPages(), SEARCH_DEBOUNCE_MS)
135
+ searchTimer = setTimeout(
136
+ () => (searchedTerm.value = value.trim()),
137
+ SEARCH_DEBOUNCE_MS,
138
+ )
130
139
  })
131
140
  onBeforeUnmount(() => searchTimer && clearTimeout(searchTimer))
132
141
 
@@ -170,13 +179,15 @@ watch(queryPath, (path) => {
170
179
  })
171
180
 
172
181
  // A first impression that shows the feature: the first page with clicks,
173
- // else the first page.
174
- watch(inventory, (data) => {
182
+ // else the first page. In the browser: the list a server render hands over
183
+ // is chosen from once mounted.
184
+ function selectFirst(data: MarketingPages | null): void {
175
185
  if (!selectedPath.value && data?.pages.length) {
176
186
  const withClicks = data.pages.find((page) => page.clicks > 0)
177
187
  selectPath((withClicks ?? data.pages[0])?.path, 'replace')
178
188
  }
179
- })
189
+ }
190
+ watch(inventory, selectFirst)
180
191
 
181
192
  const previewWrapper = ref<HTMLElement | null>(null)
182
193
 
@@ -220,12 +231,13 @@ const usedIn = computed(() =>
220
231
  ),
221
232
  )
222
233
 
223
- const refreshToken = ref(0)
224
-
225
- // Client-only: the server renders no session, so its markup (an empty list)
226
- // would not match the client's and hydration would misplace the preview.
234
+ // The preview draws in the browser only (frames, canvases): a server render
235
+ // holds its place.
227
236
  const mounted = ref(false)
228
- onMounted(() => (mounted.value = true))
237
+ onMounted(() => {
238
+ mounted.value = true
239
+ selectFirst(inventory.value)
240
+ })
229
241
 
230
242
  const countLabel = computed(() => {
231
243
  const listed = pages.value.length
@@ -245,7 +257,7 @@ async function resetSite(): Promise<void> {
245
257
  if (!site) {
246
258
  return
247
259
  }
248
- const counts = await api.countHeatmap(site._id).catch(() => null)
260
+ const counts = await countSiteHeatmap().catch(() => null)
249
261
  await confirm({
250
262
  title: t('page.marketing.pages.reset.site_title', { name: site.name }),
251
263
  description: t('page.marketing.pages.reset.site_description'),
@@ -270,7 +282,8 @@ async function resetSite(): Promise<void> {
270
282
  what: heatmapDataLabel(deleted, t, locale.value),
271
283
  }),
272
284
  })
273
- refreshToken.value++
285
+ // The dialog closes once the pages show the emptied counts.
286
+ await refreshPageBlocks()
274
287
  },
275
288
  })
276
289
  }
@@ -323,18 +336,7 @@ const moreItems = computed(() => [
323
336
  :description="t('page.marketing.pages.tracking_off.description')"
324
337
  />
325
338
 
326
- <div
327
- v-if="!mounted"
328
- class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]"
329
- aria-hidden="true"
330
- >
331
- <USkeleton class="h-[32rem] rounded-xl" />
332
- <USkeleton class="h-[32rem] rounded-xl" />
333
- </div>
334
- <div
335
- v-else
336
- class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]"
337
- >
339
+ <div class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]">
338
340
  <section
339
341
  class="dms-card flex min-w-0 flex-col overflow-hidden p-0 xl:sticky xl:top-4"
340
342
  >
@@ -516,21 +518,25 @@ const moreItems = computed(() => [
516
518
  </section>
517
519
 
518
520
  <div
519
- v-if="selectedPath && website"
521
+ v-if="(selectedPath && website) || (!mounted && pages.length > 0)"
520
522
  ref="previewWrapper"
521
523
  class="min-w-0 scroll-mt-4"
522
524
  >
525
+ <USkeleton
526
+ v-if="!mounted || !selectedPath || !website"
527
+ class="h-[32rem] rounded-xl"
528
+ aria-hidden="true"
529
+ />
523
530
  <DmsMarketingPagePreview
531
+ v-else
524
532
  :website="website"
525
533
  :path="selectedPath"
526
534
  :period="period"
527
535
  :sample-rate-label="sampleRateLabel"
528
536
  :tracker-enabled="trackerEnabled"
529
537
  :pageviews="selectedPage?.pageviews"
530
- :refresh-token="refreshToken + context.refreshToken.value"
531
538
  :used-in="usedIn"
532
- @website-updated="loadWebsite"
533
- @heatmap-reset="loadPages"
539
+ @website-updated="putWebsite"
534
540
  />
535
541
  </div>
536
542
  <DmsEmptyState