@antelopejs/dms-marketing 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +143 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +35 -0
- package/dist/config.js.map +1 -0
- package/dist/data-api/body.d.ts +1 -0
- package/dist/data-api/body.js +15 -0
- package/dist/data-api/body.js.map +1 -0
- package/dist/data-api/funnels.d.ts +32 -0
- package/dist/data-api/funnels.js +302 -0
- package/dist/data-api/funnels.js.map +1 -0
- package/dist/data-api/hidden-filter.d.ts +1 -0
- package/dist/data-api/hidden-filter.js +11 -0
- package/dist/data-api/hidden-filter.js.map +1 -0
- package/dist/data-api/index.d.ts +4 -0
- package/dist/data-api/index.js +21 -0
- package/dist/data-api/index.js.map +1 -0
- package/dist/data-api/tenant-scope.d.ts +15 -0
- package/dist/data-api/tenant-scope.js +43 -0
- package/dist/data-api/tenant-scope.js.map +1 -0
- package/dist/data-api/websites.d.ts +25 -0
- package/dist/data-api/websites.js +73 -0
- package/dist/data-api/websites.js.map +1 -0
- package/dist/db/index.d.ts +2 -0
- package/dist/db/index.js +19 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/models/funnels.model.d.ts +25 -0
- package/dist/db/models/funnels.model.js +39 -0
- package/dist/db/models/funnels.model.js.map +1 -0
- package/dist/db/models/index.d.ts +7 -0
- package/dist/db/models/index.js +24 -0
- package/dist/db/models/index.js.map +1 -0
- package/dist/db/models/marketing_events.model.d.ts +49 -0
- package/dist/db/models/marketing_events.model.js +85 -0
- package/dist/db/models/marketing_events.model.js.map +1 -0
- package/dist/db/models/marketing_sessions.model.d.ts +33 -0
- package/dist/db/models/marketing_sessions.model.js +50 -0
- package/dist/db/models/marketing_sessions.model.js.map +1 -0
- package/dist/db/models/marketing_settings.model.d.ts +23 -0
- package/dist/db/models/marketing_settings.model.js +13 -0
- package/dist/db/models/marketing_settings.model.js.map +1 -0
- package/dist/db/models/page_snapshots.model.d.ts +27 -0
- package/dist/db/models/page_snapshots.model.js +32 -0
- package/dist/db/models/page_snapshots.model.js.map +1 -0
- package/dist/db/models/retention.d.ts +6 -0
- package/dist/db/models/retention.js +26 -0
- package/dist/db/models/retention.js.map +1 -0
- package/dist/db/models/website_statistics.model.d.ts +31 -0
- package/dist/db/models/website_statistics.model.js +63 -0
- package/dist/db/models/website_statistics.model.js.map +1 -0
- package/dist/db/models/websites.model.d.ts +26 -0
- package/dist/db/models/websites.model.js +27 -0
- package/dist/db/models/websites.model.js.map +1 -0
- package/dist/db/tables/funnels.table.d.ts +31 -0
- package/dist/db/tables/funnels.table.js +54 -0
- package/dist/db/tables/funnels.table.js.map +1 -0
- package/dist/db/tables/index.d.ts +7 -0
- package/dist/db/tables/index.js +24 -0
- package/dist/db/tables/index.js.map +1 -0
- package/dist/db/tables/marketing_events.table.d.ts +15 -0
- package/dist/db/tables/marketing_events.table.js +66 -0
- package/dist/db/tables/marketing_events.table.js.map +1 -0
- package/dist/db/tables/marketing_sessions.table.d.ts +26 -0
- package/dist/db/tables/marketing_sessions.table.js +115 -0
- package/dist/db/tables/marketing_sessions.table.js.map +1 -0
- package/dist/db/tables/marketing_settings.table.d.ts +11 -0
- package/dist/db/tables/marketing_settings.table.js +51 -0
- package/dist/db/tables/marketing_settings.table.js.map +1 -0
- package/dist/db/tables/page_snapshots.table.d.ts +17 -0
- package/dist/db/tables/page_snapshots.table.js +72 -0
- package/dist/db/tables/page_snapshots.table.js.map +1 -0
- package/dist/db/tables/website_statistics.table.d.ts +14 -0
- package/dist/db/tables/website_statistics.table.js +60 -0
- package/dist/db/tables/website_statistics.table.js.map +1 -0
- package/dist/db/tables/websites.table.d.ts +13 -0
- package/dist/db/tables/websites.table.js +60 -0
- package/dist/db/tables/websites.table.js.map +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +39 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/implementations/dms-marketing/index.d.ts +2 -0
- package/dist/implementations/dms-marketing/index.js +8 -0
- package/dist/implementations/dms-marketing/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/all.d.ts +5 -0
- package/dist/pages/all.js +22 -0
- package/dist/pages/all.js.map +1 -0
- package/dist/pages/campaigns/page.d.ts +9 -0
- package/dist/pages/campaigns/page.js +27 -0
- package/dist/pages/campaigns/page.js.map +1 -0
- package/dist/pages/funnels/page.d.ts +9 -0
- package/dist/pages/funnels/page.js +27 -0
- package/dist/pages/funnels/page.js.map +1 -0
- package/dist/pages/index.d.ts +4 -0
- package/dist/pages/index.js +21 -0
- package/dist/pages/index.js.map +1 -0
- package/dist/pages/module.d.ts +1 -0
- package/dist/pages/module.js +13 -0
- package/dist/pages/module.js.map +1 -0
- package/dist/pages/overview/page.d.ts +9 -0
- package/dist/pages/overview/page.js +27 -0
- package/dist/pages/overview/page.js.map +1 -0
- package/dist/pages/pages/page.d.ts +9 -0
- package/dist/pages/pages/page.js +27 -0
- package/dist/pages/pages/page.js.map +1 -0
- package/dist/pages/settings/form.d.ts +3 -0
- package/dist/pages/settings/form.js +35 -0
- package/dist/pages/settings/form.js.map +1 -0
- package/dist/pages/settings/page.d.ts +9 -0
- package/dist/pages/settings/page.js +27 -0
- package/dist/pages/settings/page.js.map +1 -0
- package/dist/routes/collect.d.ts +7 -0
- package/dist/routes/collect.js +62 -0
- package/dist/routes/collect.js.map +1 -0
- package/dist/routes/experiments.d.ts +6 -0
- package/dist/routes/experiments.js +63 -0
- package/dist/routes/experiments.js.map +1 -0
- package/dist/routes/funnels.d.ts +16 -0
- package/dist/routes/funnels.js +44 -0
- package/dist/routes/funnels.js.map +1 -0
- package/dist/routes/index.d.ts +8 -0
- package/dist/routes/index.js +25 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/settings.d.ts +8 -0
- package/dist/routes/settings.js +50 -0
- package/dist/routes/settings.js.map +1 -0
- package/dist/routes/snapshot.d.ts +10 -0
- package/dist/routes/snapshot.js +147 -0
- package/dist/routes/snapshot.js.map +1 -0
- package/dist/routes/stats.d.ts +44 -0
- package/dist/routes/stats.js +278 -0
- package/dist/routes/stats.js.map +1 -0
- package/dist/routes/tracker.d.ts +6 -0
- package/dist/routes/tracker.js +57 -0
- package/dist/routes/tracker.js.map +1 -0
- package/dist/routes/websites.d.ts +13 -0
- package/dist/routes/websites.js +144 -0
- package/dist/routes/websites.js.map +1 -0
- package/dist/services/acquisition.d.ts +15 -0
- package/dist/services/acquisition.js +153 -0
- package/dist/services/acquisition.js.map +1 -0
- package/dist/services/bounded-cache.d.ts +14 -0
- package/dist/services/bounded-cache.js +45 -0
- package/dist/services/bounded-cache.js.map +1 -0
- package/dist/services/collect.d.ts +11 -0
- package/dist/services/collect.js +193 -0
- package/dist/services/collect.js.map +1 -0
- package/dist/services/experiment-definitions.d.ts +4 -0
- package/dist/services/experiment-definitions.js +36 -0
- package/dist/services/experiment-definitions.js.map +1 -0
- package/dist/services/experiments/assign.d.ts +5 -0
- package/dist/services/experiments/assign.js +52 -0
- package/dist/services/experiments/assign.js.map +1 -0
- package/dist/services/experiments/index.d.ts +3 -0
- package/dist/services/experiments/index.js +20 -0
- package/dist/services/experiments/index.js.map +1 -0
- package/dist/services/experiments/results.d.ts +20 -0
- package/dist/services/experiments/results.js +91 -0
- package/dist/services/experiments/results.js.map +1 -0
- package/dist/services/experiments/stats.d.ts +12 -0
- package/dist/services/experiments/stats.js +62 -0
- package/dist/services/experiments/stats.js.map +1 -0
- package/dist/services/funnels/compute.d.ts +15 -0
- package/dist/services/funnels/compute.js +68 -0
- package/dist/services/funnels/compute.js.map +1 -0
- package/dist/services/funnels/index.d.ts +2 -0
- package/dist/services/funnels/index.js +19 -0
- package/dist/services/funnels/index.js.map +1 -0
- package/dist/services/funnels/results.d.ts +14 -0
- package/dist/services/funnels/results.js +72 -0
- package/dist/services/funnels/results.js.map +1 -0
- package/dist/services/geoip.d.ts +3 -0
- package/dist/services/geoip.js +38 -0
- package/dist/services/geoip.js.map +1 -0
- package/dist/services/heatmap.d.ts +10 -0
- package/dist/services/heatmap.js +180 -0
- package/dist/services/heatmap.js.map +1 -0
- package/dist/services/index.d.ts +26 -0
- package/dist/services/index.js +43 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/ingest-guard.d.ts +11 -0
- package/dist/services/ingest-guard.js +44 -0
- package/dist/services/ingest-guard.js.map +1 -0
- package/dist/services/keyed-queue.d.ts +4 -0
- package/dist/services/keyed-queue.js +19 -0
- package/dist/services/keyed-queue.js.map +1 -0
- package/dist/services/origin-guard.d.ts +5 -0
- package/dist/services/origin-guard.js +43 -0
- package/dist/services/origin-guard.js.map +1 -0
- package/dist/services/period.d.ts +2 -0
- package/dist/services/period.js +19 -0
- package/dist/services/period.js.map +1 -0
- package/dist/services/prune.d.ts +7 -0
- package/dist/services/prune.js +49 -0
- package/dist/services/prune.js.map +1 -0
- package/dist/services/query-param.d.ts +1 -0
- package/dist/services/query-param.js +11 -0
- package/dist/services/query-param.js.map +1 -0
- package/dist/services/request-body.d.ts +2 -0
- package/dist/services/request-body.js +41 -0
- package/dist/services/request-body.js.map +1 -0
- package/dist/services/request-meta.d.ts +3 -0
- package/dist/services/request-meta.js +19 -0
- package/dist/services/request-meta.js.map +1 -0
- package/dist/services/rollup-write.d.ts +4 -0
- package/dist/services/rollup-write.js +84 -0
- package/dist/services/rollup-write.js.map +1 -0
- package/dist/services/sessionize.d.ts +23 -0
- package/dist/services/sessionize.js +78 -0
- package/dist/services/sessionize.js.map +1 -0
- package/dist/services/settings.d.ts +6 -0
- package/dist/services/settings.js +113 -0
- package/dist/services/settings.js.map +1 -0
- package/dist/services/snapshots.d.ts +24 -0
- package/dist/services/snapshots.js +139 -0
- package/dist/services/snapshots.js.map +1 -0
- package/dist/services/tenant-funnel.d.ts +3 -0
- package/dist/services/tenant-funnel.js +17 -0
- package/dist/services/tenant-funnel.js.map +1 -0
- package/dist/services/tenant-website.d.ts +5 -0
- package/dist/services/tenant-website.js +34 -0
- package/dist/services/tenant-website.js.map +1 -0
- package/dist/services/url.d.ts +10 -0
- package/dist/services/url.js +81 -0
- package/dist/services/url.js.map +1 -0
- package/dist/services/user-agent.d.ts +2 -0
- package/dist/services/user-agent.js +17 -0
- package/dist/services/user-agent.js.map +1 -0
- package/dist/services/visitor-id.d.ts +1 -0
- package/dist/services/visitor-id.js +19 -0
- package/dist/services/visitor-id.js.map +1 -0
- package/dist/services/website-cache.d.ts +3 -0
- package/dist/services/website-cache.js +22 -0
- package/dist/services/website-cache.js.map +1 -0
- package/dist/types/constants.d.ts +74 -0
- package/dist/types/constants.js +79 -0
- package/dist/types/constants.js.map +1 -0
- package/dist/types/events.d.ts +114 -0
- package/dist/types/events.js +29 -0
- package/dist/types/events.js.map +1 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +22 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/settings.d.ts +67 -0
- package/dist/types/settings.js +58 -0
- package/dist/types/settings.js.map +1 -0
- package/dist/types/snapshots.d.ts +95 -0
- package/dist/types/snapshots.js +41 -0
- package/dist/types/snapshots.js.map +1 -0
- package/dist/types/statistics.d.ts +38 -0
- package/dist/types/statistics.js +33 -0
- package/dist/types/statistics.js.map +1 -0
- package/dist/utils/experiment-type.d.ts +52 -0
- package/dist/utils/experiment-type.js +94 -0
- package/dist/utils/experiment-type.js.map +1 -0
- package/dist/utils/funnel-steps-type.d.ts +42 -0
- package/dist/utils/funnel-steps-type.js +62 -0
- package/dist/utils/funnel-steps-type.js.map +1 -0
- package/frontend-vue/.editorconfig +12 -0
- package/frontend-vue/.prettierrc +7 -0
- package/frontend-vue/app/components/CampaignsView.vue +330 -0
- package/frontend-vue/app/components/ExperimentInput.vue +128 -0
- package/frontend-vue/app/components/FunnelDetail.vue +1026 -0
- package/frontend-vue/app/components/FunnelStepsInput.vue +154 -0
- package/frontend-vue/app/components/FunnelsView.vue +493 -0
- package/frontend-vue/app/components/HeatmapCanvas.vue +116 -0
- package/frontend-vue/app/components/NoWebsiteState.vue +18 -0
- package/frontend-vue/app/components/OverviewView.vue +493 -0
- package/frontend-vue/app/components/PagePreview.vue +915 -0
- package/frontend-vue/app/components/PagesView.vue +378 -0
- package/frontend-vue/app/components/ScrollDepthOverlay.vue +158 -0
- package/frontend-vue/app/components/TextKpiCard.vue +28 -0
- package/frontend-vue/app/components/TopListTabsCard.vue +162 -0
- package/frontend-vue/app/components/TrackerSnippet.vue +31 -0
- package/frontend-vue/app/components/VariationsInput.vue +164 -0
- package/frontend-vue/app/composables/useChildId.ts +13 -0
- package/frontend-vue/app/composables/useExperiment.ts +59 -0
- package/frontend-vue/app/composables/useFunnelSteps.ts +25 -0
- package/frontend-vue/app/composables/useLatestRequest.ts +90 -0
- package/frontend-vue/app/composables/useMarketingApi.ts +420 -0
- package/frontend-vue/app/composables/useMarketingPeriod.ts +66 -0
- package/frontend-vue/app/composables/useMarketingRoutes.ts +51 -0
- package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +69 -0
- package/frontend-vue/app/composables/useMarketingWebsites.ts +80 -0
- package/frontend-vue/app/composables/useSnapshotFrame.ts +183 -0
- package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +46 -0
- package/frontend-vue/app/utils/dmsComponent.ts +18 -0
- package/frontend-vue/app/utils/heatmapPalette.ts +41 -0
- package/frontend-vue/app/utils/snapshotViewportUnits.ts +63 -0
- package/frontend-vue/dms.frontend.ts +35 -0
- package/frontend-vue/eslint.config.mjs +16 -0
- package/frontend-vue/i18n/locales/marketing-en-GB.json +382 -0
- package/frontend-vue/i18n/locales/marketing-fr-FR.json +382 -0
- package/frontend-vue/package.json +30 -0
- package/frontend-vue/pnpm-lock.yaml +5226 -0
- package/frontend-vue/pnpm-workspace.yaml +2 -0
- package/frontend-vue/tests/drafts.test.ts +54 -0
- package/frontend-vue/tests/i18n.test.ts +86 -0
- package/frontend-vue/tests/period.test.ts +35 -0
- package/frontend-vue/tests/registration.test.ts +63 -0
- package/frontend-vue/tests/routes.test.ts +31 -0
- package/frontend-vue/tests/stubs/frontend-module.ts +56 -0
- package/frontend-vue/vitest.config.ts +20 -0
- package/package.json +91 -0
- package/static/snapshot.js +497 -0
- package/static/tracker.js +503 -0
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
|
+
import { useI18n } from '#dms/frontend-module'
|
|
4
|
+
import { useLatestRequest } from '../composables/useLatestRequest'
|
|
5
|
+
import {
|
|
6
|
+
useMarketingApi,
|
|
7
|
+
type MarketingHeatmap,
|
|
8
|
+
type MarketingPageSnapshotResponse,
|
|
9
|
+
type MarketingScrollDepth,
|
|
10
|
+
type MarketingSnapshotLayout,
|
|
11
|
+
type MarketingWebsite,
|
|
12
|
+
type MarketingWebsitePatch,
|
|
13
|
+
} from '../composables/useMarketingApi'
|
|
14
|
+
import {
|
|
15
|
+
useSnapshotFrame,
|
|
16
|
+
type AnchorBox,
|
|
17
|
+
type AnchorRequest,
|
|
18
|
+
} from '../composables/useSnapshotFrame'
|
|
19
|
+
import { rewriteViewportUnits } from '../utils/snapshotViewportUnits'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Click and scroll-depth overlays of one tracked page, over a snapshot of
|
|
23
|
+
* that page. The two overlays switch above ONE persistent frame — plain
|
|
24
|
+
* buttons, never a Tab block, whose default unmounting of hidden slots would
|
|
25
|
+
* tear the frame down and re-measure it on every switch.
|
|
26
|
+
*
|
|
27
|
+
* The invariant of this surface: THE NUMBERS NEVER DEGRADE. Points, weights
|
|
28
|
+
* and totals come from the database; the snapshot is the surface they are
|
|
29
|
+
* drawn on. Without one — snapshots off for the site, no sampled visit
|
|
30
|
+
* captured yet — nothing is drawn: the counts stay in the header, the
|
|
31
|
+
* banner says why the page has no backdrop and when it will, and no empty
|
|
32
|
+
* pane pretends to be a page.
|
|
33
|
+
*
|
|
34
|
+
* The wallpaper is a captured DOM, not the live site: what a sampled visitor
|
|
35
|
+
* saw, rendered in a `srcdoc` frame sandboxed without scripts. Same-origin,
|
|
36
|
+
* so its geometry is read directly; inert, so pointer events never reach it
|
|
37
|
+
* and nothing in it can be clicked, hovered or navigated. Interacting with
|
|
38
|
+
* the real page goes through "open in a new tab".
|
|
39
|
+
*
|
|
40
|
+
* ## Geometry
|
|
41
|
+
*
|
|
42
|
+
* The frame is laid out at the chosen width and pinned to the captured
|
|
43
|
+
* viewport height until the snapshot's size is read (see useSnapshotFrame)
|
|
44
|
+
* — never at the height it is about to report, which is what would make
|
|
45
|
+
* every `100vh` page grow without bound. Once a size is published the frame
|
|
46
|
+
* expands to it, once. Viewport-relative units are rewritten to the pixels
|
|
47
|
+
* they had in the visitor's viewport before the document is rendered (see
|
|
48
|
+
* rewriteViewportUnits), so the reading is the visitor's page height and
|
|
49
|
+
* the resize changes nothing inside. The frame is remounted (`:key`)
|
|
50
|
+
* whenever the snapshot or the layout width changes, so the composable
|
|
51
|
+
* re-arms on a fresh document.
|
|
52
|
+
*/
|
|
53
|
+
const props = defineProps<{
|
|
54
|
+
website: MarketingWebsite | null
|
|
55
|
+
path: string
|
|
56
|
+
period: string
|
|
57
|
+
/** Share of visits capturing clicks — why a quiet page looks empty. */
|
|
58
|
+
sampleRate: number
|
|
59
|
+
trackerEnabled: boolean
|
|
60
|
+
/** Pageviews of this path over the window, for the sampling hint. */
|
|
61
|
+
pageviews?: number
|
|
62
|
+
/** Bumped by the parent when it resets clicks out from under this view. */
|
|
63
|
+
refreshToken?: number
|
|
64
|
+
}>()
|
|
65
|
+
|
|
66
|
+
/** The site's snapshot options were changed here; the parent owns the row. */
|
|
67
|
+
const emit = defineEmits<{ 'website-updated': [] }>()
|
|
68
|
+
|
|
69
|
+
interface LayoutPreset {
|
|
70
|
+
width: number
|
|
71
|
+
layout: MarketingSnapshotLayout
|
|
72
|
+
icon: string
|
|
73
|
+
labelKey: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Layouts offered for inspection; a mobile layout is a different page. The
|
|
78
|
+
* tablet is upright on purpose: lying flat it is 1024+ wide, a width most
|
|
79
|
+
* breakpoints already dress as desktop — the button would repeat 1280.
|
|
80
|
+
*/
|
|
81
|
+
const LAYOUT_PRESETS: LayoutPreset[] = [
|
|
82
|
+
{ width: 1280, layout: 'desktop', icon: 'i-ph-monitor', labelKey: 'page.marketing.pages.preview.layout_desktop' },
|
|
83
|
+
{ width: 768, layout: 'tablet', icon: 'i-ph-device-tablet', labelKey: 'page.marketing.pages.preview.layout_tablet' },
|
|
84
|
+
{ width: 390, layout: 'phone', icon: 'i-ph-device-mobile', labelKey: 'page.marketing.pages.preview.layout_phone' },
|
|
85
|
+
]
|
|
86
|
+
const DEFAULT_LAYOUT_WIDTH = 1280
|
|
87
|
+
/** Viewport the snapshot is measured through when it recorded none. */
|
|
88
|
+
const PROBE_VIEWPORT_HEIGHT = 900
|
|
89
|
+
/** Height of the scrollable pane the scaled surface lives in. */
|
|
90
|
+
const PANE_HEIGHT = 620
|
|
91
|
+
/** Overflow beyond the chosen layout width worth telling the operator about. */
|
|
92
|
+
const OVERFLOW_TOLERANCE = 1.05
|
|
93
|
+
const SNAPSHOT_BYTE_CAP = 1024 * 1024
|
|
94
|
+
const LIGHT_CANVAS = '#ffffff'
|
|
95
|
+
const DARK_CANVAS = '#111111'
|
|
96
|
+
|
|
97
|
+
const api = useMarketingApi()
|
|
98
|
+
const { t, locale } = useI18n()
|
|
99
|
+
|
|
100
|
+
// --- Heatmap ----------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
// Path, site and period all retrigger a load and the answers can land out of
|
|
103
|
+
// order: a slow answer for the page the user just left would otherwise repaint
|
|
104
|
+
// over the one on screen — see useLatestRequest.
|
|
105
|
+
const {
|
|
106
|
+
data: heatmap,
|
|
107
|
+
loading,
|
|
108
|
+
failed,
|
|
109
|
+
run: loadHeatmap,
|
|
110
|
+
} = useLatestRequest<MarketingHeatmap>(() =>
|
|
111
|
+
props.website && props.path && !import.meta.env.SSR
|
|
112
|
+
? api.getHeatmap(props.website._id, props.path, props.period)
|
|
113
|
+
: null,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
watch(
|
|
117
|
+
() => [props.website?._id, props.path, props.period, props.refreshToken],
|
|
118
|
+
() => void loadHeatmap(),
|
|
119
|
+
{ immediate: true },
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const points = computed(() => heatmap.value?.points ?? [])
|
|
123
|
+
const maxWeight = computed(() => heatmap.value?.maxWeight ?? 0)
|
|
124
|
+
const totalClicks = computed(() => heatmap.value?.totalClicks ?? 0)
|
|
125
|
+
|
|
126
|
+
// --- Scroll depth -----------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
const overlayMode = ref<'clicks' | 'scroll'>('clicks')
|
|
129
|
+
|
|
130
|
+
const {
|
|
131
|
+
data: scrollDepth,
|
|
132
|
+
loading: loadingScroll,
|
|
133
|
+
failed: scrollFailed,
|
|
134
|
+
run: loadScrollDepth,
|
|
135
|
+
} = useLatestRequest<MarketingScrollDepth>(() =>
|
|
136
|
+
props.website && props.path && !import.meta.env.SSR
|
|
137
|
+
? api.getScrollDepth(props.website._id, props.path, props.period)
|
|
138
|
+
: null,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Lazy on purpose: nothing is fetched until the overlay is first opened,
|
|
143
|
+
* then kept fresh against the same keys the click read watches. The key
|
|
144
|
+
* comparison is what keeps a mere clicks↔scroll round trip from refetching.
|
|
145
|
+
*/
|
|
146
|
+
let scrollLoadedKey = ''
|
|
147
|
+
|
|
148
|
+
function ensureScrollDepth(): void {
|
|
149
|
+
if (overlayMode.value !== 'scroll') {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const key = [props.website?._id, props.path, props.period, props.refreshToken].join('|')
|
|
153
|
+
if (key === scrollLoadedKey) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
scrollLoadedKey = key
|
|
157
|
+
void loadScrollDepth()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
watch(
|
|
161
|
+
() => [props.website?._id, props.path, props.period, props.refreshToken, overlayMode.value],
|
|
162
|
+
ensureScrollDepth,
|
|
163
|
+
{ immediate: true },
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const scrollViews = computed(() => scrollDepth.value?.views ?? 0)
|
|
167
|
+
const scrollReached = computed(() => scrollDepth.value?.reached ?? [])
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Where depth 0 sits on the drawn page: the bottom of the first screen.
|
|
171
|
+
* Depth is a fraction of the visitor's scrollable range; the captured
|
|
172
|
+
* viewport stands in for the visitors' real ones, so the overlay claims
|
|
173
|
+
* bands, not pixels.
|
|
174
|
+
*/
|
|
175
|
+
const scrollFoldRatio = computed(() =>
|
|
176
|
+
renderHeight.value > 0
|
|
177
|
+
? Math.min(probeHeight.value / renderHeight.value, 1)
|
|
178
|
+
: 1,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// --- Reset ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
const { confirm } = useConfirm()
|
|
184
|
+
const toast = useToast()
|
|
185
|
+
const resetting = ref(false)
|
|
186
|
+
|
|
187
|
+
/** Deletes the clicks, scrolls and snapshots of the page (every period),
|
|
188
|
+
* then refetches the proof for whichever overlay is showing. */
|
|
189
|
+
async function resetPageHeatmap() {
|
|
190
|
+
const website = props.website
|
|
191
|
+
if (!website) {
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
const confirmed = await confirm({
|
|
195
|
+
title: t('page.marketing.pages.reset.page_title'),
|
|
196
|
+
description: t('page.marketing.pages.reset.page_description', { path: props.path }),
|
|
197
|
+
confirmLabel: t('page.marketing.pages.reset.confirm'),
|
|
198
|
+
confirmColor: 'error',
|
|
199
|
+
})
|
|
200
|
+
if (!confirmed) {
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
resetting.value = true
|
|
204
|
+
try {
|
|
205
|
+
const { deleted } = await api.resetHeatmap(website._id, props.path)
|
|
206
|
+
toast.add({
|
|
207
|
+
color: 'success',
|
|
208
|
+
title: t('page.marketing.pages.reset.done', { count: deleted }, deleted),
|
|
209
|
+
})
|
|
210
|
+
scrollLoadedKey = ''
|
|
211
|
+
ensureScrollDepth()
|
|
212
|
+
await Promise.all([loadHeatmap(), loadSnapshot()])
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
toast.add({ color: 'error', title: t('page.marketing.pages.reset.error') })
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
resetting.value = false
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Snapshot ---------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
const layoutWidth = ref(DEFAULT_LAYOUT_WIDTH)
|
|
225
|
+
|
|
226
|
+
const layout = computed<MarketingSnapshotLayout>(
|
|
227
|
+
() => LAYOUT_PRESETS.find(preset => preset.width === layoutWidth.value)?.layout ?? 'desktop',
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
const snapshotsEnabled = computed(() => props.website?.snapshotsEnabled ?? false)
|
|
231
|
+
|
|
232
|
+
// Not fetched while the site has snapshots off: the option's banner is the
|
|
233
|
+
// whole story then, and a leftover capture must not contradict it.
|
|
234
|
+
const {
|
|
235
|
+
data: snapshotResponse,
|
|
236
|
+
settled: snapshotSettled,
|
|
237
|
+
run: loadSnapshot,
|
|
238
|
+
} = useLatestRequest<MarketingPageSnapshotResponse>(() =>
|
|
239
|
+
props.website && snapshotsEnabled.value && props.path && !import.meta.env.SSR
|
|
240
|
+
? api.getSnapshot(props.website._id, props.path, layout.value)
|
|
241
|
+
: null,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
watch(
|
|
245
|
+
() => [props.website?._id, snapshotsEnabled.value, props.path, layout.value, props.refreshToken],
|
|
246
|
+
() => void loadSnapshot(),
|
|
247
|
+
{ immediate: true },
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const snapshot = computed(() => snapshotResponse.value?.snapshot ?? null)
|
|
251
|
+
|
|
252
|
+
const backdrop = computed(() => snapshot.value?.html ?? null)
|
|
253
|
+
|
|
254
|
+
/** The document the frame renders: the capture, its viewport units fixed
|
|
255
|
+
* to the visitor's screen at the width being looked at. */
|
|
256
|
+
const frameDocument = computed(() =>
|
|
257
|
+
snapshot.value && backdrop.value !== null
|
|
258
|
+
? rewriteViewportUnits(backdrop.value, {
|
|
259
|
+
width: layoutWidth.value,
|
|
260
|
+
height: snapshot.value.viewport.height,
|
|
261
|
+
})
|
|
262
|
+
: '',
|
|
263
|
+
)
|
|
264
|
+
const noSnapshotYet = computed(
|
|
265
|
+
() => snapshotsEnabled.value && snapshotSettled.value && snapshot.value === null,
|
|
266
|
+
)
|
|
267
|
+
const tooHeavy = computed(() => snapshot.value !== null && backdrop.value === null)
|
|
268
|
+
const snapshotScheme = computed(() => snapshot.value?.colorScheme ?? 'light')
|
|
269
|
+
const capturedAtLabel = computed(() => {
|
|
270
|
+
if (!snapshot.value) {
|
|
271
|
+
return ''
|
|
272
|
+
}
|
|
273
|
+
const date = new Date(snapshot.value.capturedAt).toLocaleString(locale.value)
|
|
274
|
+
return t(
|
|
275
|
+
tooHeavy.value
|
|
276
|
+
? 'page.marketing.pages.preview.attempted_at'
|
|
277
|
+
: 'page.marketing.pages.preview.captured_at',
|
|
278
|
+
{ date },
|
|
279
|
+
)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
function mebibytes(bytes: number): string {
|
|
283
|
+
return new Intl.NumberFormat(locale.value, { maximumFractionDigits: 1 }).format(
|
|
284
|
+
bytes / (1024 * 1024),
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const savingOptions = ref(false)
|
|
289
|
+
|
|
290
|
+
async function updateSnapshotOptions(patch: MarketingWebsitePatch): Promise<void> {
|
|
291
|
+
const website = props.website
|
|
292
|
+
if (!website) {
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
savingOptions.value = true
|
|
296
|
+
try {
|
|
297
|
+
await api.updateWebsite(website._id, patch)
|
|
298
|
+
emit('website-updated')
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
toast.add({ color: 'error', title: t('page.marketing.pages.preview.options_error') })
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
savingOptions.value = false
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// --- Frame geometry ---------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/** Free text: an empty field must stay empty, not refill with the applied value. */
|
|
311
|
+
const heightField = ref('')
|
|
312
|
+
|
|
313
|
+
const heightOverride = computed(() => {
|
|
314
|
+
const typed = Number.parseInt(heightField.value, 10)
|
|
315
|
+
return Number.isFinite(typed) && typed > 0 ? typed : null
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
const frame = ref<HTMLIFrameElement | null>(null)
|
|
319
|
+
const {
|
|
320
|
+
measuredWidth,
|
|
321
|
+
measuredHeight,
|
|
322
|
+
tier,
|
|
323
|
+
close: closeFrame,
|
|
324
|
+
measureAnchors,
|
|
325
|
+
} = useSnapshotFrame(frame)
|
|
326
|
+
|
|
327
|
+
// Typing a height resizes the frame by hand, so the next reading would
|
|
328
|
+
// measure that — stop reading rather than latch it.
|
|
329
|
+
watch(heightOverride, (value) => {
|
|
330
|
+
if (value !== null && tier.value === 'pending') {
|
|
331
|
+
closeFrame()
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// A different width is a different rendering of the snapshot: the frame
|
|
336
|
+
// remounts on the key change, and the composable re-arms on the new one.
|
|
337
|
+
watch(layoutWidth, () => {
|
|
338
|
+
heightField.value = ''
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// A typed height belongs to the page it was typed on.
|
|
342
|
+
watch(() => [props.website?._id, props.path], () => {
|
|
343
|
+
heightField.value = ''
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
const frameKey = computed(() =>
|
|
347
|
+
[props.path, snapshot.value?.layout, snapshot.value?.capturedAt, layoutWidth.value].join('|'),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
/** Viewport the frame is pinned to until its size is read. */
|
|
351
|
+
const probeHeight = computed(() => snapshot.value?.viewport.height ?? PROBE_VIEWPORT_HEIGHT)
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Height of the drawn surface — the overlay's coordinate space. Falls back to
|
|
355
|
+
* a typical long page while the real one is unknown, so the heatmap is
|
|
356
|
+
* readable before (and without) any snapshot.
|
|
357
|
+
*/
|
|
358
|
+
const renderHeight = computed(() => {
|
|
359
|
+
const chosen = heightOverride.value ?? measuredHeight.value
|
|
360
|
+
if (chosen !== null) {
|
|
361
|
+
return Math.min(Math.max(chosen, MIN_RENDER_HEIGHT), MAX_RENDER_HEIGHT)
|
|
362
|
+
}
|
|
363
|
+
return DEFAULT_RENDER_HEIGHT
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Height of the frame itself. Pinned to the probe viewport while a reading
|
|
368
|
+
* is still possible — resizing it earlier would corrupt the very number
|
|
369
|
+
* being waited on. An operator override takes it back by hand.
|
|
370
|
+
*/
|
|
371
|
+
const frameHeight = computed(() => {
|
|
372
|
+
if (heightOverride.value !== null) {
|
|
373
|
+
return renderHeight.value
|
|
374
|
+
}
|
|
375
|
+
if (measuredHeight.value !== null) {
|
|
376
|
+
return measuredHeight.value
|
|
377
|
+
}
|
|
378
|
+
return tier.value === 'pending' ? probeHeight.value : renderHeight.value
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Width the overlay maps onto. Clicks are normalized against the document's
|
|
383
|
+
* `scrollWidth`, so a document wider than the frame must widen the canvas or
|
|
384
|
+
* every x is compressed — while the frame itself stays at the chosen layout
|
|
385
|
+
* width, which is what keeps the width buttons meaningful. The overflowing
|
|
386
|
+
* part is clipped by the pane, exactly as it is clipped inside the frame.
|
|
387
|
+
*/
|
|
388
|
+
const overlayWidth = computed(() =>
|
|
389
|
+
Math.min(Math.max(measuredWidth.value ?? 0, layoutWidth.value), MAX_RENDER_WIDTH),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
// --- Element anchoring ------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* A click is stored twice: as a document fraction, and as an offset inside
|
|
396
|
+
* the element it hit. Only the second survives being read back at another
|
|
397
|
+
* size — a document fraction divides by a box that, on a page shorter than
|
|
398
|
+
* the visitor's window, IS that window, so the same button arrives here as a
|
|
399
|
+
* different fraction per visitor. Given the element's box in the layout on
|
|
400
|
+
* screen, the offset puts the point back where the visitor put it.
|
|
401
|
+
*
|
|
402
|
+
* The boxes come from the snapshot document. Every way that can fail — no
|
|
403
|
+
* snapshot, an element that no longer exists, a selector that became
|
|
404
|
+
* ambiguous — resolves to no box, and a point with no box keeps its document
|
|
405
|
+
* fraction, never a blank one.
|
|
406
|
+
*/
|
|
407
|
+
const anchorBoxes = ref(new Map<string, AnchorBox>())
|
|
408
|
+
|
|
409
|
+
function anchorKey(anchor: AnchorRequest): string {
|
|
410
|
+
return `${anchor.selector}\0${anchor.nth}`
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const requestedAnchors = computed(() => {
|
|
414
|
+
const unique = new Map<string, AnchorRequest>()
|
|
415
|
+
for (const point of points.value) {
|
|
416
|
+
if (!point.anchor) {
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
const request = { selector: point.anchor.selector, nth: point.anchor.nth }
|
|
420
|
+
unique.set(anchorKey(request), request)
|
|
421
|
+
}
|
|
422
|
+
return [...unique.values()]
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
/** Points in the overlay's coordinate space, anchored where one resolved. */
|
|
426
|
+
const drawnPoints = computed(() =>
|
|
427
|
+
points.value.map((point) => {
|
|
428
|
+
const anchor = point.anchor
|
|
429
|
+
if (!anchor) {
|
|
430
|
+
return point
|
|
431
|
+
}
|
|
432
|
+
const box = anchorBoxes.value.get(anchorKey(anchor))
|
|
433
|
+
if (!box || overlayWidth.value <= 0 || renderHeight.value <= 0) {
|
|
434
|
+
return point
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
...point,
|
|
438
|
+
x: (box.x + anchor.ox * box.width) / overlayWidth.value,
|
|
439
|
+
y: (box.y + anchor.oy * box.height) / renderHeight.value,
|
|
440
|
+
}
|
|
441
|
+
}),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
let measureToken = 0
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Re-measure whenever the layout the boxes were taken in changes. The token
|
|
448
|
+
* drops a pass for a geometry that is no longer on screen: the frame is
|
|
449
|
+
* resized from the published reading, and a box read before that resize has
|
|
450
|
+
* been laid out measures the probe viewport.
|
|
451
|
+
*/
|
|
452
|
+
async function refreshAnchorBoxes(): Promise<void> {
|
|
453
|
+
const token = ++measureToken
|
|
454
|
+
if (tier.value !== 'measured' || requestedAnchors.value.length === 0) {
|
|
455
|
+
if (anchorBoxes.value.size > 0) {
|
|
456
|
+
anchorBoxes.value = new Map()
|
|
457
|
+
}
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
await nextTick()
|
|
461
|
+
await new Promise(resolve => requestAnimationFrame(resolve))
|
|
462
|
+
if (token !== measureToken) {
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
const resolved = new Map<string, AnchorBox>()
|
|
466
|
+
for (const box of measureAnchors(requestedAnchors.value)) {
|
|
467
|
+
resolved.set(anchorKey(box), box)
|
|
468
|
+
}
|
|
469
|
+
anchorBoxes.value = resolved
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
watch(
|
|
473
|
+
[requestedAnchors, tier, renderHeight, overlayWidth],
|
|
474
|
+
() => void refreshAnchorBoxes(),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The document does not fit the surface the canvas can allocate. Both are
|
|
479
|
+
* clamped, so the overlay is uniformly compressed against a backdrop that
|
|
480
|
+
* is merely cut off — the two no longer line up, and that is worth saying
|
|
481
|
+
* rather than showing.
|
|
482
|
+
*/
|
|
483
|
+
const oversized = computed(
|
|
484
|
+
() =>
|
|
485
|
+
(measuredHeight.value ?? 0) > MAX_RENDER_HEIGHT
|
|
486
|
+
|| (measuredWidth.value ?? 0) > MAX_RENDER_WIDTH,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
const overflowing = computed(
|
|
490
|
+
() => (measuredWidth.value ?? 0) > layoutWidth.value * OVERFLOW_TOLERANCE,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
// The surface is laid out at `layoutWidth` and zoomed down to the pane, so the
|
|
494
|
+
// overlay and the page share one coordinate space at any container size.
|
|
495
|
+
// `zoom`, not `transform: scale()`: a transformed frame is rasterized at its
|
|
496
|
+
// natural size and resampled, and at a fractional scale the thin borders of
|
|
497
|
+
// inputs and cards drop out; zoom reaches the frame's document as a page
|
|
498
|
+
// zoom, laid out at the same CSS width and painted crisp at the scale. The
|
|
499
|
+
// pane always reserves its scrollbar (`overflow-y-scroll`): letting it appear
|
|
500
|
+
// and disappear would change the width the scale is derived from, which
|
|
501
|
+
// changes the height, which decides whether the scrollbar is there at all.
|
|
502
|
+
const container = ref<HTMLElement | null>(null)
|
|
503
|
+
const containerWidth = ref(0)
|
|
504
|
+
let observer: ResizeObserver | null = null
|
|
505
|
+
|
|
506
|
+
watch(container, (el) => {
|
|
507
|
+
observer?.disconnect()
|
|
508
|
+
observer = null
|
|
509
|
+
if (!el) {
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
containerWidth.value = el.clientWidth
|
|
513
|
+
observer = new ResizeObserver(() => {
|
|
514
|
+
containerWidth.value = el.clientWidth
|
|
515
|
+
})
|
|
516
|
+
observer.observe(el)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
onBeforeUnmount(() => observer?.disconnect())
|
|
520
|
+
|
|
521
|
+
const scale = computed(() =>
|
|
522
|
+
containerWidth.value > 0 ? containerWidth.value / layoutWidth.value : 1,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
/** Height of the zoomed surface in the pane's own pixels — the scroll range
|
|
526
|
+
* of the pane and the space the scroll-depth overlay draws in. */
|
|
527
|
+
const scrolledHeight = computed(() => Math.round(renderHeight.value * scale.value))
|
|
528
|
+
|
|
529
|
+
// --- Emptiness, explained ---------------------------------------------------
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Page loads whose clicks were captured at all. Sampling is drawn once per
|
|
533
|
+
* page load (tracker.js), so this bounds whether a heatmap could exist — it
|
|
534
|
+
* is not a click estimate, and must not be shown as one next to the real
|
|
535
|
+
* count.
|
|
536
|
+
*/
|
|
537
|
+
const expectedSampledPageLoads = computed(() =>
|
|
538
|
+
props.pageviews ? Math.round(props.pageviews * props.sampleRate) : null,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
/** Silence has shared causes (collection off, sampled out) before the
|
|
542
|
+
* per-overlay one; both overlays explain themselves through this. */
|
|
543
|
+
function captureEmptyReason(fallbackKey: string): string {
|
|
544
|
+
if (!props.trackerEnabled) {
|
|
545
|
+
return t('page.marketing.pages.empty.tracking_off')
|
|
546
|
+
}
|
|
547
|
+
if (expectedSampledPageLoads.value !== null && expectedSampledPageLoads.value < 1) {
|
|
548
|
+
return t('page.marketing.pages.empty.sampled_out', {
|
|
549
|
+
rate: Math.round(props.sampleRate * 100),
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
return t(fallbackKey)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const emptyState = computed(() => {
|
|
556
|
+
if (overlayMode.value === 'clicks') {
|
|
557
|
+
if (loading.value || heatmap.value === null || totalClicks.value > 0) {
|
|
558
|
+
return null
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
title: t('page.marketing.pages.empty.title'),
|
|
562
|
+
description: captureEmptyReason('page.marketing.pages.empty.no_clicks'),
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (loadingScroll.value || scrollDepth.value === null || scrollViews.value > 0) {
|
|
566
|
+
return null
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
title: t('page.marketing.pages.empty.scroll_title'),
|
|
570
|
+
description: captureEmptyReason('page.marketing.pages.empty.no_scrolls'),
|
|
571
|
+
}
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
// --- Per-overlay surface state ----------------------------------------------
|
|
575
|
+
|
|
576
|
+
const activeFailed = computed(() =>
|
|
577
|
+
overlayMode.value === 'clicks' ? failed.value : scrollFailed.value,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
const activeLoading = computed(() =>
|
|
581
|
+
overlayMode.value === 'clicks' ? loading.value : loadingScroll.value,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
function retryActive(): void {
|
|
585
|
+
if (overlayMode.value === 'clicks') {
|
|
586
|
+
void loadHeatmap()
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
void loadScrollDepth()
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const truncatedState = computed(() => {
|
|
593
|
+
if (overlayMode.value === 'clicks' && heatmap.value?.truncated) {
|
|
594
|
+
return {
|
|
595
|
+
title: t('page.marketing.pages.truncated'),
|
|
596
|
+
description: t('page.marketing.pages.truncated_hint'),
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (overlayMode.value === 'scroll' && scrollDepth.value?.truncated) {
|
|
600
|
+
return {
|
|
601
|
+
title: t('page.marketing.pages.scroll.truncated'),
|
|
602
|
+
description: t('page.marketing.pages.scroll.truncated_hint'),
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return null
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
/** The real page, on the origin the snapshot was captured from — opened,
|
|
609
|
+
* never framed. */
|
|
610
|
+
const externalUrl = computed(() => {
|
|
611
|
+
if (!snapshot.value || !props.path) {
|
|
612
|
+
return ''
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
return new URL(props.path, snapshot.value.origin).toString()
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
return ''
|
|
619
|
+
}
|
|
620
|
+
})
|
|
621
|
+
</script>
|
|
622
|
+
|
|
623
|
+
<template>
|
|
624
|
+
<div class="flex flex-col gap-4 p-4">
|
|
625
|
+
<!-- Toolbar -->
|
|
626
|
+
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
627
|
+
<div class="min-w-0">
|
|
628
|
+
<p class="truncate font-mono text-sm text-highlighted">{{ props.path }}</p>
|
|
629
|
+
<p class="text-xs text-muted">
|
|
630
|
+
<template v-if="overlayMode === 'clicks'">
|
|
631
|
+
{{ t('page.marketing.pages.clicks', { count: totalClicks }, totalClicks) }}
|
|
632
|
+
</template>
|
|
633
|
+
<template v-else>
|
|
634
|
+
{{ t('page.marketing.pages.scroll.views', { count: scrollViews }, scrollViews) }}
|
|
635
|
+
</template>
|
|
636
|
+
<span v-if="props.pageviews">
|
|
637
|
+
· {{ t('page.marketing.pages.pageviews', { count: props.pageviews }, props.pageviews) }}
|
|
638
|
+
</span>
|
|
639
|
+
<span v-if="capturedAtLabel">
|
|
640
|
+
· {{ capturedAtLabel }}
|
|
641
|
+
</span>
|
|
642
|
+
</p>
|
|
643
|
+
</div>
|
|
644
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
645
|
+
<UFieldGroup size="sm">
|
|
646
|
+
<UButton
|
|
647
|
+
icon="i-ph-cursor-click"
|
|
648
|
+
:color="overlayMode === 'clicks' ? 'primary' : 'neutral'"
|
|
649
|
+
variant="subtle"
|
|
650
|
+
:label="t('page.marketing.pages.overlay.clicks')"
|
|
651
|
+
@click="overlayMode = 'clicks'"
|
|
652
|
+
/>
|
|
653
|
+
<UButton
|
|
654
|
+
icon="i-ph-caret-double-down"
|
|
655
|
+
:color="overlayMode === 'scroll' ? 'primary' : 'neutral'"
|
|
656
|
+
variant="subtle"
|
|
657
|
+
:label="t('page.marketing.pages.overlay.scroll')"
|
|
658
|
+
@click="overlayMode = 'scroll'"
|
|
659
|
+
/>
|
|
660
|
+
</UFieldGroup>
|
|
661
|
+
<UFieldGroup size="sm">
|
|
662
|
+
<UButton
|
|
663
|
+
v-for="preset in LAYOUT_PRESETS"
|
|
664
|
+
:key="preset.width"
|
|
665
|
+
:icon="preset.icon"
|
|
666
|
+
:color="layoutWidth === preset.width ? 'primary' : 'neutral'"
|
|
667
|
+
variant="subtle"
|
|
668
|
+
:title="`${t(preset.labelKey)} · ${preset.width}px`"
|
|
669
|
+
:aria-label="`${t(preset.labelKey)} · ${preset.width}px`"
|
|
670
|
+
:ui="{ leadingIcon: 'size-5' }"
|
|
671
|
+
@click="layoutWidth = preset.width"
|
|
672
|
+
/>
|
|
673
|
+
</UFieldGroup>
|
|
674
|
+
<UInput
|
|
675
|
+
v-model="heightField"
|
|
676
|
+
type="number"
|
|
677
|
+
:min="MIN_RENDER_HEIGHT"
|
|
678
|
+
:placeholder="`${renderHeight}`"
|
|
679
|
+
step="200"
|
|
680
|
+
size="xs"
|
|
681
|
+
class="w-24"
|
|
682
|
+
:title="t('page.marketing.pages.preview.height_override')"
|
|
683
|
+
/>
|
|
684
|
+
<UButton
|
|
685
|
+
v-if="externalUrl"
|
|
686
|
+
icon="i-ph-arrow-square-out"
|
|
687
|
+
:to="externalUrl"
|
|
688
|
+
target="_blank"
|
|
689
|
+
color="neutral"
|
|
690
|
+
variant="ghost"
|
|
691
|
+
size="xs"
|
|
692
|
+
:label="t('page.marketing.pages.preview.open_in_tab')"
|
|
693
|
+
/>
|
|
694
|
+
<UPopover v-if="props.website">
|
|
695
|
+
<UButton
|
|
696
|
+
icon="i-ph-camera"
|
|
697
|
+
color="neutral"
|
|
698
|
+
variant="ghost"
|
|
699
|
+
size="xs"
|
|
700
|
+
:loading="savingOptions"
|
|
701
|
+
:title="t('page.marketing.pages.preview.options')"
|
|
702
|
+
:aria-label="t('page.marketing.pages.preview.options')"
|
|
703
|
+
/>
|
|
704
|
+
<template #content>
|
|
705
|
+
<div class="flex w-80 flex-col gap-4 p-4">
|
|
706
|
+
<USwitch
|
|
707
|
+
:model-value="snapshotsEnabled"
|
|
708
|
+
:disabled="savingOptions"
|
|
709
|
+
:label="t('page.marketing.pages.preview.snapshots_enabled')"
|
|
710
|
+
:description="t('page.marketing.pages.preview.snapshots_enabled_hint')"
|
|
711
|
+
@update:model-value="(value: boolean) => updateSnapshotOptions({ snapshotsEnabled: value })"
|
|
712
|
+
/>
|
|
713
|
+
<USwitch
|
|
714
|
+
:model-value="props.website.snapshotMaskText === true"
|
|
715
|
+
:disabled="savingOptions || !snapshotsEnabled"
|
|
716
|
+
:label="t('page.marketing.pages.preview.mask_text')"
|
|
717
|
+
:description="t('page.marketing.pages.preview.mask_text_hint')"
|
|
718
|
+
@update:model-value="(value: boolean) => updateSnapshotOptions({ snapshotMaskText: value })"
|
|
719
|
+
/>
|
|
720
|
+
</div>
|
|
721
|
+
</template>
|
|
722
|
+
</UPopover>
|
|
723
|
+
<UButton
|
|
724
|
+
icon="i-ph-eraser"
|
|
725
|
+
color="error"
|
|
726
|
+
variant="ghost"
|
|
727
|
+
size="xs"
|
|
728
|
+
:loading="resetting"
|
|
729
|
+
:title="t('page.marketing.pages.reset.page_button')"
|
|
730
|
+
@click="resetPageHeatmap"
|
|
731
|
+
/>
|
|
732
|
+
</div>
|
|
733
|
+
</div>
|
|
734
|
+
|
|
735
|
+
<!-- Backdrop-state banners. None of these affect the numbers below. -->
|
|
736
|
+
<UAlert
|
|
737
|
+
v-if="!snapshotsEnabled"
|
|
738
|
+
icon="i-ph-camera-slash"
|
|
739
|
+
color="neutral"
|
|
740
|
+
variant="subtle"
|
|
741
|
+
:title="t('page.marketing.pages.preview.snapshots_off')"
|
|
742
|
+
:description="t('page.marketing.pages.preview.snapshots_off_hint')"
|
|
743
|
+
:actions="[{
|
|
744
|
+
label: t('page.marketing.pages.preview.enable_snapshots'),
|
|
745
|
+
color: 'neutral',
|
|
746
|
+
variant: 'subtle',
|
|
747
|
+
loading: savingOptions,
|
|
748
|
+
onClick: () => updateSnapshotOptions({ snapshotsEnabled: true }),
|
|
749
|
+
}]"
|
|
750
|
+
/>
|
|
751
|
+
|
|
752
|
+
<UAlert
|
|
753
|
+
v-else-if="noSnapshotYet"
|
|
754
|
+
icon="i-ph-camera"
|
|
755
|
+
color="neutral"
|
|
756
|
+
variant="subtle"
|
|
757
|
+
:title="t('page.marketing.pages.preview.no_snapshot')"
|
|
758
|
+
:description="t('page.marketing.pages.preview.no_snapshot_hint', { rate: Math.round(props.sampleRate * 100) })"
|
|
759
|
+
/>
|
|
760
|
+
|
|
761
|
+
<UAlert
|
|
762
|
+
v-else-if="snapshot && tooHeavy"
|
|
763
|
+
icon="i-ph-file-x"
|
|
764
|
+
color="warning"
|
|
765
|
+
variant="subtle"
|
|
766
|
+
:title="t('page.marketing.pages.preview.too_heavy')"
|
|
767
|
+
:description="t('page.marketing.pages.preview.too_heavy_hint', {
|
|
768
|
+
size: mebibytes(snapshot.bytes),
|
|
769
|
+
limit: mebibytes(SNAPSHOT_BYTE_CAP),
|
|
770
|
+
})"
|
|
771
|
+
/>
|
|
772
|
+
|
|
773
|
+
<UAlert
|
|
774
|
+
v-else-if="snapshot && !snapshot.exact"
|
|
775
|
+
icon="i-ph-arrows-out-line-horizontal"
|
|
776
|
+
color="neutral"
|
|
777
|
+
variant="subtle"
|
|
778
|
+
:title="t('page.marketing.pages.preview.approximate_layout')"
|
|
779
|
+
:description="t('page.marketing.pages.preview.approximate_layout_hint', {
|
|
780
|
+
layout: t(`page.marketing.pages.preview.layout_${snapshot.layout}`).toLowerCase(),
|
|
781
|
+
})"
|
|
782
|
+
/>
|
|
783
|
+
|
|
784
|
+
<!-- Geometry banners stand on their own: a reflowed layout can overflow
|
|
785
|
+
too, and the second fact must not hide behind the first. -->
|
|
786
|
+
<UAlert
|
|
787
|
+
v-if="oversized"
|
|
788
|
+
icon="i-ph-arrows-out-line-vertical"
|
|
789
|
+
color="warning"
|
|
790
|
+
variant="subtle"
|
|
791
|
+
:title="t('page.marketing.pages.preview.oversized')"
|
|
792
|
+
:description="t('page.marketing.pages.preview.oversized_hint')"
|
|
793
|
+
/>
|
|
794
|
+
|
|
795
|
+
<UAlert
|
|
796
|
+
v-else-if="overflowing"
|
|
797
|
+
icon="i-ph-arrows-horizontal"
|
|
798
|
+
color="neutral"
|
|
799
|
+
variant="subtle"
|
|
800
|
+
:title="t('page.marketing.pages.preview.overflow')"
|
|
801
|
+
:description="t('page.marketing.pages.preview.overflow_hint', { width: measuredWidth })"
|
|
802
|
+
/>
|
|
803
|
+
|
|
804
|
+
<UAlert
|
|
805
|
+
v-if="truncatedState"
|
|
806
|
+
icon="i-ph-warning"
|
|
807
|
+
color="warning"
|
|
808
|
+
variant="subtle"
|
|
809
|
+
:title="truncatedState.title"
|
|
810
|
+
:description="truncatedState.description"
|
|
811
|
+
/>
|
|
812
|
+
|
|
813
|
+
<UAlert
|
|
814
|
+
v-if="emptyState"
|
|
815
|
+
:icon="overlayMode === 'clicks' ? 'i-ph-cursor-click' : 'i-ph-caret-double-down'"
|
|
816
|
+
:title="emptyState.title"
|
|
817
|
+
:description="emptyState.description"
|
|
818
|
+
/>
|
|
819
|
+
|
|
820
|
+
<!-- Surface: only over a snapshot. While the snapshot is being fetched
|
|
821
|
+
the space is reserved, so a page that has one does not jump. -->
|
|
822
|
+
<div v-if="activeFailed" class="flex flex-col items-start gap-3">
|
|
823
|
+
<p class="text-sm text-error">{{ t('page.marketing.pages.error') }}</p>
|
|
824
|
+
<UButton
|
|
825
|
+
icon="i-ph-arrow-clockwise"
|
|
826
|
+
:label="t('page.marketing.pages.retry')"
|
|
827
|
+
color="neutral"
|
|
828
|
+
variant="subtle"
|
|
829
|
+
size="sm"
|
|
830
|
+
@click="retryActive"
|
|
831
|
+
/>
|
|
832
|
+
</div>
|
|
833
|
+
|
|
834
|
+
<USkeleton
|
|
835
|
+
v-else-if="snapshotsEnabled && !snapshotSettled"
|
|
836
|
+
class="w-full rounded-lg"
|
|
837
|
+
:style="{ height: `${PANE_HEIGHT}px` }"
|
|
838
|
+
/>
|
|
839
|
+
|
|
840
|
+
<!-- Kept mounted across refetches: tearing it down would re-render the
|
|
841
|
+
snapshot — and re-measure it — on every period change. -->
|
|
842
|
+
<div v-else-if="snapshot && backdrop !== null" class="relative">
|
|
843
|
+
<div
|
|
844
|
+
ref="container"
|
|
845
|
+
class="overflow-y-scroll overflow-x-hidden rounded-lg border border-default bg-elevated"
|
|
846
|
+
:style="{ height: `${PANE_HEIGHT}px` }"
|
|
847
|
+
>
|
|
848
|
+
<div class="relative" :style="{ height: `${scrolledHeight}px` }">
|
|
849
|
+
<div
|
|
850
|
+
class="absolute left-0 top-0"
|
|
851
|
+
:style="{
|
|
852
|
+
width: `${layoutWidth}px`,
|
|
853
|
+
height: `${renderHeight}px`,
|
|
854
|
+
zoom: scale,
|
|
855
|
+
}"
|
|
856
|
+
>
|
|
857
|
+
<!-- Inert on purpose: pointer-events none means no link, form or
|
|
858
|
+
hover state inside the snapshot can fire, and the wheel
|
|
859
|
+
scrolls the pane instead of dying in the frame. The sandbox
|
|
860
|
+
allows same-origin and nothing else — no script, no form,
|
|
861
|
+
no navigation — which is what lets useSnapshotFrame read the
|
|
862
|
+
document directly while a capture that arrived through a
|
|
863
|
+
public endpoint stays inert. `color-scheme` and the canvas
|
|
864
|
+
color follow the visitor's mode at capture: the page's
|
|
865
|
+
media queries resolve the way they did for them, and a page
|
|
866
|
+
with no background of its own stays on the canvas it had. -->
|
|
867
|
+
<iframe
|
|
868
|
+
:key="frameKey"
|
|
869
|
+
ref="frame"
|
|
870
|
+
:srcdoc="frameDocument"
|
|
871
|
+
class="pointer-events-none absolute left-0 top-0 w-full border-0"
|
|
872
|
+
:style="{
|
|
873
|
+
height: `${frameHeight}px`,
|
|
874
|
+
colorScheme: snapshotScheme,
|
|
875
|
+
backgroundColor: snapshotScheme === 'dark' ? DARK_CANVAS : LIGHT_CANVAS,
|
|
876
|
+
}"
|
|
877
|
+
sandbox="allow-same-origin"
|
|
878
|
+
scrolling="no"
|
|
879
|
+
referrerpolicy="no-referrer"
|
|
880
|
+
:title="props.path"
|
|
881
|
+
/>
|
|
882
|
+
<DmsMarketingHeatmapCanvas
|
|
883
|
+
v-if="overlayMode === 'clicks'"
|
|
884
|
+
:points="drawnPoints"
|
|
885
|
+
:max-weight="maxWeight"
|
|
886
|
+
:width="overlayWidth"
|
|
887
|
+
:height="renderHeight"
|
|
888
|
+
/>
|
|
889
|
+
</div>
|
|
890
|
+
|
|
891
|
+
<!-- In the pane's scaled space, not the document's: depth only
|
|
892
|
+
varies vertically, and the labels must not shrink with the
|
|
893
|
+
page — see the component. -->
|
|
894
|
+
<DmsMarketingScrollDepthOverlay
|
|
895
|
+
v-if="overlayMode === 'scroll' && scrollViews > 0"
|
|
896
|
+
:reached="scrollReached"
|
|
897
|
+
:views="scrollViews"
|
|
898
|
+
:height="scrolledHeight"
|
|
899
|
+
:fold-ratio="scrollFoldRatio"
|
|
900
|
+
/>
|
|
901
|
+
</div>
|
|
902
|
+
|
|
903
|
+
</div>
|
|
904
|
+
|
|
905
|
+
<!-- Outside the scrollport on purpose: inside, it is laid out at the top
|
|
906
|
+
of the content and scrolls out of sight on a tall page. -->
|
|
907
|
+
<div
|
|
908
|
+
v-if="activeLoading"
|
|
909
|
+
class="absolute inset-0 flex items-center justify-center rounded-lg bg-default/60"
|
|
910
|
+
>
|
|
911
|
+
<UIcon name="i-ph-circle-notch" class="size-6 animate-spin text-muted" />
|
|
912
|
+
</div>
|
|
913
|
+
</div>
|
|
914
|
+
</div>
|
|
915
|
+
</template>
|