@antelopejs/dms-marketing 0.6.0 → 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
@@ -91,7 +91,22 @@ function stepsKey(steps: MarketingFunnelStep[]): string {
91
91
  />
92
92
  </template>
93
93
  </DmsEmptyState>
94
+ <!-- The blank start comes first: the templates loading after it never
95
+ move it. -->
94
96
  <div class="mt-6 grid gap-3 md:grid-cols-3">
97
+ <button
98
+ type="button"
99
+ class="flex flex-col items-start gap-2 rounded-xl border border-dashed border-default p-4 text-left transition-colors hover:border-primary"
100
+ @click="startFrom(null)"
101
+ >
102
+ <DmsEyebrow :label="t('page.marketing.funnels.empty.blank')" />
103
+ <span class="text-sm font-semibold text-highlighted">
104
+ {{ t('page.marketing.funnels.empty.scratch') }}
105
+ </span>
106
+ <span class="text-xs text-muted">
107
+ {{ t('page.marketing.funnels.empty.scratch_description') }}
108
+ </span>
109
+ </button>
95
110
  <template v-if="templates === null && !waiting">
96
111
  <USkeleton v-for="index in 2" :key="index" class="h-32 rounded-xl" />
97
112
  </template>
@@ -132,19 +147,6 @@ function stepsKey(steps: MarketingFunnelStep[]): string {
132
147
  }}
133
148
  </span>
134
149
  </button>
135
- <button
136
- type="button"
137
- class="flex flex-col items-start gap-2 rounded-xl border border-dashed border-default p-4 text-left transition-colors hover:border-primary"
138
- @click="startFrom(null)"
139
- >
140
- <DmsEyebrow :label="t('page.marketing.funnels.empty.blank')" />
141
- <span class="text-sm font-semibold text-highlighted">
142
- {{ t('page.marketing.funnels.empty.scratch') }}
143
- </span>
144
- <span class="text-xs text-muted">
145
- {{ t('page.marketing.funnels.empty.scratch_description') }}
146
- </span>
147
- </button>
148
150
  </div>
149
151
  </section>
150
152
  </template>
@@ -371,7 +371,7 @@ const stepTwoState = computed(() =>
371
371
  <span class="font-mono">{{ website.domain }}</span>
372
372
  {{ t('page.marketing.install.and_subdomains') }}
373
373
  <DmsAutoLink
374
- :to="websitesLink()"
374
+ :to="websitesLink(website.id)"
375
375
  class="text-primary hover:underline"
376
376
  >
377
377
  {{ t('page.marketing.common.edit') }}
@@ -16,12 +16,13 @@ import {
16
16
  usePageSnapshot,
17
17
  type PagePreviewRequest,
18
18
  } from '../composables/useMarketingReads'
19
- import { funnelLink, settingsLink } from '../composables/useMarketingRoutes'
20
- import { formatNumber, formatShortDate } from '../utils/format'
21
19
  import {
22
- heatmapDataLabel,
23
- heatmapResetImpact,
24
- } from '../utils/heatmap'
20
+ funnelLink,
21
+ settingsLink,
22
+ websitesLink,
23
+ } from '../composables/useMarketingRoutes'
24
+ import { formatNumber, formatShortDate } from '../utils/format'
25
+ import { heatmapDataLabel, heatmapResetImpact } from '../utils/heatmap'
25
26
  import { heatLegendGradient } from '../utils/heatmapPalette'
26
27
  import {
27
28
  DEFAULT_RENDER_HEIGHT,
@@ -32,7 +33,13 @@ import {
32
33
  type AnchorBox,
33
34
  type AnchorRequest,
34
35
  } from '../composables/useSnapshotFrame'
36
+ import {
37
+ confirmSnapshotChange,
38
+ snapshotOptionItems,
39
+ } from '../utils/snapshot-options'
35
40
  import { rewriteViewportUnits } from '../utils/snapshotViewportUnits'
41
+ import { anchorDrift } from '../utils/anchor-drift'
42
+ import { dismissDrift, driftDismissed } from '../utils/drift-dismissals'
36
43
 
37
44
  /**
38
45
  * Click and scroll-depth overlays of one tracked page, over a snapshot of
@@ -82,8 +89,25 @@ const props = defineProps<{
82
89
  pageviews?: number
83
90
  /** Funnels with a page step on this path. */
84
91
  usedIn?: MarketingFunnelListItem[]
92
+ /**
93
+ * Height the card fills, toolbar to bottom, the snapshot scrolling inside
94
+ * it whatever its length (see PagesExplorer). Null or absent: the card
95
+ * grows with the snapshot, as when it is stacked under the list.
96
+ */
97
+ height?: number | null
98
+ /**
99
+ * Another workspace's dashboard, or `all` (see PagesExplorer): the page is
100
+ * read there, read-only — no reset, no snapshot option — and its capture
101
+ * comes masked from the backend. Absent or empty: the website's own.
102
+ */
103
+ workspace?: string
104
+ /** Whether any site of that scope keeps snapshots. */
105
+ workspaceSnapshots?: boolean
85
106
  }>()
