@antelopejs/dms-marketing 0.6.1 → 0.7.1
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 +2 -2
- package/dist/config.d.ts +3 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/data-api/websites.d.ts +10 -4
- package/dist/data-api/websites.js +79 -3
- package/dist/data-api/websites.js.map +1 -1
- package/dist/db/models/funnels.model.d.ts +0 -1
- package/dist/db/models/funnels.model.js +0 -18
- package/dist/db/models/funnels.model.js.map +1 -1
- package/dist/db/models/page_snapshots.model.d.ts +2 -1
- package/dist/db/models/page_snapshots.model.js +6 -14
- package/dist/db/models/page_snapshots.model.js.map +1 -1
- package/dist/db/models/websites.model.d.ts +1 -1
- package/dist/db/models/websites.model.js +9 -29
- package/dist/db/models/websites.model.js.map +1 -1
- package/dist/db/tables/funnels.table.d.ts +0 -1
- package/dist/db/tables/funnels.table.js +0 -4
- package/dist/db/tables/funnels.table.js.map +1 -1
- package/dist/db/tables/page_snapshots.table.d.ts +1 -1
- package/dist/db/tables/page_snapshots.table.js +4 -4
- package/dist/db/tables/page_snapshots.table.js.map +1 -1
- package/dist/db/tables/websites.table.d.ts +2 -1
- package/dist/db/tables/websites.table.js +4 -4
- package/dist/db/tables/websites.table.js.map +1 -1
- package/dist/frontend-module.d.ts +6 -0
- package/dist/frontend-module.js +46 -0
- package/dist/frontend-module.js.map +1 -0
- package/dist/implementations/dms-marketing/index.js +0 -5
- package/dist/implementations/dms-marketing/index.js.map +1 -1
- package/dist/index.js +3 -13
- package/dist/index.js.map +1 -1
- package/dist/pages/acquisition/page.d.ts +1 -0
- package/dist/pages/acquisition/page.js +9 -4
- package/dist/pages/acquisition/page.js.map +1 -1
- package/dist/pages/blocks.d.ts +11 -2
- package/dist/pages/blocks.js +43 -5
- package/dist/pages/blocks.js.map +1 -1
- package/dist/pages/funnel/page.d.ts +1 -0
- package/dist/pages/funnel/page.js +1 -0
- package/dist/pages/funnel/page.js.map +1 -1
- package/dist/pages/funnels/page.d.ts +1 -0
- package/dist/pages/funnels/page.js +4 -2
- package/dist/pages/funnels/page.js.map +1 -1
- package/dist/pages/overview/page.d.ts +1 -0
- package/dist/pages/overview/page.js +5 -6
- package/dist/pages/overview/page.js.map +1 -1
- package/dist/pages/pages/page.d.ts +1 -0
- package/dist/pages/pages/page.js +1 -0
- package/dist/pages/pages/page.js.map +1 -1
- package/dist/pages/settings/form.js +1 -0
- package/dist/pages/settings/form.js.map +1 -1
- package/dist/pages/websites/page.d.ts +2 -3
- package/dist/pages/websites/page.js +63 -5
- package/dist/pages/websites/page.js.map +1 -1
- package/dist/routes/dashboard-tracking.js +5 -1
- package/dist/routes/dashboard-tracking.js.map +1 -1
- package/dist/routes/funnels.d.ts +2 -0
- package/dist/routes/funnels.js +6 -5
- package/dist/routes/funnels.js.map +1 -1
- package/dist/routes/public-endpoints.js +1 -1
- package/dist/routes/public-endpoints.js.map +1 -1
- package/dist/routes/snapshot.d.ts +1 -1
- package/dist/routes/snapshot.js +30 -13
- package/dist/routes/snapshot.js.map +1 -1
- package/dist/routes/stats.d.ts +10 -4
- package/dist/routes/stats.js +77 -23
- package/dist/routes/stats.js.map +1 -1
- package/dist/routes/websites.d.ts +5 -1
- package/dist/routes/websites.js +31 -13
- package/dist/routes/websites.js.map +1 -1
- package/dist/services/acquisition-report.d.ts +1 -0
- package/dist/services/acquisition-report.js +1 -0
- package/dist/services/acquisition-report.js.map +1 -1
- package/dist/services/collect.js +7 -2
- package/dist/services/collect.js.map +1 -1
- package/dist/services/dashboard-tracking.d.ts +7 -14
- package/dist/services/dashboard-tracking.js +42 -26
- package/dist/services/dashboard-tracking.js.map +1 -1
- package/dist/services/declarations.d.ts +0 -2
- package/dist/services/declarations.js +0 -11
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/declared-funnel.js +11 -99
- package/dist/services/declared-funnel.js.map +1 -1
- package/dist/services/declared-snapshot.js +8 -37
- package/dist/services/declared-snapshot.js.map +1 -1
- package/dist/services/declared-website.js +5 -46
- package/dist/services/declared-website.js.map +1 -1
- package/dist/services/funnels-table.d.ts +3 -2
- package/dist/services/funnels-table.js +9 -9
- package/dist/services/funnels-table.js.map +1 -1
- package/dist/services/heatmap.d.ts +6 -0
- package/dist/services/heatmap.js +82 -17
- package/dist/services/heatmap.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/prune.js +6 -1
- package/dist/services/prune.js.map +1 -1
- package/dist/services/realtime.d.ts +3 -0
- package/dist/services/realtime.js +49 -0
- package/dist/services/realtime.js.map +1 -0
- package/dist/services/request-body.d.ts +1 -0
- package/dist/services/request-body.js +18 -0
- package/dist/services/request-body.js.map +1 -1
- package/dist/services/sessionize.d.ts +1 -0
- package/dist/services/sessionize.js +9 -0
- package/dist/services/sessionize.js.map +1 -1
- package/dist/services/snapshots.d.ts +29 -1
- package/dist/services/snapshots.js +151 -26
- package/dist/services/snapshots.js.map +1 -1
- package/dist/services/tenant-website.d.ts +11 -0
- package/dist/services/tenant-website.js +27 -5
- package/dist/services/tenant-website.js.map +1 -1
- package/dist/services/website-report.d.ts +5 -0
- package/dist/services/website-report.js +13 -1
- package/dist/services/website-report.js.map +1 -1
- package/dist/services/workspace-scope.d.ts +31 -0
- package/dist/services/workspace-scope.js +72 -0
- package/dist/services/workspace-scope.js.map +1 -0
- package/dist/types/constants.d.ts +7 -0
- package/dist/types/constants.js +8 -1
- package/dist/types/constants.js.map +1 -1
- package/dist/types/events.d.ts +25 -25
- package/dist/types/snapshots.d.ts +29 -29
- package/dist/types/snapshots.js +1 -1
- package/dist/types/snapshots.js.map +1 -1
- package/dist/utils/funnel-steps-type.d.ts +13 -13
- package/frontend-vue/app/components/ChannelsCard.vue +19 -2
- package/frontend-vue/app/components/Context.vue +50 -376
- package/frontend-vue/app/components/FunnelBuilder.vue +28 -22
- package/frontend-vue/app/components/FunnelFigure.vue +15 -2
- package/frontend-vue/app/components/FunnelReport.vue +3 -0
- package/frontend-vue/app/components/FunnelTemplates.vue +15 -13
- package/frontend-vue/app/components/InstallGuide.vue +1 -1
- package/frontend-vue/app/components/PagePreview.vue +330 -74
- package/frontend-vue/app/components/PagesExplorer.vue +230 -85
- package/frontend-vue/app/components/TopListTabs.vue +51 -18
- package/frontend-vue/app/components/WebsiteCard.vue +394 -0
- package/frontend-vue/app/components/WebsiteCreate.vue +159 -0
- package/frontend-vue/app/components/WebsitePicker.vue +210 -0
- package/frontend-vue/app/components/WebsiteSettings.vue +539 -0
- package/frontend-vue/app/components/WorkspaceScope.vue +119 -0
- package/frontend-vue/app/composables/useContextWebsite.ts +105 -0
- package/frontend-vue/app/composables/useCopyDraft.ts +47 -0
- package/frontend-vue/app/composables/useMarketingApi.ts +92 -4
- package/frontend-vue/app/composables/useMarketingReads.ts +32 -23
- package/frontend-vue/app/composables/useMarketingRoutes.ts +25 -2
- package/frontend-vue/app/composables/useRecordCrumb.ts +17 -0
- package/frontend-vue/app/composables/useSnapshotFrame.ts +35 -1
- package/frontend-vue/app/utils/anchor-drift.ts +58 -0
- package/frontend-vue/app/utils/drift-dismissals.ts +20 -0
- package/frontend-vue/app/utils/fill-height.ts +141 -0
- package/frontend-vue/app/utils/format.ts +21 -2
- package/frontend-vue/app/utils/funnel-draft.ts +22 -0
- package/frontend-vue/app/utils/heatmap.ts +17 -0
- package/frontend-vue/app/utils/page-snapshot.ts +75 -0
- package/frontend-vue/app/utils/snapshot-options.ts +148 -0
- package/frontend-vue/app/utils/snapshotScrollRoot.ts +93 -0
- package/frontend-vue/app/utils/website-card.ts +93 -0
- package/frontend-vue/app/utils/website-purge.ts +95 -0
- package/frontend-vue/app/utils/website-settings.ts +121 -0
- package/frontend-vue/app/utils/website-state.ts +93 -0
- package/frontend-vue/app/utils/workspace-scope.ts +51 -0
- package/frontend-vue/i18n/locales/marketing-en-GB.json +112 -36
- package/frontend-vue/i18n/locales/marketing-fr-FR.json +112 -36
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/anchor-drift.test.ts +59 -0
- package/frontend-vue/tests/build-flags.test.ts +38 -0
- package/frontend-vue/tests/context-bar.test.ts +226 -0
- package/frontend-vue/tests/copy-draft.test.ts +64 -0
- package/frontend-vue/tests/drift-dismissals.test.ts +12 -0
- package/frontend-vue/tests/fill-height.test.ts +110 -0
- package/frontend-vue/tests/format.test.ts +14 -0
- package/frontend-vue/tests/funnel-draft.test.ts +37 -0
- package/frontend-vue/tests/heatmap.test.ts +25 -0
- package/frontend-vue/tests/install-guide.test.ts +25 -0
- package/frontend-vue/tests/marketing-reads.test.ts +20 -6
- package/frontend-vue/tests/page-snapshot.test.ts +62 -0
- package/frontend-vue/tests/record-crumb.test.ts +43 -0
- package/frontend-vue/tests/registration.test.ts +4 -1
- package/frontend-vue/tests/routes.test.ts +21 -0
- package/frontend-vue/tests/save-funnel.test.ts +47 -0
- package/frontend-vue/tests/server-parity.test.ts +3 -0
- package/frontend-vue/tests/snapshot-options.test.ts +148 -0
- package/frontend-vue/tests/snapshot-scroll-root.test.ts +84 -0
- package/frontend-vue/tests/stubs/frontend-module.ts +5 -1
- package/frontend-vue/tests/top-list-tabs.test.ts +158 -0
- package/frontend-vue/tests/website-card.test.ts +119 -0
- package/frontend-vue/tests/website-purge.test.ts +70 -0
- package/frontend-vue/tests/website-settings.test.ts +133 -0
- package/frontend-vue/tests/website-state.test.ts +96 -0
- package/frontend-vue/tests/workspace-scope-control.test.ts +113 -0
- package/frontend-vue/tests/workspace-scope.test.ts +55 -0
- package/package.json +8 -7
- package/skills/dms-marketing/SKILL.md +54 -12
- package/static/snapshot.js +539 -48
- package/static/tracker.js +154 -5
- package/dist/services/readiness.d.ts +0 -2
- package/dist/services/readiness.js +0 -15
- package/dist/services/readiness.js.map +0 -1
- package/frontend-vue/app/components/WebsitesGrid.vue +0 -812
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { MarketingWebsiteCard } from '../app/composables/useMarketingApi'
|
|
3
|
+
import {
|
|
4
|
+
blankRequiredFields,
|
|
5
|
+
draftAsksBeforeSaving,
|
|
6
|
+
draftDiscardsSnapshots,
|
|
7
|
+
isWebsiteDraftDirty,
|
|
8
|
+
requestWebsiteSetting,
|
|
9
|
+
takeWebsiteSetting,
|
|
10
|
+
websiteDraftOf,
|
|
11
|
+
websitePatchOf,
|
|
12
|
+
} from '../app/utils/website-settings'
|
|
13
|
+
|
|
14
|
+
function card(
|
|
15
|
+
overrides: Partial<MarketingWebsiteCard> = {},
|
|
16
|
+
): MarketingWebsiteCard {
|
|
17
|
+
return {
|
|
18
|
+
id: 'site-1',
|
|
19
|
+
name: 'Shop',
|
|
20
|
+
domain: 'shop.example.com',
|
|
21
|
+
state: 'live',
|
|
22
|
+
lastActivityAt: 1,
|
|
23
|
+
extraDomains: ['staging.example.com'],
|
|
24
|
+
snapshotsEnabled: true,
|
|
25
|
+
snapshotMaskText: false,
|
|
26
|
+
createdAt: 1,
|
|
27
|
+
sessions: 0,
|
|
28
|
+
pages: 0,
|
|
29
|
+
funnels: 0,
|
|
30
|
+
snapshots: 4,
|
|
31
|
+
sampleRate: 0.1,
|
|
32
|
+
rejected: [],
|
|
33
|
+
dashboard: false,
|
|
34
|
+
...overrides,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('the settings draft', () => {
|
|
39
|
+
it('starts from the site, a paused one with tracking off', () => {
|
|
40
|
+
expect(websiteDraftOf(card())).toEqual({
|
|
41
|
+
name: 'Shop',
|
|
42
|
+
domain: 'shop.example.com',
|
|
43
|
+
hosts: ['staging.example.com'],
|
|
44
|
+
tracking: true,
|
|
45
|
+
snapshots: true,
|
|
46
|
+
mask: false,
|
|
47
|
+
})
|
|
48
|
+
expect(websiteDraftOf(card({ state: 'paused' })).tracking).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('copies the hosts, so typing never edits the card', () => {
|
|
52
|
+
const site = card()
|
|
53
|
+
websiteDraftOf(site).hosts.push('other.example.com')
|
|
54
|
+
expect(site.extraDomains).toEqual(['staging.example.com'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('saves trimmed values, empty hosts dropped', () => {
|
|
58
|
+
expect(
|
|
59
|
+
websitePatchOf({
|
|
60
|
+
name: ' Shop ',
|
|
61
|
+
domain: ' shop.example.com',
|
|
62
|
+
hosts: [' a.example.com ', ' '],
|
|
63
|
+
tracking: false,
|
|
64
|
+
snapshots: false,
|
|
65
|
+
mask: false,
|
|
66
|
+
}),
|
|
67
|
+
).toEqual({
|
|
68
|
+
name: 'Shop',
|
|
69
|
+
domain: 'shop.example.com',
|
|
70
|
+
extraDomains: ['a.example.com'],
|
|
71
|
+
trackingEnabled: false,
|
|
72
|
+
snapshotsEnabled: false,
|
|
73
|
+
snapshotMaskText: false,
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('requires the domain and the name, domain first', () => {
|
|
78
|
+
expect(blankRequiredFields({ name: ' ', domain: '' })).toEqual([
|
|
79
|
+
'domain',
|
|
80
|
+
'name',
|
|
81
|
+
])
|
|
82
|
+
expect(blankRequiredFields({ name: 'Shop', domain: 'a.b' })).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('is dirty once a value differs, not for spaces around one', () => {
|
|
86
|
+
const site = card()
|
|
87
|
+
const draft = websiteDraftOf(site)
|
|
88
|
+
expect(isWebsiteDraftDirty(site, draft)).toBe(false)
|
|
89
|
+
expect(isWebsiteDraftDirty(site, { ...draft, name: ' Shop ' })).toBe(false)
|
|
90
|
+
expect(isWebsiteDraftDirty(site, { ...draft, tracking: false })).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('snapshot options in the modal', () => {
|
|
95
|
+
it('warns when a site holding snapshots changes them', () => {
|
|
96
|
+
const site = card()
|
|
97
|
+
const draft = websiteDraftOf(site)
|
|
98
|
+
expect(draftDiscardsSnapshots(site, draft)).toBe(false)
|
|
99
|
+
expect(draftDiscardsSnapshots(site, { ...draft, mask: true })).toBe(true)
|
|
100
|
+
expect(
|
|
101
|
+
draftDiscardsSnapshots(card({ snapshots: 0 }), { ...draft, mask: true }),
|
|
102
|
+
).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('asks before saving a change, unless it turns them on for a site with none', () => {
|
|
106
|
+
const site = card()
|
|
107
|
+
const draft = websiteDraftOf(site)
|
|
108
|
+
expect(draftAsksBeforeSaving(site, draft)).toBe(false)
|
|
109
|
+
expect(draftAsksBeforeSaving(site, { ...draft, snapshots: false })).toBe(
|
|
110
|
+
true,
|
|
111
|
+
)
|
|
112
|
+
const none = card({ snapshotsEnabled: false, snapshots: 0 })
|
|
113
|
+
expect(
|
|
114
|
+
draftAsksBeforeSaving(none, { ...websiteDraftOf(none), snapshots: true }),
|
|
115
|
+
).toBe(false)
|
|
116
|
+
// No snapshots yet, still a change of what later captures keep.
|
|
117
|
+
expect(
|
|
118
|
+
draftAsksBeforeSaving(card({ snapshots: 0 }), { ...draft, mask: true }),
|
|
119
|
+
).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('the setting a card opens the modal on', () => {
|
|
124
|
+
it('is taken once, by the modal of its own site', () => {
|
|
125
|
+
requestWebsiteSetting('site-1', 'snapshots')
|
|
126
|
+
// The modal of another site drops the request.
|
|
127
|
+
expect(takeWebsiteSetting('site-2')).toBeUndefined()
|
|
128
|
+
expect(takeWebsiteSetting('site-1')).toBeUndefined()
|
|
129
|
+
requestWebsiteSetting('site-1', 'snapshots')
|
|
130
|
+
expect(takeWebsiteSetting('site-1')).toBe('snapshots')
|
|
131
|
+
expect(takeWebsiteSetting('site-1')).toBeUndefined()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
WEBSITE_LIVE_WINDOW_MS,
|
|
4
|
+
websiteDisplayState,
|
|
5
|
+
websiteStateLine,
|
|
6
|
+
websiteStateShort,
|
|
7
|
+
} from '../app/utils/website-state'
|
|
8
|
+
|
|
9
|
+
const NOW = Date.UTC(2026, 9, 18, 12)
|
|
10
|
+
const MINUTE = 60_000
|
|
11
|
+
const HOUR = 60 * MINUTE
|
|
12
|
+
|
|
13
|
+
const t = (key: string, named: Record<string, unknown> = {}) =>
|
|
14
|
+
named.ago ? `${key} ${String(named.ago)}` : key
|
|
15
|
+
|
|
16
|
+
describe('a website reads live', () => {
|
|
17
|
+
it('only while its last event is recent', () => {
|
|
18
|
+
const recent = { state: 'live' as const, lastActivityAt: NOW - MINUTE }
|
|
19
|
+
expect(websiteDisplayState(recent, NOW)).toBe('live')
|
|
20
|
+
const edge = {
|
|
21
|
+
state: 'live' as const,
|
|
22
|
+
lastActivityAt: NOW - WEBSITE_LIVE_WINDOW_MS,
|
|
23
|
+
}
|
|
24
|
+
expect(websiteDisplayState(edge, NOW)).toBe('live')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// QA m10: "Live · last event 7 hours ago" with a green dot.
|
|
28
|
+
it('as idle, last event time kept, once it went quiet', () => {
|
|
29
|
+
const quiet = { state: 'live' as const, lastActivityAt: NOW - 7 * HOUR }
|
|
30
|
+
expect(websiteDisplayState(quiet, NOW)).toBe('idle')
|
|
31
|
+
const line = websiteStateLine(quiet, NOW, t, 'en-GB')
|
|
32
|
+
expect(line.startsWith('page.marketing.context.idle ')).toBe(true)
|
|
33
|
+
expect(line).toContain('7')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('keeps the other states as the backend sends them', () => {
|
|
37
|
+
for (const state of ['imported', 'waiting', 'paused'] as const) {
|
|
38
|
+
const site = {
|
|
39
|
+
state,
|
|
40
|
+
lastActivityAt: state === 'waiting' ? null : NOW - 7 * HOUR,
|
|
41
|
+
}
|
|
42
|
+
expect(websiteDisplayState(site, NOW)).toBe(state)
|
|
43
|
+
}
|
|
44
|
+
expect(
|
|
45
|
+
websiteStateLine(
|
|
46
|
+
{ state: 'paused', lastActivityAt: NOW },
|
|
47
|
+
NOW,
|
|
48
|
+
t,
|
|
49
|
+
'en-GB',
|
|
50
|
+
),
|
|
51
|
+
).toBe('page.marketing.context.state.paused')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('the short state of a website', () => {
|
|
56
|
+
it('is the age of its last event', () => {
|
|
57
|
+
const recent = { state: 'live' as const, lastActivityAt: NOW - 12_000 }
|
|
58
|
+
expect(websiteStateShort(recent, NOW, t, 'en-GB')).toBe('12 sec ago')
|
|
59
|
+
// Intl spaces it with a no-break space.
|
|
60
|
+
expect(websiteStateShort(recent, NOW, t, 'fr-FR')).toMatch(/^il y a 12\ss$/)
|
|
61
|
+
const quiet = { state: 'live' as const, lastActivityAt: NOW - 7 * HOUR }
|
|
62
|
+
expect(websiteStateShort(quiet, NOW, t, 'en-GB')).toBe('7 hr ago')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// QA: "● dans 3 s" right after events arrived, the server's clock ahead.
|
|
66
|
+
it('reads just now under 5 seconds, never a time ahead', () => {
|
|
67
|
+
for (const lastActivityAt of [NOW + 3_000, NOW, NOW - 4_000]) {
|
|
68
|
+
const site = { state: 'live' as const, lastActivityAt }
|
|
69
|
+
expect(websiteStateShort(site, NOW, t, 'fr-FR')).toBe(
|
|
70
|
+
'page.marketing.context.just_now',
|
|
71
|
+
)
|
|
72
|
+
expect(websiteStateLine(site, NOW, t, 'fr-FR')).toBe(
|
|
73
|
+
'page.marketing.context.live page.marketing.context.just_now',
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('is the state in a word without a last event', () => {
|
|
79
|
+
expect(
|
|
80
|
+
websiteStateShort(
|
|
81
|
+
{ state: 'waiting', lastActivityAt: null },
|
|
82
|
+
NOW,
|
|
83
|
+
t,
|
|
84
|
+
'en-GB',
|
|
85
|
+
),
|
|
86
|
+
).toBe('page.marketing.context.state_short.waiting')
|
|
87
|
+
expect(
|
|
88
|
+
websiteStateShort(
|
|
89
|
+
{ state: 'paused', lastActivityAt: NOW },
|
|
90
|
+
NOW,
|
|
91
|
+
t,
|
|
92
|
+
'en-GB',
|
|
93
|
+
),
|
|
94
|
+
).toBe('page.marketing.context.state_short.paused')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { createSSRApp, defineComponent, h } from 'vue'
|
|
4
|
+
import { renderToString } from 'vue/server-renderer'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import WorkspaceScope from '../app/components/WorkspaceScope.vue'
|
|
7
|
+
import { ALL_WORKSPACES } from '../app/utils/workspace-scope'
|
|
8
|
+
|
|
9
|
+
const EXPLORER = join(process.cwd(), 'app', 'components', 'PagesExplorer.vue')
|
|
10
|
+
|
|
11
|
+
const OWN = { id: 'tenant-a', name: 'Alpha', current: true }
|
|
12
|
+
const OTHER = { id: 'tenant-b', name: 'Beta', current: false }
|
|
13
|
+
|
|
14
|
+
/** The Nuxt UI parts, drawn as their slot; the tooltip keeps its text. */
|
|
15
|
+
const UI_STUBS = {
|
|
16
|
+
UDropdownMenu: defineComponent({
|
|
17
|
+
setup:
|
|
18
|
+
(_props, { slots }) =>
|
|
19
|
+
() =>
|
|
20
|
+
h('div', slots.default?.()),
|
|
21
|
+
}),
|
|
22
|
+
UButton: defineComponent({
|
|
23
|
+
inheritAttrs: false,
|
|
24
|
+
setup:
|
|
25
|
+
(_props, { slots, attrs }) =>
|
|
26
|
+
() =>
|
|
27
|
+
h(
|
|
28
|
+
'button',
|
|
29
|
+
{
|
|
30
|
+
'aria-label': attrs['aria-label'],
|
|
31
|
+
'aria-describedby': attrs['aria-describedby'],
|
|
32
|
+
},
|
|
33
|
+
slots.default?.(),
|
|
34
|
+
),
|
|
35
|
+
}),
|
|
36
|
+
UTooltip: defineComponent({
|
|
37
|
+
props: { text: String },
|
|
38
|
+
setup:
|
|
39
|
+
(props, { slots }) =>
|
|
40
|
+
() =>
|
|
41
|
+
h('div', { 'data-tooltip': props.text }, slots.default?.()),
|
|
42
|
+
}),
|
|
43
|
+
UIcon: defineComponent({ setup: () => () => h('i') }),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function renderScope(modelValue: string): Promise<string> {
|
|
47
|
+
const app = createSSRApp(
|
|
48
|
+
defineComponent({
|
|
49
|
+
setup: () => () =>
|
|
50
|
+
h(WorkspaceScope, { workspaces: [OWN, OTHER], modelValue }),
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
for (const [name, component] of Object.entries(UI_STUBS)) {
|
|
54
|
+
app.component(name, component)
|
|
55
|
+
}
|
|
56
|
+
return renderToString(app)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('the workspace selector', () => {
|
|
60
|
+
it('says nothing more on the own workspace', async () => {
|
|
61
|
+
const html = await renderScope('')
|
|
62
|
+
expect(html).toContain('page.marketing.pages.workspace.switch')
|
|
63
|
+
expect(html).not.toContain('page.marketing.pages.workspace.note_label')
|
|
64
|
+
expect(html).not.toContain('aria-describedby')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('puts the read-only note in a focusable tooltip, never as a line', async () => {
|
|
68
|
+
for (const [value, note] of [
|
|
69
|
+
[ALL_WORKSPACES, 'page.marketing.pages.workspace.combined_note'],
|
|
70
|
+
[OTHER.id, 'page.marketing.pages.workspace.other_note'],
|
|
71
|
+
]) {
|
|
72
|
+
const html = await renderScope(value!)
|
|
73
|
+
expect(html).not.toMatch(/<p\b/)
|
|
74
|
+
expect(html).toContain(`data-tooltip="${note}"`)
|
|
75
|
+
const icon = html.match(
|
|
76
|
+
/<span[^>]*tabindex="0"[^>]*aria-label="page\.marketing\.pages\.workspace\.note_label"[^>]*>/,
|
|
77
|
+
)?.[0]
|
|
78
|
+
expect(icon).toBeDefined()
|
|
79
|
+
const noteId = icon?.match(/aria-describedby="([^"]+)"/)?.[1]
|
|
80
|
+
expect(noteId).toBeTruthy()
|
|
81
|
+
// The selector and the icon both point at the note's text.
|
|
82
|
+
expect(html).toContain(`id="${noteId}" class="sr-only">${note}<`)
|
|
83
|
+
expect(html).toContain(
|
|
84
|
+
`<button aria-label="page.marketing.pages.workspace.switch" aria-describedby="${noteId}"`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('sits in the pages panel, above its search', () => {
|
|
90
|
+
const source = readFileSync(EXPLORER, 'utf8')
|
|
91
|
+
const template = source.slice(source.indexOf('<template>'))
|
|
92
|
+
const panel = template.indexOf('class="dms-card flex min-w-0 flex-col')
|
|
93
|
+
const scope = template.indexOf('<DmsMarketingWorkspaceScope')
|
|
94
|
+
const search = template.indexOf('v-model="search"')
|
|
95
|
+
expect(panel).toBeGreaterThan(-1)
|
|
96
|
+
expect(scope).toBeGreaterThan(panel)
|
|
97
|
+
expect(search).toBeGreaterThan(scope)
|
|
98
|
+
// Only once: no copy of it left above the panes.
|
|
99
|
+
expect(template.lastIndexOf('<DmsMarketingWorkspaceScope')).toBe(scope)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('drops the selected row read in another workspace', () => {
|
|
103
|
+
// The header's views come from the row read for the scope on screen:
|
|
104
|
+
// a workspace switch never leaves the previous one's views in it.
|
|
105
|
+
const source = readFileSync(EXPLORER, 'utf8')
|
|
106
|
+
expect(source).toMatch(
|
|
107
|
+
/const readScope = computed\(\(\) =>\s*JSON\.stringify\(\[workspace\.value, pagesPeriod\.value\]\)/,
|
|
108
|
+
)
|
|
109
|
+
expect(source).toMatch(
|
|
110
|
+
/watch\(readScope, \(\) => \{\s*selectedPage\.value = undefined/,
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
ALL_WORKSPACES,
|
|
4
|
+
effectiveWorkspace,
|
|
5
|
+
rememberedWorkspace,
|
|
6
|
+
rememberWorkspace,
|
|
7
|
+
} from '../app/utils/workspace-scope'
|
|
8
|
+
|
|
9
|
+
const OWN = { id: 'tenant-a', name: 'Alpha', current: true }
|
|
10
|
+
const OTHER = { id: 'tenant-b', name: 'Beta', current: false }
|
|
11
|
+
|
|
12
|
+
describe('the heatmaps workspace scope', () => {
|
|
13
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
14
|
+
|
|
15
|
+
it('reads the own workspace without a selector: one workspace, or none offered', () => {
|
|
16
|
+
expect(effectiveWorkspace(ALL_WORKSPACES, [])).toBe('')
|
|
17
|
+
expect(effectiveWorkspace(OTHER.id, [OWN])).toBe('')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('applies what the selector offers, and nothing else', () => {
|
|
21
|
+
const options = [OWN, OTHER]
|
|
22
|
+
expect(effectiveWorkspace(ALL_WORKSPACES, options)).toBe(ALL_WORKSPACES)
|
|
23
|
+
expect(effectiveWorkspace(OTHER.id, options)).toBe(OTHER.id)
|
|
24
|
+
expect(effectiveWorkspace(OWN.id, options)).toBe('')
|
|
25
|
+
expect(effectiveWorkspace('tenant-gone', options)).toBe('')
|
|
26
|
+
expect(effectiveWorkspace('', options)).toBe('')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('is remembered per browser, the own workspace clearing it', () => {
|
|
30
|
+
const store = new Map<string, string>()
|
|
31
|
+
vi.stubGlobal('localStorage', {
|
|
32
|
+
getItem: (key: string) => store.get(key) ?? null,
|
|
33
|
+
setItem: (key: string, value: string) => store.set(key, value),
|
|
34
|
+
removeItem: (key: string) => store.delete(key),
|
|
35
|
+
})
|
|
36
|
+
expect(rememberedWorkspace()).toBe('')
|
|
37
|
+
rememberWorkspace(ALL_WORKSPACES)
|
|
38
|
+
expect(rememberedWorkspace()).toBe(ALL_WORKSPACES)
|
|
39
|
+
rememberWorkspace('')
|
|
40
|
+
expect(rememberedWorkspace()).toBe('')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('survives a blocked storage', () => {
|
|
44
|
+
vi.stubGlobal('localStorage', {
|
|
45
|
+
getItem: () => {
|
|
46
|
+
throw new Error('blocked')
|
|
47
|
+
},
|
|
48
|
+
setItem: () => {
|
|
49
|
+
throw new Error('blocked')
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
expect(rememberedWorkspace()).toBe('')
|
|
53
|
+
expect(() => rememberWorkspace(ALL_WORKSPACES)).not.toThrow()
|
|
54
|
+
})
|
|
55
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-marketing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"ab-testing",
|
|
@@ -47,10 +47,10 @@
|
|
|
47
47
|
"@antelopejs/interface-api": ">=0.0.15 <1.0.0",
|
|
48
48
|
"@antelopejs/interface-api-util": ">=0.1.3 <1.0.0",
|
|
49
49
|
"@antelopejs/interface-core": ">=0.2.4 <1.0.0",
|
|
50
|
-
"@antelopejs/interface-data-api": ">=0.
|
|
51
|
-
"@antelopejs/interface-database": ">=0.1.
|
|
52
|
-
"@antelopejs/interface-database-decorators": ">=0.1.
|
|
53
|
-
"@antelopejs/interface-dms": ">=0.
|
|
50
|
+
"@antelopejs/interface-data-api": ">=0.4.0 <1.0.0",
|
|
51
|
+
"@antelopejs/interface-database": ">=0.1.11 <1.0.0",
|
|
52
|
+
"@antelopejs/interface-database-decorators": ">=0.1.10 <1.0.0",
|
|
53
|
+
"@antelopejs/interface-dms": ">=0.9.0 <1.0.0",
|
|
54
54
|
"@antelopejs/interface-dms-marketing": ">=0.5.0 <0.6.0",
|
|
55
55
|
"defu": "^6.1.4",
|
|
56
56
|
"maxmind": "^5.0.7",
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
"zod": "~3.24.4"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@antelopejs/core": ">=1.
|
|
63
|
+
"@antelopejs/core": ">=1.17.1 <2",
|
|
64
64
|
"@antelopejs/interface-mongodb": ">=0.0.6 <1.0.0",
|
|
65
|
-
"@antelopejs/mongodb": "1.
|
|
65
|
+
"@antelopejs/mongodb": "1.5.0",
|
|
66
66
|
"@antelopejs/tooling-configs": ">=0.0.9 <1.0.0",
|
|
67
67
|
"@types/node": "^22.19.15",
|
|
68
68
|
"@types/sanitize-html": "^2.16.2",
|
|
@@ -92,6 +92,7 @@
|
|
|
92
92
|
"./skills"
|
|
93
93
|
]
|
|
94
94
|
},
|
|
95
|
+
"dmsFrontend": "dist/frontend-module.js",
|
|
95
96
|
"scripts": {
|
|
96
97
|
"build": "pnpm --filter @antelopejs/interface-dms-marketing build && rimraf dist && tsc",
|
|
97
98
|
"test": "pnpm run test:backend && pnpm run test:layer",
|
|
@@ -16,15 +16,27 @@ https://dms.antelopejs.com/docs/modules/marketing (`architecture` holds the full
|
|
|
16
16
|
## Setup
|
|
17
17
|
|
|
18
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.
|
|
19
|
+
0.4 is the DMS 0.8 line. Each analytics page declares `static toolbar = marketingToolbar()`: the DMS
|
|
20
|
+
`pageToolbar()` (website picker leading, Direct / Pause, period); it needs a DMS shipping `pageToolbar()` child options. Its period uses the DMS short codes
|
|
21
|
+
(1D / 7D / 30D / 90D); the custom range's calendar segment is icon-only, with its accessible name, from the DMS.
|
|
22
|
+
The comparison is the toolbar's default dropdown (vs previous period, vs previous year, none); the routes read the `compareFrom`/`compareTo` it sends, and "none" sends none, so no change is shown.
|
|
23
|
+
- Live: the analytics pages declare `live` (`CONTEXT_PAGE_LIVE`, 15 s, 60 s past 8 days). Ingestion publishes
|
|
24
|
+
`marketing.events` (`{ count }`, batched 1/s) once a batch is in the rollup; every stock block carries it as
|
|
25
|
+
`realtimeTopic`, tables add `liveHold`, the Overview's KPI cards (no topic) are read by the bar on arrivals.
|
|
26
|
+
No module clock: Pause freezes every block. DMS page topics are not tenant-scoped yet: other tenants'
|
|
27
|
+
arrivals wake the pages and count in the paused "N new".
|
|
20
28
|
- `ajs project modules add @antelopejs/dms-marketing`. Options: `trackerEnabled`, `rawEventsRetention`,
|
|
21
|
-
`statisticsRetention`, `snapshotRetention`, `sessionIdleTimeout` (all ms), `heatmapSampleRate` (0-1),
|
|
29
|
+
`statisticsRetention`, `snapshotRetention`, `sessionIdleTimeout` (all ms), `heatmapSampleRate` (0-1), `snapshotMaxBytes`,
|
|
22
30
|
`visitorHashSecret` (set it when running several instances), `geoipDatabasePath` (.mmdb, opt-in),
|
|
23
31
|
`dashboardTracking` (see below).
|
|
24
32
|
The Settings page overrides switch, retentions and sampling at runtime for the whole deployment.
|
|
25
33
|
- Behind a proxy set the DMS `auth.trustedProxies`; the old `trustedProxyHops` option is gone.
|
|
26
34
|
- One website row per tracked site (Websites page or `POST /api/marketing/websites {name, domain, extraDomains?}`).
|
|
27
35
|
Its `domain` gates ingestion: the beacon's Origin must be that apex, a subdomain or an extra host.
|
|
36
|
+
- The Websites page is a DMS TableView (cards display, plain grid on offer) over
|
|
37
|
+
`/api/marketing/tables/websites`, whose list answers each row with its card; "Add a website" and a site's
|
|
38
|
+
settings open in modals. Link a site's settings with `/modules/marketing/websites?record=<id>`
|
|
39
|
+
(`&setting=snapshots` scrolls to the snapshot switch).
|
|
28
40
|
|
|
29
41
|
## Visitor site
|
|
30
42
|
|
|
@@ -37,34 +49,63 @@ window.dmsMarketing?.track("signup_submitted", { plan: "pro" }); // name <= 50 c
|
|
|
37
49
|
const arm = await window.dmsMarketing?.variation("hero-cta"); // null = render the control
|
|
38
50
|
```
|
|
39
51
|
|
|
52
|
+
- A site's data is purged, the site kept, with `POST /api/marketing/websites/:id/purge {scope: "heatmaps"|"all"}`
|
|
53
|
+
(tenant owner; settings modal, Danger zone): heatmaps = clicks, scroll depths and snapshots; all = every
|
|
54
|
+
event, session, rollup, snapshot and import. Funnels stay, their results start again from zero.
|
|
40
55
|
- The dashboard origin forwards the scripts and beacons because the module declares them `publicEndpoints`
|
|
41
56
|
(renderer 0.6 refuses other cross-origin writes); the backend origin works too.
|
|
42
57
|
- Never `type="module"` (breaks `document.currentScript`). Always guard with `?.`.
|
|
43
|
-
- Snapshots (heatmap backdrops) are opt-in per site
|
|
58
|
+
- Snapshots (heatmap backdrops) are opt-in per site (Websites page, a site's settings modal, General tab, next to Collect data); mask with
|
|
59
|
+
`data-dms-marketing-mask` / `data-dms-marketing-block`. Captures drop transient overlays on their own (top
|
|
60
|
+
layer, fixed/floating role=dialog/menu/listbox/tooltip, fixed toasts and toast viewports); block anything else
|
|
61
|
+
per element. Captures inline only the CSS rules matching the page
|
|
62
|
+
and upload gzipped; cap `snapshotMaxBytes` (default 1 MiB compressed, 64 KiB–8 MiB). One capture per
|
|
63
|
+
(path, layout) a day; a page without a snapshot (reset, option change, retention) is captured at its next
|
|
64
|
+
sampled visit (within 5 min across instances); a refused upload does not count as a capture.
|
|
65
|
+
The pages list shows each path's snapshot age ("No snapshot" when none) from one read per site. The
|
|
66
|
+
preview names the page captured: origin + path, a query string or fragment is never stored. It warns when
|
|
67
|
+
≥ 10 % (and ≥ 5) of the anchored clicks target elements absent from the snapshot: the page changed, reset it.
|
|
68
|
+
- App shells whose document does not scroll (an inner panel does): the tracker and the capture detect the
|
|
69
|
+
largest visible vertical scroller covering half the window; put `data-marketing-scroll-root` on the
|
|
70
|
+
container to name it explicitly. Scroll depth, click `dy` and the backdrop then cover the whole content.
|
|
44
71
|
|
|
45
72
|
## Tracking the dashboard itself
|
|
46
73
|
|
|
47
74
|
For a demo, a playground or a back office measuring its own admin, no tag to write:
|
|
48
75
|
|
|
49
76
|
```typescript
|
|
50
|
-
config: { dashboardTracking: { enabled: true, websiteName: "Dashboard", excludePaths: ["/settings/**"]
|
|
77
|
+
config: { dashboardTracking: { enabled: true, websiteName: "Dashboard", excludePaths: ["/settings/**"],
|
|
78
|
+
snapshots: true } } // snapshots: optional, off by default
|
|
51
79
|
```
|
|
52
80
|
|
|
53
81
|
- Off by default. On, the module's frontend loads the tracker on every signed-in dashboard page and
|
|
54
82
|
`POST /api/marketing/dashboard-tracking` creates one website per tenant on first use (domain = the
|
|
55
|
-
dashboard's host; later hosts join `extraDomains`). Rename
|
|
83
|
+
dashboard's host; later hosts join `extraDomains`). Rename or pause it like any site; it is never
|
|
84
|
+
deleted (`DELETE` answers 409): purge its data instead.
|
|
85
|
+
- `snapshots: true` provisions it with page snapshots on (text masked) for heatmap backdrops. Config owns the
|
|
86
|
+
switch until a person changes it on the Websites page (`snapshotsSetBy: "owner"`): never re-enabled after.
|
|
56
87
|
- `/auth/**` is never counted; signing out pauses the tag, a workspace switch retargets it. SPA navigations
|
|
57
88
|
count. Restart `ajs dms dev` / rebuild the frontend after toggling the option.
|
|
89
|
+
- The module area (`/modules`, `/modules/store`, `/modules/<module>/**`) is never counted either, not
|
|
90
|
+
configurable: dashboard tracking measures the owner's product, not shipped modules. Collect/snapshot drop
|
|
91
|
+
it server-side for the dashboard's website.
|
|
92
|
+
- Platform owners (`users.owner`) on their own dashboard website can read other workspaces' dashboard
|
|
93
|
+
heatmaps: `GET /api/marketing/stats/workspaces?website=<own dashboard id>` lists the DMS core tenants
|
|
94
|
+
with a dashboard site (empty for anyone else or with fewer than 2), and `stats/pages|heatmap|scroll|snapshot`
|
|
95
|
+
take `workspace=<tenantId>|all` (merged per tenant; 404 for non platform owners or a customer site).
|
|
96
|
+
Snapshots outside the caller's workspace are text-masked server-side (`masked: true`). UI: `?workspace=`; the
|
|
97
|
+
selector (`WorkspaceScope.vue`) sits at the top of the pages panel, above the search, its read-only note in an
|
|
98
|
+
info tooltip (`aria-describedby`); the workspaces are read with the page (SSR, awaited) so the panel never grows.
|
|
58
99
|
- Tracker attributes behind it, usable on any site: `data-exclude-paths="/x,/x/*,/x/**"`, and a live
|
|
59
100
|
`data-website-id` (changing it retargets, removing it pauses).
|
|
60
101
|
|
|
61
102
|
## Content from code
|
|
62
103
|
|
|
63
|
-
Never write the module's tables. From a
|
|
64
|
-
`
|
|
104
|
+
Never write the module's tables. From a `Hook.TENANT_BEING_PROVISIONED` handler (once per tenant, the
|
|
105
|
+
`default` one included; never `start()` or DATABASE_INITIALIZED) use `@antelopejs/interface-dms-marketing`:
|
|
65
106
|
|
|
66
107
|
```typescript
|
|
67
|
-
|
|
108
|
+
RegisterHook(Hook.TENANT_BEING_PROVISIONED, async ({ tenantId }) => {
|
|
68
109
|
await EnsureWebsite({ tenantId, id: "shop-site", name: "Shop", domain: "shop.example.com",
|
|
69
110
|
snapshotsEnabled: true }); // id = data-website-id, global
|
|
70
111
|
await EnsureFunnel({ tenantId, id: "shop-quote", websiteId: "shop-site", name: "Quote",
|
|
@@ -75,11 +116,12 @@ await EnsurePageSnapshot({ tenantId, websiteId: "shop-site", url: "https://shop.
|
|
|
75
116
|
html, viewport: { width: 1280, height: 800 }, document: { width: 1280, height: 2600 } });
|
|
76
117
|
await ImportAnalytics({ tenantId, importId: "shop-history", websiteId: "shop-site", sessions: [
|
|
77
118
|
{ visitor: "v1", userAgent, country: "BE", events: [{ kind: "pageview", at: "2026-10-01T09:00:00Z", url }] }] });
|
|
119
|
+
});
|
|
78
120
|
```
|
|
79
121
|
|
|
80
|
-
- Ensure*
|
|
81
|
-
|
|
82
|
-
|
|
122
|
+
- Ensure* write once, from `Hook.TENANT_BEING_PROVISIONED`: `created`, or `kept` for an id that already exists
|
|
123
|
+
(no hash, no refresh); delete a record in the dashboard to re-take it. Dashboard rules apply (hostnames,
|
|
124
|
+
1-10 steps, 2-8 arms); a split is created draft, running (one open run) or stopped (one closed run).
|
|
83
125
|
- ImportAnalytics replays through the live collect pipeline with trusted timestamps (same sessions,
|
|
84
126
|
raw events and daily rows); once per importId + website, later calls `in_progress` (first still replaying, counts not final),
|
|
85
127
|
then `skipped` (or `failed`); ≤ 10,000 sessions and
|
|
@@ -87,7 +129,7 @@ await ImportAnalytics({ tenantId, importId: "shop-history", websiteId: "shop-sit
|
|
|
87
129
|
draft splits. `startedAt` on a running split makes imported exposures count.
|
|
88
130
|
- EnsurePageSnapshot: the heatmap backdrop of a page the tracker cannot capture (not online, imported
|
|
89
131
|
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
|
|
132
|
+
capture (a `<style>` with `<` before a letter, `!`, `/` or `?` is dropped: CSS-escape that character); needs `snapshotsEnabled`; a live capture replaces it; an existing snapshot of that page and layout is `kept`.
|
|
91
133
|
- `tenantId` is required on every content call: no default. Full contract: the interface package's
|
|
92
134
|
`docs/1.index.md`.
|
|
93
135
|
|