@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,64 @@
1
+ import { computed, ref } from 'vue'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { useCopyDraft } from '../app/composables/useCopyDraft'
4
+
5
+ /** A form as `useFormDirty` sees it: values against a baseline. */
6
+ function form(initial: string) {
7
+ const value = ref(initial)
8
+ const baseline = ref(initial)
9
+ const dirty = computed(() => value.value !== baseline.value)
10
+ const markClean = () => {
11
+ baseline.value = value.value
12
+ }
13
+ const copy = useCopyDraft({ values: () => value.value, dirty, markClean })
14
+ return { value, dirty, copy }
15
+ }
16
+
17
+ describe('a form opened on a copy', () => {
18
+ it('shows the copy as a change to save, but holds no leave until edited', () => {
19
+ const { value, dirty, copy } = form('')
20
+ value.value = 'Copy of checkout'
21
+ copy.open()
22
+ expect(dirty.value).toBe(true)
23
+ expect(copy.unsaved.value).toBe(false)
24
+ value.value = 'Copy of checkout (B)'
25
+ expect(copy.unsaved.value).toBe(true)
26
+ value.value = 'Copy of checkout'
27
+ expect(copy.unsaved.value).toBe(false)
28
+ })
29
+
30
+ // QA m8: Cancel on a copy did nothing and the leave stayed held.
31
+ it('settles a cancelled copy, edits included: nothing holds the leave after', () => {
32
+ const { value, dirty, copy } = form('')
33
+ value.value = 'Copy of checkout'
34
+ copy.open()
35
+ value.value = 'Edited copy'
36
+ copy.settle()
37
+ expect(copy.isCopy.value).toBe(false)
38
+ expect(dirty.value).toBe(false)
39
+ expect(copy.unsaved.value).toBe(false)
40
+ })
41
+
42
+ // QA B09: a saved copy still held the leave, so its report never opened.
43
+ it('settles a saved copy: leaving to its report asks nothing', () => {
44
+ const { value, dirty, copy } = form('')
45
+ value.value = 'Copy of checkout'
46
+ copy.open()
47
+ value.value = 'Checkout (B)'
48
+ expect(copy.unsaved.value).toBe(true)
49
+ copy.settle()
50
+ expect(copy.isCopy.value).toBe(false)
51
+ expect(dirty.value).toBe(false)
52
+ expect(copy.unsaved.value).toBe(false)
53
+ })
54
+ })
55
+
56
+ describe('a form opened on a stored value', () => {
57
+ it('holds the leave exactly while dirty', () => {
58
+ const { value, copy } = form('Checkout')
59
+ expect(copy.isCopy.value).toBe(false)
60
+ expect(copy.unsaved.value).toBe(false)
61
+ value.value = 'Checkout v2'
62
+ expect(copy.unsaved.value).toBe(true)
63
+ })
64
+ })
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { dismissDrift, driftDismissed } from '../app/utils/drift-dismissals'
3
+
4
+ describe('dismissed drift warnings', () => {
5
+ it('stay dismissed for that page of that website only', () => {
6
+ expect(driftDismissed('site-a', '/pricing')).toBe(false)
7
+ dismissDrift('site-a', '/pricing')
8
+ expect(driftDismissed('site-a', '/pricing')).toBe(true)
9
+ expect(driftDismissed('site-a', '/')).toBe(false)
10
+ expect(driftDismissed('site-b', '/pricing')).toBe(false)
11
+ })
12
+ })
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ fillHeight,
4
+ MIN_FILL_HEIGHT,
5
+ trailingExtent,
6
+ type TrailingLevel,
7
+ } from '../app/utils/fill-height'
8
+
9
+ describe('the height the pages panes fill', () => {
10
+ it('is what the viewport leaves below their top and above what follows', () => {
11
+ // 1000 px window, panes from 290 px, a 40 px note and padding after them.
12
+ expect(fillHeight({ viewHeight: 1000, top: 290, trailing: 40 })).toBe(670)
13
+ })
14
+
15
+ it('grows and shrinks with the viewport alone', () => {
16
+ const at = (viewHeight: number) =>
17
+ fillHeight({ viewHeight, top: 274, trailing: 37 })
18
+ expect(at(900)).toBe(589)
19
+ expect(at(1100)).toBe(789)
20
+ expect(at(900)).toBe(589)
21
+ })
22
+
23
+ it('stops shrinking at its floor', () => {
24
+ expect(fillHeight({ viewHeight: 600, top: 290, trailing: 40 })).toBe(
25
+ MIN_FILL_HEIGHT,
26
+ )
27
+ })
28
+ })
29
+
30
+ describe('what the content lays out after the panes', () => {
31
+ it('is how far the following siblings reach, plus the closing padding', () => {
32
+ expect(
33
+ trailingExtent([
34
+ // The panes, then a note 16 px below them, 40 px high.
35
+ { bottom: 800, marginBottom: 0, following: [856], closing: 0 },
36
+ // Their block ends the content, which closes on 24 px of padding.
37
+ { bottom: 856, marginBottom: 0, following: [], closing: 24 },
38
+ ]),
39
+ ).toBe(80)
40
+ })
41
+
42
+ it('counts the bottom margin of a box nothing follows', () => {
43
+ expect(
44
+ trailingExtent([
45
+ { bottom: 500, marginBottom: 12, following: [], closing: 8 },
46
+ ]),
47
+ ).toBe(20)
48
+ })
49
+
50
+ it('ignores siblings laid out beside the box rather than after it', () => {
51
+ expect(
52
+ trailingExtent([
53
+ { bottom: 500, marginBottom: 0, following: [320], closing: 0 },
54
+ ]),
55
+ ).toBe(0)
56
+ })
57
+ })
58
+
59
+ /**
60
+ * An app shell, as the pages surface sits in it: a scroll viewport with
61
+ * 24 px of padding, a wrapper stretched to at least the viewport
62
+ * (`min-height: 100%`), a 250 px header, then the panes' block — the panes
63
+ * and a 40 px note 16 px below them.
64
+ */
65
+ function shell(viewHeight: number, paneHeight: number): TrailingLevel[] {
66
+ const top = 274
67
+ const panesBottom = top + paneHeight
68
+ const blockBottom = panesBottom + 16 + 40
69
+ const wrapperBottom = Math.max(blockBottom, viewHeight - 24)
70
+ return [
71
+ {
72
+ bottom: panesBottom,
73
+ marginBottom: 0,
74
+ following: [blockBottom],
75
+ closing: 0,
76
+ },
77
+ { bottom: blockBottom, marginBottom: 0, following: [], closing: 0 },
78
+ { bottom: wrapperBottom, marginBottom: 0, following: [], closing: 24 },
79
+ ]
80
+ }
81
+
82
+ /** One measurement, as a resize triggers it: from the panes' current height. */
83
+ function measure(viewHeight: number, paneHeight: number): number {
84
+ return fillHeight({
85
+ viewHeight,
86
+ top: 274,
87
+ trailing: trailingExtent(shell(viewHeight, paneHeight)),
88
+ })
89
+ }
90
+
91
+ describe('the panes in a shell that stretches its content', () => {
92
+ it('fill a fresh window', () => {
93
+ expect(measure(900, 0)).toBe(546)
94
+ })
95
+
96
+ it('grow with the window in one measurement', () => {
97
+ // The content is shorter than the grown viewport: the wrapper's empty
98
+ // space below the panes is not content after them.
99
+ expect(measure(1100, measure(900, 0))).toBe(746)
100
+ })
101
+
102
+ it('shrink with the window in one measurement', () => {
103
+ expect(measure(900, measure(1100, 0))).toBe(546)
104
+ })
105
+
106
+ it('settle at once: measuring again changes nothing', () => {
107
+ const height = measure(1100, 546)
108
+ expect(measure(1100, height)).toBe(height)
109
+ })
110
+ })
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import {
3
3
  displayName,
4
4
  formatDuration,
5
+ formatRelativeTime,
5
6
  formatShortDate,
6
7
  formatSignedDelta,
7
8
  initials,
@@ -19,6 +20,19 @@ describe('formatting', () => {
19
20
  expect(formatDuration(3720)).toBe('1h 2m')
20
21
  })
21
22
 
23
+ it('never writes an event age in the future', () => {
24
+ const now = Date.UTC(2026, 9, 18, 12)
25
+ // The server's clock a few seconds ahead of the reader's.
26
+ expect(formatRelativeTime(now + 3_000, now, 'en-GB', 'short')).toBe('now')
27
+ expect(formatRelativeTime(now + 3_000, now, 'fr-FR', 'short')).toBe(
28
+ 'maintenant',
29
+ )
30
+ expect(formatRelativeTime(now - 4_000, now, 'en-GB')).toBe('now')
31
+ expect(formatRelativeTime(now - 12_000, now, 'en-GB')).toBe(
32
+ '12 seconds ago',
33
+ )
34
+ })
35
+
22
36
  it('signs deltas after rounding, so a tiny change reads as flat', () => {
23
37
  expect(formatSignedDelta(-2.34, 'points', 'en')).toBe('−2.3 pt')
24
38
  expect(formatSignedDelta(8, 'seconds', 'en')).toBe('+8s')
@@ -5,9 +5,14 @@ import {
5
5
  splitSampleSize,
6
6
  templateSteps,
7
7
  variationSnippet,
8
+ withRequestedSplit,
9
+ DEFAULT_WINDOW_HOURS,
10
+ WINDOW_PRESET_HOURS,
8
11
  type FunnelDraftValues,
9
12
  } from '../app/utils/funnel-draft'
10
13
  import { sampleSizePerArm } from '../app/utils/sample-size'
14
+ import en from '../i18n/locales/marketing-en-GB.json'
15
+ import fr from '../i18n/locales/marketing-fr-FR.json'
11
16
 
12
17
  function draft(overrides: Partial<FunnelDraftValues> = {}): FunnelDraftValues {
13
18
  return {
@@ -68,6 +73,20 @@ describe('template links', () => {
68
73
  })
69
74
  })
70
75
 
76
+ // QA B08: "Split into A/B test" opened a plain funnel with its split off.
77
+ describe('a split requested by the link', () => {
78
+ it("turns a stored funnel's split on, and never off", () => {
79
+ expect(withRequestedSplit(draft({ split: false }), '1').split).toBe(true)
80
+ expect(withRequestedSplit(draft({ split: false }), undefined).split).toBe(
81
+ false,
82
+ )
83
+ expect(withRequestedSplit(draft({ split: true }), undefined).split).toBe(
84
+ true,
85
+ )
86
+ expect(withRequestedSplit(draft({ split: false }), ['1']).split).toBe(false)
87
+ })
88
+ })
89
+
71
90
  describe('draft issues', () => {
72
91
  it('are empty for a valid funnel', () => {
73
92
  expect(draftIssues(draft(), false)).toEqual({})
@@ -192,3 +211,21 @@ describe('variation snippet', () => {
192
211
  expect(code).toContain('// garder control')
193
212
  })
194
213
  })
214
+
215
+ describe('window presets', () => {
216
+ // QA m8: a preset read "Whole session" while the funnel saved "24 h".
217
+ it('label each preset with the hours it saves, in every locale', () => {
218
+ for (const messages of [en, fr]) {
219
+ const labels: Record<string, string> =
220
+ messages.page.marketing.builder.window_presets
221
+ expect(Object.keys(labels).map(Number)).toEqual([...WINDOW_PRESET_HOURS])
222
+ for (const hours of WINDOW_PRESET_HOURS) {
223
+ expect(labels[hours]?.replace(/\s/g, '')).toBe(`${hours}h`)
224
+ }
225
+ }
226
+ })
227
+
228
+ it('preselect the default window', () => {
229
+ expect(WINDOW_PRESET_HOURS).toContain(DEFAULT_WINDOW_HOURS)
230
+ })
231
+ })
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import {
3
3
  heatmapDataLabel,
4
4
  normalizePagePath,
5
+ selectedPageRow,
5
6
  } from '../app/utils/heatmap'
6
7
 
7
8
  /** A `t` that shows which key and which plural form were asked for. */
@@ -29,4 +30,28 @@ describe('heatmap helpers', () => {
29
30
  '18 events[n] + 1 snapshots',
30
31
  )
31
32
  })
33
+
34
+ describe('selectedPageRow', () => {
35
+ const pricing = { path: '/pricing', pageviews: 42 }
36
+ const home = { path: '/', pageviews: 7 }
37
+
38
+ it('takes the selected path row from the list just read', () => {
39
+ expect(
40
+ selectedPageRow([home, { ...pricing, pageviews: 3 }], '/pricing', pricing),
41
+ ).toEqual({ path: '/pricing', pageviews: 3 })
42
+ })
43
+
44
+ it('keeps the row a filter hides from the list', () => {
45
+ expect(selectedPageRow([home], '/pricing', pricing)).toBe(pricing)
46
+ })
47
+
48
+ it('keeps nothing once dropped (another workspace or other days)', () => {
49
+ expect(selectedPageRow([home], '/pricing', undefined)).toBeUndefined()
50
+ })
51
+
52
+ it('never keeps the row of another path', () => {
53
+ expect(selectedPageRow([home], '/about', pricing)).toBeUndefined()
54
+ expect(selectedPageRow([home], undefined, pricing)).toBeUndefined()
55
+ })
56
+ })
32
57
  })
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ // vitest runs with the frontend module as its root.
6
+ const SOURCE = readFileSync(
7
+ join(process.cwd(), 'app', 'components', 'InstallGuide.vue'),
8
+ 'utf8',
9
+ )
10
+
11
+ /** The `:to` of the link whose label is the given i18n key. */
12
+ function linkTargetOf(labelKey: string): string | undefined {
13
+ const link = new RegExp(
14
+ String.raw`:to="([^"]+)"[^>]*>\s*\{\{\s*t\('${labelKey.replaceAll('.', String.raw`\.`)}'\)`,
15
+ )
16
+ return link.exec(SOURCE)?.[1]
17
+ }
18
+
19
+ describe('InstallGuide', () => {
20
+ it("opens the added site's settings modal from step 1's Edit", () => {
21
+ expect(linkTargetOf('page.marketing.common.edit')).toBe(
22
+ 'websitesLink(website.id)',
23
+ )
24
+ })
25
+ })
@@ -2,6 +2,7 @@ import { createSSRApp, defineComponent, h, ref, toValue } from 'vue'
2
2
  import { renderToString } from 'vue/server-renderer'
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
  import {
5
+ useDashboardWorkspaces,
5
6
  useFunnelTemplates,
6
7
  useHeatmapCountRead,
7
8
  useMarketingContextRead,
@@ -152,13 +153,11 @@ describe('useMarketingNow', () => {
152
153
  })
153
154
 
154
155
  describe('the reads with triggers of their own', () => {
155
- it('reads the context bar on its own clock, not with the page', () => {
156
- useMarketingContextRead({ refreshInterval: 60_000 })
156
+ it('reads the context bar on its own triggers, on no clock of its own', () => {
157
+ useMarketingContextRead()
157
158
  const [context] = calls
158
- expect(context?.options).toMatchObject({
159
- refreshOnPageRefresh: false,
160
- refreshInterval: 60_000,
161
- })
159
+ expect(context?.options).toMatchObject({ refreshOnPageRefresh: false })
160
+ expect(context?.options).not.toHaveProperty('refreshInterval')
162
161
  })
163
162
 
164
163
  it('polls the connection check when asked to', () => {
@@ -180,6 +179,21 @@ describe('the reads with triggers of their own', () => {
180
179
  })
181
180
  })
182
181
 
182
+ describe('the dashboard workspaces', () => {
183
+ // The pages panel grew a row after the first paint while the workspaces
184
+ // were read in the browser only.
185
+ it('are read with the page, server render included, once there is a site', () => {
186
+ const website = ref('')
187
+ useDashboardWorkspaces(website)
188
+ const [workspaces] = calls
189
+ expect(workspaces?.options.server).not.toBe(false)
190
+ expect(workspaces?.url()).toBeNull()
191
+ website.value = 'dashboard-site'
192
+ expect(workspaces?.url()).toBe('/api/marketing/stats/workspaces')
193
+ expect(query(workspaces)).toEqual({ website: 'dashboard-site' })
194
+ })
195
+ })
196
+
183
197
  describe('the page preview', () => {
184
198
  it('reads the clicks in the browser only, once there is a site and a path', () => {
185
199
  const website = ref<string | undefined>(undefined)
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { pageSnapshotBadge } from '../app/utils/page-snapshot'
3
+
4
+ /** A `t` that shows which key, values and plural form were asked for. */
5
+ function t(key: string, named: Record<string, unknown> = {}, plural?: number) {
6
+ const name = key.split('.').at(-1)
7
+ const values = Object.values(named).join(',')
8
+ return plural === undefined
9
+ ? `${name}(${values})`
10
+ : `${name}:${plural}(${values})`
11
+ }
12
+
13
+ const NOW = Date.UTC(2026, 9, 17, 15)
14
+ const HOUR = 3_600_000
15
+
16
+ describe('the snapshot mark of a pages row', () => {
17
+ it('says nothing while the site has snapshots off', () => {
18
+ expect(pageSnapshotBadge(undefined, NOW, t, 'en-GB')).toBeNull()
19
+ })
20
+
21
+ it('says a page has none, muted', () => {
22
+ expect(pageSnapshotBadge(null, NOW, t, 'en-GB')).toEqual({
23
+ icon: 'i-ph-camera-slash',
24
+ label: 'none()',
25
+ title: 'none_title()',
26
+ muted: true,
27
+ })
28
+ })
29
+
30
+ it('gives the age of the newest capture and names its layouts', () => {
31
+ const badge = pageSnapshotBadge(
32
+ {
33
+ capturedAt: NOW - 3 * HOUR,
34
+ layouts: ['desktop', 'phone'],
35
+ tooHeavy: false,
36
+ },
37
+ NOW,
38
+ t,
39
+ 'fr-FR',
40
+ )
41
+ expect(badge).toEqual({
42
+ icon: 'i-ph-camera',
43
+ // Intl writes a no-break space before the unit.
44
+ label: new Intl.RelativeTimeFormat('fr-FR', { style: 'short' }).format(
45
+ -3,
46
+ 'hour',
47
+ ),
48
+ title: 'title:2(17 oct.,layout_desktop(), layout_phone())',
49
+ muted: false,
50
+ })
51
+ })
52
+
53
+ it('tells a page too heavy to capture', () => {
54
+ const badge = pageSnapshotBadge(
55
+ { capturedAt: NOW, layouts: [], tooHeavy: true },
56
+ NOW,
57
+ t,
58
+ 'en-GB',
59
+ )
60
+ expect(badge).toMatchObject({ icon: 'i-ph-file-x', label: 'too_heavy()' })
61
+ })
62
+ })
@@ -0,0 +1,43 @@
1
+ import { nextTick, ref } from 'vue'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { useRecordCrumb } from '../app/composables/useRecordCrumb'
4
+
5
+ let labels: { path: string; label: string | undefined }[]
6
+
7
+ beforeEach(() => {
8
+ labels = []
9
+ // The DMS's own: the label its header ends the breadcrumb with.
10
+ vi.stubGlobal('usePageRecordLabel', () => ({
11
+ setLabel: (path: string, label: string | undefined) =>
12
+ labels.push({ path, label }),
13
+ }))
14
+ })
15
+
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals()
18
+ })
19
+
20
+ // QA m13: the funnel report's breadcrumb stopped at "Funnel".
21
+ describe('the record crumb', () => {
22
+ it("names the page's record once it is loaded, and follows it", async () => {
23
+ const name = ref<string | null>(null)
24
+ useRecordCrumb(() => name.value)
25
+ name.value = 'Checkout'
26
+ await nextTick()
27
+ name.value = 'Pricing to signup'
28
+ await nextTick()
29
+ expect(labels).toEqual([
30
+ { path: '/', label: undefined },
31
+ { path: '/', label: 'Checkout' },
32
+ { path: '/', label: 'Pricing to signup' },
33
+ ])
34
+ })
35
+
36
+ it("gives the page's own name back while no record is shown", async () => {
37
+ const name = ref<string | null>('Checkout')
38
+ useRecordCrumb(() => name.value)
39
+ name.value = null
40
+ await nextTick()
41
+ expect(labels.at(-1)).toEqual({ path: '/', label: undefined })
42
+ })
43
+ })
@@ -16,7 +16,10 @@ const BACKEND_ADDRESSED = [
16
16
  'FunnelTemplates',
17
17
  'FunnelReport',
18
18
  'FunnelBuilder',
19
- 'WebsitesGrid',
19
+ 'WebsiteCard',
20
+ 'WebsiteSettings',
21
+ 'WebsiteCreate',
22
+ 'FirstRun',
20
23
  'InstallGuide',
21
24
  'FunnelStepsInput',
22
25
  'ExperimentInput',
@@ -7,6 +7,8 @@ import {
7
7
  installLink,
8
8
  overviewLink,
9
9
  pagesLink,
10
+ websiteSettingOf,
11
+ websitesLink,
10
12
  withWebsite,
11
13
  } from '../app/composables/useMarketingRoutes'
12
14
 
@@ -80,4 +82,23 @@ describe('module links', () => {
80
82
  '/modules/marketing/acquisition',
81
83
  )
82
84
  })
85
+
86
+ it('open the Websites page on a site setting', () => {
87
+ expect(websitesLink()).toBe('/modules/marketing/websites')
88
+ expect(websitesLink('site 1')).toBe(
89
+ '/modules/marketing/websites?record=site+1',
90
+ )
91
+ expect(websitesLink('site-1', 'snapshots')).toBe(
92
+ '/modules/marketing/websites?record=site-1&setting=snapshots',
93
+ )
94
+ // A setting without a site has no modal to open.
95
+ expect(websitesLink(null, 'snapshots')).toBe('/modules/marketing/websites')
96
+ })
97
+
98
+ it('read the setting to scroll to back from the Websites page query', () => {
99
+ expect(websiteSettingOf({})).toBeUndefined()
100
+ expect(websiteSettingOf({ setting: 'snapshots' })).toBe('snapshots')
101
+ expect(websiteSettingOf({ setting: 'other' })).toBeUndefined()
102
+ expect(websiteSettingOf({ setting: ['snapshots'] })).toBeUndefined()
103
+ })
83
104
  })
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ saveFunnel,
4
+ type MarketingFunnelInput,
5
+ type MarketingFunnelListItem,
6
+ } from '../app/composables/useMarketingApi'
7
+
8
+ const INPUT: MarketingFunnelInput = {
9
+ websiteId: 'acme-shop',
10
+ name: 'Checkout',
11
+ steps: [{ kind: 'url', value: '/cart' }],
12
+ conversionWindowHours: 24,
13
+ experiment: null,
14
+ }
15
+
16
+ // What interface-data-api 0.3 answers on `new`: the created row, not its key
17
+ // in an array.
18
+ const CREATED: MarketingFunnelListItem = {
19
+ _id: 'funnel-new',
20
+ websiteId: 'acme-shop',
21
+ name: 'Checkout',
22
+ steps: [{ kind: 'url', value: '/cart' }],
23
+ conversionWindowHours: 24,
24
+ experiment: null,
25
+ }
26
+
27
+ function fakeApi() {
28
+ return {
29
+ createFunnel: vi.fn(async () => CREATED),
30
+ updateFunnel: vi.fn(async () => CREATED),
31
+ }
32
+ }
33
+
34
+ describe('saving a funnel', () => {
35
+ it('answers the id of the row the insert created', async () => {
36
+ const api = fakeApi()
37
+ expect(await saveFunnel(api, INPUT)).toBe('funnel-new')
38
+ expect(api.updateFunnel).not.toHaveBeenCalled()
39
+ })
40
+
41
+ it('updates the definition it edits and answers its id', async () => {
42
+ const api = fakeApi()
43
+ expect(await saveFunnel(api, INPUT, 'funnel-1')).toBe('funnel-1')
44
+ expect(api.updateFunnel).toHaveBeenCalledWith('funnel-1', INPUT)
45
+ expect(api.createFunnel).not.toHaveBeenCalled()
46
+ })
47
+ })
@@ -23,6 +23,9 @@ describe('front rules mirror the server', () => {
23
23
  server.MAX_FUNNEL_STEP_VALUE_LENGTH,
24
24
  )
25
25
  expect(draft.MAX_WINDOW_HOURS).toBe(server.MAX_CONVERSION_WINDOW_HOURS)
26
+ expect(draft.DEFAULT_WINDOW_HOURS).toBe(
27
+ server.DEFAULT_CONVERSION_WINDOW_HOURS,
28
+ )
26
29
  expect(draft.MIN_VARIATIONS).toBe(server.MIN_EXPERIMENT_VARIATIONS)
27
30
  expect(draft.MAX_VARIATIONS).toBe(server.MAX_EXPERIMENT_VARIATIONS)
28
31
  expect(draft.MAX_EXPERIMENT_KEY_LENGTH).toBe(