86
107
 
108
+ /** Read past the viewer's workspace (see `workspace`). */
109
+ const foreign = computed(() => !!props.workspace)
110
+
87
111
  /** The site's snapshot options were saved here, as the backend answered
88
112
  * them; the parent owns the row. */
89
113
  const emit = defineEmits<{
@@ -129,7 +153,8 @@ const PROBE_VIEWPORT_HEIGHT = 900
129
153
  const PANE_HEIGHT = 620
130
154
  /** Overflow beyond the chosen layout width worth telling the operator about. */
131
155
  const OVERFLOW_TOLERANCE = 1.05
132
- const SNAPSHOT_BYTE_CAP = 1024 * 1024
156
+ /** The upload cap of a backend that does not say its own. */
157
+ const DEFAULT_SNAPSHOT_BYTE_CAP = 1024 * 1024
133
158
  const LIGHT_CANVAS = '#ffffff'
134
159
  const DARK_CANVAS = '#111111'
135
160
 
@@ -145,6 +170,7 @@ const previewRequest: PagePreviewRequest = {
145
170
  websiteId: () => props.website?._id,
146
171
  path: () => props.path,
147
172
  period: () => props.period,
173
+ workspace: () => props.workspace,
148
174
  }
149
175
  const heatmapRead = usePageHeatmap(previewRequest)
150
176
  const heatmap = computed(() =>
@@ -265,8 +291,10 @@ const layout = computed<MarketingSnapshotLayout>(
265
291
  ?.layout ?? 'desktop',
266
292
  )
267
293
 
268
- const snapshotsEnabled = computed(
269
- () => props.website?.snapshotsEnabled ?? false,
294
+ const snapshotsEnabled = computed(() =>
295
+ foreign.value
296
+ ? props.workspaceSnapshots === true
297
+ : (props.website?.snapshotsEnabled ?? false),
270
298
  )
271
299
 
272
300
  // Not fetched while the site has snapshots off: the option's banner is the
@@ -277,6 +305,7 @@ const snapshotRead = usePageSnapshot({
277
305
  layout,
278
306
  enabled: snapshotsEnabled,
279
307
  maskText: () => props.website?.snapshotMaskText === true,
308
+ workspace: () => props.workspace,
280
309
  })
281
310
  const isSnapshotAsked = computed(
282
311
  () => snapshotsEnabled.value && !!props.website?._id && !!props.path,
@@ -322,11 +351,37 @@ function mebibytes(bytes: number): string {
322
351
 
323
352
  const savingOptions = ref(false)
324
353
 
354
+ const countSiteHeatmap = useHeatmapCountRead(() => props.website?._id ?? '')
355
+
356
+ /**
357
+ * Whether the change may go ahead: it deletes the site's snapshots and
358
+ * changes what later captures keep, so it is asked first — with the count
359
+ * when there are some (see asksBeforeSnapshotChange). Declining leaves the
360
+ * website as it is, and the menu's checkbox with it.
361
+ */
362
+ function confirmSnapshotDiscard(
363
+ website: MarketingWebsite,
364
+ patch: MarketingWebsitePatch,
365
+ ): Promise<boolean> {
366
+ return confirmSnapshotChange(website, patch, {
367
+ count: async () => {
368
+ savingOptions.value = true
369
+ try {
370
+ return (await countSiteHeatmap()).snapshots
371
+ } finally {
372
+ savingOptions.value = false
373
+ }
374
+ },
375
+ confirm,
376
+ t,
377
+ })
378
+ }
379
+
325
380
  async function updateSnapshotOptions(
326
381
  patch: MarketingWebsitePatch,
327
382
  ): Promise<void> {
328
383
  const website = props.website
329
- if (!website) {
384
+ if (!website || !(await confirmSnapshotDiscard(website, patch))) {
330
385
  return
331
386
  }
332
387
  savingOptions.value = true
@@ -345,7 +400,7 @@ async function updateSnapshotOptions(
345
400
  // --- Frame geometry ---------------------------------------------------------
346
401
 
347
402
  const frame = ref<HTMLIFrameElement | null>(null)
348
- const { measuredWidth, measuredHeight, tier, measureAnchors } =
403
+ const { measuredWidth, measuredHeight, tier, measureAnchors, missingAnchors } =
349
404
  useSnapshotFrame(frame)
350
405
 
351
406
  const frameKey = computed(() =>
@@ -417,6 +472,9 @@ const overlayWidth = computed(() =>
417
472
  * fraction, never a blank one.
418
473
  */
419
474
  const anchorBoxes = ref(new Map<string, AnchorBox>())
475
+ /** Keys of the anchors the snapshot has no element for; null until the
476
+ * frame has been measured. */
477
+ const missingAnchorKeys = ref<Set<string> | null>(null)
420
478
 
421
479
  function anchorKey(anchor: AnchorRequest): string {
422
480
  return `${anchor.selector}\0${anchor.nth}`
@@ -467,6 +525,7 @@ async function refreshAnchorBoxes(): Promise<void> {
467
525
  if (anchorBoxes.value.size > 0) {
468
526
  anchorBoxes.value = new Map()
469
527
  }
528
+ missingAnchorKeys.value = null
470
529
  return
471
530
  }
472
531
  await nextTick()
@@ -479,8 +538,23 @@ async function refreshAnchorBoxes(): Promise<void> {
479
538
  resolved.set(anchorKey(box), box)
480
539
  }
481
540
  anchorBoxes.value = resolved
541
+ missingAnchorKeys.value = new Set(
542
+ missingAnchors(requestedAnchors.value).map(anchorKey),
543
+ )
482
544
  }
483
545
 
546
+ /**
547
+ * Clicks aiming at elements this snapshot does not have: drawn at their
548
+ * document fraction, and past a threshold a sign the page changed since it
549
+ * was captured (see anchorDrift).
550
+ */
551
+ const drift = computed(() => {
552
+ const missing = missingAnchorKeys.value
553
+ return missing
554
+ ? anchorDrift(points.value, (anchor) => missing.has(anchorKey(anchor)))
555
+ : null
556
+ })
557
+
484
558
  watch(
485
559
  [requestedAnchors, tier, renderHeight, overlayWidth],
486
560
  () => void refreshAnchorBoxes(),
@@ -547,20 +621,26 @@ const scrolledHeight = computed(() =>
547
621
  Math.round(renderHeight.value * scale.value),
548
622
  )
549
623
 
624
+ const filled = computed(() => !!props.height)
625
+
550
626
  /**
551
- * The pane grows with the page, up to the window's height: a short capture
552
- * leaves no empty band below it, and a long one scrolls inside a pane that
553
- * still fits the screen. The border is counted, or a 2px scroll would remain.
627
+ * Given a height, the pane takes what the card leaves it, the same for
628
+ * every snapshot: a long page scrolls inside it, a short one ends on the
629
+ * end-of-page band. Without one, the pane grows with the page up to the
630
+ * window's height. The border is counted, or a 2px scroll would remain.
554
631
  */
555
- const paneStyle = computed(() => ({
556
- height: `${scrolledHeight.value + 2}px`,
557
- maxHeight: 'calc(100dvh - 8rem)',
558
- minHeight: '12rem',
559
- }))
632
+ const paneStyle = computed(() =>
633
+ filled.value
634
+ ? { height: '100%' }
635
+ : {
636
+ height: `${scrolledHeight.value + 2}px`,
637
+ maxHeight: 'calc(100dvh - 8rem)',
638
+ minHeight: '12rem',
639
+ },
640
+ )
560
641
 
561
642
  // --- Emptiness, explained ---------------------------------------------------
562
643
 
563
-
564
644
  /** Silence has shared causes (collection off, no view sampled) before the
565
645
  * per-overlay one; both overlays explain themselves through this. */
566
646
  function captureEmptyReason(fallbackKey: string): string {
@@ -668,10 +748,40 @@ interface StatusStrip {
668
748
  neutral?: boolean
669
749
  icon: string
670
750
  title: string
671
- description: string
751
+ description?: string
752
+ /** The explanation, behind an info button instead of under the title: a
753
+ * strip that must stay one line above the snapshot. */
754
+ details?: string
672
755
  action?: StripAction
756
+ /** A second, quieter action: the setting behind the strip, where it lives. */
757
+ secondary?: StripAction
758
+ /** Closes the strip; the next one that applies, if any, takes its place. */
759
+ dismiss?: () => void
673
760
  }
674
761
 
762
+ /** Where the site's snapshot switch lives: the Websites page's settings,
763
+ * scrolled to it. The menu's toggles are only a shortcut to it. */
764
+ const snapshotSettingsLink = computed(() =>
765
+ props.website && !foreign.value
766
+ ? websitesLink(props.website._id, 'snapshots')
767
+ : '',
768
+ )
769
+
770
+ function snapshotSettingsAction(): StripAction | undefined {
771
+ return snapshotSettingsLink.value
772
+ ? {
773
+ label: t('page.marketing.pages.preview.snapshot_settings'),
774
+ to: snapshotSettingsLink.value,
775
+ }
776
+ : undefined
777
+ }
778
+
779
+ /** The drift strip was closed on this page this session (see
780
+ * drift-dismissals). */
781
+ const driftDismissedHere = computed(
782
+ () => !!props.website && driftDismissed(props.website._id, props.path),
783
+ )
784
+
675
785
  /**
676
786
  * Highest priority first: one strip says what needs attention, instead of
677
787
  * every applicable alert stacked above the page. The numbers in the header
@@ -697,10 +807,14 @@ const statusStrip = computed<StatusStrip | null>(() => {
697
807
  icon: 'i-ph-camera-slash',
698
808
  title: t('page.marketing.pages.preview.snapshots_off'),
699
809
  description: t('page.marketing.pages.preview.snapshots_off_hint'),
700
- action: {
701
- label: t('page.marketing.pages.preview.enable_snapshots'),
702
- run: () => void updateSnapshotOptions({ snapshotsEnabled: true }),
703
- },
810
+ action: foreign.value
811
+ ? undefined
812
+ : {
813
+ label: t('page.marketing.pages.preview.enable_snapshots'),
814
+ run: () =>
815
+ void updateSnapshotOptions({ snapshotsEnabled: true }),
816
+ },
817
+ secondary: snapshotSettingsAction(),
704
818
  }
705
819
  : null,
706
820
  noSnapshotYet.value
@@ -714,6 +828,7 @@ const statusStrip = computed<StatusStrip | null>(() => {
714
828
  : t('page.marketing.pages.preview.no_snapshot_hint_rate', {
715
829
  rate: props.sampleRateLabel,
716
830
  }),
831
+ secondary: snapshotSettingsAction(),
717
832
  }
718
833
  : null,
719
834
  snapshot.value && tooHeavy.value
@@ -723,8 +838,44 @@ const statusStrip = computed<StatusStrip | null>(() => {
723
838
  title: t('page.marketing.pages.preview.too_heavy'),
724
839
  description: t('page.marketing.pages.preview.too_heavy_hint', {
725
840
  size: mebibytes(snapshot.value.bytes),
726
- limit: mebibytes(SNAPSHOT_BYTE_CAP),
841
+ limit: mebibytes(
842
+ snapshot.value.maxBytes ?? DEFAULT_SNAPSHOT_BYTE_CAP,
843
+ ),
844
+ }),
845
+ secondary: snapshotSettingsAction(),
846
+ }
847
+ : null,
848
+ overlayMode.value === 'clicks' &&
849
+ drift.value?.significant &&
850
+ !driftDismissedHere.value
851
+ ? {
852
+ tone: 'warning',
853
+ icon: 'i-ph-git-diff',
854
+ title: t('page.marketing.pages.preview.drift', {
855
+ share: String(
856
+ formatPercentage(drift.value.share, locale.value, {
857
+ maximumFractionDigits: 0,
858
+ }),
859
+ ),
860
+ }),
861
+ // One line: the share and the way out. Why, and what the reset
862
+ // does, wait behind the info button.
863
+ details: t('page.marketing.pages.preview.drift_hint', {
864
+ date: snapshot.value
865
+ ? formatShortDate(snapshot.value.capturedAt, locale.value)
866
+ : '',
727
867
  }),
868
+ action: foreign.value
869
+ ? undefined
870
+ : {
871
+ label: t('page.marketing.pages.reset.page_button'),
872
+ run: () => void resetPageHeatmap(),
873
+ },
874
+ dismiss: () => {
875
+ if (props.website) {
876
+ dismissDrift(props.website._id, props.path)
877
+ }
878
+ },
728
879
  }
729
880
  : null,
730
881
  truncatedState.value
@@ -788,13 +939,23 @@ const statusLine = computed(() => {
788
939
  })
789
940
  })
790
941
 
791
- const maskLine = computed(() =>
792
- t(
942
+ /** Where the snapshot was taken: origin and path, as stored. */
943
+ const capturedUrl = computed(() =>
944
+ snapshot.value
945
+ ? (snapshot.value.url ?? `${snapshot.value.origin}${props.path}`)
946
+ : '',
947
+ )
948
+
949
+ const maskLine = computed(() => {
950
+ if (snapshot.value?.masked || foreign.value) {
951
+ return t('page.marketing.pages.preview.workspace_masked')
952
+ }
953
+ return t(
793
954
  props.website?.snapshotMaskText
794
955
  ? 'page.marketing.pages.preview.all_masked'
795
956
  : 'page.marketing.pages.preview.fields_masked',
796
- ),
797
- )
957
+ )
958
+ })
798
959
 
799
960
  // --- Most clicked -----------------------------------------------------------
800
961
 
@@ -926,45 +1087,53 @@ watch(
926
1087
  { flush: 'post' },
927
1088
  )
928
1089
 
929
- const moreItems = computed(() => [
1090
+ // Another workspace's page is read-only: its options and reset are its own.
1091
+ const moreItems = computed(() =>
930
1092
  [
931
- {
932
- type: 'checkbox' as const,
933
- label: t('page.marketing.pages.preview.snapshots_enabled'),
934
- checked: snapshotsEnabled.value,
935
- onUpdateChecked: (value: boolean): void =>
936
- void updateSnapshotOptions({ snapshotsEnabled: value }),
937
- },
938
- {
939
- type: 'checkbox' as const,
940
- label: t('page.marketing.pages.preview.mask_text'),
941
- checked: props.website?.snapshotMaskText === true,
942
- disabled: !snapshotsEnabled.value,
943
- onUpdateChecked: (value: boolean): void =>
944
- void updateSnapshotOptions({ snapshotMaskText: value }),
945
- },
946
- ],
947
- ...(externalUrl.value
948
- ? [
949
- [
950
- {
951
- label: t('page.marketing.pages.preview.open_in_tab'),
952
- icon: 'i-ph-arrow-square-out',
953
- to: externalUrl.value,
954
- target: '_blank',
955
- },
956
- ],
957
- ]
958
- : []),
959
- [
960
- {
961
- label: t('page.marketing.pages.reset.page_button'),
962
- icon: 'i-ph-eraser',
963
- color: 'error' as const,
964
- onSelect: () => void resetPageHeatmap(),
965
- },
966
- ],
967
- ])
1093
+ [
1094
+ ...(foreign.value
1095
+ ? []
1096
+ : snapshotOptionItems(
1097
+ props.website,
1098
+ (patch) => void updateSnapshotOptions(patch),
1099
+ t,
1100
+ )),
1101
+ ...(snapshotSettingsLink.value
1102
+ ? [
1103
+ {
1104
+ label: t('page.marketing.pages.preview.snapshot_settings'),
1105
+ icon: 'i-ph-gear-six',
1106
+ to: snapshotSettingsLink.value,
1107
+ },
1108
+ ]
1109
+ : []),
1110
+ ],
1111
+ ...(externalUrl.value
1112
+ ? [
1113
+ [
1114
+ {
1115
+ label: t('page.marketing.pages.preview.open_in_tab'),
1116
+ icon: 'i-ph-arrow-square-out',
1117
+ to: externalUrl.value,
1118
+ target: '_blank',
1119
+ },
1120
+ ],
1121
+ ]
1122
+ : []),
1123
+ ...(foreign.value
1124
+ ? []
1125
+ : [
1126
+ [
1127
+ {
1128
+ label: t('page.marketing.pages.reset.page_button'),
1129
+ icon: 'i-ph-eraser',
1130
+ color: 'error' as const,
1131
+ onSelect: () => void resetPageHeatmap(),
1132
+ },
1133
+ ],
1134
+ ]),
1135
+ ].filter((group) => group.length > 0),
1136
+ )
968
1137
 
969
1138
  // --- Surface ----------------------------------------------------------------
970
1139
 
@@ -1018,7 +1187,10 @@ const surfaceEmpty = computed(() => {
1018
1187
  </script>
1019
1188
 
1020
1189
  <template>
1021
- <section class="dms-card flex min-w-0 flex-col overflow-hidden p-0">
1190
+ <section
1191
+ class="dms-card flex min-w-0 flex-col overflow-hidden p-0"
1192
+ :style="filled ? { height: `${props.height}px` } : undefined"
1193
+ >
1022
1194
  <!-- Toolbar -->
1023
1195
  <header
1024
1196
  class="flex flex-wrap items-center justify-between gap-3 border-b border-default px-4 py-3"
@@ -1078,7 +1250,11 @@ const surfaceEmpty = computed(() => {
1078
1250
  :aria-label="t('page.marketing.pages.preview.layout')"
1079
1251
  />
1080
1252
  </span>
1081
- <UDropdownMenu :items="moreItems" :content="{ align: 'end' }">
1253
+ <UDropdownMenu
1254
+ v-if="moreItems.length > 0"
1255
+ :items="moreItems"
1256
+ :content="{ align: 'end' }"
1257
+ >
1082
1258
  <UButton
1083
1259
  icon="i-ph-dots-three"
1084
1260
  size="xs"
@@ -1099,9 +1275,29 @@ const surfaceEmpty = computed(() => {
1099
1275
  :icon="statusStrip.icon"
1100
1276
  :title="statusStrip.title"
1101
1277
  :description="statusStrip.description"
1278
+ :dismissible="!!statusStrip.dismiss"
1279
+ @dismiss="statusStrip.dismiss?.()"
1102
1280
  >
1103
- <template v-if="statusStrip.action" #actions>
1281
+ <template v-if="statusStrip.details" #description>
1282
+ <UPopover :content="{ align: 'start' }">
1283
+ <UButton
1284
+ icon="i-ph-info"
1285
+ size="xs"
1286
+ color="neutral"
1287
+ variant="ghost"
1288
+ class="-my-1 align-middle"
1289
+ :aria-label="t('page.marketing.pages.preview.strip_details')"
1290
+ />
1291
+ <template #content>
1292
+ <p class="max-w-sm p-3 text-xs leading-relaxed text-muted">
1293
+ {{ statusStrip.details }}
1294
+ </p>
1295
+ </template>
1296
+ </UPopover>
1297
+ </template>
1298
+ <template v-if="statusStrip.action || statusStrip.secondary" #actions>
1104
1299
  <UButton
1300
+ v-if="statusStrip.action"
1105
1301
  size="xs"
1106
1302
  color="neutral"
1107
1303
  variant="outline"
@@ -1110,6 +1306,15 @@ const surfaceEmpty = computed(() => {
1110
1306
  :to="statusStrip.action.to"
1111
1307
  @click="statusStrip.action.run?.()"
1112
1308
  />
1309
+ <UButton
1310
+ v-if="statusStrip.secondary"
1311
+ size="xs"
1312
+ color="neutral"
1313
+ variant="ghost"
1314
+ :label="statusStrip.secondary.label"
1315
+ :to="statusStrip.secondary.to"
1316
+ @click="statusStrip.secondary.run?.()"
1317
+ />
1113
1318
  </template>
1114
1319
  </DmsBanner>
1115
1320
  </div>
@@ -1121,23 +1326,63 @@ const surfaceEmpty = computed(() => {
1121
1326
  <UIcon name="i-ph-camera" class="size-3.5 text-success" />
1122
1327
  {{ statusLine }}
1123
1328
  </span>
1329
+ <span
1330
+ class="min-w-0 truncate font-mono text-dimmed"
1331
+ :title="t('page.marketing.pages.preview.captured_from_hint')"
1332
+ >
1333
+ {{
1334
+ t('page.marketing.pages.preview.captured_from', { url: capturedUrl })
1335
+ }}
1336
+ </span>
1124
1337
  <span class="text-dimmed">· {{ maskLine }}</span>
1338
+ <UButton
1339
+ v-if="snapshotSettingsLink"
1340
+ :to="snapshotSettingsLink"
1341
+ size="xs"
1342
+ color="neutral"
1343
+ variant="link"
1344
+ icon="i-ph-gear-six"
1345
+ class="ms-auto"
1346
+ :label="t('page.marketing.pages.preview.snapshot_settings')"
1347
+ />
1348
+ </p>
1349
+ <!-- The status line's place while the capture is read, at its height (a
1350
+ text-xs line): arriving in a free row, it pushed the pane down. -->
1351
+ <p
1352
+ v-else-if="!snapshotSettled"
1353
+ class="flex items-center border-b border-default px-4 py-2"
1354
+ aria-hidden="true"
1355
+ >
1356
+ <USkeleton class="h-4 w-72 max-w-full" />
1125
1357
  </p>
1126
1358
 
1127
- <div class="grid min-h-0 xl:grid-cols-[minmax(0,1fr)_15rem]">
1128
- <div class="min-w-0 p-3">
1359
+ <div
1360
+ class="grid min-h-0 xl:grid-cols-[minmax(0,1fr)_15rem]"
1361
+ :class="filled ? 'flex-1 xl:grid-rows-[minmax(0,1fr)]' : ''"
1362
+ >
1363
+ <div class="flex min-h-0 min-w-0 flex-col p-3">
1129
1364
  <USkeleton
1130
1365
  v-if="surface === 'loading'"
1131
1366
  class="w-full rounded-lg"
1132
- :style="{ height: `${PANE_HEIGHT}px` }"
1367
+ :class="filled ? 'min-h-0 flex-1' : ''"
1368
+ :style="filled ? undefined : { height: `${PANE_HEIGHT}px` }"
1133
1369
  />
1134
1370
 
1135
1371
  <!-- Kept mounted across refetches: tearing it down would re-render the
1136
1372
  snapshot — and re-measure it — on every period change. -->
1137
- <div v-else-if="surface === 'frame'" class="relative">
1373
+ <div
1374
+ v-else-if="surface === 'frame'"
1375
+ class="relative"
1376
+ :class="filled ? 'min-h-0 flex-1' : ''"
1377
+ >
1138
1378
  <div
1139
1379
  ref="container"
1140
1380
  class="overflow-x-hidden overflow-y-scroll rounded-lg border border-default bg-elevated"
1381
+ :class="
1382
+ filled
1383
+ ? 'bg-[repeating-linear-gradient(135deg,transparent_0_8px,var(--ui-border)_8px_9px)]'
1384
+ : ''
1385
+ "
1141
1386
  :style="paneStyle"
1142
1387
  >
1143
1388
  <!-- A layout narrower than the pane keeps its width, centred. -->
@@ -1220,6 +1465,16 @@ const surfaceEmpty = computed(() => {
1220
1465
  :fold-ratio="scrollFoldRatio"
1221
1466
  />
1222
1467
  </div>
1468
+ <!-- Where the captured page stops: whatever of the pane is left
1469
+ below is not page, and must not read as a blank part of it. -->
1470
+ <p
1471
+ v-if="filled"
1472
+ class="mx-auto flex items-center justify-center gap-1.5 border-t border-dashed border-accented py-2 text-[11px] text-dimmed"
1473
+ :style="{ width: `${surfaceWidth}px` }"
1474
+ >
1475
+ <UIcon name="i-ph-arrow-line-down" class="size-3.5" />
1476
+ {{ t('page.marketing.pages.preview.end_of_page') }}
1477
+ </p>
1223
1478
  </div>
1224
1479
 
1225
1480
  <!-- Outside the scrollport on purpose: inside, it is laid out at the
@@ -1261,6 +1516,7 @@ const surfaceEmpty = computed(() => {
1261
1516
 
1262
1517
  <aside
1263
1518
  class="flex flex-col gap-4 border-t border-default p-4 xl:border-t-0 xl:border-l"
1519
+ :class="filled ? 'min-h-0 overflow-y-auto' : ''"
1264
1520
  >
1265
1521
  <template v-if="overlayMode === 'clicks'">
1266
1522
  <div class="flex flex-col gap-1.5">