@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,11 +1,16 @@
1
1
  <script setup lang="ts">
2
- import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
  import { useDmsRouter, useI18n } from '#dms/frontend-module'
4
4
  import {
5
5
  useMarketingApi,
6
6
  type MarketingWebsiteCard,
7
7
  } from '../composables/useMarketingApi'
8
8
  import { installLink, overviewLink } from '../composables/useMarketingRoutes'
9
+ import {
10
+ useWebsiteCards,
11
+ useWebsiteDeletionImpactRead,
12
+ } from '../composables/useMarketingReads'
13
+ import { useMarketingNow } from '../composables/useMarketingNow'
9
14
  import {
10
15
  formatNumber,
11
16
  formatRelativeTime,
@@ -51,25 +56,23 @@ const { confirm } = useConfirm()
51
56
 
52
57
  void props
53
58
 
54
- const cards = ref<MarketingWebsiteCard[] | null>(null)
55
- const failed = ref(false)
59
+ const cardsRead = useWebsiteCards()
60
+ const cards = cardsRead.data
61
+ /** The cards could not be read; shown once a retry is over. */
62
+ const failed = computed(
63
+ () => !!cardsRead.error.value && cardsRead.status.value !== 'pending',
64
+ )
56
65
 
57
- async function load(): Promise<void> {
58
- failed.value = false
59
- try {
60
- cards.value = await api.listWebsiteCards()
61
- } catch {
62
- failed.value = true
63
- }
64
- }
66
+ /** The cards again: a retry after a failed read. */
67
+ const load = (): Promise<void> => cardsRead.refresh()
65
68
 
66
- const now = ref(Date.now())
67
- let clock: ReturnType<typeof setInterval> | null = null
68
- onMounted(() => {
69
- void load()
70
- clock = setInterval(() => (now.value = Date.now()), CLOCK_TICK_MS)
71
- })
72
- onBeforeUnmount(() => clock && clearInterval(clock))
69
+ /** The website a deletion is asked about. */
70
+ const deletionTarget = ref('')
71
+ const readDeletionImpact = useWebsiteDeletionImpactRead(deletionTarget)
72
+
73
+ const now = useMarketingNow(CLOCK_TICK_MS)
74
+ /** The browser's zone, which a server render writes the dates in. */
75
+ const readerZone = readerTimeZone()
73
76
 
74
77
  function stateLine(card: MarketingWebsiteCard): string {
75
78
  if (card.state === 'live' && card.lastActivityAt) {
@@ -106,7 +109,9 @@ async function runCardAction(
106
109
  try {
107
110
  await action()
108
111
  toast.add({ color: 'success', title: successTitle })
109
- await load()
112
+ // Their state and counts are the backend's: the page reads them again,
113
+ // the card's spinner on until it shows them.
114
+ await refreshPageBlocks()
110
115
  } catch (error) {
111
116
  const conflict = hostConflictOf(error)
112
117
  if (conflict) {
@@ -226,7 +231,7 @@ async function save(): Promise<void> {
226
231
  })
227
232
  toast.add({ color: 'success', title: t('page.marketing.websites.saved') })
228
233
  drawerOpen.value = false
229
- await load()
234
+ refreshPageBlocks()
230
235
  } catch (error) {
231
236
  // A refused field lives on the General tab: show it there.
232
237
  const conflict = hostConflictOf(error)
@@ -253,7 +258,8 @@ async function save(): Promise<void> {
253
258
  async function remove(card: MarketingWebsiteCard): Promise<void> {
254
259
  let impact
255
260
  try {
256
- impact = await api.getWebsiteDeletionImpact(card.id)
261
+ deletionTarget.value = card.id
262
+ impact = await readDeletionImpact()
257
263
  } catch (error) {
258
264
  fieldErrors.toastError(error, {
259
265
  toastTitle: 'page.marketing.websites.delete.impact_error',
@@ -305,7 +311,8 @@ async function remove(card: MarketingWebsiteCard): Promise<void> {
305
311
  title: t('page.marketing.websites.delete.done', { name: card.name }),
306
312
  })
307
313
  drawerOpen.value = false
308
- await load()
314
+ // Its card goes; the others are unchanged, so nothing is read again.
315
+ cards.value = cards.value?.filter((other) => other.id !== card.id) ?? null
309
316
  },
310
317
  })
311
318
  }
@@ -565,7 +572,9 @@ async function remove(card: MarketingWebsiteCard): Promise<void> {
565
572
  card.rejected.length > 0
566
573
  ? 'page.marketing.websites.waiting_refused'
567
574
  : 'page.marketing.websites.waiting_description',
568
- { date: formatShortDate(card.createdAt, locale) },
575
+ {
576
+ date: formatShortDate(card.createdAt, locale, readerZone),
577
+ },
569
578
  )
570
579
  }}
571
580
  </p>
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Frontend marketing facade.
3
3
  *
4
- * Single composable wrapping every /api/marketing/* fetch the module pages
5
- * need — the one file to update when endpoint shapes change (same swap-point
6
- * convention as dms-api's useApiIntrospection).
4
+ * The /api/marketing/* writes, and the funnel preview the builder posts its
5
+ * draft to. Every read goes through `useMarketingReads`. The response shapes
6
+ * are declared here.
7
7
  */
8
8
 
9
9
  import type {
@@ -394,24 +394,10 @@ export interface MarketingScrollDepth {
394
394
 
395
395
  // --- Facade -----------------------------------------------------------------
396
396
 
397
- function periodUrl(url: string, period: MarketingPeriod): string {
398
- return appendPeriodToUrl(url, period) ?? url
399
- }
400
-
401
- /** Far above any real tenant's funnel count — the data-api list answers 10
402
- * rows unless told otherwise, and caps at 100 whatever is asked. */
403
- const FUNNEL_LIST_LIMIT = 100
404
-
405
397
  export function useMarketingApi() {
406
398
  const { $authFetch } = useAuthFetch()
407
399
 
408
400
  return {
409
- listWebsites: () =>
410
- $authFetch<MarketingWebsite[]>('/api/marketing/websites'),
411
-
412
- getContext: () =>
413
- $authFetch<MarketingContextPayload>('/api/marketing/context'),
414
-
415
401
  createWebsite: (input: MarketingWebsiteInput) =>
416
402
  $authFetch<MarketingWebsite>('/api/marketing/websites', {
417
403
  method: 'POST',
@@ -424,21 +410,6 @@ export function useMarketingApi() {
424
410
  body: { website },
425
411
  }),
426
412
 
427
- listWebsiteCards: () =>
428
- $authFetch<MarketingWebsiteCard[]>('/api/marketing/websites/summary'),
429
-
430
- // `since` (epoch ms, server clock): only a pageview from then on counts.
431
- getConnection: (website: string, since?: number) =>
432
- $authFetch<MarketingConnectionStatus>(
433
- `/api/marketing/websites/${encodeURIComponent(website)}/connection`,
434
- { query: { since } },
435
- ),
436
-
437
- getWebsiteDeletionImpact: (website: string) =>
438
- $authFetch<MarketingWebsiteDeletionImpact>(
439
- `/api/marketing/websites/${encodeURIComponent(website)}/deletion`,
440
- ),
441
-
442
413
  allowHost: (website: string, hostname: string) =>
443
414
  $authFetch<MarketingWebsite>(
444
415
  `/api/marketing/websites/${encodeURIComponent(website)}/allow-host`,
@@ -451,22 +422,6 @@ export function useMarketingApi() {
451
422
  { method: 'DELETE' },
452
423
  ),
453
424
 
454
- getFunnelTemplates: (website: string, period: MarketingPeriod) =>
455
- $authFetch<MarketingFunnelTemplate[]>(
456
- periodUrl('/api/marketing/funnels/templates', period),
457
- {
458
- query: { website },
459
- },
460
- ),
461
-
462
- getFunnelSuggestions: (website: string) =>
463
- $authFetch<MarketingFunnelSuggestions>(
464
- '/api/marketing/funnels/suggestions',
465
- {
466
- query: { website },
467
- },
468
- ),
469
-
470
425
  previewFunnel: (
471
426
  website: string,
472
427
  steps: MarketingFunnelStep[],
@@ -484,85 +439,17 @@ export function useMarketingApi() {
484
439
  { method: 'PUT', body: patch },
485
440
  ),
486
441
 
487
- // Query strings via ofetch's `query` option — encoding is its job.
488
- listPages: (
489
- website: string,
490
- period: MarketingPeriod,
491
- search = '',
492
- sort: MarketingPagesSort = 'views',
493
- ) =>
494
- $authFetch<MarketingPages>(
495
- periodUrl('/api/marketing/stats/pages', period),
496
- {
497
- query: { website, search, sort },
498
- },
499
- ),
500
-
501
- getHeatmap: (website: string, path: string, period: MarketingPeriod) =>
502
- $authFetch<MarketingHeatmap>(
503
- periodUrl('/api/marketing/stats/heatmap', period),
504
- {
505
- query: { website, path },
506
- },
507
- ),
508
-
509
- getScrollDepth: (website: string, path: string, period: MarketingPeriod) =>
510
- $authFetch<MarketingScrollDepth>(
511
- periodUrl('/api/marketing/stats/scroll', period),
512
- {
513
- query: { website, path },
514
- },
515
- ),
516
-
517
- getSnapshot: (
518
- website: string,
519
- path: string,
520
- layout: MarketingSnapshotLayout,
521
- ) =>
522
- $authFetch<MarketingPageSnapshotResponse>(
523
- '/api/marketing/stats/snapshot',
524
- {
525
- query: { website, path, layout },
526
- },
527
- ),
528
-
529
442
  // Whole click and scroll history, every period: without `path` the whole
530
- // site's.
443
+ // site's (counted first by `useHeatmapCountRead`).
531
444
  resetHeatmap: (website: string, path?: string) =>
532
445
  $authFetch<MarketingHeatmapData>('/api/marketing/stats/heatmap', {
533
446
  method: 'DELETE',
534
447
  query: { website, path },
535
448
  }),
536
449
 
537
- /** What `resetHeatmap` would delete with the same arguments. */
538
- countHeatmap: (website: string, path?: string) =>
539
- $authFetch<MarketingHeatmapData>('/api/marketing/stats/heatmap/count', {
540
- query: { website, path },
541
- }),
542
-
543
- // Definitions CRUD over the funnels data-api routes. `is` is the DMS
544
- // DataType compare mode — the data-api's own `eq` parses but matches
545
- // nothing. The list defaults to 10 rows, hence the explicit limit.
546
- listFunnels: (website: string) =>
547
- $authFetch<{ results: MarketingFunnelListItem[]; total: number }>(
548
- '/api/marketing/tables/funnels/list',
549
- {
550
- query: {
551
- filter_websiteId: `is:${website}`,
552
- limit: FUNNEL_LIST_LIMIT,
553
- sortKey: 'createdAt',
554
- sortDirection: 'asc',
555
- },
556
- },
557
- ),
558
-
559
- // One definition, as the builder edits it: no results computed.
560
- getFunnel: (id: string) =>
561
- $authFetch<MarketingFunnelListItem>('/api/marketing/tables/funnels/get', {
562
- query: { id },
563
- }),
564
-
565
- // The insert answers the generated keys, an array — not the row.
450
+ // Definitions CRUD over the funnels data-api routes; the list is read by
451
+ // `useWebsiteFunnels`, one definition by `useFunnelRead`. The insert
452
+ // answers the generated keys, an array — not the row.
566
453
  createFunnel: (input: MarketingFunnelInput) =>
567
454
  $authFetch<string[]>('/api/marketing/tables/funnels/new', {
568
455
  method: 'POST',
@@ -1,10 +1,4 @@
1
- import {
2
- computed,
3
- inject,
4
- type ComputedRef,
5
- type InjectionKey,
6
- type Ref,
7
- } from 'vue'
1
+ import { computed, inject, type ComputedRef, type InjectionKey } from 'vue'
8
2
 
9
3
  export type WebsiteState = 'live' | 'waiting' | 'paused'
10
4
 
@@ -25,8 +19,6 @@ export interface MarketingContextPayload {
25
19
  export interface MarketingContextState {
26
20
  websites: ComputedRef<ContextWebsite[]>
27
21
  selected: ComputedRef<ContextWebsite | null>
28
- /** Bumped by the bar's refresh button. */
29
- refreshToken: Ref<number>
30
22
  select: (id: string) => Promise<void>
31
23
  reload: () => Promise<void>
32
24
  }
@@ -48,7 +40,6 @@ function detachedContext(): MarketingContextState {
48
40
  return {
49
41
  websites: computed(() => []),
50
42
  selected: computed(() => null),
51
- refreshToken: { value: 0 } as Ref<number>,
52
43
  select: async () => {},
53
44
  reload: async () => {},
54
45
  }
@@ -0,0 +1,29 @@
1
+ import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
2
+
3
+ const RENDERED_AT_STATE_KEY = 'marketing-rendered-at'
4
+
5
+ /**
6
+ * The current time, in epoch ms, for what a page writes relative to it
7
+ * ("12 seconds ago", a site still live): the time the server rendered the
8
+ * page, which the browser hydrating its HTML starts from too, then the
9
+ * browser's clock once mounted, every `tickMs`.
10
+ */
11
+ export function useMarketingNow(tickMs: number): Ref<number> {
12
+ const renderedAt = useDmsState<number>(
13
+ RENDERED_AT_STATE_KEY,
14
+ () => Date.now(),
15
+ { transfer: true },
16
+ )
17
+ const now = ref(renderedAt.value)
18
+ let clock: ReturnType<typeof setInterval> | null = null
19
+ onMounted(() => {
20
+ now.value = Date.now()
21
+ clock = setInterval(() => (now.value = Date.now()), tickMs)
22
+ })
23
+ onBeforeUnmount(() => {
24
+ if (clock) {
25
+ clearInterval(clock)
26
+ }
27
+ })
28
+ return now
29
+ }
@@ -0,0 +1,314 @@
1
+ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
2
+ import type { MarketingContextPayload } from './useMarketingContext'
3
+ import type {
4
+ MarketingConnectionStatus,
5
+ MarketingFunnelListItem,
6
+ MarketingFunnelSuggestions,
7
+ MarketingFunnelTemplate,
8
+ MarketingHeatmap,
9
+ MarketingHeatmapData,
10
+ MarketingPageSnapshotResponse,
11
+ MarketingPages,
12
+ MarketingPagesSort,
13
+ MarketingPeriod,
14
+ MarketingScrollDepth,
15
+ MarketingSnapshotLayout,
16
+ MarketingWebsite,
17
+ MarketingWebsiteCard,
18
+ MarketingWebsiteDeletionImpact,
19
+ } from './useMarketingApi'
20
+
21
+ /**
22
+ * The module's reads, through `useApiData`. What the pages render: a server
23
+ * render reads it and hands it to the browser with the page, the components
24
+ * showing one request share it, and a newer request replaces one still in
25
+ * flight (the answer for a site, page or period the user left never paints
26
+ * over the one on screen). The page preview and the funnel builder draw in
27
+ * the browser only (`server: false`); a dialog or the builder reads what it
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`.
31
+ *
32
+ * A read bound to the period waits for it: the server knows it only once the
33
+ * browser's time zone is known, and the browser once the context bar's
34
+ * selector has set up. Until then nothing is read and the block shows its
35
+ * skeleton, on both sides.
36
+ */
37
+
38
+ /** Far above any real tenant's funnel count (the data-api caps at 100). */
39
+ const FUNNEL_LIST_LIMIT = 100
40
+
41
+ /** `url` with the period's bounds, or nothing to read without a period. */
42
+ function periodRead(
43
+ url: string,
44
+ period: MaybeRefOrGetter<MarketingPeriod>,
45
+ ): string | null {
46
+ const state = toValue(period)
47
+ return state ? (appendPeriodToUrl(url, state) ?? url) : null
48
+ }
49
+
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
+ /**
60
+ * 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.
63
+ */
64
+ export function useMarketingContextRead(
65
+ options: ContextReadOptions = {},
66
+ ): ApiDataResult<MarketingContextPayload> {
67
+ return useApiData<MarketingContextPayload>('/api/marketing/context', {
68
+ refreshOnPageRefresh: false,
69
+ refreshInterval: options.refreshInterval,
70
+ })
71
+ }
72
+
73
+ /** The Websites page's cards. */
74
+ export function useWebsiteCards(): ApiDataResult<MarketingWebsiteCard[]> {
75
+ return useApiData<MarketingWebsiteCard[]>('/api/marketing/websites/summary')
76
+ }
77
+
78
+ /** The tenant's websites, with their capture options. */
79
+ export function useWebsites(): ApiDataResult<MarketingWebsite[]> {
80
+ return useApiData<MarketingWebsite[]>('/api/marketing/websites')
81
+ }
82
+
83
+ /** The funnels of a website, oldest first; nothing read without one. */
84
+ export function useWebsiteFunnels(
85
+ websiteId: MaybeRefOrGetter<string>,
86
+ ): ApiDataResult<MarketingFunnelListItem[]> {
87
+ return useApiData<MarketingFunnelListItem[]>(
88
+ () => (toValue(websiteId) ? '/api/marketing/tables/funnels/list' : null),
89
+ {
90
+ query: () => ({
91
+ filter_websiteId: `is:${toValue(websiteId)}`,
92
+ limit: FUNNEL_LIST_LIMIT,
93
+ sortKey: 'createdAt',
94
+ sortDirection: 'asc',
95
+ }),
96
+ transform: (value) =>
97
+ (value as { results: MarketingFunnelListItem[] }).results,
98
+ },
99
+ )
100
+ }
101
+
102
+ export interface PageInventoryRequest {
103
+ websiteId: MaybeRefOrGetter<string>
104
+ period: MaybeRefOrGetter<MarketingPeriod>
105
+ search: MaybeRefOrGetter<string>
106
+ sort: MaybeRefOrGetter<MarketingPagesSort>
107
+ }
108
+
109
+ /** The paths a website's visitors loaded over the period. */
110
+ export function usePageInventory(
111
+ request: PageInventoryRequest,
112
+ ): ApiDataResult<MarketingPages> {
113
+ return useApiData<MarketingPages>(
114
+ () =>
115
+ toValue(request.websiteId)
116
+ ? periodRead('/api/marketing/stats/pages', request.period)
117
+ : null,
118
+ {
119
+ query: () => ({
120
+ website: toValue(request.websiteId),
121
+ search: toValue(request.search),
122
+ sort: toValue(request.sort),
123
+ }),
124
+ },
125
+ )
126
+ }
127
+
128
+ /** Funnels built from a website's own journeys over the period. */
129
+ export function useFunnelTemplates(
130
+ websiteId: MaybeRefOrGetter<string | undefined>,
131
+ period: MaybeRefOrGetter<MarketingPeriod>,
132
+ ): ApiDataResult<MarketingFunnelTemplate[]> {
133
+ return useApiData<MarketingFunnelTemplate[]>(
134
+ () =>
135
+ toValue(websiteId)
136
+ ? periodRead('/api/marketing/funnels/templates', period)
137
+ : null,
138
+ { query: () => ({ website: toValue(websiteId) }) },
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Whether a website's tag reached the collector: since its creation, or
144
+ * since `since` (epoch ms, server clock). Polled every
145
+ * `options.refreshInterval` while it is above 0.
146
+ */
147
+ export function useWebsiteConnection(
148
+ websiteId: MaybeRefOrGetter<string>,
149
+ since: MaybeRefOrGetter<number | null>,
150
+ options: ContextReadOptions = {},
151
+ ): ApiDataResult<MarketingConnectionStatus> {
152
+ const path = computed(() => {
153
+ const id = toValue(websiteId)
154
+ return id
155
+ ? `/api/marketing/websites/${encodeURIComponent(id)}/connection`
156
+ : null
157
+ })
158
+ return useApiData<MarketingConnectionStatus>(path, {
159
+ query: () => ({ since: toValue(since) ?? undefined }),
160
+ refreshInterval: options.refreshInterval,
161
+ })
162
+ }
163
+
164
+ /** `url` with the period's bounds, or the routes' default without one. */
165
+ function withPeriod(url: string, period: MarketingPeriod): string {
166
+ return period ? (appendPeriodToUrl(url, period) ?? url) : url
167
+ }
168
+
169
+ /** The page of a website the preview shows, over the context's period. */
170
+ export interface PagePreviewRequest {
171
+ websiteId: MaybeRefOrGetter<string | undefined>
172
+ path: MaybeRefOrGetter<string>
173
+ period: MaybeRefOrGetter<MarketingPeriod>
174
+ }
175
+
176
+ function previewRead<T>(
177
+ url: string,
178
+ request: PagePreviewRequest,
179
+ ): ApiDataResult<T> {
180
+ return useApiData<T>(
181
+ () =>
182
+ toValue(request.websiteId) && toValue(request.path)
183
+ ? withPeriod(url, toValue(request.period))
184
+ : null,
185
+ {
186
+ query: () => ({
187
+ website: toValue(request.websiteId),
188
+ path: toValue(request.path),
189
+ }),
190
+ server: false,
191
+ },
192
+ )
193
+ }
194
+
195
+ /** The clicks of a page over the period, drawn in the browser. */
196
+ export function usePageHeatmap(
197
+ request: PagePreviewRequest,
198
+ ): ApiDataResult<MarketingHeatmap> {
199
+ return previewRead('/api/marketing/stats/heatmap', request)
200
+ }
201
+
202
+ /** How far a page's visitors scrolled over the period, drawn in the browser. */
203
+ export function usePageScrollDepth(
204
+ request: PagePreviewRequest,
205
+ ): ApiDataResult<MarketingScrollDepth> {
206
+ return previewRead('/api/marketing/stats/scroll', request)
207
+ }
208
+
209
+ /** The capture of a page a layout shows; nothing read while `enabled` is false. */
210
+ export interface PageSnapshotRequest {
211
+ websiteId: MaybeRefOrGetter<string | undefined>
212
+ path: MaybeRefOrGetter<string>
213
+ layout: MaybeRefOrGetter<MarketingSnapshotLayout>
214
+ enabled: MaybeRefOrGetter<boolean>
215
+ }
216
+
217
+ /** A page's capture in a layout, drawn in the browser. */
218
+ export function usePageSnapshot(
219
+ request: PageSnapshotRequest,
220
+ ): ApiDataResult<MarketingPageSnapshotResponse> {
221
+ return useApiData<MarketingPageSnapshotResponse>(
222
+ () =>
223
+ toValue(request.enabled) &&
224
+ toValue(request.websiteId) &&
225
+ toValue(request.path)
226
+ ? '/api/marketing/stats/snapshot'
227
+ : null,
228
+ {
229
+ query: () => ({
230
+ website: toValue(request.websiteId),
231
+ path: toValue(request.path),
232
+ layout: toValue(request.layout),
233
+ }),
234
+ server: false,
235
+ },
236
+ )
237
+ }
238
+
239
+ /** The pages and events a website's visitors sent, for the funnel builder. */
240
+ export function useFunnelSuggestions(
241
+ websiteId: MaybeRefOrGetter<string>,
242
+ ): ApiDataResult<MarketingFunnelSuggestions> {
243
+ return useApiData<MarketingFunnelSuggestions>(
244
+ () => (toValue(websiteId) ? '/api/marketing/funnels/suggestions' : null),
245
+ { query: () => ({ website: toValue(websiteId) }), server: false },
246
+ )
247
+ }
248
+
249
+ /** The query of a read made on demand. */
250
+ type ReadQuery = MaybeRefOrGetter<Record<string, string | undefined>>
251
+
252
+ /**
253
+ * A read a dialog or the funnel builder makes when it asks, through
254
+ * `useApiData` with `immediate: false`: the call reads the current URL and
255
+ * resolves with its answer, or rejects with what the read failed with. The
256
+ * caller keeps the answer, so the page's refreshes leave it.
257
+ */
258
+ export type ReadOnDemand<T> = () => Promise<T>
259
+
260
+ function useReadOnDemand<T>(
261
+ url: MaybeRefOrGetter<string | null>,
262
+ query?: ReadQuery,
263
+ ): ReadOnDemand<T> {
264
+ const read = useApiData<T>(url, {
265
+ query,
266
+ immediate: false,
267
+ refreshOnPageRefresh: false,
268
+ })
269
+ return async () => {
270
+ await read.execute({ dedupe: 'defer' })
271
+ if (read.error.value) {
272
+ throw read.error.value
273
+ }
274
+ return read.data.value as T
275
+ }
276
+ }
277
+
278
+ /** What deleting a website discards, for its confirmation. */
279
+ export function useWebsiteDeletionImpactRead(
280
+ websiteId: MaybeRefOrGetter<string>,
281
+ ): ReadOnDemand<MarketingWebsiteDeletionImpact> {
282
+ return useReadOnDemand(() => {
283
+ const id = toValue(websiteId)
284
+ return id
285
+ ? `/api/marketing/websites/${encodeURIComponent(id)}/deletion`
286
+ : null
287
+ })
288
+ }
289
+
290
+ /** What resetting the clicks of a page, or of the whole site, deletes. */
291
+ export function useHeatmapCountRead(
292
+ websiteId: MaybeRefOrGetter<string>,
293
+ path: MaybeRefOrGetter<string | undefined> = undefined,
294
+ ): ReadOnDemand<MarketingHeatmapData> {
295
+ return useReadOnDemand(
296
+ () => (toValue(websiteId) ? '/api/marketing/stats/heatmap/count' : null),
297
+ () => ({ website: toValue(websiteId), path: toValue(path) }),
298
+ )
299
+ }
300
+
301
+ /** One funnel definition, as the builder edits it. */
302
+ export function useFunnelRead(
303
+ funnelId: MaybeRefOrGetter<string>,
304
+ ): ReadOnDemand<MarketingFunnelListItem> {
305
+ return useReadOnDemand(
306
+ () => (toValue(funnelId) ? '/api/marketing/tables/funnels/get' : null),
307
+ () => ({ id: toValue(funnelId) }),
308
+ )
309
+ }
310
+
311
+ /** The websites and the saved pick, for the builder outside the context bar. */
312
+ export function useContextOnDemand(): ReadOnDemand<MarketingContextPayload> {
313
+ return useReadOnDemand('/api/marketing/context')
314
+ }