@antelopejs/dms-marketing 0.6.1 → 0.7.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.
Files changed (181) hide show
  1. package/dist/config.d.ts +3 -0
  2. package/dist/config.js +11 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/data-api/websites.d.ts +10 -4
  5. package/dist/data-api/websites.js +79 -3
  6. package/dist/data-api/websites.js.map +1 -1
  7. package/dist/db/models/page_snapshots.model.d.ts +2 -0
  8. package/dist/db/models/page_snapshots.model.js +6 -0
  9. package/dist/db/models/page_snapshots.model.js.map +1 -1
  10. package/dist/db/models/websites.model.d.ts +1 -0
  11. package/dist/db/models/websites.model.js +9 -0
  12. package/dist/db/models/websites.model.js.map +1 -1
  13. package/dist/db/tables/page_snapshots.table.d.ts +1 -0
  14. package/dist/db/tables/page_snapshots.table.js +4 -0
  15. package/dist/db/tables/page_snapshots.table.js.map +1 -1
  16. package/dist/db/tables/websites.table.d.ts +2 -0
  17. package/dist/db/tables/websites.table.js +4 -0
  18. package/dist/db/tables/websites.table.js.map +1 -1
  19. package/dist/frontend-module.d.ts +6 -0
  20. package/dist/frontend-module.js +46 -0
  21. package/dist/frontend-module.js.map +1 -0
  22. package/dist/index.js +3 -11
  23. package/dist/index.js.map +1 -1
  24. package/dist/pages/acquisition/page.d.ts +1 -0
  25. package/dist/pages/acquisition/page.js +9 -4
  26. package/dist/pages/acquisition/page.js.map +1 -1
  27. package/dist/pages/blocks.d.ts +11 -2
  28. package/dist/pages/blocks.js +43 -5
  29. package/dist/pages/blocks.js.map +1 -1
  30. package/dist/pages/funnel/page.d.ts +1 -0
  31. package/dist/pages/funnel/page.js +1 -0
  32. package/dist/pages/funnel/page.js.map +1 -1
  33. package/dist/pages/funnels/page.d.ts +1 -0
  34. package/dist/pages/funnels/page.js +4 -2
  35. package/dist/pages/funnels/page.js.map +1 -1
  36. package/dist/pages/overview/page.d.ts +1 -0
  37. package/dist/pages/overview/page.js +5 -6
  38. package/dist/pages/overview/page.js.map +1 -1
  39. package/dist/pages/pages/page.d.ts +1 -0
  40. package/dist/pages/pages/page.js +1 -0
  41. package/dist/pages/pages/page.js.map +1 -1
  42. package/dist/pages/settings/form.js +1 -0
  43. package/dist/pages/settings/form.js.map +1 -1
  44. package/dist/pages/websites/page.d.ts +2 -3
  45. package/dist/pages/websites/page.js +63 -5
  46. package/dist/pages/websites/page.js.map +1 -1
  47. package/dist/routes/dashboard-tracking.js +5 -1
  48. package/dist/routes/dashboard-tracking.js.map +1 -1
  49. package/dist/routes/funnels.d.ts +2 -0
  50. package/dist/routes/funnels.js +6 -5
  51. package/dist/routes/funnels.js.map +1 -1
  52. package/dist/routes/public-endpoints.js +1 -1
  53. package/dist/routes/public-endpoints.js.map +1 -1
  54. package/dist/routes/snapshot.d.ts +1 -1
  55. package/dist/routes/snapshot.js +30 -13
  56. package/dist/routes/snapshot.js.map +1 -1
  57. package/dist/routes/stats.d.ts +10 -4
  58. package/dist/routes/stats.js +77 -23
  59. package/dist/routes/stats.js.map +1 -1
  60. package/dist/routes/websites.d.ts +5 -1
  61. package/dist/routes/websites.js +31 -13
  62. package/dist/routes/websites.js.map +1 -1
  63. package/dist/services/acquisition-report.d.ts +1 -0
  64. package/dist/services/acquisition-report.js +1 -0
  65. package/dist/services/acquisition-report.js.map +1 -1
  66. package/dist/services/collect.js +7 -2
  67. package/dist/services/collect.js.map +1 -1
  68. package/dist/services/dashboard-tracking.d.ts +7 -14
  69. package/dist/services/dashboard-tracking.js +42 -26
  70. package/dist/services/dashboard-tracking.js.map +1 -1
  71. package/dist/services/funnels-table.d.ts +3 -2
  72. package/dist/services/funnels-table.js +9 -9
  73. package/dist/services/funnels-table.js.map +1 -1
  74. package/dist/services/heatmap.d.ts +6 -0
  75. package/dist/services/heatmap.js +82 -17
  76. package/dist/services/heatmap.js.map +1 -1
  77. package/dist/services/index.d.ts +1 -0
  78. package/dist/services/index.js +1 -0
  79. package/dist/services/index.js.map +1 -1
  80. package/dist/services/prune.js +6 -1
  81. package/dist/services/prune.js.map +1 -1
  82. package/dist/services/realtime.d.ts +3 -0
  83. package/dist/services/realtime.js +49 -0
  84. package/dist/services/realtime.js.map +1 -0
  85. package/dist/services/request-body.d.ts +1 -0
  86. package/dist/services/request-body.js +18 -0
  87. package/dist/services/request-body.js.map +1 -1
  88. package/dist/services/sessionize.d.ts +1 -0
  89. package/dist/services/sessionize.js +9 -0
  90. package/dist/services/sessionize.js.map +1 -1
  91. package/dist/services/snapshots.d.ts +28 -1
  92. package/dist/services/snapshots.js +147 -26
  93. package/dist/services/snapshots.js.map +1 -1
  94. package/dist/services/tenant-website.d.ts +11 -0
  95. package/dist/services/tenant-website.js +27 -5
  96. package/dist/services/tenant-website.js.map +1 -1
  97. package/dist/services/website-report.d.ts +5 -0
  98. package/dist/services/website-report.js +13 -1
  99. package/dist/services/website-report.js.map +1 -1
  100. package/dist/services/workspace-scope.d.ts +31 -0
  101. package/dist/services/workspace-scope.js +72 -0
  102. package/dist/services/workspace-scope.js.map +1 -0
  103. package/dist/types/constants.d.ts +7 -0
  104. package/dist/types/constants.js +8 -1
  105. package/dist/types/constants.js.map +1 -1
  106. package/dist/types/events.d.ts +25 -25
  107. package/dist/types/snapshots.d.ts +29 -29
  108. package/dist/types/snapshots.js +1 -1
  109. package/dist/types/snapshots.js.map +1 -1
  110. package/dist/utils/funnel-steps-type.d.ts +13 -13
  111. package/frontend-vue/app/components/ChannelsCard.vue +19 -2
  112. package/frontend-vue/app/components/Context.vue +50 -376
  113. package/frontend-vue/app/components/FunnelBuilder.vue +28 -22
  114. package/frontend-vue/app/components/FunnelFigure.vue +15 -2
  115. package/frontend-vue/app/components/FunnelReport.vue +3 -0
  116. package/frontend-vue/app/components/FunnelTemplates.vue +15 -13
  117. package/frontend-vue/app/components/InstallGuide.vue +1 -1
  118. package/frontend-vue/app/components/PagePreview.vue +330 -74
  119. package/frontend-vue/app/components/PagesExplorer.vue +230 -85
  120. package/frontend-vue/app/components/TopListTabs.vue +51 -18
  121. package/frontend-vue/app/components/WebsiteCard.vue +394 -0
  122. package/frontend-vue/app/components/WebsiteCreate.vue +159 -0
  123. package/frontend-vue/app/components/WebsitePicker.vue +210 -0
  124. package/frontend-vue/app/components/WebsiteSettings.vue +539 -0
  125. package/frontend-vue/app/components/WorkspaceScope.vue +119 -0
  126. package/frontend-vue/app/composables/useContextWebsite.ts +105 -0
  127. package/frontend-vue/app/composables/useCopyDraft.ts +47 -0
  128. package/frontend-vue/app/composables/useMarketingApi.ts +92 -4
  129. package/frontend-vue/app/composables/useMarketingReads.ts +32 -23
  130. package/frontend-vue/app/composables/useMarketingRoutes.ts +25 -2
  131. package/frontend-vue/app/composables/useRecordCrumb.ts +17 -0
  132. package/frontend-vue/app/composables/useSnapshotFrame.ts +35 -1
  133. package/frontend-vue/app/utils/anchor-drift.ts +58 -0
  134. package/frontend-vue/app/utils/drift-dismissals.ts +20 -0
  135. package/frontend-vue/app/utils/fill-height.ts +141 -0
  136. package/frontend-vue/app/utils/format.ts +21 -2
  137. package/frontend-vue/app/utils/funnel-draft.ts +22 -0
  138. package/frontend-vue/app/utils/heatmap.ts +17 -0
  139. package/frontend-vue/app/utils/page-snapshot.ts +75 -0
  140. package/frontend-vue/app/utils/snapshot-options.ts +148 -0
  141. package/frontend-vue/app/utils/snapshotScrollRoot.ts +93 -0
  142. package/frontend-vue/app/utils/website-card.ts +93 -0
  143. package/frontend-vue/app/utils/website-purge.ts +95 -0
  144. package/frontend-vue/app/utils/website-settings.ts +121 -0
  145. package/frontend-vue/app/utils/website-state.ts +93 -0
  146. package/frontend-vue/app/utils/workspace-scope.ts +51 -0
  147. package/frontend-vue/i18n/locales/marketing-en-GB.json +112 -36
  148. package/frontend-vue/i18n/locales/marketing-fr-FR.json +112 -36
  149. package/frontend-vue/package.json +1 -1
  150. package/frontend-vue/tests/anchor-drift.test.ts +59 -0
  151. package/frontend-vue/tests/build-flags.test.ts +38 -0
  152. package/frontend-vue/tests/context-bar.test.ts +226 -0
  153. package/frontend-vue/tests/copy-draft.test.ts +64 -0
  154. package/frontend-vue/tests/drift-dismissals.test.ts +12 -0
  155. package/frontend-vue/tests/fill-height.test.ts +110 -0
  156. package/frontend-vue/tests/format.test.ts +14 -0
  157. package/frontend-vue/tests/funnel-draft.test.ts +37 -0
  158. package/frontend-vue/tests/heatmap.test.ts +25 -0
  159. package/frontend-vue/tests/install-guide.test.ts +25 -0
  160. package/frontend-vue/tests/marketing-reads.test.ts +20 -6
  161. package/frontend-vue/tests/page-snapshot.test.ts +62 -0
  162. package/frontend-vue/tests/record-crumb.test.ts +43 -0
  163. package/frontend-vue/tests/registration.test.ts +4 -1
  164. package/frontend-vue/tests/routes.test.ts +21 -0
  165. package/frontend-vue/tests/save-funnel.test.ts +47 -0
  166. package/frontend-vue/tests/server-parity.test.ts +3 -0
  167. package/frontend-vue/tests/snapshot-options.test.ts +148 -0
  168. package/frontend-vue/tests/snapshot-scroll-root.test.ts +84 -0
  169. package/frontend-vue/tests/stubs/frontend-module.ts +5 -1
  170. package/frontend-vue/tests/top-list-tabs.test.ts +158 -0
  171. package/frontend-vue/tests/website-card.test.ts +119 -0
  172. package/frontend-vue/tests/website-purge.test.ts +70 -0
  173. package/frontend-vue/tests/website-settings.test.ts +133 -0
  174. package/frontend-vue/tests/website-state.test.ts +96 -0
  175. package/frontend-vue/tests/workspace-scope-control.test.ts +113 -0
  176. package/frontend-vue/tests/workspace-scope.test.ts +55 -0
  177. package/package.json +5 -4
  178. package/skills/dms-marketing/SKILL.md +47 -6
  179. package/static/snapshot.js +539 -48
  180. package/static/tracker.js +154 -5
  181. 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.6.1",
