@antelopejs/dms-marketing 0.3.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -15
- package/dist/config.d.ts +1 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/data-api/funnels.d.ts +2 -0
- package/dist/data-api/funnels.js +8 -1
- package/dist/data-api/funnels.js.map +1 -1
- package/dist/db/models/funnels.model.d.ts +2 -0
- package/dist/db/models/funnels.model.js +9 -0
- package/dist/db/models/funnels.model.js.map +1 -1
- package/dist/db/models/index.d.ts +1 -0
- package/dist/db/models/index.js +1 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/marketing_events.model.d.ts +22 -0
- package/dist/db/models/marketing_events.model.js +70 -5
- package/dist/db/models/marketing_events.model.js.map +1 -1
- package/dist/db/models/marketing_preferences.model.d.ts +25 -0
- package/dist/db/models/marketing_preferences.model.js +29 -0
- package/dist/db/models/marketing_preferences.model.js.map +1 -0
- package/dist/db/models/marketing_sessions.model.d.ts +2 -1
- package/dist/db/models/marketing_sessions.model.js +11 -1
- package/dist/db/models/marketing_sessions.model.js.map +1 -1
- package/dist/db/models/page_snapshots.model.d.ts +2 -1
- package/dist/db/models/page_snapshots.model.js +7 -7
- package/dist/db/models/page_snapshots.model.js.map +1 -1
- package/dist/db/models/website_statistics.model.d.ts +4 -1
- package/dist/db/models/website_statistics.model.js +15 -1
- package/dist/db/models/website_statistics.model.js.map +1 -1
- package/dist/db/tables/index.d.ts +1 -0
- package/dist/db/tables/index.js +1 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/marketing_preferences.table.d.ts +6 -0
- package/dist/db/tables/marketing_preferences.table.js +31 -0
- package/dist/db/tables/marketing_preferences.table.js.map +1 -0
- package/dist/hooks/index.js +8 -4
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.js +18 -3
- package/dist/index.js.map +1 -1
- package/dist/pages/acquisition/page.d.ts +9 -0
- package/dist/pages/acquisition/page.js +181 -0
- package/dist/pages/acquisition/page.js.map +1 -0
- package/dist/pages/all.d.ts +5 -1
- package/dist/pages/all.js +5 -1
- package/dist/pages/all.js.map +1 -1
- package/dist/pages/blocks.d.ts +18 -0
- package/dist/pages/blocks.js +56 -0
- package/dist/pages/blocks.js.map +1 -0
- package/dist/pages/funnel/page.d.ts +9 -0
- package/dist/pages/funnel/page.js +36 -0
- package/dist/pages/funnel/page.js.map +1 -0
- package/dist/pages/funnel-builder/page.d.ts +9 -0
- package/dist/pages/funnel-builder/page.js +29 -0
- package/dist/pages/funnel-builder/page.js.map +1 -0
- package/dist/pages/funnels/page.d.ts +1 -1
- package/dist/pages/funnels/page.js +200 -3
- package/dist/pages/funnels/page.js.map +1 -1
- package/dist/pages/index.d.ts +1 -0
- package/dist/pages/index.js +1 -0
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/{campaigns → install}/page.d.ts +3 -3
- package/dist/pages/{campaigns → install}/page.js +14 -12
- package/dist/pages/install/page.js.map +1 -0
- package/dist/pages/module.d.ts +3 -0
- package/dist/pages/module.js +30 -3
- package/dist/pages/module.js.map +1 -1
- package/dist/pages/overview/page.d.ts +1 -1
- package/dist/pages/overview/page.js +122 -3
- package/dist/pages/overview/page.js.map +1 -1
- package/dist/pages/pages/page.d.ts +1 -1
- package/dist/pages/pages/page.js +9 -4
- package/dist/pages/pages/page.js.map +1 -1
- package/dist/pages/quick-actions.d.ts +1 -0
- package/dist/pages/quick-actions.js +25 -0
- package/dist/pages/quick-actions.js.map +1 -0
- package/dist/pages/settings/form.d.ts +2 -1
- package/dist/pages/settings/form.js +62 -20
- package/dist/pages/settings/form.js.map +1 -1
- package/dist/pages/settings/page.d.ts +4 -1
- package/dist/pages/settings/page.js +50 -4
- package/dist/pages/settings/page.js.map +1 -1
- package/dist/pages/websites/page.d.ts +11 -0
- package/dist/pages/websites/page.js +62 -0
- package/dist/pages/websites/page.js.map +1 -0
- package/dist/routes/blocks.d.ts +50 -0
- package/dist/routes/blocks.js +269 -0
- package/dist/routes/blocks.js.map +1 -0
- package/dist/routes/collect.d.ts +1 -2
- package/dist/routes/collect.js +19 -12
- package/dist/routes/collect.js.map +1 -1
- package/dist/routes/context.d.ts +8 -0
- package/dist/routes/context.js +54 -0
- package/dist/routes/context.js.map +1 -0
- package/dist/routes/experiments.js +2 -1
- package/dist/routes/experiments.js.map +1 -1
- package/dist/routes/funnels.d.ts +21 -3
- package/dist/routes/funnels.js +150 -7
- package/dist/routes/funnels.js.map +1 -1
- package/dist/routes/index.d.ts +2 -0
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/settings.d.ts +6 -1
- package/dist/routes/settings.js +65 -4
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/snapshot.d.ts +1 -1
- package/dist/routes/snapshot.js +24 -25
- package/dist/routes/snapshot.js.map +1 -1
- package/dist/routes/stats.d.ts +14 -23
- package/dist/routes/stats.js +73 -97
- package/dist/routes/stats.js.map +1 -1
- package/dist/routes/tracker.js +6 -4
- package/dist/routes/tracker.js.map +1 -1
- package/dist/routes/websites.d.ts +4 -0
- package/dist/routes/websites.js +111 -12
- package/dist/routes/websites.js.map +1 -1
- package/dist/services/acquisition-report.d.ts +62 -0
- package/dist/services/acquisition-report.js +181 -0
- package/dist/services/acquisition-report.js.map +1 -0
- package/dist/services/acquisition.d.ts +9 -3
- package/dist/services/acquisition.js +35 -5
- package/dist/services/acquisition.js.map +1 -1
- package/dist/services/catalog.d.ts +3 -0
- package/dist/services/catalog.js +17 -0
- package/dist/services/catalog.js.map +1 -0
- package/dist/services/collect.d.ts +1 -2
- package/dist/services/collect.js +49 -24
- package/dist/services/collect.js.map +1 -1
- package/dist/services/composed.d.ts +9 -0
- package/dist/services/composed.js +62 -0
- package/dist/services/composed.js.map +1 -0
- package/dist/services/context.d.ts +20 -0
- package/dist/services/context.js +76 -0
- package/dist/services/context.js.map +1 -0
- package/dist/services/dashboard.d.ts +74 -0
- package/dist/services/dashboard.js +343 -0
- package/dist/services/dashboard.js.map +1 -0
- package/dist/services/experiments/results.d.ts +12 -1
- package/dist/services/experiments/results.js +19 -2
- package/dist/services/experiments/results.js.map +1 -1
- package/dist/services/experiments/stats.d.ts +7 -1
- package/dist/services/experiments/stats.js +43 -5
- package/dist/services/experiments/stats.js.map +1 -1
- package/dist/services/funnel-report.d.ts +64 -0
- package/dist/services/funnel-report.js +280 -0
- package/dist/services/funnel-report.js.map +1 -0
- package/dist/services/funnels/compute.d.ts +2 -0
- package/dist/services/funnels/compute.js +25 -13
- package/dist/services/funnels/compute.js.map +1 -1
- package/dist/services/funnels/results.js +4 -1
- package/dist/services/funnels/results.js.map +1 -1
- package/dist/services/funnels/step-path.d.ts +4 -0
- package/dist/services/funnels/step-path.js +47 -0
- package/dist/services/funnels/step-path.js.map +1 -0
- package/dist/services/funnels-table.d.ts +31 -0
- package/dist/services/funnels-table.js +230 -0
- package/dist/services/funnels-table.js.map +1 -0
- package/dist/services/heatmap.d.ts +15 -3
- package/dist/services/heatmap.js +54 -11
- package/dist/services/heatmap.js.map +1 -1
- package/dist/services/hourly-traffic.d.ts +4 -0
- package/dist/services/hourly-traffic.js +35 -0
- package/dist/services/hourly-traffic.js.map +1 -0
- package/dist/services/origin-guard.d.ts +9 -1
- package/dist/services/origin-guard.js +46 -1
- package/dist/services/origin-guard.js.map +1 -1
- package/dist/services/period.d.ts +26 -1
- package/dist/services/period.js +99 -9
- package/dist/services/period.js.map +1 -1
- package/dist/services/prune.d.ts +6 -0
- package/dist/services/prune.js +35 -0
- package/dist/services/prune.js.map +1 -1
- package/dist/services/request-body.d.ts +1 -2
- package/dist/services/request-body.js +10 -36
- package/dist/services/request-body.js.map +1 -1
- package/dist/services/request-meta.d.ts +0 -1
- package/dist/services/request-meta.js +0 -11
- package/dist/services/request-meta.js.map +1 -1
- package/dist/services/rollups.d.ts +25 -0
- package/dist/services/rollups.js +102 -0
- package/dist/services/rollups.js.map +1 -0
- package/dist/services/sessionize.d.ts +5 -1
- package/dist/services/sessionize.js +52 -10
- package/dist/services/sessionize.js.map +1 -1
- package/dist/services/settings.d.ts +41 -2
- package/dist/services/settings.js +219 -6
- package/dist/services/settings.js.map +1 -1
- package/dist/services/snapshots.d.ts +3 -2
- package/dist/services/snapshots.js +110 -9
- package/dist/services/snapshots.js.map +1 -1
- package/dist/services/tenant-website.js +1 -0
- package/dist/services/tenant-website.js.map +1 -1
- package/dist/services/url.js +12 -4
- package/dist/services/url.js.map +1 -1
- package/dist/services/website-hosts.d.ts +15 -0
- package/dist/services/website-hosts.js +67 -0
- package/dist/services/website-hosts.js.map +1 -0
- package/dist/services/website-report.d.ts +40 -0
- package/dist/services/website-report.js +84 -0
- package/dist/services/website-report.js.map +1 -0
- package/dist/types/constants.d.ts +14 -2
- package/dist/types/constants.js +17 -6
- package/dist/types/constants.js.map +1 -1
- package/dist/types/events.d.ts +221 -68
- package/dist/types/events.js +60 -11
- package/dist/types/events.js.map +1 -1
- package/dist/types/snapshots.d.ts +6 -0
- package/dist/types/snapshots.js +2 -0
- package/dist/types/snapshots.js.map +1 -1
- package/dist/types/statistics.d.ts +2 -1
- package/dist/types/statistics.js +9 -2
- package/dist/types/statistics.js.map +1 -1
- package/dist/utils/experiment-type.js +1 -1
- package/dist/utils/experiment-type.js.map +1 -1
- package/dist/utils/funnel-steps-type.d.ts +21 -3
- package/dist/utils/funnel-steps-type.js +7 -2
- package/dist/utils/funnel-steps-type.js.map +1 -1
- package/frontend-vue/app/components/ChannelsCard.vue +265 -0
- package/frontend-vue/app/components/Context.vue +479 -0
- package/frontend-vue/app/components/ExperimentInput.vue +3 -1
- package/frontend-vue/app/components/ExperimentReport.vue +736 -0
- package/frontend-vue/app/components/FirstRun.vue +60 -0
- package/frontend-vue/app/components/FunnelBuilder.vue +1595 -0
- package/frontend-vue/app/components/FunnelFigure.vue +640 -0
- package/frontend-vue/app/components/FunnelReport.vue +532 -0
- package/frontend-vue/app/components/FunnelStepsInput.vue +3 -1
- package/frontend-vue/app/components/FunnelTemplates.vue +161 -0
- package/frontend-vue/app/components/HeatmapCanvas.vue +11 -4
- package/frontend-vue/app/components/InstallGuide.vue +753 -0
- package/frontend-vue/app/components/PagePreview.vue +889 -416
- package/frontend-vue/app/components/PagesExplorer.vue +552 -0
- package/frontend-vue/app/components/ScrollDepthOverlay.vue +48 -20
- package/frontend-vue/app/components/TopListTabs.vue +317 -0
- package/frontend-vue/app/components/TrackerSnippet.vue +50 -16
- package/frontend-vue/app/components/VariationsInput.vue +15 -5
- package/frontend-vue/app/components/WebsitesGrid.vue +799 -0
- package/frontend-vue/app/composables/useContextLink.ts +16 -0
- package/frontend-vue/app/composables/useExperiment.ts +1 -2
- package/frontend-vue/app/composables/useFunnelSteps.ts +1 -2
- package/frontend-vue/app/composables/useLatestRequest.ts +2 -4
- package/frontend-vue/app/composables/useMarketingApi.ts +298 -133
- package/frontend-vue/app/composables/useMarketingContext.ts +55 -0
- package/frontend-vue/app/composables/useMarketingRoutes.ts +80 -27
- package/frontend-vue/app/composables/useSnapshotFrame.ts +26 -4
- package/frontend-vue/app/constants.ts +22 -0
- package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +2 -1
- package/frontend-vue/app/utils/channels.ts +37 -0
- package/frontend-vue/app/utils/format.ts +132 -0
- package/frontend-vue/app/utils/funnel-draft.ts +264 -0
- package/frontend-vue/app/utils/heatmap.ts +82 -0
- package/frontend-vue/app/utils/heatmapPalette.ts +10 -0
- package/frontend-vue/app/utils/host-conflict.ts +48 -0
- package/frontend-vue/app/utils/sample-size.ts +17 -0
- package/frontend-vue/app/utils/snapshotViewportUnits.ts +15 -5
- package/frontend-vue/app/utils/snippet.ts +53 -0
- package/frontend-vue/app/utils/step-path.ts +32 -0
- package/frontend-vue/dms.frontend.ts +4 -3
- package/frontend-vue/i18n/locales/marketing-en-GB.json +1083 -316
- package/frontend-vue/i18n/locales/marketing-fr-FR.json +1087 -320
- package/frontend-vue/package.json +1 -2
- package/frontend-vue/pnpm-lock.yaml +51 -19
- package/frontend-vue/tests/drafts.test.ts +3 -3
- package/frontend-vue/tests/format.test.ts +73 -0
- package/frontend-vue/tests/funnel-draft.test.ts +194 -0
- package/frontend-vue/tests/heatmap.test.ts +32 -0
- package/frontend-vue/tests/i18n.test.ts +87 -4
- package/frontend-vue/tests/latest-request.test.ts +6 -3
- package/frontend-vue/tests/locale-typography.test.ts +115 -0
- package/frontend-vue/tests/registration.test.ts +17 -11
- package/frontend-vue/tests/routes.test.ts +67 -7
- package/frontend-vue/tests/server-parity.test.ts +77 -0
- package/frontend-vue/tests/stubs/frontend-module.ts +18 -3
- package/frontend-vue/vitest.config.ts +3 -1
- package/package.json +10 -8
- package/static/snapshot.js +46 -16
- package/static/tracker.js +139 -48
- package/dist/pages/campaigns/page.js.map +0 -1
- package/frontend-vue/app/components/CampaignsView.vue +0 -330
- package/frontend-vue/app/components/FunnelDetail.vue +0 -1026
- package/frontend-vue/app/components/FunnelsView.vue +0 -493
- package/frontend-vue/app/components/NoWebsiteState.vue +0 -18
- package/frontend-vue/app/components/OverviewView.vue +0 -493
- package/frontend-vue/app/components/PagesView.vue +0 -391
- package/frontend-vue/app/components/TextKpiCard.vue +0 -28
- package/frontend-vue/app/components/TopListTabsCard.vue +0 -162
- package/frontend-vue/app/composables/useChildId.ts +0 -13
- package/frontend-vue/app/composables/useMarketingPeriod.ts +0 -65
- package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +0 -69
- package/frontend-vue/app/composables/useMarketingWebsites.ts +0 -80
- package/frontend-vue/app/utils/dmsComponent.ts +0 -18
- package/frontend-vue/tests/period.test.ts +0 -35
|
@@ -4,14 +4,27 @@ import { useI18n } from '#dms/frontend-module'
|
|
|
4
4
|
import { useLatestRequest } from '../composables/useLatestRequest'
|
|
5
5
|
import {
|
|
6
6
|
useMarketingApi,
|
|
7
|
+
type MarketingPeriod,
|
|
7
8
|
type MarketingHeatmap,
|
|
8
9
|
type MarketingPageSnapshotResponse,
|
|
9
10
|
type MarketingScrollDepth,
|
|
10
11
|
type MarketingSnapshotLayout,
|
|
11
12
|
type MarketingWebsite,
|
|
12
13
|
type MarketingWebsitePatch,
|
|
14
|
+
type MarketingFunnelListItem,
|
|
13
15
|
} from '../composables/useMarketingApi'
|
|
16
|
+
import { funnelLink, settingsLink } from '../composables/useMarketingRoutes'
|
|
17
|
+
import { formatNumber, formatShortDate } from '../utils/format'
|
|
14
18
|
import {
|
|
19
|
+
heatmapDataLabel,
|
|
20
|
+
heatmapResetImpact,
|
|
21
|
+
} from '../utils/heatmap'
|
|
22
|
+
import { heatLegendGradient } from '../utils/heatmapPalette'
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_RENDER_HEIGHT,
|
|
25
|
+
MAX_RENDER_HEIGHT,
|
|
26
|
+
MAX_RENDER_WIDTH,
|
|
27
|
+
MIN_RENDER_HEIGHT,
|
|
15
28
|
useSnapshotFrame,
|
|
16
29
|
type AnchorBox,
|
|
17
30
|
type AnchorRequest,
|
|
@@ -53,18 +66,29 @@ import { rewriteViewportUnits } from '../utils/snapshotViewportUnits'
|
|
|
53
66
|
const props = defineProps<{
|
|
54
67
|
website: MarketingWebsite | null
|
|
55
68
|
path: string
|
|
56
|
-
period
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
/** Days the counts cover — the context bar's period scope. */
|
|
70
|
+
period: MarketingPeriod
|
|
71
|
+
/**
|
|
72
|
+
* Measured share of the site's page loads sampled for clicks, written for
|
|
73
|
+
* a sentence — why a quiet page looks empty. Null while too few pageviews
|
|
74
|
+
* tell it.
|
|
75
|
+
*/
|
|
76
|
+
sampleRateLabel: string | null
|
|
59
77
|
trackerEnabled: boolean
|
|
60
78
|
/** Pageviews of this path over the window, for the sampling hint. */
|
|
61
79
|
pageviews?: number
|
|
62
80
|
/** Bumped by the parent when it resets clicks out from under this view. */
|
|
63
81
|
refreshToken?: number
|
|
82
|
+
/** Funnels with a page step on this path. */
|
|
83
|
+
usedIn?: MarketingFunnelListItem[]
|
|
64
84
|
}>()
|
|
65
85
|
|
|
66
86
|
/** The site's snapshot options were changed here; the parent owns the row. */
|
|
67
|
-
const emit = defineEmits<{
|
|
87
|
+
const emit = defineEmits<{
|
|
88
|
+
'website-updated': []
|
|
89
|
+
/** The page lost its clicks: the inventory counts them too. */
|
|
90
|
+
'heatmap-reset': []
|
|
91
|
+
}>()
|
|
68
92
|
|
|
69
93
|
interface LayoutPreset {
|
|
70
94
|
width: number
|
|
@@ -79,9 +103,24 @@ interface LayoutPreset {
|
|
|
79
103
|
* breakpoints already dress as desktop — the button would repeat 1280.
|
|
80
104
|
*/
|
|
81
105
|
const LAYOUT_PRESETS: LayoutPreset[] = [
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
|
|
106
|
+
{
|
|
107
|
+
width: 1280,
|
|
108
|
+
layout: 'desktop',
|
|
109
|
+
icon: 'i-ph-monitor',
|
|
110
|
+
labelKey: 'page.marketing.pages.preview.layout_desktop',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
width: 768,
|
|
114
|
+
layout: 'tablet',
|
|
115
|
+
icon: 'i-ph-device-tablet',
|
|
116
|
+
labelKey: 'page.marketing.pages.preview.layout_tablet',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
width: 390,
|
|
120
|
+
layout: 'phone',
|
|
121
|
+
icon: 'i-ph-device-mobile',
|
|
122
|
+
labelKey: 'page.marketing.pages.preview.layout_phone',
|
|
123
|
+
},
|
|
85
124
|
]
|
|
86
125
|
const DEFAULT_LAYOUT_WIDTH = 1280
|
|
87
126
|
/** Viewport the snapshot is measured through when it recorded none. */
|
|
@@ -113,12 +152,6 @@ const {
|
|
|
113
152
|
: null,
|
|
114
153
|
)
|
|
115
154
|
|
|
116
|
-
watch(
|
|
117
|
-
() => [props.website?._id, props.path, props.period, props.refreshToken],
|
|
118
|
-
() => void loadHeatmap(),
|
|
119
|
-
{ immediate: true },
|
|
120
|
-
)
|
|
121
|
-
|
|
122
155
|
const points = computed(() => heatmap.value?.points ?? [])
|
|
123
156
|
const maxWeight = computed(() => heatmap.value?.maxWeight ?? 0)
|
|
124
157
|
const totalClicks = computed(() => heatmap.value?.totalClicks ?? 0)
|
|
@@ -127,6 +160,9 @@ const totalClicks = computed(() => heatmap.value?.totalClicks ?? 0)
|
|
|
127
160
|
|
|
128
161
|
const overlayMode = ref<'clicks' | 'scroll'>('clicks')
|
|
129
162
|
|
|
163
|
+
// Read with the clicks, not when its overlay opens: every sampled view
|
|
164
|
+
// reports one depth, so its count is the page's sampled views the header
|
|
165
|
+
// and the empty states speak of.
|
|
130
166
|
const {
|
|
131
167
|
data: scrollDepth,
|
|
132
168
|
loading: loadingScroll,
|
|
@@ -138,34 +174,35 @@ const {
|
|
|
138
174
|
: null,
|
|
139
175
|
)
|
|
140
176
|
|
|
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
177
|
watch(
|
|
161
|
-
() => [
|
|
162
|
-
|
|
178
|
+
() => [
|
|
179
|
+
props.website?._id,
|
|
180
|
+
props.path,
|
|
181
|
+
props.period?.range?.from.getTime(),
|
|
182
|
+
props.period?.range?.to.getTime(),
|
|
183
|
+
props.refreshToken,
|
|
184
|
+
],
|
|
185
|
+
() => {
|
|
186
|
+
void loadHeatmap()
|
|
187
|
+
void loadScrollDepth()
|
|
188
|
+
},
|
|
163
189
|
{ immediate: true },
|
|
164
190
|
)
|
|
165
191
|
|
|
166
192
|
const scrollViews = computed(() => scrollDepth.value?.views ?? 0)
|
|
167
193
|
const scrollReached = computed(() => scrollDepth.value?.reached ?? [])
|
|
168
194
|
|
|
195
|
+
/** Deepest depth at least half of the sampled views reached. */
|
|
196
|
+
const medianDepth = computed(() => {
|
|
197
|
+
const half = scrollViews.value / 2
|
|
198
|
+
for (let depth = scrollReached.value.length - 1; depth >= 0; depth--) {
|
|
199
|
+
if ((scrollReached.value[depth] ?? 0) >= half) {
|
|
200
|
+
return depth
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return 0
|
|
204
|
+
})
|
|
205
|
+
|
|
169
206
|
/**
|
|
170
207
|
* Where depth 0 sits on the drawn page: the bottom of the first screen.
|
|
171
208
|
* Depth is a fraction of the visitor's scrollable range; the captured
|
|
@@ -185,38 +222,49 @@ const toast = useToast()
|
|
|
185
222
|
const resetting = ref(false)
|
|
186
223
|
|
|
187
224
|
/** Deletes the clicks, scrolls and snapshots of the page (every period),
|
|
188
|
-
*
|
|
225
|
+
* counted by the server before asking, then refetches both overlays. */
|
|
189
226
|
async function resetPageHeatmap() {
|
|
190
227
|
const website = props.website
|
|
191
228
|
if (!website) {
|
|
192
229
|
return
|
|
193
230
|
}
|
|
194
|
-
const
|
|
231
|
+
const path = props.path
|
|
232
|
+
resetting.value = true
|
|
233
|
+
const counts = await api
|
|
234
|
+
.countHeatmap(website._id, path)
|
|
235
|
+
.catch(() => null)
|
|
236
|
+
.finally(() => (resetting.value = false))
|
|
237
|
+
await confirm({
|
|
195
238
|
title: t('page.marketing.pages.reset.page_title'),
|
|
196
|
-
description: t('page.marketing.pages.reset.page_description'
|
|
197
|
-
|
|
198
|
-
|
|
239
|
+
description: t('page.marketing.pages.reset.page_description'),
|
|
240
|
+
color: 'error',
|
|
241
|
+
icon: 'i-ph-eraser',
|
|
242
|
+
confirmLabel:
|
|
243
|
+
counts && counts.events + counts.snapshots > 0
|
|
244
|
+
? t('page.marketing.pages.reset.confirm_count', {
|
|
245
|
+
what: heatmapDataLabel(counts, t, locale.value),
|
|
246
|
+
})
|
|
247
|
+
: t('page.marketing.pages.reset.confirm'),
|
|
248
|
+
confirmText: path,
|
|
249
|
+
impact: heatmapResetImpact(counts, t, locale.value),
|
|
250
|
+
onConfirm: async () => {
|
|
251
|
+
resetting.value = true
|
|
252
|
+
try {
|
|
253
|
+
const deleted = await api.resetHeatmap(website._id, path)
|
|
254
|
+
toast.add({
|
|
255
|
+
color: 'success',
|
|
256
|
+
title: t('page.marketing.pages.reset.page_done'),
|
|
257
|
+
description: t('page.marketing.pages.reset.done', {
|
|
258
|
+
what: heatmapDataLabel(deleted, t, locale.value),
|
|
259
|
+
}),
|
|
260
|
+
})
|
|
261
|
+
emit('heatmap-reset')
|
|
262
|
+
await Promise.all([loadHeatmap(), loadScrollDepth(), loadSnapshot()])
|
|
263
|
+
} finally {
|
|
264
|
+
resetting.value = false
|
|
265
|
+
}
|
|
266
|
+
},
|
|
199
267
|
})
|
|
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
268
|
}
|
|
221
269
|
|
|
222
270
|
// --- Snapshot ---------------------------------------------------------------
|
|
@@ -224,10 +272,14 @@ async function resetPageHeatmap() {
|
|
|
224
272
|
const layoutWidth = ref(DEFAULT_LAYOUT_WIDTH)
|
|
225
273
|
|
|
226
274
|
const layout = computed<MarketingSnapshotLayout>(
|
|
227
|
-
() =>
|
|
275
|
+
() =>
|
|
276
|
+
LAYOUT_PRESETS.find((preset) => preset.width === layoutWidth.value)
|
|
277
|
+
?.layout ?? 'desktop',
|
|
228
278
|
)
|
|
229
279
|
|
|
230
|
-
const snapshotsEnabled = computed(
|
|
280
|
+
const snapshotsEnabled = computed(
|
|
281
|
+
() => props.website?.snapshotsEnabled ?? false,
|
|
282
|
+
)
|
|
231
283
|
|
|
232
284
|
// Not fetched while the site has snapshots off: the option's banner is the
|
|
233
285
|
// whole story then, and a leftover capture must not contradict it.
|
|
@@ -242,7 +294,13 @@ const {
|
|
|
242
294
|
)
|
|
243
295
|
|
|
244
296
|
watch(
|
|
245
|
-
() => [
|
|
297
|
+
() => [
|
|
298
|
+
props.website?._id,
|
|
299
|
+
snapshotsEnabled.value,
|
|
300
|
+
props.path,
|
|
301
|
+
layout.value,
|
|
302
|
+
props.refreshToken,
|
|
303
|
+
],
|
|
246
304
|
() => void loadSnapshot(),
|
|
247
305
|
{ immediate: true },
|
|
248
306
|
)
|
|
@@ -262,32 +320,25 @@ const frameDocument = computed(() =>
|
|
|
262
320
|
: '',
|
|
263
321
|
)
|
|
264
322
|
const noSnapshotYet = computed(
|
|
265
|
-
() =>
|
|
323
|
+
() =>
|
|
324
|
+
snapshotsEnabled.value && snapshotSettled.value && snapshot.value === null,
|
|
325
|
+
)
|
|
326
|
+
const tooHeavy = computed(
|
|
327
|
+
() => snapshot.value !== null && backdrop.value === null,
|
|
266
328
|
)
|
|
267
|
-
const tooHeavy = computed(() => snapshot.value !== null && backdrop.value === null)
|
|
268
329
|
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
330
|
|
|
282
331
|
function mebibytes(bytes: number): string {
|
|
283
|
-
return new Intl.NumberFormat(locale.value, {
|
|
284
|
-
|
|
285
|
-
)
|
|
332
|
+
return new Intl.NumberFormat(locale.value, {
|
|
333
|
+
maximumFractionDigits: 1,
|
|
334
|
+
}).format(bytes / (1024 * 1024))
|
|
286
335
|
}
|
|
287
336
|
|
|
288
337
|
const savingOptions = ref(false)
|
|
289
338
|
|
|
290
|
-
async function updateSnapshotOptions(
|
|
339
|
+
async function updateSnapshotOptions(
|
|
340
|
+
patch: MarketingWebsitePatch,
|
|
341
|
+
): Promise<void> {
|
|
291
342
|
const website = props.website
|
|
292
343
|
if (!website) {
|
|
293
344
|
return
|
|
@@ -296,59 +347,35 @@ async function updateSnapshotOptions(patch: MarketingWebsitePatch): Promise<void
|
|
|
296
347
|
try {
|
|
297
348
|
await api.updateWebsite(website._id, patch)
|
|
298
349
|
emit('website-updated')
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
350
|
+
} catch {
|
|
351
|
+
toast.add({
|
|
352
|
+
color: 'error',
|
|
353
|
+
title: t('page.marketing.pages.preview.options_error'),
|
|
354
|
+
})
|
|
355
|
+
} finally {
|
|
304
356
|
savingOptions.value = false
|
|
305
357
|
}
|
|
306
358
|
}
|
|
307
359
|
|
|
308
360
|
// --- Frame geometry ---------------------------------------------------------
|
|
309
361
|
|
|
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
362
|
const frame = ref<HTMLIFrameElement | null>(null)
|
|
319
|
-
const {
|
|
320
|
-
|
|
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
|
-
})
|
|
363
|
+
const { measuredWidth, measuredHeight, tier, measureAnchors } =
|
|
364
|
+
useSnapshotFrame(frame)
|
|
345
365
|
|
|
346
366
|
const frameKey = computed(() =>
|
|
347
|
-
[
|
|
367
|
+
[
|
|
368
|
+
props.path,
|
|
369
|
+
snapshot.value?.layout,
|
|
370
|
+
snapshot.value?.capturedAt,
|
|
371
|
+
layoutWidth.value,
|
|
372
|
+
].join('|'),
|
|
348
373
|
)
|
|
349
374
|
|
|
350
375
|
/** Viewport the frame is pinned to until its size is read. */
|
|
351
|
-
const probeHeight = computed(
|
|
376
|
+
const probeHeight = computed(
|
|
377
|
+
() => snapshot.value?.viewport.height ?? PROBE_VIEWPORT_HEIGHT,
|
|
378
|
+
)
|
|
352
379
|
|
|
353
380
|
/**
|
|
354
381
|
* Height of the drawn surface — the overlay's coordinate space. Falls back to
|
|
@@ -356,7 +383,7 @@ const probeHeight = computed(() => snapshot.value?.viewport.height ?? PROBE_VIEW
|
|
|
356
383
|
* readable before (and without) any snapshot.
|
|
357
384
|
*/
|
|
358
385
|
const renderHeight = computed(() => {
|
|
359
|
-
const chosen =
|
|
386
|
+
const chosen = measuredHeight.value
|
|
360
387
|
if (chosen !== null) {
|
|
361
388
|
return Math.min(Math.max(chosen, MIN_RENDER_HEIGHT), MAX_RENDER_HEIGHT)
|
|
362
389
|
}
|
|
@@ -369,9 +396,6 @@ const renderHeight = computed(() => {
|
|
|
369
396
|
* being waited on. An operator override takes it back by hand.
|
|
370
397
|
*/
|
|
371
398
|
const frameHeight = computed(() => {
|
|
372
|
-
if (heightOverride.value !== null) {
|
|
373
|
-
return renderHeight.value
|
|
374
|
-
}
|
|
375
399
|
if (measuredHeight.value !== null) {
|
|
376
400
|
return measuredHeight.value
|
|
377
401
|
}
|
|
@@ -386,7 +410,10 @@ const frameHeight = computed(() => {
|
|
|
386
410
|
* part is clipped by the pane, exactly as it is clipped inside the frame.
|
|
387
411
|
*/
|
|
388
412
|
const overlayWidth = computed(() =>
|
|
389
|
-
Math.min(
|
|
413
|
+
Math.min(
|
|
414
|
+
Math.max(measuredWidth.value ?? 0, layoutWidth.value),
|
|
415
|
+
MAX_RENDER_WIDTH,
|
|
416
|
+
),
|
|
390
417
|
)
|
|
391
418
|
|
|
392
419
|
// --- Element anchoring ------------------------------------------------------
|
|
@@ -458,7 +485,7 @@ async function refreshAnchorBoxes(): Promise<void> {
|
|
|
458
485
|
return
|
|
459
486
|
}
|
|
460
487
|
await nextTick()
|
|
461
|
-
await new Promise(resolve => requestAnimationFrame(resolve))
|
|
488
|
+
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
462
489
|
if (token !== measureToken) {
|
|
463
490
|
return
|
|
464
491
|
}
|
|
@@ -482,8 +509,8 @@ watch(
|
|
|
482
509
|
*/
|
|
483
510
|
const oversized = computed(
|
|
484
511
|
() =>
|
|
485
|
-
(measuredHeight.value ?? 0) > MAX_RENDER_HEIGHT
|
|
486
|
-
|
|
512
|
+
(measuredHeight.value ?? 0) > MAX_RENDER_HEIGHT ||
|
|
513
|
+
(measuredWidth.value ?? 0) > MAX_RENDER_WIDTH,
|
|
487
514
|
)
|
|
488
515
|
|
|
489
516
|
const overflowing = computed(
|
|
@@ -518,36 +545,49 @@ watch(container, (el) => {
|
|
|
518
545
|
|
|
519
546
|
onBeforeUnmount(() => observer?.disconnect())
|
|
520
547
|
|
|
548
|
+
// Only ever scaled down: a phone or tablet layout narrower than the pane is
|
|
549
|
+
// shown at its own width, centred like a device, never blown up to fill it.
|
|
521
550
|
const scale = computed(() =>
|
|
522
|
-
containerWidth.value > 0
|
|
551
|
+
containerWidth.value > 0
|
|
552
|
+
? Math.min(1, containerWidth.value / layoutWidth.value)
|
|
553
|
+
: 1,
|
|
523
554
|
)
|
|
524
555
|
|
|
556
|
+
/** Width of the drawn surface in the pane's own pixels. */
|
|
557
|
+
const surfaceWidth = computed(() => Math.round(layoutWidth.value * scale.value))
|
|
558
|
+
|
|
525
559
|
/** Height of the zoomed surface in the pane's own pixels — the scroll range
|
|
526
560
|
* of the pane and the space the scroll-depth overlay draws in. */
|
|
527
|
-
const scrolledHeight = computed(() =>
|
|
528
|
-
|
|
529
|
-
|
|
561
|
+
const scrolledHeight = computed(() =>
|
|
562
|
+
Math.round(renderHeight.value * scale.value),
|
|
563
|
+
)
|
|
530
564
|
|
|
531
565
|
/**
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* count.
|
|
566
|
+
* The pane grows with the page, up to the window's height: a short capture
|
|
567
|
+
* leaves no empty band below it, and a long one scrolls inside a pane that
|
|
568
|
+
* still fits the screen. The border is counted, or a 2px scroll would remain.
|
|
536
569
|
*/
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
)
|
|
570
|
+
const paneStyle = computed(() => ({
|
|
571
|
+
height: `${scrolledHeight.value + 2}px`,
|
|
572
|
+
maxHeight: 'calc(100dvh - 8rem)',
|
|
573
|
+
minHeight: '12rem',
|
|
574
|
+
}))
|
|
540
575
|
|
|
541
|
-
|
|
576
|
+
// --- Emptiness, explained ---------------------------------------------------
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
/** Silence has shared causes (collection off, no view sampled) before the
|
|
542
580
|
* per-overlay one; both overlays explain themselves through this. */
|
|
543
581
|
function captureEmptyReason(fallbackKey: string): string {
|
|
544
582
|
if (!props.trackerEnabled) {
|
|
545
583
|
return t('page.marketing.pages.empty.tracking_off')
|
|
546
584
|
}
|
|
547
|
-
if (
|
|
548
|
-
return
|
|
549
|
-
|
|
550
|
-
|
|
585
|
+
if (props.pageviews && scrollViews.value === 0) {
|
|
586
|
+
return props.sampleRateLabel === null
|
|
587
|
+
? t('page.marketing.pages.empty.none_sampled')
|
|
588
|
+
: t('page.marketing.pages.empty.none_sampled_rate', {
|
|
589
|
+
rate: props.sampleRateLabel,
|
|
590
|
+
})
|
|
551
591
|
}
|
|
552
592
|
return t(fallbackKey)
|
|
553
593
|
}
|
|
@@ -562,7 +602,11 @@ const emptyState = computed(() => {
|
|
|
562
602
|
description: captureEmptyReason('page.marketing.pages.empty.no_clicks'),
|
|
563
603
|
}
|
|
564
604
|
}
|
|
565
|
-
if (
|
|
605
|
+
if (
|
|
606
|
+
loadingScroll.value ||
|
|
607
|
+
scrollDepth.value === null ||
|
|
608
|
+
scrollViews.value > 0
|
|
609
|
+
) {
|
|
566
610
|
return null
|
|
567
611
|
}
|
|
568
612
|
return {
|
|
@@ -605,311 +649,740 @@ const truncatedState = computed(() => {
|
|
|
605
649
|
return null
|
|
606
650
|
})
|
|
607
651
|
|
|
608
|
-
|
|
609
|
-
|
|
652
|
+
const LOCAL_HOST = /^(localhost|127\.|\[?::1\]?$)/
|
|
653
|
+
|
|
654
|
+
/** The real page — opened, never framed: on the origin the snapshot was
|
|
655
|
+
* captured from, else on the site's declared domain. */
|
|
610
656
|
const externalUrl = computed(() => {
|
|
611
|
-
|
|
657
|
+
const domain = props.website?.domain
|
|
658
|
+
const origin =
|
|
659
|
+
snapshot.value?.origin ??
|
|
660
|
+
(domain
|
|
661
|
+
? `${LOCAL_HOST.test(domain) ? 'http' : 'https'}://${domain}`
|
|
662
|
+
: null)
|
|
663
|
+
if (!origin || !props.path) {
|
|
612
664
|
return ''
|
|
613
665
|
}
|
|
614
666
|
try {
|
|
615
|
-
return new URL(props.path,
|
|
667
|
+
return new URL(props.path, origin).toString()
|
|
668
|
+
} catch {
|
|
669
|
+
return ''
|
|
616
670
|
}
|
|
617
|
-
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
// --- One status strip -------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
interface StripAction {
|
|
676
|
+
label: string
|
|
677
|
+
run?: () => void
|
|
678
|
+
to?: string
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
interface StatusStrip {
|
|
682
|
+
tone: 'error' | 'warning' | 'info' | 'primary' | 'success'
|
|
683
|
+
neutral?: boolean
|
|
684
|
+
icon: string
|
|
685
|
+
title: string
|
|
686
|
+
description: string
|
|
687
|
+
action?: StripAction
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Highest priority first: one strip says what needs attention, instead of
|
|
692
|
+
* every applicable alert stacked above the page. The numbers in the header
|
|
693
|
+
* stay exact whatever it says.
|
|
694
|
+
*/
|
|
695
|
+
const statusStrip = computed<StatusStrip | null>(() => {
|
|
696
|
+
const candidates: Array<StatusStrip | null> = [
|
|
697
|
+
!props.trackerEnabled
|
|
698
|
+
? {
|
|
699
|
+
tone: 'error',
|
|
700
|
+
icon: 'i-ph-plugs',
|
|
701
|
+
title: t('page.marketing.pages.strip.collection_off'),
|
|
702
|
+
description: t('page.marketing.pages.strip.collection_off_hint'),
|
|
703
|
+
action: {
|
|
704
|
+
label: t('page.marketing.pages.strip.open_settings'),
|
|
705
|
+
to: settingsLink(),
|
|
706
|
+
},
|
|
707
|
+
}
|
|
708
|
+
: null,
|
|
709
|
+
!snapshotsEnabled.value
|
|
710
|
+
? {
|
|
711
|
+
tone: 'info',
|
|
712
|
+
icon: 'i-ph-camera-slash',
|
|
713
|
+
title: t('page.marketing.pages.preview.snapshots_off'),
|
|
714
|
+
description: t('page.marketing.pages.preview.snapshots_off_hint'),
|
|
715
|
+
action: {
|
|
716
|
+
label: t('page.marketing.pages.preview.enable_snapshots'),
|
|
717
|
+
run: () => void updateSnapshotOptions({ snapshotsEnabled: true }),
|
|
718
|
+
},
|
|
719
|
+
}
|
|
720
|
+
: null,
|
|
721
|
+
noSnapshotYet.value
|
|
722
|
+
? {
|
|
723
|
+
tone: 'primary',
|
|
724
|
+
icon: 'i-ph-hourglass',
|
|
725
|
+
title: t('page.marketing.pages.preview.no_snapshot'),
|
|
726
|
+
description:
|
|
727
|
+
props.sampleRateLabel === null
|
|
728
|
+
? t('page.marketing.pages.preview.no_snapshot_hint')
|
|
729
|
+
: t('page.marketing.pages.preview.no_snapshot_hint_rate', {
|
|
730
|
+
rate: props.sampleRateLabel,
|
|
731
|
+
}),
|
|
732
|
+
}
|
|
733
|
+
: null,
|
|
734
|
+
snapshot.value && tooHeavy.value
|
|
735
|
+
? {
|
|
736
|
+
tone: 'warning',
|
|
737
|
+
icon: 'i-ph-file-x',
|
|
738
|
+
title: t('page.marketing.pages.preview.too_heavy'),
|
|
739
|
+
description: t('page.marketing.pages.preview.too_heavy_hint', {
|
|
740
|
+
size: mebibytes(snapshot.value.bytes),
|
|
741
|
+
limit: mebibytes(SNAPSHOT_BYTE_CAP),
|
|
742
|
+
}),
|
|
743
|
+
}
|
|
744
|
+
: null,
|
|
745
|
+
truncatedState.value
|
|
746
|
+
? {
|
|
747
|
+
tone: 'warning',
|
|
748
|
+
icon: 'i-ph-warning',
|
|
749
|
+
title: truncatedState.value.title,
|
|
750
|
+
description: truncatedState.value.description,
|
|
751
|
+
}
|
|
752
|
+
: null,
|
|
753
|
+
oversized.value
|
|
754
|
+
? {
|
|
755
|
+
tone: 'warning',
|
|
756
|
+
icon: 'i-ph-arrows-out-line-vertical',
|
|
757
|
+
title: t('page.marketing.pages.preview.oversized'),
|
|
758
|
+
description: t('page.marketing.pages.preview.oversized_hint'),
|
|
759
|
+
}
|
|
760
|
+
: null,
|
|
761
|
+
snapshot.value && !snapshot.value.exact
|
|
762
|
+
? {
|
|
763
|
+
tone: 'info',
|
|
764
|
+
neutral: true,
|
|
765
|
+
icon: 'i-ph-arrows-out-line-horizontal',
|
|
766
|
+
title: t('page.marketing.pages.preview.approximate_layout', {
|
|
767
|
+
layout: t(
|
|
768
|
+
`page.marketing.pages.preview.layout_${snapshot.value.layout}`,
|
|
769
|
+
).toLowerCase(),
|
|
770
|
+
width: layoutWidth.value,
|
|
771
|
+
}),
|
|
772
|
+
description: t(
|
|
773
|
+
'page.marketing.pages.preview.approximate_layout_hint',
|
|
774
|
+
),
|
|
775
|
+
}
|
|
776
|
+
: null,
|
|
777
|
+
overflowing.value
|
|
778
|
+
? {
|
|
779
|
+
tone: 'info',
|
|
780
|
+
neutral: true,
|
|
781
|
+
icon: 'i-ph-arrows-horizontal',
|
|
782
|
+
title: t('page.marketing.pages.preview.overflow'),
|
|
783
|
+
description: t('page.marketing.pages.preview.overflow_hint', {
|
|
784
|
+
width: measuredWidth.value,
|
|
785
|
+
}),
|
|
786
|
+
}
|
|
787
|
+
: null,
|
|
788
|
+
]
|
|
789
|
+
return candidates.find((candidate) => candidate !== null) ?? null
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
const statusLine = computed(() => {
|
|
793
|
+
if (!snapshot.value) {
|
|
618
794
|
return ''
|
|
619
795
|
}
|
|
796
|
+
const layoutLabel = t(
|
|
797
|
+
`page.marketing.pages.preview.layout_${snapshot.value.layout}`,
|
|
798
|
+
).toLowerCase()
|
|
799
|
+
return t('page.marketing.pages.preview.status_line', {
|
|
800
|
+
date: formatShortDate(snapshot.value.capturedAt, locale.value),
|
|
801
|
+
layout: layoutLabel,
|
|
802
|
+
width: snapshot.value.viewport.width,
|
|
803
|
+
})
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
const maskLine = computed(() =>
|
|
807
|
+
t(
|
|
808
|
+
props.website?.snapshotMaskText
|
|
809
|
+
? 'page.marketing.pages.preview.all_masked'
|
|
810
|
+
: 'page.marketing.pages.preview.fields_masked',
|
|
811
|
+
),
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
// --- Most clicked -----------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
const MOST_CLICKED = 5
|
|
817
|
+
|
|
818
|
+
interface ClickedElement {
|
|
819
|
+
key: string
|
|
820
|
+
selector: string
|
|
821
|
+
weight: number
|
|
822
|
+
text: string
|
|
823
|
+
box: AnchorBox | undefined
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** The anchored elements with the most clicks, the backend's own cells summed. */
|
|
827
|
+
const mostClicked = computed<ClickedElement[]>(() => {
|
|
828
|
+
const groups = new Map<string, ClickedElement>()
|
|
829
|
+
for (const point of points.value) {
|
|
830
|
+
if (!point.anchor) {
|
|
831
|
+
continue
|
|
832
|
+
}
|
|
833
|
+
const key = anchorKey(point.anchor)
|
|
834
|
+
const existing = groups.get(key)
|
|
835
|
+
if (existing) {
|
|
836
|
+
existing.weight += point.weight
|
|
837
|
+
continue
|
|
838
|
+
}
|
|
839
|
+
const box = anchorBoxes.value.get(key)
|
|
840
|
+
groups.set(key, {
|
|
841
|
+
key,
|
|
842
|
+
selector: point.anchor.selector,
|
|
843
|
+
weight: point.weight,
|
|
844
|
+
text: box?.text ?? '',
|
|
845
|
+
box,
|
|
846
|
+
})
|
|
847
|
+
}
|
|
848
|
+
return [...groups.values()]
|
|
849
|
+
.sort((a, b) => b.weight - a.weight)
|
|
850
|
+
.slice(0, MOST_CLICKED)
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
/** Half a pin's footprint on screen, and the step that unstacks two pins. */
|
|
854
|
+
const PIN_HALF_WIDTH = 28
|
|
855
|
+
const PIN_HALF_HEIGHT = 11
|
|
856
|
+
const PIN_STEP = 22
|
|
857
|
+
|
|
858
|
+
interface Pin {
|
|
859
|
+
key: string
|
|
860
|
+
weight: number
|
|
861
|
+
left: number
|
|
862
|
+
top: number
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Count badges pinned on the hottest elements, in the surface's own pixels:
|
|
867
|
+
* kept inside the surface (an element flush with the right edge would cut
|
|
868
|
+
* its badge in half) and pushed down past the badges already placed.
|
|
869
|
+
*/
|
|
870
|
+
const pins = computed(() => {
|
|
871
|
+
if (overlayMode.value !== 'clicks') {
|
|
872
|
+
return []
|
|
873
|
+
}
|
|
874
|
+
const unit = 1 / scale.value
|
|
875
|
+
const placed: Pin[] = []
|
|
876
|
+
for (const element of mostClicked.value) {
|
|
877
|
+
const box = element.box
|
|
878
|
+
if (!box) {
|
|
879
|
+
continue
|
|
880
|
+
}
|
|
881
|
+
const left = Math.min(
|
|
882
|
+
Math.max(box.x + box.width, PIN_HALF_WIDTH * unit),
|
|
883
|
+
layoutWidth.value - PIN_HALF_WIDTH * unit,
|
|
884
|
+
)
|
|
885
|
+
let top = Math.max(box.y, PIN_HALF_HEIGHT * unit)
|
|
886
|
+
while (
|
|
887
|
+
placed.some(
|
|
888
|
+
(pin) =>
|
|
889
|
+
Math.abs(pin.left - left) < 2 * PIN_HALF_WIDTH * unit &&
|
|
890
|
+
Math.abs(pin.top - top) < PIN_STEP * unit,
|
|
891
|
+
)
|
|
892
|
+
) {
|
|
893
|
+
top += PIN_STEP * unit
|
|
894
|
+
}
|
|
895
|
+
placed.push({ key: element.key, weight: element.weight, left, top })
|
|
896
|
+
}
|
|
897
|
+
return placed
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
// --- Toolbar ------------------------------------------------------------------
|
|
901
|
+
|
|
902
|
+
const overlayItems = computed(() => [
|
|
903
|
+
{
|
|
904
|
+
value: 'clicks',
|
|
905
|
+
label: t('page.marketing.pages.overlay.clicks'),
|
|
906
|
+
icon: 'i-ph-cursor-click',
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
value: 'scroll',
|
|
910
|
+
label: t('page.marketing.pages.overlay.scroll'),
|
|
911
|
+
icon: 'i-ph-caret-double-down',
|
|
912
|
+
},
|
|
913
|
+
])
|
|
914
|
+
|
|
915
|
+
const layoutItems = computed(() =>
|
|
916
|
+
LAYOUT_PRESETS.map((preset) => ({
|
|
917
|
+
value: preset.width,
|
|
918
|
+
label: '',
|
|
919
|
+
icon: preset.icon,
|
|
920
|
+
})),
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Names the icon-only layout segments: DmsSegmented renders an item's label
|
|
925
|
+
* as its only text and has no per-item aria-label, so a screen reader would
|
|
926
|
+
* read three unnamed radios. Set on the rendered buttons, in preset order.
|
|
927
|
+
*/
|
|
928
|
+
const layoutSwitch = ref<HTMLElement | null>(null)
|
|
929
|
+
|
|
930
|
+
watch(
|
|
931
|
+
[layoutSwitch, locale],
|
|
932
|
+
([wrapper]) => {
|
|
933
|
+
wrapper?.querySelectorAll('[role="radio"]').forEach((radio, index) => {
|
|
934
|
+
const preset = LAYOUT_PRESETS[index]
|
|
935
|
+
if (preset) {
|
|
936
|
+
radio.setAttribute('aria-label', t(preset.labelKey))
|
|
937
|
+
radio.setAttribute('title', t(preset.labelKey))
|
|
938
|
+
}
|
|
939
|
+
})
|
|
940
|
+
},
|
|
941
|
+
{ flush: 'post' },
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
const moreItems = computed(() => [
|
|
945
|
+
[
|
|
946
|
+
{
|
|
947
|
+
type: 'checkbox' as const,
|
|
948
|
+
label: t('page.marketing.pages.preview.snapshots_enabled'),
|
|
949
|
+
checked: snapshotsEnabled.value,
|
|
950
|
+
onUpdateChecked: (value: boolean) =>
|
|
951
|
+
void updateSnapshotOptions({ snapshotsEnabled: value }),
|
|
952
|
+
},
|
|
953
|
+
{
|
|
954
|
+
type: 'checkbox' as const,
|
|
955
|
+
label: t('page.marketing.pages.preview.mask_text'),
|
|
956
|
+
checked: props.website?.snapshotMaskText === true,
|
|
957
|
+
disabled: !snapshotsEnabled.value,
|
|
958
|
+
onUpdateChecked: (value: boolean) =>
|
|
959
|
+
void updateSnapshotOptions({ snapshotMaskText: value }),
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
...(externalUrl.value
|
|
963
|
+
? [
|
|
964
|
+
[
|
|
965
|
+
{
|
|
966
|
+
label: t('page.marketing.pages.preview.open_in_tab'),
|
|
967
|
+
icon: 'i-ph-arrow-square-out',
|
|
968
|
+
to: externalUrl.value,
|
|
969
|
+
target: '_blank',
|
|
970
|
+
},
|
|
971
|
+
],
|
|
972
|
+
]
|
|
973
|
+
: []),
|
|
974
|
+
[
|
|
975
|
+
{
|
|
976
|
+
label: t('page.marketing.pages.reset.page_button'),
|
|
977
|
+
icon: 'i-ph-eraser',
|
|
978
|
+
color: 'error' as const,
|
|
979
|
+
onSelect: () => void resetPageHeatmap(),
|
|
980
|
+
},
|
|
981
|
+
],
|
|
982
|
+
])
|
|
983
|
+
|
|
984
|
+
// --- Surface ----------------------------------------------------------------
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* What the left cell draws. One empty state whose texts change with the
|
|
988
|
+
* reason, never several swapped in turn: replacing one DmsEmptyState by
|
|
989
|
+
* another landed the new one outside the cell.
|
|
990
|
+
*/
|
|
991
|
+
const surface = computed(() => {
|
|
992
|
+
if (activeFailed.value) {
|
|
993
|
+
return 'error'
|
|
994
|
+
}
|
|
995
|
+
if (snapshot.value && backdrop.value !== null) {
|
|
996
|
+
return 'frame'
|
|
997
|
+
}
|
|
998
|
+
if (emptyState.value) {
|
|
999
|
+
return 'empty'
|
|
1000
|
+
}
|
|
1001
|
+
if (snapshotsEnabled.value && !snapshotSettled.value) {
|
|
1002
|
+
return 'loading'
|
|
1003
|
+
}
|
|
1004
|
+
return 'no_backdrop'
|
|
1005
|
+
})
|
|
1006
|
+
|
|
1007
|
+
const surfaceEmpty = computed(() => {
|
|
1008
|
+
if (surface.value === 'error') {
|
|
1009
|
+
return {
|
|
1010
|
+
variant: 'error' as const,
|
|
1011
|
+
icon: undefined,
|
|
1012
|
+
title: t('page.marketing.common.load_error'),
|
|
1013
|
+
description: undefined,
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (surface.value === 'empty' && emptyState.value) {
|
|
1017
|
+
return {
|
|
1018
|
+
variant: undefined,
|
|
1019
|
+
icon:
|
|
1020
|
+
overlayMode.value === 'clicks'
|
|
1021
|
+
? 'i-ph-cursor-click'
|
|
1022
|
+
: 'i-ph-caret-double-down',
|
|
1023
|
+
...emptyState.value,
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
variant: undefined,
|
|
1028
|
+
icon: 'i-ph-image-broken',
|
|
1029
|
+
title: t('page.marketing.pages.preview.no_backdrop'),
|
|
1030
|
+
description: t('page.marketing.pages.preview.no_backdrop_hint'),
|
|
1031
|
+
}
|
|
620
1032
|
})
|
|
621
1033
|
</script>
|
|
622
1034
|
|
|
623
1035
|
<template>
|
|
624
|
-
<
|
|
1036
|
+
<section class="dms-card flex min-w-0 flex-col overflow-hidden p-0">
|
|
625
1037
|
<!-- Toolbar -->
|
|
626
|
-
<
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
<
|
|
634
|
-
{{
|
|
635
|
-
</
|
|
636
|
-
<
|
|
637
|
-
|
|
1038
|
+
<header
|
|
1039
|
+
class="flex flex-wrap items-center justify-between gap-3 border-b border-default px-4 py-3"
|
|
1040
|
+
>
|
|
1041
|
+
<div class="min-w-0 flex-1 basis-64">
|
|
1042
|
+
<p
|
|
1043
|
+
class="flex min-w-0 items-center gap-2 font-mono text-base font-semibold text-highlighted"
|
|
1044
|
+
>
|
|
1045
|
+
<span class="min-w-0 truncate" :title="props.path">
|
|
1046
|
+
{{ props.path }}
|
|
1047
|
+
</span>
|
|
1048
|
+
<UButton
|
|
1049
|
+
v-if="externalUrl"
|
|
1050
|
+
:to="externalUrl"
|
|
1051
|
+
target="_blank"
|
|
1052
|
+
icon="i-ph-arrow-square-out"
|
|
1053
|
+
size="xs"
|
|
1054
|
+
color="neutral"
|
|
1055
|
+
variant="ghost"
|
|
1056
|
+
class="shrink-0"
|
|
1057
|
+
:aria-label="t('page.marketing.pages.preview.open_in_tab')"
|
|
1058
|
+
/>
|
|
1059
|
+
</p>
|
|
1060
|
+
<p class="flex flex-wrap gap-x-3 text-xs text-muted">
|
|
1061
|
+
<span v-if="props.pageviews !== undefined">
|
|
1062
|
+
<b class="font-mono text-highlighted">
|
|
1063
|
+
{{ formatNumber(props.pageviews, locale) }}
|
|
1064
|
+
</b>
|
|
1065
|
+
{{ t('page.marketing.pages.views_unit', props.pageviews) }}
|
|
638
1066
|
</span>
|
|
639
|
-
<span
|
|
640
|
-
|
|
1067
|
+
<span>
|
|
1068
|
+
<b class="font-mono text-highlighted">
|
|
1069
|
+
{{ formatNumber(totalClicks, locale) }}
|
|
1070
|
+
</b>
|
|
1071
|
+
{{ t('page.marketing.pages.clicks_unit', totalClicks) }}
|
|
1072
|
+
</span>
|
|
1073
|
+
<span v-if="scrollDepth !== null">
|
|
1074
|
+
<b class="font-mono text-highlighted">
|
|
1075
|
+
{{ formatNumber(scrollViews, locale) }}
|
|
1076
|
+
</b>
|
|
1077
|
+
{{ t('page.marketing.pages.sampled_unit', scrollViews) }}
|
|
641
1078
|
</span>
|
|
642
1079
|
</p>
|
|
643
1080
|
</div>
|
|
644
|
-
<div class="flex flex-wrap items-center gap-2">
|
|
645
|
-
<
|
|
646
|
-
|
|
647
|
-
|
|
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"
|
|
1081
|
+
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
|
1082
|
+
<DmsSegmented
|
|
1083
|
+
v-model="overlayMode"
|
|
1084
|
+
:items="overlayItems"
|
|
680
1085
|
size="xs"
|
|
681
|
-
|
|
682
|
-
:title="t('page.marketing.pages.preview.height_override')"
|
|
1086
|
+
:aria-label="t('page.marketing.pages.overlay.label')"
|
|
683
1087
|
/>
|
|
684
|
-
<
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
/>
|
|
694
|
-
<UPopover v-if="props.website">
|
|
1088
|
+
<span ref="layoutSwitch" class="contents">
|
|
1089
|
+
<DmsSegmented
|
|
1090
|
+
v-model="layoutWidth"
|
|
1091
|
+
:items="layoutItems"
|
|
1092
|
+
size="xs"
|
|
1093
|
+
:aria-label="t('page.marketing.pages.preview.layout')"
|
|
1094
|
+
/>
|
|
1095
|
+
</span>
|
|
1096
|
+
<UDropdownMenu :items="moreItems" :content="{ align: 'end' }">
|
|
695
1097
|
<UButton
|
|
696
|
-
icon="i-ph-
|
|
1098
|
+
icon="i-ph-dots-three"
|
|
1099
|
+
size="xs"
|
|
697
1100
|
color="neutral"
|
|
698
1101
|
variant="ghost"
|
|
699
|
-
|
|
700
|
-
:
|
|
701
|
-
:title="t('page.marketing.pages.preview.options')"
|
|
702
|
-
:aria-label="t('page.marketing.pages.preview.options')"
|
|
1102
|
+
:loading="savingOptions || resetting"
|
|
1103
|
+
:aria-label="t('page.marketing.pages.preview.more')"
|
|
703
1104
|
/>
|
|
704
|
-
|
|
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
|
-
/>
|
|
1105
|
+
</UDropdownMenu>
|
|
732
1106
|
</div>
|
|
733
|
-
</
|
|
1107
|
+
</header>
|
|
734
1108
|
|
|
735
|
-
<!--
|
|
736
|
-
<
|
|
737
|
-
|
|
738
|
-
|
|
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"
|
|
1109
|
+
<!-- One status line, or the one strip that needs attention -->
|
|
1110
|
+
<div v-if="statusStrip" class="px-3 pt-3">
|
|
1111
|
+
<DmsBanner
|
|
1112
|
+
:tone="statusStrip.tone"
|
|
829
1113
|
size="sm"
|
|
830
|
-
|
|
831
|
-
|
|
1114
|
+
:icon="statusStrip.icon"
|
|
1115
|
+
:title="statusStrip.title"
|
|
1116
|
+
:description="statusStrip.description"
|
|
1117
|
+
>
|
|
1118
|
+
<template v-if="statusStrip.action" #actions>
|
|
1119
|
+
<UButton
|
|
1120
|
+
size="xs"
|
|
1121
|
+
color="neutral"
|
|
1122
|
+
variant="outline"
|
|
1123
|
+
:loading="savingOptions"
|
|
1124
|
+
:label="statusStrip.action.label"
|
|
1125
|
+
:to="statusStrip.action.to"
|
|
1126
|
+
@click="statusStrip.action.run?.()"
|
|
1127
|
+
/>
|
|
1128
|
+
</template>
|
|
1129
|
+
</DmsBanner>
|
|
832
1130
|
</div>
|
|
1131
|
+
<p
|
|
1132
|
+
v-else-if="snapshot"
|
|
1133
|
+
class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-default px-4 py-2 text-xs text-muted"
|
|
1134
|
+
>
|
|
1135
|
+
<span class="flex items-center gap-1.5">
|
|
1136
|
+
<UIcon name="i-ph-camera" class="size-3.5 text-success" />
|
|
1137
|
+
{{ statusLine }}
|
|
1138
|
+
</span>
|
|
1139
|
+
<span class="text-dimmed">· {{ maskLine }}</span>
|
|
1140
|
+
</p>
|
|
1141
|
+
|
|
1142
|
+
<div class="grid min-h-0 xl:grid-cols-[minmax(0,1fr)_15rem]">
|
|
1143
|
+
<div class="min-w-0 p-3">
|
|
1144
|
+
<USkeleton
|
|
1145
|
+
v-if="surface === 'loading'"
|
|
1146
|
+
class="w-full rounded-lg"
|
|
1147
|
+
:style="{ height: `${PANE_HEIGHT}px` }"
|
|
1148
|
+
/>
|
|
833
1149
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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` }">
|
|
1150
|
+
<!-- Kept mounted across refetches: tearing it down would re-render the
|
|
1151
|
+
snapshot — and re-measure it — on every period change. -->
|
|
1152
|
+
<div v-else-if="surface === 'frame'" class="relative">
|
|
849
1153
|
<div
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
height: `${renderHeight}px`,
|
|
854
|
-
zoom: scale,
|
|
855
|
-
}"
|
|
1154
|
+
ref="container"
|
|
1155
|
+
class="overflow-x-hidden overflow-y-scroll rounded-lg border border-default bg-elevated"
|
|
1156
|
+
:style="paneStyle"
|
|
856
1157
|
>
|
|
857
|
-
<!--
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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"
|
|
1158
|
+
<!-- A layout narrower than the pane keeps its width, centred. -->
|
|
1159
|
+
<div
|
|
1160
|
+
class="relative mx-auto overflow-hidden"
|
|
1161
|
+
:class="
|
|
1162
|
+
surfaceWidth < containerWidth ? 'ring-1 ring-accented' : ''
|
|
1163
|
+
"
|
|
872
1164
|
:style="{
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
backgroundColor: snapshotScheme === 'dark' ? DARK_CANVAS : LIGHT_CANVAS,
|
|
1165
|
+
width: `${surfaceWidth}px`,
|
|
1166
|
+
height: `${scrolledHeight}px`,
|
|
876
1167
|
}"
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1168
|
+
>
|
|
1169
|
+
<div
|
|
1170
|
+
class="absolute top-0 left-0"
|
|
1171
|
+
:style="{
|
|
1172
|
+
width: `${layoutWidth}px`,
|
|
1173
|
+
height: `${renderHeight}px`,
|
|
1174
|
+
zoom: scale,
|
|
1175
|
+
}"
|
|
1176
|
+
>
|
|
1177
|
+
<!-- Inert on purpose: pointer-events none means no link, form or
|
|
1178
|
+
hover state inside the snapshot can fire. The sandbox allows
|
|
1179
|
+
same-origin and nothing else — no script, no form, no
|
|
1180
|
+
navigation — which is what lets useSnapshotFrame read the
|
|
1181
|
+
document while a capture that arrived through a public
|
|
1182
|
+
endpoint stays inert. Hidden from assistive technology:
|
|
1183
|
+
"Most clicked" is its readable alternative. -->
|
|
1184
|
+
<iframe
|
|
1185
|
+
:key="frameKey"
|
|
1186
|
+
ref="frame"
|
|
1187
|
+
:srcdoc="frameDocument"
|
|
1188
|
+
class="pointer-events-none absolute top-0 left-0 w-full border-0"
|
|
1189
|
+
:style="{
|
|
1190
|
+
height: `${frameHeight}px`,
|
|
1191
|
+
colorScheme: snapshotScheme,
|
|
1192
|
+
backgroundColor:
|
|
1193
|
+
snapshotScheme === 'dark' ? DARK_CANVAS : LIGHT_CANVAS,
|
|
1194
|
+
}"
|
|
1195
|
+
sandbox="allow-same-origin"
|
|
1196
|
+
scrolling="no"
|
|
1197
|
+
referrerpolicy="no-referrer"
|
|
1198
|
+
aria-hidden="true"
|
|
1199
|
+
tabindex="-1"
|
|
1200
|
+
inert
|
|
1201
|
+
:title="props.path"
|
|
1202
|
+
/>
|
|
1203
|
+
<DmsMarketingHeatmapCanvas
|
|
1204
|
+
v-if="overlayMode === 'clicks'"
|
|
1205
|
+
:points="drawnPoints"
|
|
1206
|
+
:max-weight="maxWeight"
|
|
1207
|
+
:width="overlayWidth"
|
|
1208
|
+
:height="renderHeight"
|
|
1209
|
+
/>
|
|
1210
|
+
<span
|
|
1211
|
+
v-for="pin in pins"
|
|
1212
|
+
:key="pin.key"
|
|
1213
|
+
aria-hidden="true"
|
|
1214
|
+
class="pointer-events-none absolute flex -translate-x-1/2 -translate-y-1/2 items-center gap-1 rounded-full bg-neutral-900/90 px-2 py-0.5 font-mono font-semibold text-white shadow"
|
|
1215
|
+
:style="{
|
|
1216
|
+
left: `${pin.left * scale}px`,
|
|
1217
|
+
top: `${pin.top * scale}px`,
|
|
1218
|
+
zoom: 1 / scale,
|
|
1219
|
+
fontSize: '12px',
|
|
1220
|
+
}"
|
|
1221
|
+
>
|
|
1222
|
+
<span class="size-1.5 rounded-full bg-red-500" />
|
|
1223
|
+
{{ formatNumber(pin.weight, locale) }}
|
|
1224
|
+
</span>
|
|
1225
|
+
</div>
|
|
1226
|
+
|
|
1227
|
+
<!-- In the pane's scaled space, not the document's: depth only
|
|
1228
|
+
varies vertically, and the labels must not shrink with the
|
|
1229
|
+
page — see the component. -->
|
|
1230
|
+
<DmsMarketingScrollDepthOverlay
|
|
1231
|
+
v-if="overlayMode === 'scroll' && scrollViews > 0"
|
|
1232
|
+
:reached="scrollReached"
|
|
1233
|
+
:views="scrollViews"
|
|
1234
|
+
:height="scrolledHeight"
|
|
1235
|
+
:fold-ratio="scrollFoldRatio"
|
|
1236
|
+
/>
|
|
1237
|
+
</div>
|
|
889
1238
|
</div>
|
|
890
1239
|
|
|
891
|
-
<!--
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1240
|
+
<!-- Outside the scrollport on purpose: inside, it is laid out at the
|
|
1241
|
+
top of the content and scrolls out of sight on a tall page. -->
|
|
1242
|
+
<div
|
|
1243
|
+
v-if="activeLoading"
|
|
1244
|
+
class="absolute inset-0 flex items-center justify-center rounded-lg bg-default/60"
|
|
1245
|
+
>
|
|
1246
|
+
<UIcon
|
|
1247
|
+
name="i-ph-circle-notch"
|
|
1248
|
+
class="size-6 animate-spin text-muted"
|
|
1249
|
+
/>
|
|
1250
|
+
</div>
|
|
1251
|
+
</div>
|
|
1252
|
+
|
|
1253
|
+
<!-- One empty state for every reason, in a plain wrapper: see `surface`. -->
|
|
1254
|
+
<div v-else>
|
|
1255
|
+
<DmsEmptyState
|
|
1256
|
+
:variant="surfaceEmpty.variant"
|
|
1257
|
+
:icon="surfaceEmpty.icon"
|
|
1258
|
+
:hatched="surface !== 'error'"
|
|
1259
|
+
:size="surface === 'error' ? 'sm' : undefined"
|
|
1260
|
+
:title="surfaceEmpty.title"
|
|
1261
|
+
:description="surfaceEmpty.description"
|
|
1262
|
+
>
|
|
1263
|
+
<template v-if="surface === 'error'" #actions>
|
|
1264
|
+
<UButton
|
|
1265
|
+
size="xs"
|
|
1266
|
+
color="neutral"
|
|
1267
|
+
variant="outline"
|
|
1268
|
+
icon="i-ph-arrow-clockwise"
|
|
1269
|
+
:label="t('page.marketing.common.retry')"
|
|
1270
|
+
@click="retryActive"
|
|
1271
|
+
/>
|
|
1272
|
+
</template>
|
|
1273
|
+
</DmsEmptyState>
|
|
1274
|
+
</div>
|
|
903
1275
|
</div>
|
|
904
1276
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
<div
|
|
908
|
-
v-if="activeLoading"
|
|
909
|
-
class="absolute inset-0 flex items-center justify-center rounded-lg bg-default/60"
|
|
1277
|
+
<aside
|
|
1278
|
+
class="flex flex-col gap-4 border-t border-default p-4 xl:border-t-0 xl:border-l"
|
|
910
1279
|
>
|
|
911
|
-
<
|
|
912
|
-
|
|
1280
|
+
<template v-if="overlayMode === 'clicks'">
|
|
1281
|
+
<div class="flex flex-col gap-1.5">
|
|
1282
|
+
<DmsEyebrow :label="t('page.marketing.pages.density')" />
|
|
1283
|
+
<span
|
|
1284
|
+
class="h-2 rounded-full"
|
|
1285
|
+
:style="{ backgroundImage: heatLegendGradient() }"
|
|
1286
|
+
/>
|
|
1287
|
+
<span class="flex justify-between text-[10px] text-dimmed">
|
|
1288
|
+
<span>{{ t('page.marketing.pages.density_few') }}</span>
|
|
1289
|
+
<span>{{ t('page.marketing.pages.density_many') }}</span>
|
|
1290
|
+
</span>
|
|
1291
|
+
</div>
|
|
1292
|
+
<div class="flex flex-col gap-2">
|
|
1293
|
+
<DmsEyebrow :label="t('page.marketing.pages.most_clicked')" />
|
|
1294
|
+
<p v-if="mostClicked.length === 0" class="text-xs text-muted">
|
|
1295
|
+
{{ t('page.marketing.pages.most_clicked_empty') }}
|
|
1296
|
+
</p>
|
|
1297
|
+
<ul v-else class="flex flex-col gap-2.5">
|
|
1298
|
+
<li
|
|
1299
|
+
v-for="element in mostClicked"
|
|
1300
|
+
:key="element.key"
|
|
1301
|
+
class="flex min-w-0 flex-col"
|
|
1302
|
+
>
|
|
1303
|
+
<span class="flex items-start justify-between gap-2">
|
|
1304
|
+
<span
|
|
1305
|
+
class="truncate text-[13px] font-medium text-highlighted"
|
|
1306
|
+
:class="element.text ? '' : 'font-mono'"
|
|
1307
|
+
:title="element.text || element.selector"
|
|
1308
|
+
>
|
|
1309
|
+
{{
|
|
1310
|
+
element.text
|
|
1311
|
+
? t('page.marketing.pages.quoted', {
|
|
1312
|
+
text: element.text,
|
|
1313
|
+
})
|
|
1314
|
+
: element.selector
|
|
1315
|
+
}}
|
|
1316
|
+
</span>
|
|
1317
|
+
<span class="font-mono text-xs text-highlighted">
|
|
1318
|
+
{{ formatNumber(element.weight, locale) }}
|
|
1319
|
+
</span>
|
|
1320
|
+
</span>
|
|
1321
|
+
<span
|
|
1322
|
+
v-if="element.text"
|
|
1323
|
+
class="truncate font-mono text-[10px] text-dimmed"
|
|
1324
|
+
:title="element.selector"
|
|
1325
|
+
>
|
|
1326
|
+
{{ element.selector }}
|
|
1327
|
+
</span>
|
|
1328
|
+
</li>
|
|
1329
|
+
</ul>
|
|
1330
|
+
</div>
|
|
1331
|
+
</template>
|
|
1332
|
+
<div v-else class="flex flex-col gap-2">
|
|
1333
|
+
<DmsEyebrow :label="t('page.marketing.pages.scroll.legend_title')" />
|
|
1334
|
+
<span
|
|
1335
|
+
class="h-2 rounded-full"
|
|
1336
|
+
:style="{ backgroundImage: heatLegendGradient(true) }"
|
|
1337
|
+
/>
|
|
1338
|
+
<span class="flex justify-between text-[10px] text-dimmed">
|
|
1339
|
+
<span>{{ t('page.marketing.pages.scroll.legend_all') }}</span>
|
|
1340
|
+
<span>{{ t('page.marketing.pages.scroll.legend_few') }}</span>
|
|
1341
|
+
</span>
|
|
1342
|
+
<span
|
|
1343
|
+
v-if="scrollViews > 0 && scrollFoldRatio > 0 && scrollFoldRatio < 1"
|
|
1344
|
+
class="flex items-center gap-2 text-xs text-muted"
|
|
1345
|
+
>
|
|
1346
|
+
<span
|
|
1347
|
+
class="h-2 w-2 shrink-0 rounded-full bg-default shadow ring-1 ring-default"
|
|
1348
|
+
aria-hidden="true"
|
|
1349
|
+
/>
|
|
1350
|
+
<span class="h-px w-4 shrink-0 bg-current" aria-hidden="true" />
|
|
1351
|
+
{{ t('page.marketing.pages.scroll.fold_line') }}
|
|
1352
|
+
</span>
|
|
1353
|
+
<p v-if="scrollViews > 0" class="text-xs text-muted">
|
|
1354
|
+
{{
|
|
1355
|
+
t('page.marketing.pages.scroll.median', {
|
|
1356
|
+
depth: medianDepth,
|
|
1357
|
+
})
|
|
1358
|
+
}}
|
|
1359
|
+
</p>
|
|
1360
|
+
<p class="text-xs text-dimmed">
|
|
1361
|
+
{{ t('page.marketing.pages.scroll.lines_hint') }}
|
|
1362
|
+
</p>
|
|
1363
|
+
</div>
|
|
1364
|
+
<div v-if="usedIn && usedIn.length > 0" class="flex flex-col gap-2">
|
|
1365
|
+
<DmsEyebrow :label="t('page.marketing.pages.used_in')" />
|
|
1366
|
+
<DmsAutoLink
|
|
1367
|
+
v-for="funnel in usedIn"
|
|
1368
|
+
:key="funnel._id"
|
|
1369
|
+
:to="funnelLink(funnel._id)"
|
|
1370
|
+
class="flex items-center gap-2 rounded-lg border border-default p-2.5 transition-colors hover:border-primary"
|
|
1371
|
+
>
|
|
1372
|
+
<DmsIconWell icon="i-ph-funnel" size="sm" tone="primary" />
|
|
1373
|
+
<span class="truncate text-sm font-medium text-highlighted">
|
|
1374
|
+
{{ funnel.name }}
|
|
1375
|
+
</span>
|
|
1376
|
+
</DmsAutoLink>
|
|
1377
|
+
</div>
|
|
1378
|
+
<p
|
|
1379
|
+
v-if="overlayMode === 'clicks'"
|
|
1380
|
+
class="mt-auto flex gap-1.5 text-xs text-dimmed"
|
|
1381
|
+
>
|
|
1382
|
+
<UIcon name="i-ph-crosshair" class="mt-0.5 size-3.5 shrink-0" />
|
|
1383
|
+
{{ t('page.marketing.pages.anchoring_note') }}
|
|
1384
|
+
</p>
|
|
1385
|
+
</aside>
|
|
913
1386
|
</div>
|
|
914
|
-
</
|
|
1387
|
+
</section>
|
|
915
1388
|
</template>
|