@antelopejs/dms-marketing 0.5.0 → 0.6.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/README.md +57 -131
- package/dist/config.d.ts +10 -1
- package/dist/config.js +29 -1
- package/dist/config.js.map +1 -1
- package/dist/db/models/funnels.model.d.ts +2 -0
- package/dist/db/models/funnels.model.js +18 -0
- package/dist/db/models/funnels.model.js.map +1 -1
- package/dist/db/models/index.d.ts +1 -0
- package/dist/db/models/index.js +1 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/marketing_events.model.d.ts +1 -0
- package/dist/db/models/marketing_imports.model.d.ts +27 -0
- package/dist/db/models/marketing_imports.model.js +38 -0
- package/dist/db/models/marketing_imports.model.js.map +1 -0
- package/dist/db/models/marketing_preferences.model.d.ts +1 -0
- package/dist/db/models/marketing_sessions.model.d.ts +1 -0
- package/dist/db/models/marketing_settings.model.d.ts +1 -0
- package/dist/db/models/page_snapshots.model.d.ts +2 -0
- package/dist/db/models/page_snapshots.model.js +14 -0
- package/dist/db/models/page_snapshots.model.js.map +1 -1
- package/dist/db/models/website_statistics.model.d.ts +1 -0
- package/dist/db/models/websites.model.d.ts +2 -0
- package/dist/db/models/websites.model.js +29 -0
- package/dist/db/models/websites.model.js.map +1 -1
- package/dist/db/tables/funnels.table.d.ts +1 -0
- package/dist/db/tables/funnels.table.js +4 -0
- package/dist/db/tables/funnels.table.js.map +1 -1
- package/dist/db/tables/index.d.ts +1 -0
- package/dist/db/tables/index.js +1 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/marketing_imports.table.d.ts +15 -0
- package/dist/db/tables/marketing_imports.table.js +63 -0
- package/dist/db/tables/marketing_imports.table.js.map +1 -0
- package/dist/db/tables/page_snapshots.table.d.ts +1 -0
- package/dist/db/tables/page_snapshots.table.js +4 -0
- package/dist/db/tables/page_snapshots.table.js.map +1 -1
- package/dist/db/tables/websites.table.d.ts +1 -0
- package/dist/db/tables/websites.table.js +4 -0
- package/dist/db/tables/websites.table.js.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/implementations/dms-marketing/index.d.ts +5 -1
- package/dist/implementations/dms-marketing/index.js +25 -0
- package/dist/implementations/dms-marketing/index.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/dashboard-tracking.d.ts +9 -0
- package/dist/routes/dashboard-tracking.js +57 -0
- package/dist/routes/dashboard-tracking.js.map +1 -0
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/stats.js.map +1 -1
- package/dist/routes/websites.js +12 -25
- package/dist/routes/websites.js.map +1 -1
- package/dist/services/analytics-import.d.ts +2 -0
- package/dist/services/analytics-import.js +294 -0
- package/dist/services/analytics-import.js.map +1 -0
- package/dist/services/collect.d.ts +20 -0
- package/dist/services/collect.js +43 -17
- package/dist/services/collect.js.map +1 -1
- package/dist/services/context.d.ts +8 -3
- package/dist/services/context.js +21 -7
- package/dist/services/context.js.map +1 -1
- package/dist/services/dashboard-tracking.d.ts +25 -0
- package/dist/services/dashboard-tracking.js +85 -0
- package/dist/services/dashboard-tracking.js.map +1 -0
- package/dist/services/dashboard.js +7 -3
- package/dist/services/dashboard.js.map +1 -1
- package/dist/services/declarations.d.ts +11 -0
- package/dist/services/declarations.js +64 -0
- package/dist/services/declarations.js.map +1 -0
- package/dist/services/declared-funnel.d.ts +2 -0
- package/dist/services/declared-funnel.js +230 -0
- package/dist/services/declared-funnel.js.map +1 -0
- package/dist/services/declared-snapshot.d.ts +2 -0
- package/dist/services/declared-snapshot.js +113 -0
- package/dist/services/declared-snapshot.js.map +1 -0
- package/dist/services/declared-website.d.ts +2 -0
- package/dist/services/declared-website.js +118 -0
- package/dist/services/declared-website.js.map +1 -0
- package/dist/services/geoip.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/readiness.d.ts +2 -0
- package/dist/services/readiness.js +15 -0
- package/dist/services/readiness.js.map +1 -0
- package/dist/services/sessionize.d.ts +1 -0
- package/dist/services/sessionize.js +3 -0
- package/dist/services/sessionize.js.map +1 -1
- package/dist/services/snapshots.d.ts +3 -0
- package/dist/services/snapshots.js +6 -0
- package/dist/services/snapshots.js.map +1 -1
- package/dist/services/tenant-website.js +1 -0
- package/dist/services/tenant-website.js.map +1 -1
- package/dist/services/website-report.js +4 -3
- package/dist/services/website-report.js.map +1 -1
- package/dist/services/website-rules.d.ts +8 -0
- package/dist/services/website-rules.js +34 -0
- package/dist/services/website-rules.js.map +1 -0
- package/dist/types/constants.d.ts +3 -0
- package/dist/types/constants.js +5 -2
- package/dist/types/constants.js.map +1 -1
- package/dist/types/snapshots.d.ts +55 -0
- package/dist/types/snapshots.js +8 -1
- package/dist/types/snapshots.js.map +1 -1
- package/frontend-vue/app/components/Context.vue +5 -1
- package/frontend-vue/app/components/FirstRun.vue +1 -1
- package/frontend-vue/app/components/PagePreview.vue +1 -0
- package/frontend-vue/app/components/WebsitesGrid.vue +6 -2
- package/frontend-vue/app/composables/useMarketingApi.ts +12 -0
- package/frontend-vue/app/composables/useMarketingContext.ts +2 -1
- package/frontend-vue/app/composables/useMarketingReads.ts +6 -0
- package/frontend-vue/app/constants.ts +6 -0
- package/frontend-vue/app/plugins/dashboard-tracking.client.ts +44 -0
- package/frontend-vue/app/utils/dashboard-tracker.ts +127 -0
- package/frontend-vue/app/utils/format.ts +6 -3
- package/frontend-vue/dms.frontend.ts +10 -0
- package/frontend-vue/i18n/locales/marketing-en-GB.json +2 -0
- package/frontend-vue/i18n/locales/marketing-fr-FR.json +2 -0
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/dashboard-tracking.test.ts +219 -0
- package/frontend-vue/tests/format.test.ts +9 -0
- package/frontend-vue/tests/marketing-reads.test.ts +23 -0
- package/frontend-vue/tests/server-parity.test.ts +5 -0
- package/frontend-vue/tests/stubs/frontend-module.ts +7 -0
- package/package.json +34 -15
- package/skills/dms-marketing/SKILL.md +107 -0
- package/static/tracker.js +90 -5
- package/frontend-vue/pnpm-workspace.yaml +0 -2
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { nextTick, ref } from 'vue'
|
|
3
|
+
import frontendModule from '../dms.frontend'
|
|
4
|
+
import { MODULE_CONFIG_KEY } from '../app/constants'
|
|
5
|
+
import {
|
|
6
|
+
applyDashboardTracker,
|
|
7
|
+
DASHBOARD_TRACKER_ELEMENT_ID,
|
|
8
|
+
dashboardTrackingOptions,
|
|
9
|
+
followDashboardSession,
|
|
10
|
+
} from '../app/utils/dashboard-tracker'
|
|
11
|
+
|
|
12
|
+
/** Just enough of a script element and a document for the tag helper. */
|
|
13
|
+
class FakeElement {
|
|
14
|
+
id = ''
|
|
15
|
+
async = false
|
|
16
|
+
src = ''
|
|
17
|
+
readonly attributes = new Map<string, string>()
|
|
18
|
+
setAttribute(name: string, value: string) {
|
|
19
|
+
this.attributes.set(name, value)
|
|
20
|
+
}
|
|
21
|
+
removeAttribute(name: string) {
|
|
22
|
+
this.attributes.delete(name)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fakeDocument() {
|
|
27
|
+
const appended: FakeElement[] = []
|
|
28
|
+
const doc = {
|
|
29
|
+
appended,
|
|
30
|
+
getElementById: (id: string) =>
|
|
31
|
+
appended.find((element) => element.id === id) ?? null,
|
|
32
|
+
createElement: () => new FakeElement(),
|
|
33
|
+
head: {
|
|
34
|
+
appendChild: (element: FakeElement) => {
|
|
35
|
+
appended.push(element)
|
|
36
|
+
return element
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
return doc as typeof doc & Parameters<typeof applyDashboardTracker>[0]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SRC = 'https://dms.example.com/api/marketing/tracker.js'
|
|
44
|
+
const EXCLUDED = ['/auth/**', '/settings/*']
|
|
45
|
+
|
|
46
|
+
function sdk(publicOptions: Record<string, unknown>) {
|
|
47
|
+
return {
|
|
48
|
+
options: { public: publicOptions },
|
|
49
|
+
registerComponent: vi.fn(),
|
|
50
|
+
registerPage: vi.fn(),
|
|
51
|
+
registerDynamicPage: vi.fn(),
|
|
52
|
+
registerLayout: vi.fn(),
|
|
53
|
+
registerErrorPage: vi.fn(),
|
|
54
|
+
registerPlugin: vi.fn(),
|
|
55
|
+
registerMiddleware: vi.fn(),
|
|
56
|
+
provide: vi.fn(),
|
|
57
|
+
use: vi.fn(),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('dashboard tracking options', () => {
|
|
62
|
+
it('is off without the backend option', () => {
|
|
63
|
+
expect(dashboardTrackingOptions({})).toBeNull()
|
|
64
|
+
expect(dashboardTrackingOptions({ [MODULE_CONFIG_KEY]: {} })).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('reads the excluded paths, strings only', () => {
|
|
68
|
+
expect(
|
|
69
|
+
dashboardTrackingOptions({
|
|
70
|
+
[MODULE_CONFIG_KEY]: {
|
|
71
|
+
dashboardTracking: { excludePaths: ['/auth/**', 3, '/a'] },
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
).toEqual({ excludePaths: ['/auth/**', '/a'] })
|
|
75
|
+
expect(
|
|
76
|
+
dashboardTrackingOptions({
|
|
77
|
+
[MODULE_CONFIG_KEY]: { dashboardTracking: {} },
|
|
78
|
+
}),
|
|
79
|
+
).toEqual({ excludePaths: [] })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('registers its client plugin only when the backend enables it', async () => {
|
|
83
|
+
const off = sdk({})
|
|
84
|
+
await frontendModule.setup(off)
|
|
85
|
+
expect(off.registerPlugin).toHaveBeenCalledTimes(1)
|
|
86
|
+
|
|
87
|
+
const on = sdk({
|
|
88
|
+
[MODULE_CONFIG_KEY]: { dashboardTracking: { excludePaths: EXCLUDED } },
|
|
89
|
+
})
|
|
90
|
+
await frontendModule.setup(on)
|
|
91
|
+
expect(on.registerPlugin).toHaveBeenCalledTimes(2)
|
|
92
|
+
for (const [, options] of on.registerPlugin.mock.calls) {
|
|
93
|
+
expect(options).toEqual({ clientOnly: true })
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('the dashboard tag', () => {
|
|
99
|
+
it('is not inserted before there is a website to report to', () => {
|
|
100
|
+
const doc = fakeDocument()
|
|
101
|
+
applyDashboardTracker(doc, {
|
|
102
|
+
src: SRC,
|
|
103
|
+
websiteId: null,
|
|
104
|
+
excludePaths: EXCLUDED,
|
|
105
|
+
})
|
|
106
|
+
expect(doc.appended).toHaveLength(0)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('is inserted once, then paused and retargeted in place', () => {
|
|
110
|
+
const doc = fakeDocument()
|
|
111
|
+
applyDashboardTracker(doc, {
|
|
112
|
+
src: SRC,
|
|
113
|
+
websiteId: 'site-a',
|
|
114
|
+
excludePaths: EXCLUDED,
|
|
115
|
+
})
|
|
116
|
+
expect(doc.appended).toHaveLength(1)
|
|
117
|
+
const tag = doc.appended[0]!
|
|
118
|
+
expect(tag.id).toBe(DASHBOARD_TRACKER_ELEMENT_ID)
|
|
119
|
+
expect(tag.src).toBe(SRC)
|
|
120
|
+
expect(tag.async).toBe(true)
|
|
121
|
+
expect(tag.attributes.get('data-website-id')).toBe('site-a')
|
|
122
|
+
expect(tag.attributes.get('data-exclude-paths')).toBe(
|
|
123
|
+
'/auth/**,/settings/*',
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
applyDashboardTracker(doc, {
|
|
127
|
+
src: SRC,
|
|
128
|
+
websiteId: null,
|
|
129
|
+
excludePaths: EXCLUDED,
|
|
130
|
+
})
|
|
131
|
+
expect(tag.attributes.has('data-website-id')).toBe(false)
|
|
132
|
+
|
|
133
|
+
applyDashboardTracker(doc, {
|
|
134
|
+
src: SRC,
|
|
135
|
+
websiteId: 'site-b',
|
|
136
|
+
excludePaths: EXCLUDED,
|
|
137
|
+
})
|
|
138
|
+
expect(doc.appended).toHaveLength(1)
|
|
139
|
+
expect(tag.attributes.get('data-website-id')).toBe('site-b')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('following the signed-in workspace', () => {
|
|
144
|
+
function binding(answers: Record<string, () => Promise<string>>) {
|
|
145
|
+
const workspace = ref<string | null>(null)
|
|
146
|
+
const applied: (string | null)[] = []
|
|
147
|
+
const provision = vi.fn(() => answers[workspace.value!]!())
|
|
148
|
+
const stop = followDashboardSession({
|
|
149
|
+
workspace: () => workspace.value,
|
|
150
|
+
provision,
|
|
151
|
+
apply: (websiteId) => applied.push(websiteId),
|
|
152
|
+
})
|
|
153
|
+
return { workspace, applied, provision, stop }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function settle() {
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
158
|
+
await nextTick()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
it('asks nothing while nobody is signed in', async () => {
|
|
162
|
+
const { applied, provision, stop } = binding({})
|
|
163
|
+
await settle()
|
|
164
|
+
expect(provision).not.toHaveBeenCalled()
|
|
165
|
+
expect(applied).toEqual([null])
|
|
166
|
+
stop()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('pauses at once on a change, then reports to the new website', async () => {
|
|
170
|
+
const { workspace, applied, stop } = binding({
|
|
171
|
+
t1: async () => 'site-1',
|
|
172
|
+
t2: async () => 'site-2',
|
|
173
|
+
})
|
|
174
|
+
workspace.value = 't1'
|
|
175
|
+
await settle()
|
|
176
|
+
expect(applied).toEqual([null, null, 'site-1'])
|
|
177
|
+
|
|
178
|
+
workspace.value = 't2'
|
|
179
|
+
// Synchronous: paused before the navigation that follows the switch.
|
|
180
|
+
expect(applied.at(-1)).toBeNull()
|
|
181
|
+
await settle()
|
|
182
|
+
expect(applied.at(-1)).toBe('site-2')
|
|
183
|
+
|
|
184
|
+
workspace.value = null
|
|
185
|
+
expect(applied.at(-1)).toBeNull()
|
|
186
|
+
await settle()
|
|
187
|
+
expect(applied.at(-1)).toBeNull()
|
|
188
|
+
stop()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('drops an answer that arrives after another change', async () => {
|
|
192
|
+
let release: (id: string) => void = () => {}
|
|
193
|
+
const { workspace, applied, stop } = binding({
|
|
194
|
+
slow: () =>
|
|
195
|
+
new Promise<string>((resolve) => {
|
|
196
|
+
release = resolve
|
|
197
|
+
}),
|
|
198
|
+
})
|
|
199
|
+
workspace.value = 'slow'
|
|
200
|
+
workspace.value = null
|
|
201
|
+
release('site-slow')
|
|
202
|
+
await settle()
|
|
203
|
+
expect(applied).not.toContain('site-slow')
|
|
204
|
+
expect(applied.at(-1)).toBeNull()
|
|
205
|
+
stop()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('stays paused when the backend refuses', async () => {
|
|
209
|
+
const { workspace, applied, stop } = binding({
|
|
210
|
+
t1: async () => {
|
|
211
|
+
throw new Error('404')
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
workspace.value = 't1'
|
|
215
|
+
await settle()
|
|
216
|
+
expect(applied.at(-1)).toBeNull()
|
|
217
|
+
stop()
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -46,6 +46,15 @@ describe('formatting', () => {
|
|
|
46
46
|
expect(initials('docs')).toBe('DO')
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
+
it('builds initials from letters and digits only', () => {
|
|
50
|
+
expect(initials('Acme — public site')).toBe('AP')
|
|
51
|
+
expect(initials('shop.acme.test')).toBe('SA')
|
|
52
|
+
expect(initials('— Acme')).toBe('AC')
|
|
53
|
+
expect(initials('42 · store')).toBe('4S')
|
|
54
|
+
expect(initials('Été à Liège')).toBe('ÉÀ')
|
|
55
|
+
expect(initials('—')).toBe('?')
|
|
56
|
+
})
|
|
57
|
+
|
|
49
58
|
it('falls back to the raw code on a malformed region', () => {
|
|
50
59
|
expect(displayName('FR', 'region', 'en')).toBe('France')
|
|
51
60
|
expect(displayName('not a code!', 'region', 'en')).toBe('not a code!')
|
|
@@ -205,6 +205,7 @@ describe('the page preview', () => {
|
|
|
205
205
|
path: '/pricing',
|
|
206
206
|
layout: 'phone',
|
|
207
207
|
enabled,
|
|
208
|
+
maskText: false,
|
|
208
209
|
})
|
|
209
210
|
const [snapshot] = calls
|
|
210
211
|
expect(snapshot?.url()).toBeNull()
|
|
@@ -216,6 +217,28 @@ describe('the page preview', () => {
|
|
|
216
217
|
layout: 'phone',
|
|
217
218
|
})
|
|
218
219
|
})
|
|
220
|
+
|
|
221
|
+
it('reads the capture again once the text mask changes, which discards it', () => {
|
|
222
|
+
const maskText = ref(false)
|
|
223
|
+
usePageSnapshot({
|
|
224
|
+
websiteId: 'acme-shop',
|
|
225
|
+
path: '/pricing',
|
|
226
|
+
layout: 'desktop',
|
|
227
|
+
enabled: true,
|
|
228
|
+
maskText,
|
|
229
|
+
})
|
|
230
|
+
const [snapshot] = calls
|
|
231
|
+
// Same URL and query: only a watched source reads it again.
|
|
232
|
+
const sources = snapshot?.options.watch as (() => unknown)[] | undefined
|
|
233
|
+
expect(sources?.map((source) => source())).toEqual([false])
|
|
234
|
+
maskText.value = true
|
|
235
|
+
expect(sources?.map((source) => source())).toEqual([true])
|
|
236
|
+
expect(query(snapshot)).toEqual({
|
|
237
|
+
website: 'acme-shop',
|
|
238
|
+
path: '/pricing',
|
|
239
|
+
layout: 'desktop',
|
|
240
|
+
})
|
|
241
|
+
})
|
|
219
242
|
})
|
|
220
243
|
|
|
221
244
|
describe('the reads a dialog makes when it asks', () => {
|
|
@@ -8,9 +8,14 @@ import { normalizeFunnelStepPath } from '../app/utils/step-path'
|
|
|
8
8
|
import {
|
|
9
9
|
MAX_WEBSITE_DOMAIN_LENGTH,
|
|
10
10
|
MAX_WEBSITE_NAME_LENGTH,
|
|
11
|
+
MODULE_CONFIG_KEY,
|
|
11
12
|
} from '../app/constants'
|
|
12
13
|
|
|
13
14
|
describe('front rules mirror the server', () => {
|
|
15
|
+
it('reads its public options where the backend puts them', () => {
|
|
16
|
+
expect(MODULE_CONFIG_KEY).toBe(server.FRONTEND_CONFIG_KEY)
|
|
17
|
+
})
|
|
18
|
+
|
|
14
19
|
it('caps a funnel draft as the server does', () => {
|
|
15
20
|
expect(draft.MAX_NAME_LENGTH).toBe(server.MAX_NAME_LENGTH)
|
|
16
21
|
expect(draft.MAX_STEPS).toBe(server.MAX_FUNNEL_STEPS)
|
|
@@ -67,3 +67,10 @@ export function useDmsCookie<T>(
|
|
|
67
67
|
): { value: T } {
|
|
68
68
|
return { value: options.default() }
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
export function useDmsUserSession(): {
|
|
72
|
+
loggedIn: { value: boolean }
|
|
73
|
+
session: { value: { activeTenantId?: string } | null }
|
|
74
|
+
} {
|
|
75
|
+
return { loggedIn: { value: false }, session: { value: null } }
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-marketing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "First-party marketing analytics for the AntelopeJS DMS: traffic dashboards, conversion funnels with A/B splits and click heatmaps, without cookies or external services.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ab-testing",
|
|
7
|
+
"analytics",
|
|
8
|
+
"antelopejs",
|
|
9
|
+
"dms",
|
|
10
|
+
"dms-module",
|
|
11
|
+
"heatmap",
|
|
12
|
+
"marketing"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://dms.antelopejs.com/docs/modules/marketing",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/AntelopeJS/dms-marketing/issues"
|
|
17
|
+
},
|
|
4
18
|
"license": "Apache-2.0",
|
|
5
19
|
"repository": {
|
|
6
20
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/AntelopeJS/dms-marketing.git"
|
|
21
|
+
"url": "git+https://github.com/AntelopeJS/dms-marketing.git",
|
|
22
|
+
"directory": "packages/dms-marketing"
|
|
8
23
|
},
|
|
9
24
|
"files": [
|
|
10
25
|
"LICENSE",
|
|
11
26
|
"dist",
|
|
12
27
|
"!dist/.tsbuildinfo",
|
|
13
28
|
"frontend-vue",
|
|
29
|
+
"skills",
|
|
14
30
|
"static"
|
|
15
31
|
],
|
|
16
32
|
"main": "dist/index.js",
|
|
@@ -28,14 +44,14 @@
|
|
|
28
44
|
"registry": "https://registry.npmjs.org/"
|
|
29
45
|
},
|
|
30
46
|
"dependencies": {
|
|
31
|
-
"@antelopejs/interface-api": ">=0.0.
|
|
32
|
-
"@antelopejs/interface-api-util": ">=0.1.
|
|
33
|
-
"@antelopejs/interface-core": ">=0.
|
|
34
|
-
"@antelopejs/interface-data-api": ">=0.2.
|
|
35
|
-
"@antelopejs/interface-database": ">=0.1.
|
|
36
|
-
"@antelopejs/interface-database-decorators": ">=0.1.
|
|
37
|
-
"@antelopejs/interface-dms": ">=0.7.
|
|
38
|
-
"@antelopejs/interface-dms-marketing": ">=0.
|
|
47
|
+
"@antelopejs/interface-api": ">=0.0.15 <1.0.0",
|
|
48
|
+
"@antelopejs/interface-api-util": ">=0.1.3 <1.0.0",
|
|
49
|
+
"@antelopejs/interface-core": ">=0.2.4 <1.0.0",
|
|
50
|
+
"@antelopejs/interface-data-api": ">=0.2.2 <1.0.0",
|
|
51
|
+
"@antelopejs/interface-database": ">=0.1.10 <1.0.0",
|
|
52
|
+
"@antelopejs/interface-database-decorators": ">=0.1.9 <1.0.0",
|
|
53
|
+
"@antelopejs/interface-dms": ">=0.7.2 <1.0.0",
|
|
54
|
+
"@antelopejs/interface-dms-marketing": ">=0.5.0 <0.6.0",
|
|
39
55
|
"defu": "^6.1.4",
|
|
40
56
|
"maxmind": "^5.0.7",
|
|
41
57
|
"node-cron": "^4.2.1",
|
|
@@ -44,10 +60,10 @@
|
|
|
44
60
|
"zod": "~3.24.4"
|
|
45
61
|
},
|
|
46
62
|
"devDependencies": {
|
|
47
|
-
"@antelopejs/core": ">=1.
|
|
48
|
-
"@antelopejs/interface-mongodb": ">=0.0.
|
|
49
|
-
"@antelopejs/mongodb": "1.4.
|
|
50
|
-
"@antelopejs/tooling-configs": ">=0.0.
|
|
63
|
+
"@antelopejs/core": ">=1.16.0 <2",
|
|
64
|
+
"@antelopejs/interface-mongodb": ">=0.0.6 <1.0.0",
|
|
65
|
+
"@antelopejs/mongodb": "1.4.3",
|
|
66
|
+
"@antelopejs/tooling-configs": ">=0.0.9 <1.0.0",
|
|
51
67
|
"@types/node": "^22.19.15",
|
|
52
68
|
"@types/sanitize-html": "^2.16.2",
|
|
53
69
|
"knip": "^6.34.0",
|
|
@@ -71,7 +87,10 @@
|
|
|
71
87
|
"@/*": [
|
|
72
88
|
"*"
|
|
73
89
|
]
|
|
74
|
-
}
|
|
90
|
+
},
|
|
91
|
+
"skills": [
|
|
92
|
+
"./skills"
|
|
93
|
+
]
|
|
75
94
|
},
|
|
76
95
|
"scripts": {
|
|
77
96
|
"build": "pnpm --filter @antelopejs/interface-dms-marketing build && rimraf dist && tsc",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dms-marketing
|
|
3
|
+
description: First-party, cookieless web analytics with the @antelopejs/dms-marketing module of the AntelopeJS DMS. Use when installing or configuring dms-marketing, embedding its tracker on a site, sending custom events or A/B variations from page code (dmsMarketing.track / variation), defining funnels or A/B tests, shipping websites, funnels or demo/migrated history from code (EnsureWebsite / EnsureFunnel / ImportAnalytics), reading heatmaps from another module via GetClickHeatmap, or debugging missing, refused (403/404) or empty analytics.
|
|
4
|
+
icon: i-lucide-chart-line
|
|
5
|
+
category: analytics
|
|
6
|
+
tags: [analytics, tracking, funnels, ab-testing, heatmaps, dms-module]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# dms-marketing
|
|
10
|
+
|
|
11
|
+
Traffic dashboards, funnels with optional A/B splits and click heatmaps, all stored in the project's MongoDB.
|
|
12
|
+
Pages under `/modules/marketing` (owner-only): overview, acquisition, pages (heatmaps), funnels, funnel,
|
|
13
|
+
funnel-builder, websites, install, settings. User docs: `docs/` of this package, published at
|
|
14
|
+
https://dms.antelopejs.com/docs/modules/marketing (`architecture` holds the full HTTP and event contracts).
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
- Marketing 0.5 requires `@antelopejs/dms` >=0.9 (interface-dms 0.7, renderer `@antelopejs/dms-frontend` 0.6);
|
|
19
|
+
0.4 is the DMS 0.8 line.
|
|
20
|
+
- `ajs project modules add @antelopejs/dms-marketing`. Options: `trackerEnabled`, `rawEventsRetention`,
|
|
21
|
+
`statisticsRetention`, `snapshotRetention`, `sessionIdleTimeout` (all ms), `heatmapSampleRate` (0-1),
|
|
22
|
+
`visitorHashSecret` (set it when running several instances), `geoipDatabasePath` (.mmdb, opt-in),
|
|
23
|
+
`dashboardTracking` (see below).
|
|
24
|
+
The Settings page overrides switch, retentions and sampling at runtime for the whole deployment.
|
|
25
|
+
- Behind a proxy set the DMS `auth.trustedProxies`; the old `trustedProxyHops` option is gone.
|
|
26
|
+
- One website row per tracked site (Websites page or `POST /api/marketing/websites {name, domain, extraDomains?}`).
|
|
27
|
+
Its `domain` gates ingestion: the beacon's Origin must be that apex, a subdomain or an extra host.
|
|
28
|
+
|
|
29
|
+
## Visitor site
|
|
30
|
+
|
|
31
|
+
```html
|
|
32
|
+
<script defer src="https://<dashboard>/api/marketing/tracker.js" data-website-id="<id>"></script>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
window.dmsMarketing?.track("signup_submitted", { plan: "pro" }); // name <= 50 chars, data <= 4 KiB
|
|
37
|
+
const arm = await window.dmsMarketing?.variation("hero-cta"); // null = render the control
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- The dashboard origin forwards the scripts and beacons because the module declares them `publicEndpoints`
|
|
41
|
+
(renderer 0.6 refuses other cross-origin writes); the backend origin works too.
|
|
42
|
+
- Never `type="module"` (breaks `document.currentScript`). Always guard with `?.`.
|
|
43
|
+
- Snapshots (heatmap backdrops) are opt-in per site; mask with `data-dms-marketing-mask` / `data-dms-marketing-block`.
|
|
44
|
+
|
|
45
|
+
## Tracking the dashboard itself
|
|
46
|
+
|
|
47
|
+
For a demo, a playground or a back office measuring its own admin, no tag to write:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
config: { dashboardTracking: { enabled: true, websiteName: "Dashboard", excludePaths: ["/settings/**"] } }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- Off by default. On, the module's frontend loads the tracker on every signed-in dashboard page and
|
|
54
|
+
`POST /api/marketing/dashboard-tracking` creates one website per tenant on first use (domain = the
|
|
55
|
+
dashboard's host; later hosts join `extraDomains`). Rename, pause or delete it like any site.
|
|
56
|
+
- `/auth/**` is never counted; signing out pauses the tag, a workspace switch retargets it. SPA navigations
|
|
57
|
+
count. Restart `ajs dms dev` / rebuild the frontend after toggling the option.
|
|
58
|
+
- Tracker attributes behind it, usable on any site: `data-exclude-paths="/x,/x/*,/x/**"`, and a live
|
|
59
|
+
`data-website-id` (changing it retargets, removing it pauses).
|
|
60
|
+
|
|
61
|
+
## Content from code
|
|
62
|
+
|
|
63
|
+
Never write the module's tables. From a module's `start()` (await) — or unawaited with a `.catch` in
|
|
64
|
+
`construct()` / a DATABASE_INITIALIZED handler — use `@antelopejs/interface-dms-marketing`:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
const tenantId = "default"; // explicit, never implied
|
|
68
|
+
await EnsureWebsite({ tenantId, id: "shop-site", name: "Shop", domain: "shop.example.com",
|
|
69
|
+
snapshotsEnabled: true }); // id = data-website-id, global
|
|
70
|
+
await EnsureFunnel({ tenantId, id: "shop-quote", websiteId: "shop-site", name: "Quote",
|
|
71
|
+
steps: [{ kind: "url", value: "/pricing" }, { kind: "custom", value: "quote_submitted" }],
|
|
72
|
+
experiment: { key: "hero-cta", status: "running", startedAt: "2026-07-01T00:00:00Z",
|
|
73
|
+
variations: [{ key: "control", weight: 1 }, { key: "bold", weight: 1 }] } });
|
|
74
|
+
await EnsurePageSnapshot({ tenantId, websiteId: "shop-site", url: "https://shop.example.com/pricing",
|
|
75
|
+
html, viewport: { width: 1280, height: 800 }, document: { width: 1280, height: 2600 } });
|
|
76
|
+
await ImportAnalytics({ tenantId, importId: "shop-history", websiteId: "shop-site", sessions: [
|
|
77
|
+
{ visitor: "v1", userAgent, country: "BE", events: [{ kind: "pageview", at: "2026-10-01T09:00:00Z", url }] }] });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- Ensure* answer `created` / `unchanged` / `updated` / `kept`: a record someone changed since the last
|
|
81
|
+
declaration (hash stored on the row) is never overwritten; delete it in the dashboard to re-take it.
|
|
82
|
+
Dashboard rules apply (hostnames, 1-10 steps, 2-8 arms, draft → running ⇄ stopped, arms frozen out of draft).
|
|
83
|
+
- ImportAnalytics replays through the live collect pipeline with trusted timestamps (same sessions,
|
|
84
|
+
raw events and daily rows); once per importId + website, later calls `in_progress` (first still replaying, counts not final),
|
|
85
|
+
then `skipped` (or `failed`); ≤ 10,000 sessions and
|
|
86
|
+
1,000 events/session per call; drops future, past-retention, invalid and bot events and exposures of
|
|
87
|
+
draft splits. `startedAt` on a running split makes imported exposures count.
|
|
88
|
+
- EnsurePageSnapshot: the heatmap backdrop of a page the tracker cannot capture (not online, imported
|
|
89
|
+
history); keyed by path + layout of `viewport` (desktop ≥ 1024, tablet ≥ 600, phone); sanitized like a
|
|
90
|
+
capture; needs `snapshotsEnabled`; a live capture replaces it and is never overwritten (`kept`).
|
|
91
|
+
- `tenantId` is required on every content call: no default. Full contract: the interface package's
|
|
92
|
+
`docs/1.index.md`.
|
|
93
|
+
|
|
94
|
+
## Other modules
|
|
95
|
+
|
|
96
|
+
Tenant users cannot open module pages. To show analytics to them, build a project page from DMS blocks
|
|
97
|
+
(custom block only for the overlay, with `.meta()`), and read data through the optional interface:
|
|
98
|
+
`GetClickHeatmap({ tenantId, path, from?, to?, websiteId? })` from `@antelopejs/interface-dms-marketing`
|
|
99
|
+
(declare it in `optionalDependencies`; calls reject without an implementer).
|
|
100
|
+
|
|
101
|
+
## Gotchas
|
|
102
|
+
|
|
103
|
+
- Collect `403` = host not declared on the website; `404` = unknown website or its Collect data is off;
|
|
104
|
+
all events `dropped` = collection paused in Settings.
|
|
105
|
+
- The tracker script is cached 1 h: settings reach returning visitors within that window.
|
|
106
|
+
- Funnels and A/B results read raw events (default 90 days); dashboards read daily rollups only.
|
|
107
|
+
- Funnel steps are page paths or custom event names, matched in order within one session and window.
|
package/static/tracker.js
CHANGED
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* data-host-url override the collect origin (defaults to the script origin)
|
|
10
10
|
* data-heatmap-sample 0-1 click/scroll sampling rate; overrides the module setting
|
|
11
11
|
* data-do-not-track "false" to ignore the browser DNT and GPC signals
|
|
12
|
+
* data-exclude-paths comma-separated pages left uncounted: `/x` (that
|
|
13
|
+
* page), `/x/*` (one segment below) or `/x/**` (the
|
|
14
|
+
* page and everything below)
|
|
15
|
+
*
|
|
16
|
+
* The tag stays live: changing its `data-website-id` sends what follows to
|
|
17
|
+
* that website, and removing it pauses collection until one is set again.
|
|
18
|
+
* The dashboard's own tag relies on it to follow sign-out and workspace
|
|
19
|
+
* switches without a reload.
|
|
12
20
|
*
|
|
13
21
|
* Cookieless by design: no identifier is generated or stored client-side —
|
|
14
22
|
* the backend derives an anonymous visitor hash per request. Payload shape
|
|
@@ -118,11 +126,8 @@
|
|
|
118
126
|
return Math.min(Math.max(rate, 0), 1);
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
publishApi();
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
129
|
+
/** Null while the page removed it: nothing is collected meanwhile. */
|
|
130
|
+
let websiteId = script.getAttribute("data-website-id");
|
|
126
131
|
|
|
127
132
|
// A tag included twice (theme and tag manager) would count everything twice.
|
|
128
133
|
if (window[INSTALLED_GLOBAL]) {
|
|
@@ -151,6 +156,12 @@
|
|
|
151
156
|
config,
|
|
152
157
|
);
|
|
153
158
|
|
|
159
|
+
/** Patterns of `data-exclude-paths`, each a path starting with "/". */
|
|
160
|
+
const excludedPaths = (script.getAttribute("data-exclude-paths") || "")
|
|
161
|
+
.split(",")
|
|
162
|
+
.map((pattern) => pattern.trim())
|
|
163
|
+
.filter((pattern) => pattern.charAt(0) === "/");
|
|
164
|
+
|
|
154
165
|
const heatmapSampled = Math.random() < sampleRate;
|
|
155
166
|
const queue = [];
|
|
156
167
|
let flushTimer = null;
|
|
@@ -193,6 +204,11 @@
|
|
|
193
204
|
}
|
|
194
205
|
|
|
195
206
|
function enqueue(event) {
|
|
207
|
+
// Attribute writes are synchronous; MutationObserver delivery is not.
|
|
208
|
+
// Until retargeting completes, never put new events in the old queue.
|
|
209
|
+
if (!websiteId || script.getAttribute("data-website-id") !== websiteId || pageExcluded) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
196
212
|
queue.push(event);
|
|
197
213
|
if (queue.length >= MAX_BATCH) {
|
|
198
214
|
flush();
|
|
@@ -203,9 +219,38 @@
|
|
|
203
219
|
}
|
|
204
220
|
}
|
|
205
221
|
|
|
222
|
+
// --- Excluded pages -------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
function withoutTrailingSlash(path) {
|
|
225
|
+
return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function pathMatches(pattern, path) {
|
|
229
|
+
if (pattern.endsWith("/**")) {
|
|
230
|
+
const base = pattern.slice(0, -3);
|
|
231
|
+
return path === base || path.startsWith(`${base}/`);
|
|
232
|
+
}
|
|
233
|
+
if (pattern.endsWith("/*")) {
|
|
234
|
+
const base = pattern.slice(0, -2);
|
|
235
|
+
const rest = path.startsWith(`${base}/`)
|
|
236
|
+
? path.slice(base.length + 1)
|
|
237
|
+
: "";
|
|
238
|
+
return rest !== "" && rest.indexOf("/") < 0;
|
|
239
|
+
}
|
|
240
|
+
return path === withoutTrailingSlash(pattern);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isExcludedPath(pathname) {
|
|
244
|
+
const path = withoutTrailingSlash(pathname);
|
|
245
|
+
return excludedPaths.some((pattern) => pathMatches(pattern, path));
|
|
246
|
+
}
|
|
247
|
+
|
|
206
248
|
// --- Pageviews (initial + SPA navigations) --------------------------------
|
|
207
249
|
|
|
208
250
|
let lastPath = null;
|
|
251
|
+
/** The page on screen matches `data-exclude-paths`: nothing it does is
|
|
252
|
+
* sent, its clicks and scroll depth included. */
|
|
253
|
+
let pageExcluded = false;
|
|
209
254
|
/** URL the scroll depth is being measured against — kept because a SPA
|
|
210
255
|
* navigation rewrites `location` before the depth of the page being left
|
|
211
256
|
* is flushed. */
|
|
@@ -219,8 +264,12 @@
|
|
|
219
264
|
}
|
|
220
265
|
flushScrollDepth();
|
|
221
266
|
lastPath = location.pathname;
|
|
267
|
+
pageExcluded = isExcludedPath(lastPath);
|
|
222
268
|
scrolledUrl = currentUrl();
|
|
223
269
|
maxScrollDepth = -1;
|
|
270
|
+
if (!websiteId || pageExcluded) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
224
273
|
// The rate it applies, for the server's "recorded on N % of page loads":
|
|
225
274
|
// counting scroll events against pageviews undercounts the views left
|
|
226
275
|
// before their depth is sent.
|
|
@@ -440,6 +489,9 @@
|
|
|
440
489
|
function requestSnapshot() {
|
|
441
490
|
if (
|
|
442
491
|
!heatmapSampled ||
|
|
492
|
+
!websiteId ||
|
|
493
|
+
script.getAttribute("data-website-id") !== websiteId ||
|
|
494
|
+
pageExcluded ||
|
|
443
495
|
snapshotsRequested >= MAX_SNAPSHOTS_PER_LOAD ||
|
|
444
496
|
window.top !== window ||
|
|
445
497
|
document.visibilityState !== "visible" ||
|
|
@@ -562,7 +614,14 @@
|
|
|
562
614
|
/** Assigned variation of a running experiment, or null. Reports the
|
|
563
615
|
* exposure, so only call it where the variation is actually applied. */
|
|
564
616
|
variation(experiment) {
|
|
617
|
+
const requestedWebsite = websiteId;
|
|
618
|
+
if (!requestedWebsite || script.getAttribute("data-website-id") !== requestedWebsite) {
|
|
619
|
+
return Promise.resolve(null);
|
|
620
|
+
}
|
|
565
621
|
return loadAssignments().then((assignments) => {
|
|
622
|
+
if (websiteId !== requestedWebsite || script.getAttribute("data-website-id") !== requestedWebsite) {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
566
625
|
const assigned = assignments[experiment];
|
|
567
626
|
if (typeof assigned !== "string") {
|
|
568
627
|
return null;
|
|
@@ -590,5 +649,31 @@
|
|
|
590
649
|
flush();
|
|
591
650
|
});
|
|
592
651
|
|
|
652
|
+
/**
|
|
653
|
+
* The tag's website changed: what was collected so far leaves under the
|
|
654
|
+
* previous one, and the page on screen counts for the new one. The
|
|
655
|
+
* assignments were the previous website's experiments.
|
|
656
|
+
*/
|
|
657
|
+
function followWebsiteId() {
|
|
658
|
+
const next = script.getAttribute("data-website-id") || null;
|
|
659
|
+
if (next === websiteId) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
flushScrollDepth();
|
|
663
|
+
flush();
|
|
664
|
+
websiteId = next;
|
|
665
|
+
assignmentsPromise = null;
|
|
666
|
+
exposedExperiments.clear();
|
|
667
|
+
lastPath = null;
|
|
668
|
+
trackPageview();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (typeof MutationObserver === "function") {
|
|
672
|
+
new MutationObserver(followWebsiteId).observe(script, {
|
|
673
|
+
attributes: true,
|
|
674
|
+
attributeFilter: ["data-website-id"],
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
593
678
|
trackPageview();
|
|
594
679
|
})();
|