3
+ "version": "0.7.0",
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",
@@ -50,7 +50,7 @@
50
50
  "@antelopejs/interface-data-api": ">=0.2.2 <1.0.0",
51
51
  "@antelopejs/interface-database": ">=0.1.10 <1.0.0",
52
52
  "@antelopejs/interface-database-decorators": ">=0.1.9 <1.0.0",
53
- "@antelopejs/interface-dms": ">=0.7.2 <1.0.0",
53
+ "@antelopejs/interface-dms": ">=0.8.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.16.0 <2",
63
+ "@antelopejs/core": ">=1.17.0 <2",
64
64
  "@antelopejs/interface-mongodb": ">=0.0.6 <1.0.0",
65
- "@antelopejs/mongodb": "1.4.3",
65
+ "@antelopejs/mongodb": "1.4.4",
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,24 +49,53 @@ 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; mask with `data-dms-marketing-mask` / `data-dms-marketing-block`.
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, pause or delete it like any site.
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
 
@@ -87,7 +128,7 @@ await ImportAnalytics({ tenantId, importId: "shop-history", websiteId: "shop-sit
87
128
  draft splits. `startedAt` on a running split makes imported exposures count.
88
129
  - EnsurePageSnapshot: the heatmap backdrop of a page the tracker cannot capture (not online, imported
89
130
  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`).
131
+ capture (a `<style>` with `<` before a letter, `!`, `/` or `?` is dropped: CSS-escape that character); needs `snapshotsEnabled`; a live capture replaces it and is never overwritten (`kept`).
91
132
  - `tenantId` is required on every content call: no default. Full contract: the interface package's
92
133
  `docs/1.index.md`.
93
134