@antelopejs/dms-marketing 0.4.1 → 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/index.js +2 -0
- package/dist/index.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/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 -1
- package/frontend-vue/tests/format.test.ts +21 -0
- package/frontend-vue/tests/marketing-reads.test.ts +252 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -101,10 +101,46 @@ export function formatDateRange(from: Date, to: Date, locale: string): string {
|
|
|
101
101
|
}).formatRange(from, to)
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
/**
|
|
105
|
+
* When the statistics' day (a UTC day) starts on the reader's clock today,
|
|
106
|
+
* "02:00"; empty where it starts at their midnight. `timeZone` is the
|
|
107
|
+
* reader's (`undefined`: the zone the code runs in).
|
|
108
|
+
*/
|
|
109
|
+
export function utcDayStart(
|
|
110
|
+
now: number,
|
|
111
|
+
locale: string,
|
|
112
|
+
timeZone?: string,
|
|
113
|
+
): string {
|
|
114
|
+
const today = new Date(now)
|
|
115
|
+
const utcMidnight = new Date(
|
|
116
|
+
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),
|
|
117
|
+
)
|
|
118
|
+
const clock = new Intl.DateTimeFormat('en-US', {
|
|
119
|
+
hour: 'numeric',
|
|
120
|
+
minute: 'numeric',
|
|
121
|
+
hourCycle: 'h23',
|
|
122
|
+
timeZone,
|
|
123
|
+
}).format(utcMidnight)
|
|
124
|
+
if (clock === '0:00' || clock === '00:00') {
|
|
125
|
+
return ''
|
|
126
|
+
}
|
|
127
|
+
return new Intl.DateTimeFormat(locale, {
|
|
128
|
+
hour: '2-digit',
|
|
129
|
+
minute: '2-digit',
|
|
130
|
+
timeZone,
|
|
131
|
+
}).format(utcMidnight)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** "Sep 29", in `timeZone` (`undefined`: the zone the code runs in). */
|
|
135
|
+
export function formatShortDate(
|
|
136
|
+
at: number | Date,
|
|
137
|
+
locale: string,
|
|
138
|
+
timeZone?: string,
|
|
139
|
+
): string {
|
|
105
140
|
return new Intl.DateTimeFormat(locale, {
|
|
106
141
|
month: 'short',
|
|
107
142
|
day: 'numeric',
|
|
143
|
+
timeZone,
|
|
108
144
|
}).format(at)
|
|
109
145
|
}
|
|
110
146
|
|
|
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import {
|
|
3
3
|
displayName,
|
|
4
4
|
formatDuration,
|
|
5
|
+
formatShortDate,
|
|
5
6
|
formatSignedDelta,
|
|
6
7
|
initials,
|
|
7
8
|
percentChangeTrend,
|
|
9
|
+
utcDayStart,
|
|
8
10
|
} from '../app/utils/format'
|
|
9
11
|
import { sampleSizePerArm } from '../app/utils/sample-size'
|
|
10
12
|
import { normalizeFunnelStepPath } from '../app/utils/step-path'
|
|
@@ -71,3 +73,22 @@ describe('helpers', () => {
|
|
|
71
73
|
).toContain('"data-website-id": "site-1"')
|
|
72
74
|
})
|
|
73
75
|
})
|
|
76
|
+
|
|
77
|
+
describe('dates in the reader zone', () => {
|
|
78
|
+
// 16 Oct 2026: Paris on summer time (UTC+2), New York on UTC-4.
|
|
79
|
+
const now = Date.UTC(2026, 9, 16, 12)
|
|
80
|
+
|
|
81
|
+
it('says when the statistics day starts on the reader clock, nothing at UTC', () => {
|
|
82
|
+
expect(utcDayStart(now, 'en-GB', 'Europe/Paris')).toBe('02:00')
|
|
83
|
+
expect(utcDayStart(now, 'en-GB', 'America/New_York')).toBe('20:00')
|
|
84
|
+
expect(utcDayStart(now, 'en-GB', 'UTC')).toBe('')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('writes a short date on the reader day, wherever the code runs', () => {
|
|
88
|
+
const lateEvening = Date.UTC(2026, 9, 16, 2)
|
|
89
|
+
expect(formatShortDate(lateEvening, 'en', 'Europe/Paris')).toBe('Oct 16')
|
|
90
|
+
expect(formatShortDate(lateEvening, 'en', 'America/New_York')).toBe(
|
|
91
|
+
'Oct 15',
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
})
|