@antelopejs/dms-marketing 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.js +18 -2
- 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 -1
- 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 +21 -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/package.json +9 -7
- 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
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
|
|
4
|
+
import { useLatestRequest } from '../composables/useLatestRequest'
|
|
5
|
+
import {
|
|
6
|
+
useMarketingApi,
|
|
7
|
+
type MarketingFunnelListItem,
|
|
8
|
+
type MarketingPages,
|
|
9
|
+
type MarketingPageEntry,
|
|
10
|
+
type MarketingPagesSort,
|
|
11
|
+
type MarketingWebsite,
|
|
12
|
+
} from '../composables/useMarketingApi'
|
|
13
|
+
import { useMarketingContext } from '../composables/useMarketingContext'
|
|
14
|
+
import { installLink } from '../composables/useMarketingRoutes'
|
|
15
|
+
import { PERIOD_SCOPE } from '../constants'
|
|
16
|
+
import { parseFunnelStepsValue } from '../composables/useFunnelSteps'
|
|
17
|
+
import { formatNumber } from '../utils/format'
|
|
18
|
+
import {
|
|
19
|
+
heatmapDataLabel,
|
|
20
|
+
heatmapResetImpact,
|
|
21
|
+
normalizePagePath,
|
|
22
|
+
} from '../utils/heatmap'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pages & heatmaps: the paths visitors loaded on the selected website, with
|
|
26
|
+
* the clicks and scroll depth of the selected one over its snapshot.
|
|
27
|
+
*
|
|
28
|
+
* Keyed on nothing but a website id and a pathname the tracker reported — no
|
|
29
|
+
* route registry, no sitemap — which is what makes it hold for any site. The
|
|
30
|
+
* filter is also a path field: a day keeps a capped number of paths, so a page
|
|
31
|
+
* can be missing from the list while its clicks are perfectly queryable.
|
|
32
|
+
*/
|
|
33
|
+
const props = withDefaults(
|
|
34
|
+
defineProps<{
|
|
35
|
+
periodScope?: string
|
|
36
|
+
componentId?: string
|
|
37
|
+
pageId?: string
|
|
38
|
+
}>(),
|
|
39
|
+
{ periodScope: PERIOD_SCOPE, componentId: undefined, pageId: undefined },
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const SEARCH_DEBOUNCE_MS = 300
|
|
43
|
+
const SKELETON_ROWS = 8
|
|
44
|
+
/** Where the list and the preview sit side by side (Tailwind `xl`). */
|
|
45
|
+
const SIDE_BY_SIDE_QUERY = '(min-width: 1280px)'
|
|
46
|
+
|
|
47
|
+
const { t, locale } = useI18n()
|
|
48
|
+
const route = useDmsRoute()
|
|
49
|
+
const router = useDmsRouter()
|
|
50
|
+
const api = useMarketingApi()
|
|
51
|
+
const context = useMarketingContext()
|
|
52
|
+
const toast = useToast()
|
|
53
|
+
const { confirm } = useConfirm()
|
|
54
|
+
|
|
55
|
+
const websiteId = computed(() => context.selected.value?.id ?? '')
|
|
56
|
+
const period = usePeriodScope(props.periodScope)
|
|
57
|
+
/** The days read: a comparison change alone reads nothing again. */
|
|
58
|
+
const periodDays = computed(() => {
|
|
59
|
+
const range = period.value?.range
|
|
60
|
+
return range ? `${range.from.toISOString()}|${range.to.toISOString()}` : ''
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const queryPath = () =>
|
|
64
|
+
typeof route.query.path === 'string' ? route.query.path : ''
|
|
65
|
+
const selectedPath = ref<string | undefined>(queryPath() || undefined)
|
|
66
|
+
const search = ref('')
|
|
67
|
+
const sort = ref<MarketingPagesSort>('views')
|
|
68
|
+
|
|
69
|
+
// The full row: the context carries the id, the preview needs the snapshot options.
|
|
70
|
+
const website = ref<MarketingWebsite | null>(null)
|
|
71
|
+
|
|
72
|
+
async function loadWebsite(): Promise<void> {
|
|
73
|
+
const list = await api.listWebsites()
|
|
74
|
+
website.value = list.find((site) => site._id === websiteId.value) ?? null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const {
|
|
78
|
+
data: inventory,
|
|
79
|
+
loading,
|
|
80
|
+
settled,
|
|
81
|
+
failed,
|
|
82
|
+
run: loadPages,
|
|
83
|
+
} = useLatestRequest<MarketingPages>(() =>
|
|
84
|
+
websiteId.value && !import.meta.env.SSR
|
|
85
|
+
? api.listPages(
|
|
86
|
+
websiteId.value,
|
|
87
|
+
period.value,
|
|
88
|
+
search.value.trim(),
|
|
89
|
+
sort.value,
|
|
90
|
+
)
|
|
91
|
+
: null,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const funnels = ref<MarketingFunnelListItem[]>([])
|
|
95
|
+
|
|
96
|
+
async function loadFunnels(): Promise<void> {
|
|
97
|
+
try {
|
|
98
|
+
funnels.value = (await api.listFunnels(websiteId.value)).results
|
|
99
|
+
} catch {
|
|
100
|
+
funnels.value = []
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
watch(
|
|
105
|
+
[websiteId, context.refreshToken],
|
|
106
|
+
([id], previous) => {
|
|
107
|
+
if (previous?.[0] !== undefined && id !== previous[0]) {
|
|
108
|
+
selectedPath.value = undefined
|
|
109
|
+
}
|
|
110
|
+
if (id) {
|
|
111
|
+
void loadWebsite()
|
|
112
|
+
void loadFunnels()
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{ immediate: true },
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
watch(
|
|
119
|
+
[websiteId, periodDays, context.refreshToken, sort],
|
|
120
|
+
() => void loadPages(),
|
|
121
|
+
{ immediate: true },
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
|
125
|
+
watch(search, () => {
|
|
126
|
+
if (searchTimer) {
|
|
127
|
+
clearTimeout(searchTimer)
|
|
128
|
+
}
|
|
129
|
+
searchTimer = setTimeout(() => void loadPages(), SEARCH_DEBOUNCE_MS)
|
|
130
|
+
})
|
|
131
|
+
onBeforeUnmount(() => searchTimer && clearTimeout(searchTimer))
|
|
132
|
+
|
|
133
|
+
const pages = computed(() => inventory.value?.pages ?? [])
|
|
134
|
+
const sampleRate = computed(() => inventory.value?.sampleRate ?? null)
|
|
135
|
+
/** The measured sampling share as a sentence writes it: "10%", "0.4%". */
|
|
136
|
+
const sampleRateLabel = computed(() => {
|
|
137
|
+
const rate = sampleRate.value
|
|
138
|
+
return rate === null
|
|
139
|
+
? null
|
|
140
|
+
: String(
|
|
141
|
+
formatPercentage(rate, locale.value, {
|
|
142
|
+
maximumFractionDigits: rate > 0 && rate < 0.01 ? 1 : 0,
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
const trackerEnabled = computed(() => inventory.value?.trackerEnabled ?? true)
|
|
147
|
+
const waiting = computed(() => context.selected.value?.state === 'waiting')
|
|
148
|
+
|
|
149
|
+
function metricOf(page: { pageviews: number; clicks: number }): number {
|
|
150
|
+
return sort.value === 'clicks' ? page.clicks : page.pageviews
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const maxMetric = computed(() => Math.max(1, ...pages.value.map(metricOf)))
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A pick in the list is a navigation the Back button undoes; a selection
|
|
157
|
+
* made for the reader (first load, website switch) only completes the URL.
|
|
158
|
+
* Keeps the rest of the query: the context bar mirrors its own state there.
|
|
159
|
+
*/
|
|
160
|
+
function selectPath(path: string | undefined, mode: 'push' | 'replace'): void {
|
|
161
|
+
selectedPath.value = path
|
|
162
|
+
void router[mode]({ query: { ...route.query, path: path ?? undefined } })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Back and Forward move the selection with the URL. */
|
|
166
|
+
watch(queryPath, (path) => {
|
|
167
|
+
if (path && path !== selectedPath.value) {
|
|
168
|
+
selectedPath.value = path
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// A first impression that shows the feature: the first page with clicks,
|
|
173
|
+
// else the first page.
|
|
174
|
+
watch(inventory, (data) => {
|
|
175
|
+
if (!selectedPath.value && data?.pages.length) {
|
|
176
|
+
const withClicks = data.pages.find((page) => page.clicks > 0)
|
|
177
|
+
selectPath((withClicks ?? data.pages[0])?.path, 'replace')
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const previewWrapper = ref<HTMLElement | null>(null)
|
|
182
|
+
|
|
183
|
+
/** Stacked under the list (below xl), the preview is out of sight: bring it in. */
|
|
184
|
+
async function pick(path: string): Promise<void> {
|
|
185
|
+
selectPath(path, 'push')
|
|
186
|
+
if (window.matchMedia(SIDE_BY_SIDE_QUERY).matches) {
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
await nextTick()
|
|
190
|
+
previewWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const typedPath = computed(() => normalizePagePath(search.value))
|
|
194
|
+
|
|
195
|
+
/** Enter inspects what was typed, listed or not, reduced like a tracked URL. */
|
|
196
|
+
function inspectTyped(): void {
|
|
197
|
+
if (typedPath.value) {
|
|
198
|
+
void pick(typedPath.value)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The selected path's row, kept while a filter hides it from the list: the
|
|
204
|
+
* preview header keeps its views.
|
|
205
|
+
*/
|
|
206
|
+
const selectedPage = ref<MarketingPageEntry>()
|
|
207
|
+
watch([pages, selectedPath], ([list, path]) => {
|
|
208
|
+
const found = list.find((page) => page.path === path)
|
|
209
|
+
if (found || selectedPage.value?.path !== path) {
|
|
210
|
+
selectedPage.value = found
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
/** Funnels with a page step on the selected path, for the "Used in" card. */
|
|
215
|
+
const usedIn = computed(() =>
|
|
216
|
+
funnels.value.filter((funnel) =>
|
|
217
|
+
parseFunnelStepsValue(funnel.steps).some(
|
|
218
|
+
(step) => step.kind === 'url' && step.value === selectedPath.value,
|
|
219
|
+
),
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
const refreshToken = ref(0)
|
|
224
|
+
|
|
225
|
+
// Client-only: the server renders no session, so its markup (an empty list)
|
|
226
|
+
// would not match the client's and hydration would misplace the preview.
|
|
227
|
+
const mounted = ref(false)
|
|
228
|
+
onMounted(() => (mounted.value = true))
|
|
229
|
+
|
|
230
|
+
const countLabel = computed(() => {
|
|
231
|
+
const listed = pages.value.length
|
|
232
|
+
const total = inventory.value?.total ?? listed
|
|
233
|
+
return total > listed
|
|
234
|
+
? t('page.marketing.pages.count_of', { count: listed, total }, total)
|
|
235
|
+
: t('page.marketing.pages.count', { count: listed }, listed)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const sortItems = computed(() => [
|
|
239
|
+
{ value: 'views', label: t('page.marketing.pages.views') },
|
|
240
|
+
{ value: 'clicks', label: t('page.marketing.pages.overlay.clicks') },
|
|
241
|
+
])
|
|
242
|
+
|
|
243
|
+
async function resetSite(): Promise<void> {
|
|
244
|
+
const site = website.value
|
|
245
|
+
if (!site) {
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
const counts = await api.countHeatmap(site._id).catch(() => null)
|
|
249
|
+
await confirm({
|
|
250
|
+
title: t('page.marketing.pages.reset.site_title', { name: site.name }),
|
|
251
|
+
description: t('page.marketing.pages.reset.site_description'),
|
|
252
|
+
color: 'error',
|
|
253
|
+
icon: 'i-ph-eraser',
|
|
254
|
+
confirmLabel:
|
|
255
|
+
counts && counts.events + counts.snapshots > 0
|
|
256
|
+
? t('page.marketing.pages.reset.confirm_count', {
|
|
257
|
+
what: heatmapDataLabel(counts, t, locale.value),
|
|
258
|
+
})
|
|
259
|
+
: t('page.marketing.pages.reset.confirm'),
|
|
260
|
+
confirmText: site.domain,
|
|
261
|
+
// A hostname is not case-sensitive: neither is the typed check.
|
|
262
|
+
confirmTextCaseInsensitive: true,
|
|
263
|
+
impact: heatmapResetImpact(counts, t, locale.value),
|
|
264
|
+
onConfirm: async () => {
|
|
265
|
+
const deleted = await api.resetHeatmap(site._id)
|
|
266
|
+
toast.add({
|
|
267
|
+
color: 'success',
|
|
268
|
+
title: t('page.marketing.pages.reset.site_done', { name: site.name }),
|
|
269
|
+
description: t('page.marketing.pages.reset.done', {
|
|
270
|
+
what: heatmapDataLabel(deleted, t, locale.value),
|
|
271
|
+
}),
|
|
272
|
+
})
|
|
273
|
+
refreshToken.value++
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const moreItems = computed(() => [
|
|
279
|
+
[
|
|
280
|
+
{
|
|
281
|
+
label: t('page.marketing.pages.reset.site_button'),
|
|
282
|
+
icon: 'i-ph-eraser',
|
|
283
|
+
color: 'error' as const,
|
|
284
|
+
onSelect: () => void resetSite(),
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
])
|
|
288
|
+
</script>
|
|
289
|
+
|
|
290
|
+
<template>
|
|
291
|
+
<div class="flex flex-col gap-4">
|
|
292
|
+
<!-- The Pages page's context bar shows its own guide first; this one
|
|
293
|
+
serves the block placed under a bar without it. -->
|
|
294
|
+
<DmsEmptyState
|
|
295
|
+
v-if="waiting"
|
|
296
|
+
icon="i-ph-hourglass"
|
|
297
|
+
class="dms-card"
|
|
298
|
+
:title="
|
|
299
|
+
t('page.marketing.pages.waiting.title', {
|
|
300
|
+
website: context.selected.value?.name ?? '',
|
|
301
|
+
})
|
|
302
|
+
"
|
|
303
|
+
:description="t('page.marketing.pages.waiting.description')"
|
|
304
|
+
>
|
|
305
|
+
<template #actions>
|
|
306
|
+
<UButton
|
|
307
|
+
size="sm"
|
|
308
|
+
icon="i-ph-plugs-connected"
|
|
309
|
+
:label="t('page.marketing.pages.waiting.action')"
|
|
310
|
+
:to="installLink(websiteId)"
|
|
311
|
+
/>
|
|
312
|
+
</template>
|
|
313
|
+
</DmsEmptyState>
|
|
314
|
+
|
|
315
|
+
<template v-else>
|
|
316
|
+
<!-- The preview's own strip says it when a page is shown. -->
|
|
317
|
+
<DmsBanner
|
|
318
|
+
v-if="!trackerEnabled && !(selectedPath && website)"
|
|
319
|
+
tone="error"
|
|
320
|
+
size="sm"
|
|
321
|
+
icon="i-ph-plugs"
|
|
322
|
+
:title="t('page.marketing.pages.tracking_off.title')"
|
|
323
|
+
:description="t('page.marketing.pages.tracking_off.description')"
|
|
324
|
+
/>
|
|
325
|
+
|
|
326
|
+
<div
|
|
327
|
+
v-if="!mounted"
|
|
328
|
+
class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]"
|
|
329
|
+
aria-hidden="true"
|
|
330
|
+
>
|
|
331
|
+
<USkeleton class="h-[32rem] rounded-xl" />
|
|
332
|
+
<USkeleton class="h-[32rem] rounded-xl" />
|
|
333
|
+
</div>
|
|
334
|
+
<div
|
|
335
|
+
v-else
|
|
336
|
+
class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]"
|
|
337
|
+
>
|
|
338
|
+
<section
|
|
339
|
+
class="dms-card flex min-w-0 flex-col overflow-hidden p-0 xl:sticky xl:top-4"
|
|
340
|
+
>
|
|
341
|
+
<div class="flex items-center gap-2 border-b border-default p-3">
|
|
342
|
+
<UInput
|
|
343
|
+
v-model="search"
|
|
344
|
+
size="sm"
|
|
345
|
+
icon="i-ph-magnifying-glass"
|
|
346
|
+
class="flex-1"
|
|
347
|
+
:placeholder="t('page.marketing.pages.search_placeholder')"
|
|
348
|
+
@keyup.enter="inspectTyped"
|
|
349
|
+
/>
|
|
350
|
+
<UDropdownMenu :items="moreItems" :content="{ align: 'end' }">
|
|
351
|
+
<UButton
|
|
352
|
+
icon="i-ph-dots-three"
|
|
353
|
+
size="sm"
|
|
354
|
+
color="neutral"
|
|
355
|
+
variant="ghost"
|
|
356
|
+
:aria-label="t('page.marketing.pages.more_list')"
|
|
357
|
+
/>
|
|
358
|
+
</UDropdownMenu>
|
|
359
|
+
</div>
|
|
360
|
+
<div class="flex items-center justify-between gap-2 px-4 py-2">
|
|
361
|
+
<DmsEyebrow :label="countLabel" />
|
|
362
|
+
<DmsSegmented
|
|
363
|
+
v-model="sort"
|
|
364
|
+
:items="sortItems"
|
|
365
|
+
size="xs"
|
|
366
|
+
:aria-label="t('page.marketing.pages.sort')"
|
|
367
|
+
/>
|
|
368
|
+
</div>
|
|
369
|
+
<DmsEmptyState
|
|
370
|
+
v-if="failed"
|
|
371
|
+
variant="error"
|
|
372
|
+
size="sm"
|
|
373
|
+
:title="t('page.marketing.common.load_error')"
|
|
374
|
+
>
|
|
375
|
+
<template #actions>
|
|
376
|
+
<UButton
|
|
377
|
+
size="xs"
|
|
378
|
+
color="neutral"
|
|
379
|
+
variant="outline"
|
|
380
|
+
icon="i-ph-arrow-clockwise"
|
|
381
|
+
:label="t('page.marketing.common.retry')"
|
|
382
|
+
@click="loadPages"
|
|
383
|
+
/>
|
|
384
|
+
</template>
|
|
385
|
+
</DmsEmptyState>
|
|
386
|
+
<ul
|
|
387
|
+
v-else-if="!settled"
|
|
388
|
+
class="flex flex-col gap-1 p-2"
|
|
389
|
+
aria-hidden="true"
|
|
390
|
+
>
|
|
391
|
+
<li v-for="index in SKELETON_ROWS" :key="index" class="px-2 py-2">
|
|
392
|
+
<USkeleton class="h-8 w-full" />
|
|
393
|
+
</li>
|
|
394
|
+
</ul>
|
|
395
|
+
<DmsEmptyState
|
|
396
|
+
v-else-if="pages.length === 0 && search.trim()"
|
|
397
|
+
icon="i-ph-magnifying-glass"
|
|
398
|
+
size="sm"
|
|
399
|
+
:title="t('page.marketing.pages.no_match.title')"
|
|
400
|
+
:description="t('page.marketing.pages.no_match.description')"
|
|
401
|
+
>
|
|
402
|
+
<template v-if="typedPath" #actions>
|
|
403
|
+
<UButton
|
|
404
|
+
size="xs"
|
|
405
|
+
color="neutral"
|
|
406
|
+
variant="outline"
|
|
407
|
+
icon="i-ph-cursor-click"
|
|
408
|
+
:label="
|
|
409
|
+
t('page.marketing.pages.no_match.inspect', {
|
|
410
|
+
path: typedPath,
|
|
411
|
+
})
|
|
412
|
+
"
|
|
413
|
+
@click="inspectTyped"
|
|
414
|
+
/>
|
|
415
|
+
</template>
|
|
416
|
+
</DmsEmptyState>
|
|
417
|
+
<DmsEmptyState
|
|
418
|
+
v-else-if="pages.length === 0"
|
|
419
|
+
icon="i-ph-browser"
|
|
420
|
+
size="sm"
|
|
421
|
+
:title="
|
|
422
|
+
t('page.marketing.pages.no_pages.title', {
|
|
423
|
+
website: context.selected.value?.name ?? '',
|
|
424
|
+
})
|
|
425
|
+
"
|
|
426
|
+
:description="t('page.marketing.pages.no_pages.description')"
|
|
427
|
+
/>
|
|
428
|
+
<ul
|
|
429
|
+
v-else
|
|
430
|
+
class="flex max-h-[45vh] flex-col gap-1 overflow-y-auto p-2 xl:max-h-[70vh]"
|
|
431
|
+
:class="loading ? 'opacity-60' : ''"
|
|
432
|
+
>
|
|
433
|
+
<li v-for="page in pages" :key="page.path">
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
class="flex w-full flex-col gap-1 rounded-lg border px-3 py-2 text-left transition-colors"
|
|
437
|
+
:class="
|
|
438
|
+
page.path === selectedPath
|
|
439
|
+
? 'border-primary bg-primary/10'
|
|
440
|
+
: 'border-transparent hover:bg-elevated'
|
|
441
|
+
"
|
|
442
|
+
:title="page.path"
|
|
443
|
+
:aria-current="page.path === selectedPath ? 'true' : undefined"
|
|
444
|
+
@click="pick(page.path)"
|
|
445
|
+
>
|
|
446
|
+
<span class="flex items-center gap-2">
|
|
447
|
+
<span
|
|
448
|
+
class="min-w-0 flex-1 truncate font-mono text-[13px]"
|
|
449
|
+
:class="
|
|
450
|
+
page.path === selectedPath
|
|
451
|
+
? 'font-medium text-highlighted'
|
|
452
|
+
: 'text-highlighted'
|
|
453
|
+
"
|
|
454
|
+
>
|
|
455
|
+
{{ page.path }}
|
|
456
|
+
</span>
|
|
457
|
+
<span class="font-mono text-xs tabular-nums text-highlighted">
|
|
458
|
+
{{ formatNumber(metricOf(page), locale) }}
|
|
459
|
+
</span>
|
|
460
|
+
</span>
|
|
461
|
+
<span class="flex items-center gap-2">
|
|
462
|
+
<span
|
|
463
|
+
class="flex flex-1 items-center gap-1 text-[11px]"
|
|
464
|
+
:class="
|
|
465
|
+
page.path === selectedPath ? 'text-muted' : 'text-dimmed'
|
|
466
|
+
"
|
|
467
|
+
>
|
|
468
|
+
<template v-if="sort === 'views'">
|
|
469
|
+
<UIcon name="i-ph-cursor-click" class="size-3" />
|
|
470
|
+
{{
|
|
471
|
+
t(
|
|
472
|
+
'page.marketing.pages.row_clicks',
|
|
473
|
+
{ count: formatNumber(page.clicks, locale) },
|
|
474
|
+
page.clicks,
|
|
475
|
+
)
|
|
476
|
+
}}
|
|
477
|
+
</template>
|
|
478
|
+
<template v-else>
|
|
479
|
+
<UIcon name="i-ph-eye" class="size-3" />
|
|
480
|
+
{{
|
|
481
|
+
t(
|
|
482
|
+
'page.marketing.pages.row_views',
|
|
483
|
+
{ count: formatNumber(page.pageviews, locale) },
|
|
484
|
+
page.pageviews,
|
|
485
|
+
)
|
|
486
|
+
}}
|
|
487
|
+
</template>
|
|
488
|
+
</span>
|
|
489
|
+
<span
|
|
490
|
+
class="h-1 w-14 overflow-hidden rounded-full bg-elevated"
|
|
491
|
+
aria-hidden="true"
|
|
492
|
+
>
|
|
493
|
+
<span
|
|
494
|
+
class="block h-full rounded-full bg-primary"
|
|
495
|
+
:style="{
|
|
496
|
+
width: `${(metricOf(page) / maxMetric) * 100}%`,
|
|
497
|
+
}"
|
|
498
|
+
/>
|
|
499
|
+
</span>
|
|
500
|
+
</span>
|
|
501
|
+
</button>
|
|
502
|
+
</li>
|
|
503
|
+
</ul>
|
|
504
|
+
<footer
|
|
505
|
+
class="flex items-center gap-1.5 border-t border-default px-4 py-2.5 text-xs text-dimmed"
|
|
506
|
+
>
|
|
507
|
+
<UIcon name="i-ph-info" class="size-3.5 shrink-0" />
|
|
508
|
+
{{
|
|
509
|
+
inventory?.truncated
|
|
510
|
+
? t('page.marketing.pages.list_truncated', {
|
|
511
|
+
count: pages.length,
|
|
512
|
+
})
|
|
513
|
+
: t('page.marketing.pages.missing_hint')
|
|
514
|
+
}}
|
|
515
|
+
</footer>
|
|
516
|
+
</section>
|
|
517
|
+
|
|
518
|
+
<div
|
|
519
|
+
v-if="selectedPath && website"
|
|
520
|
+
ref="previewWrapper"
|
|
521
|
+
class="min-w-0 scroll-mt-4"
|
|
522
|
+
>
|
|
523
|
+
<DmsMarketingPagePreview
|
|
524
|
+
:website="website"
|
|
525
|
+
:path="selectedPath"
|
|
526
|
+
:period="period"
|
|
527
|
+
:sample-rate-label="sampleRateLabel"
|
|
528
|
+
:tracker-enabled="trackerEnabled"
|
|
529
|
+
:pageviews="selectedPage?.pageviews"
|
|
530
|
+
:refresh-token="refreshToken + context.refreshToken.value"
|
|
531
|
+
:used-in="usedIn"
|
|
532
|
+
@website-updated="loadWebsite"
|
|
533
|
+
@heatmap-reset="loadPages"
|
|
534
|
+
/>
|
|
535
|
+
</div>
|
|
536
|
+
<DmsEmptyState
|
|
537
|
+
v-else-if="settled && pages.length > 0"
|
|
538
|
+
icon="i-ph-cursor-click"
|
|
539
|
+
class="dms-card"
|
|
540
|
+
:title="t('page.marketing.pages.select_hint')"
|
|
541
|
+
/>
|
|
542
|
+
</div>
|
|
543
|
+
<p v-if="sampleRateLabel !== null" class="text-xs text-dimmed">
|
|
544
|
+
{{
|
|
545
|
+
t('page.marketing.pages.sampling_note', {
|
|
546
|
+
rate: sampleRateLabel,
|
|
547
|
+
})
|
|
548
|
+
}}
|
|
549
|
+
</p>
|
|
550
|
+
</template>
|
|
551
|
+
</div>
|
|
552
|
+
</template>
|
|
@@ -22,7 +22,7 @@ import { heatColorAt } from '../utils/heatmapPalette'
|
|
|
22
22
|
const props = defineProps<{
|
|
23
23
|
/** reached[d] = views whose max depth is ≥ d percent, d = 0..100. */
|
|
24
24
|
reached: number[]
|
|
25
|
-
/**
|
|
25
|
+
/** Sampled views with a measured depth — the denominator of every share. */
|
|
26
26
|
views: number
|
|
27
27
|
/** Height of the overlay in the pane's own pixels (already scaled). */
|
|
28
28
|
height: number
|
|
@@ -35,7 +35,9 @@ const DEPTH_SCALE = 100
|
|
|
35
35
|
* nothing a reader could see. */
|
|
36
36
|
const GRADIENT_STEP = 5
|
|
37
37
|
const OVERLAY_ALPHA = 0.35
|
|
38
|
-
/** Shares worth a
|
|
38
|
+
/** Shares worth a marked line, labelled with the share alone so the chips
|
|
39
|
+
* cover as little of the page as possible; the aside explains them and the
|
|
40
|
+
* pointer readout says the full sentence. */
|
|
39
41
|
const REACH_MARKS = [75, 50, 25]
|
|
40
42
|
|
|
41
43
|
const { t } = useI18n()
|
|
@@ -57,7 +59,9 @@ const gradient = computed(() => {
|
|
|
57
59
|
const colorFor = (fraction: number) => heatColorAt(fraction, OVERLAY_ALPHA)
|
|
58
60
|
const stops = [`${colorFor(1)} 0%`]
|
|
59
61
|
for (let depth = 0; depth <= DEPTH_SCALE; depth += GRADIENT_STEP) {
|
|
60
|
-
stops.push(
|
|
62
|
+
stops.push(
|
|
63
|
+
`${colorFor(reachAt(depth))} ${(yRatio(depth) * 100).toFixed(2)}%`,
|
|
64
|
+
)
|
|
61
65
|
}
|
|
62
66
|
return `linear-gradient(to bottom, ${stops.join(', ')})`
|
|
63
67
|
})
|
|
@@ -73,12 +77,12 @@ function depthReachedBy(percent: number): number {
|
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
const marks = computed(() => {
|
|
76
|
-
const placed: Array<{ percent: number
|
|
80
|
+
const placed: Array<{ percent: number; y: number }> = []
|
|
77
81
|
for (const percent of REACH_MARKS) {
|
|
78
82
|
const y = yRatio(depthReachedBy(percent)) * props.height
|
|
79
83
|
// Collapsed shares (everyone stops at one depth) would stack identical
|
|
80
84
|
// lines; the strongest claim wins.
|
|
81
|
-
if (placed.every(mark => Math.abs(mark.y - y) > 1)) {
|
|
85
|
+
if (placed.every((mark) => Math.abs(mark.y - y) > 1)) {
|
|
82
86
|
placed.push({ percent, y })
|
|
83
87
|
}
|
|
84
88
|
}
|
|
@@ -88,17 +92,23 @@ const marks = computed(() => {
|
|
|
88
92
|
const foldY = computed(() => props.foldRatio * props.height)
|
|
89
93
|
const showFold = computed(() => props.foldRatio > 0 && props.foldRatio < 1)
|
|
90
94
|
|
|
91
|
-
const hover = ref<{ y: number
|
|
95
|
+
const hover = ref<{ y: number; percent: number } | null>(null)
|
|
92
96
|
|
|
93
97
|
function onPointerMove(event: PointerEvent): void {
|
|
94
98
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
|
95
99
|
if (rect.height <= 0 || props.foldRatio >= 1) {
|
|
96
100
|
return
|
|
97
101
|
}
|
|
98
|
-
const ratio = Math.min(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
const ratio = Math.min(
|
|
103
|
+
Math.max((event.clientY - rect.top) / rect.height, 0),
|
|
104
|
+
1,
|
|
105
|
+
)
|
|
106
|
+
const depth =
|
|
107
|
+
ratio <= props.foldRatio
|
|
108
|
+
? 0
|
|
109
|
+
: Math.round(
|
|
110
|
+
((ratio - props.foldRatio) / (1 - props.foldRatio)) * DEPTH_SCALE,
|
|
111
|
+
)
|
|
102
112
|
hover.value = {
|
|
103
113
|
y: ratio * props.height,
|
|
104
114
|
percent: Math.round(reachAt(depth) * 100),
|
|
@@ -112,18 +122,26 @@ function onPointerMove(event: PointerEvent): void {
|
|
|
112
122
|
@pointermove="onPointerMove"
|
|
113
123
|
@pointerleave="hover = null"
|
|
114
124
|
>
|
|
115
|
-
<div
|
|
125
|
+
<div
|
|
126
|
+
class="pointer-events-none absolute inset-0"
|
|
127
|
+
:style="{ background: gradient }"
|
|
128
|
+
/>
|
|
116
129
|
|
|
130
|
+
<!-- Named in the legend, not on the page: a label on the line sat on
|
|
131
|
+
whatever the first screen ends with (a button, a price). -->
|
|
117
132
|
<div
|
|
118
133
|
v-if="showFold"
|
|
119
134
|
class="pointer-events-none absolute inset-x-0"
|
|
120
|
-
:
|
|
135
|
+
:aria-label="t('page.marketing.pages.scroll.fold_line')"
|
|
136
|
+
role="img"
|
|
137
|
+
:style="{
|
|
138
|
+
top: `${foldY}px`,
|
|
139
|
+
borderTop: '1px solid rgba(255, 255, 255, 0.55)',
|
|
140
|
+
}"
|
|
121
141
|
>
|
|
122
142
|
<span
|
|
123
|
-
class="absolute left-
|
|
124
|
-
|
|
125
|
-
{{ t('page.marketing.pages.scroll.fold_line') }}
|
|
126
|
-
</span>
|
|
143
|
+
class="absolute left-0 top-0 h-2 w-2 -translate-y-1/2 rounded-full bg-default shadow ring-1 ring-white/70"
|
|
144
|
+
/>
|
|
127
145
|
</div>
|
|
128
146
|
|
|
129
147
|
<div
|
|
@@ -137,21 +155,31 @@ function onPointerMove(event: PointerEvent): void {
|
|
|
137
155
|
}"
|
|
138
156
|
>
|
|
139
157
|
<span
|
|
140
|
-
class="absolute right-2 top-0 -translate-y-1/2 rounded-full bg-default/90 px-
|
|
158
|
+
class="absolute right-2 top-0 -translate-y-1/2 rounded-full bg-default/90 px-1.5 py-0.5 font-mono text-[11px] font-medium text-highlighted shadow"
|
|
159
|
+
:title="
|
|
160
|
+
t('page.marketing.pages.scroll.reach_line', { percent: mark.percent })
|
|
161
|
+
"
|
|
141
162
|
>
|
|
142
|
-
{{ t('page.marketing.pages.scroll.
|
|
163
|
+
{{ t('page.marketing.pages.scroll.mark', { percent: mark.percent }) }}
|
|
143
164
|
</span>
|
|
144
165
|
</div>
|
|
145
166
|
|
|
146
167
|
<div
|
|
147
168
|
v-if="hover"
|
|
148
169
|
class="pointer-events-none absolute inset-x-0"
|
|
149
|
-
:style="{
|
|
170
|
+
:style="{
|
|
171
|
+
top: `${hover.y}px`,
|
|
172
|
+
borderTop: '1px solid rgba(255, 255, 255, 0.95)',
|
|
173
|
+
}"
|
|
150
174
|
>
|
|
151
175
|
<span
|
|
152
176
|
class="absolute left-2 top-0 -translate-y-1/2 rounded-full bg-default/90 px-2 py-0.5 text-xs font-medium text-highlighted shadow"
|
|
153
177
|
>
|
|
154
|
-
{{
|
|
178
|
+
{{
|
|
179
|
+
t('page.marketing.pages.scroll.reach_line', {
|
|
180
|
+
percent: hover.percent,
|
|
181
|
+
})
|
|
182
|
+
}}
|
|
155
183
|
</span>
|
|
156
184
|
</div>
|
|
157
185
|
</div>
|