@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.
@@ -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.4.1",
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.6.0 <1.0.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",