@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.
- package/dist/hooks/index.js +1 -5
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/funnels/page.js +5 -5
- package/dist/pages/funnels/page.js.map +1 -1
- package/dist/routes/public-endpoints.d.ts +2 -0
- package/dist/routes/public-endpoints.js +14 -0
- package/dist/routes/public-endpoints.js.map +1 -0
- package/frontend-vue/app/components/Context.vue +37 -61
- package/frontend-vue/app/components/FunnelBuilder.vue +22 -19
- package/frontend-vue/app/components/FunnelReport.vue +1 -1
- package/frontend-vue/app/components/FunnelTemplates.vue +16 -27
- package/frontend-vue/app/components/InstallGuide.vue +40 -57
- package/frontend-vue/app/components/PagePreview.vue +60 -76
- package/frontend-vue/app/components/PagesExplorer.vue +86 -80
- package/frontend-vue/app/components/WebsitesGrid.vue +32 -23
- package/frontend-vue/app/composables/useMarketingApi.ts +7 -120
- package/frontend-vue/app/composables/useMarketingContext.ts +1 -10
- package/frontend-vue/app/composables/useMarketingNow.ts +29 -0
- package/frontend-vue/app/composables/useMarketingReads.ts +314 -0
- package/frontend-vue/app/utils/format.ts +37 -1
- package/frontend-vue/package.json +1 -2
- package/frontend-vue/pnpm-lock.yaml +51 -19
- package/frontend-vue/tests/format.test.ts +21 -0
- package/frontend-vue/tests/marketing-reads.test.ts +252 -0
- package/frontend-vue/tests/registration.test.ts +0 -4
- package/frontend-vue/vitest.config.ts +3 -1
- package/package.json +3 -3
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed,
|
|
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
|
|
55
|
-
const
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
}
|