@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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { createSSRApp, defineComponent, h, ref, toValue } from 'vue'
|
|
2
|
+
import { renderToString } from 'vue/server-renderer'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
useFunnelTemplates,
|
|
6
|
+
useHeatmapCountRead,
|
|
7
|
+
useMarketingContextRead,
|
|
8
|
+
usePageHeatmap,
|
|
9
|
+
usePageInventory,
|
|
10
|
+
usePageSnapshot,
|
|
11
|
+
useWebsiteConnection,
|
|
12
|
+
useWebsiteFunnels,
|
|
13
|
+
} from '../app/composables/useMarketingReads'
|
|
14
|
+
import { useMarketingNow } from '../app/composables/useMarketingNow'
|
|
15
|
+
|
|
16
|
+
/** What a read asked `useApiData` for. */
|
|
17
|
+
interface ReadCall {
|
|
18
|
+
url: () => string | null
|
|
19
|
+
options: Record<string, unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let calls: ReadCall[]
|
|
23
|
+
|
|
24
|
+
const PERIOD = {
|
|
25
|
+
preset: 'last-7-days',
|
|
26
|
+
comparison: 'none',
|
|
27
|
+
range: {
|
|
28
|
+
from: new Date('2026-10-09T00:00:00.000Z'),
|
|
29
|
+
to: new Date('2026-10-15T23:59:59.999Z'),
|
|
30
|
+
},
|
|
31
|
+
compareRange: null,
|
|
32
|
+
key: 'last-7-days',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
calls = []
|
|
37
|
+
vi.stubGlobal(
|
|
38
|
+
'useApiData',
|
|
39
|
+
(url: unknown, options: Record<string, unknown> = {}) => {
|
|
40
|
+
calls.push({ url: () => toValue(url as () => string | null), options })
|
|
41
|
+
return { data: ref(null) }
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
// The DMS's own, as the module's blocks append a period.
|
|
45
|
+
vi.stubGlobal(
|
|
46
|
+
'appendPeriodToUrl',
|
|
47
|
+
(url: string, state: typeof PERIOD | null) =>
|
|
48
|
+
state?.range
|
|
49
|
+
? `${url}?from=${state.range.from.toISOString()}&to=${state.range.to.toISOString()}`
|
|
50
|
+
: url,
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.unstubAllGlobals()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const query = (call: ReadCall | undefined) =>
|
|
59
|
+
toValue(call?.options.query as () => Record<string, unknown>)
|
|
60
|
+
|
|
61
|
+
describe('the reads the pages render', () => {
|
|
62
|
+
it('reads nothing bound to the period before there is one, nor without a website', () => {
|
|
63
|
+
const website = ref('')
|
|
64
|
+
const period = ref<typeof PERIOD | null>(null)
|
|
65
|
+
usePageInventory({
|
|
66
|
+
websiteId: website,
|
|
67
|
+
period,
|
|
68
|
+
search: '',
|
|
69
|
+
sort: 'views',
|
|
70
|
+
})
|
|
71
|
+
useFunnelTemplates(website, period)
|
|
72
|
+
const [pages, templates] = calls
|
|
73
|
+
expect(pages?.url()).toBeNull()
|
|
74
|
+
expect(templates?.url()).toBeNull()
|
|
75
|
+
website.value = 'acme-shop'
|
|
76
|
+
expect(pages?.url()).toBeNull()
|
|
77
|
+
period.value = PERIOD
|
|
78
|
+
expect(pages?.url()).toBe(
|
|
79
|
+
'/api/marketing/stats/pages?from=2026-10-09T00:00:00.000Z&to=2026-10-15T23:59:59.999Z',
|
|
80
|
+
)
|
|
81
|
+
expect(query(pages)).toEqual({
|
|
82
|
+
website: 'acme-shop',
|
|
83
|
+
search: '',
|
|
84
|
+
sort: 'views',
|
|
85
|
+
})
|
|
86
|
+
expect(templates?.url()).toContain('/api/marketing/funnels/templates?from=')
|
|
87
|
+
expect(query(templates)).toEqual({ website: 'acme-shop' })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('lists a website funnels oldest first, read again with the page refresh', () => {
|
|
91
|
+
useWebsiteFunnels('acme-shop')
|
|
92
|
+
const [funnels] = calls
|
|
93
|
+
expect(funnels?.url()).toBe('/api/marketing/tables/funnels/list')
|
|
94
|
+
expect(query(funnels)).toMatchObject({
|
|
95
|
+
filter_websiteId: 'is:acme-shop',
|
|
96
|
+
sortKey: 'createdAt',
|
|
97
|
+
sortDirection: 'asc',
|
|
98
|
+
})
|
|
99
|
+
// No trigger of its own: the page's refreshes read it again.
|
|
100
|
+
expect(funnels?.options.watch).toBeUndefined()
|
|
101
|
+
expect(funnels?.options.refreshOnPageRefresh).toBeUndefined()
|
|
102
|
+
const transform = funnels?.options.transform as (value: unknown) => unknown
|
|
103
|
+
expect(transform({ results: [{ _id: 'f1' }], total: 1 })).toEqual([
|
|
104
|
+
{ _id: 'f1' },
|
|
105
|
+
])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('checks a website connection since a server time, once there is a website', () => {
|
|
109
|
+
const website = ref('')
|
|
110
|
+
const since = ref<number | null>(null)
|
|
111
|
+
useWebsiteConnection(website, since)
|
|
112
|
+
const [connection] = calls
|
|
113
|
+
expect(connection?.url()).toBeNull()
|
|
114
|
+
website.value = 'shop/eu'
|
|
115
|
+
expect(connection?.url()).toBe(
|
|
116
|
+
'/api/marketing/websites/shop%2Feu/connection',
|
|
117
|
+
)
|
|
118
|
+
expect(query(connection)).toEqual({ since: undefined })
|
|
119
|
+
since.value = 1_700_000_000_000
|
|
120
|
+
expect(query(connection)).toEqual({ since: 1_700_000_000_000 })
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('useMarketingNow', () => {
|
|
125
|
+
it('starts from the time the server rendered at, handed to the browser', async () => {
|
|
126
|
+
const state = new Map<string, { value: unknown }>()
|
|
127
|
+
const transferred: string[] = []
|
|
128
|
+
vi.stubGlobal(
|
|
129
|
+
'useDmsState',
|
|
130
|
+
(key: string, init: () => unknown, options?: { transfer?: boolean }) => {
|
|
131
|
+
if (options?.transfer) {
|
|
132
|
+
transferred.push(key)
|
|
133
|
+
}
|
|
134
|
+
if (!state.has(key)) {
|
|
135
|
+
state.set(key, ref(init()))
|
|
136
|
+
}
|
|
137
|
+
return state.get(key)
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
state.set('marketing-rendered-at', ref(1_000))
|
|
141
|
+
const app = createSSRApp(
|
|
142
|
+
defineComponent({
|
|
143
|
+
setup: () => {
|
|
144
|
+
const now = useMarketingNow(15_000)
|
|
145
|
+
return () => h('span', String(now.value))
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
expect(await renderToString(app)).toBe('<span>1000</span>')
|
|
150
|
+
expect(transferred).toEqual(['marketing-rendered-at'])
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('the reads with triggers of their own', () => {
|
|
155
|
+
it('reads the context bar on its own clock, not with the page', () => {
|
|
156
|
+
useMarketingContextRead({ refreshInterval: 60_000 })
|
|
157
|
+
const [context] = calls
|
|
158
|
+
expect(context?.options).toMatchObject({
|
|
159
|
+
refreshOnPageRefresh: false,
|
|
160
|
+
refreshInterval: 60_000,
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('polls the connection check when asked to', () => {
|
|
165
|
+
useWebsiteConnection('acme-shop', null, { refreshInterval: 4000 })
|
|
166
|
+
const [connection] = calls
|
|
167
|
+
expect(connection?.options.refreshInterval).toBe(4000)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('follows a reactive polling period, which stops the check at 0', () => {
|
|
171
|
+
const interval = ref(4000)
|
|
172
|
+
useWebsiteConnection('acme-shop', null, { refreshInterval: interval })
|
|
173
|
+
// One read: the polling is the check's own interval, not a second caller.
|
|
174
|
+
expect(calls).toHaveLength(1)
|
|
175
|
+
const [connection] = calls
|
|
176
|
+
const polling = () => toValue(connection?.options.refreshInterval as number)
|
|
177
|
+
expect(polling()).toBe(4000)
|
|
178
|
+
interval.value = 0
|
|
179
|
+
expect(polling()).toBe(0)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('the page preview', () => {
|
|
184
|
+
it('reads the clicks in the browser only, once there is a site and a path', () => {
|
|
185
|
+
const website = ref<string | undefined>(undefined)
|
|
186
|
+
const period = ref<typeof PERIOD | null>(null)
|
|
187
|
+
usePageHeatmap({ websiteId: website, path: '/pricing', period })
|
|
188
|
+
const [heatmap] = calls
|
|
189
|
+
expect(heatmap?.options.server).toBe(false)
|
|
190
|
+
expect(heatmap?.url()).toBeNull()
|
|
191
|
+
website.value = 'acme-shop'
|
|
192
|
+
// Without a period, the route's default window.
|
|
193
|
+
expect(heatmap?.url()).toBe('/api/marketing/stats/heatmap')
|
|
194
|
+
period.value = PERIOD
|
|
195
|
+
expect(heatmap?.url()).toBe(
|
|
196
|
+
'/api/marketing/stats/heatmap?from=2026-10-09T00:00:00.000Z&to=2026-10-15T23:59:59.999Z',
|
|
197
|
+
)
|
|
198
|
+
expect(query(heatmap)).toEqual({ website: 'acme-shop', path: '/pricing' })
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('reads no capture while the site keeps none', () => {
|
|
202
|
+
const enabled = ref(false)
|
|
203
|
+
usePageSnapshot({
|
|
204
|
+
websiteId: 'acme-shop',
|
|
205
|
+
path: '/pricing',
|
|
206
|
+
layout: 'phone',
|
|
207
|
+
enabled,
|
|
208
|
+
})
|
|
209
|
+
const [snapshot] = calls
|
|
210
|
+
expect(snapshot?.url()).toBeNull()
|
|
211
|
+
enabled.value = true
|
|
212
|
+
expect(snapshot?.url()).toBe('/api/marketing/stats/snapshot')
|
|
213
|
+
expect(query(snapshot)).toEqual({
|
|
214
|
+
website: 'acme-shop',
|
|
215
|
+
path: '/pricing',
|
|
216
|
+
layout: 'phone',
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('the reads a dialog makes when it asks', () => {
|
|
222
|
+
it('reads nothing before, then answers or rejects with the read', async () => {
|
|
223
|
+
const data = ref<unknown>(null)
|
|
224
|
+
const error = ref<unknown>(null)
|
|
225
|
+
const execute = vi.fn(async () => {
|
|
226
|
+
data.value = { events: 3, snapshots: 1 }
|
|
227
|
+
})
|
|
228
|
+
vi.stubGlobal(
|
|
229
|
+
'useApiData',
|
|
230
|
+
(url: unknown, options: Record<string, unknown> = {}) => {
|
|
231
|
+
calls.push({ url: () => toValue(url as () => string | null), options })
|
|
232
|
+
return { data, error, execute }
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
const countHeatmap = useHeatmapCountRead('acme-shop', '/pricing')
|
|
236
|
+
const [count] = calls
|
|
237
|
+
expect(count?.options).toMatchObject({
|
|
238
|
+
immediate: false,
|
|
239
|
+
refreshOnPageRefresh: false,
|
|
240
|
+
})
|
|
241
|
+
expect(execute).not.toHaveBeenCalled()
|
|
242
|
+
expect(count?.url()).toBe('/api/marketing/stats/heatmap/count')
|
|
243
|
+
expect(query(count)).toEqual({ website: 'acme-shop', path: '/pricing' })
|
|
244
|
+
await expect(countHeatmap()).resolves.toEqual({ events: 3, snapshots: 1 })
|
|
245
|
+
|
|
246
|
+
const failure = new Error('gone')
|
|
247
|
+
execute.mockImplementationOnce(async () => {
|
|
248
|
+
error.value = failure
|
|
249
|
+
})
|
|
250
|
+
await expect(countHeatmap()).rejects.toBe(failure)
|
|
251
|
+
})
|
|
252
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-marketing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
|
|
35
35
|
"@antelopejs/interface-database": ">=0.1.8 <1.0.0",
|
|
36
36
|
"@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
|
|
37
|
-
"@antelopejs/interface-dms": ">=0.
|
|
37
|
+
"@antelopejs/interface-dms": ">=0.7.0 <1.0.0",
|
|
38
38
|
"@antelopejs/interface-dms-marketing": ">=0.4.0 <0.5.0",
|
|
39
39
|
"defu": "^6.1.4",
|
|
40
40
|
"maxmind": "^5.0.7",
|