@antelopejs/dms-marketing 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +143 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +35 -0
- package/dist/config.js.map +1 -0
- package/dist/data-api/body.d.ts +1 -0
- package/dist/data-api/body.js +15 -0
- package/dist/data-api/body.js.map +1 -0
- package/dist/data-api/funnels.d.ts +32 -0
- package/dist/data-api/funnels.js +302 -0
- package/dist/data-api/funnels.js.map +1 -0
- package/dist/data-api/hidden-filter.d.ts +1 -0
- package/dist/data-api/hidden-filter.js +11 -0
- package/dist/data-api/hidden-filter.js.map +1 -0
- package/dist/data-api/index.d.ts +4 -0
- package/dist/data-api/index.js +21 -0
- package/dist/data-api/index.js.map +1 -0
- package/dist/data-api/tenant-scope.d.ts +15 -0
- package/dist/data-api/tenant-scope.js +43 -0
- package/dist/data-api/tenant-scope.js.map +1 -0
- package/dist/data-api/websites.d.ts +25 -0
- package/dist/data-api/websites.js +73 -0
- package/dist/data-api/websites.js.map +1 -0
- package/dist/db/index.d.ts +2 -0
- package/dist/db/index.js +19 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/models/funnels.model.d.ts +25 -0
- package/dist/db/models/funnels.model.js +39 -0
- package/dist/db/models/funnels.model.js.map +1 -0
- package/dist/db/models/index.d.ts +7 -0
- package/dist/db/models/index.js +24 -0
- package/dist/db/models/index.js.map +1 -0
- package/dist/db/models/marketing_events.model.d.ts +49 -0
- package/dist/db/models/marketing_events.model.js +85 -0
- package/dist/db/models/marketing_events.model.js.map +1 -0
- package/dist/db/models/marketing_sessions.model.d.ts +33 -0
- package/dist/db/models/marketing_sessions.model.js +50 -0
- package/dist/db/models/marketing_sessions.model.js.map +1 -0
- package/dist/db/models/marketing_settings.model.d.ts +23 -0
- package/dist/db/models/marketing_settings.model.js +13 -0
- package/dist/db/models/marketing_settings.model.js.map +1 -0
- package/dist/db/models/page_snapshots.model.d.ts +27 -0
- package/dist/db/models/page_snapshots.model.js +32 -0
- package/dist/db/models/page_snapshots.model.js.map +1 -0
- package/dist/db/models/retention.d.ts +6 -0
- package/dist/db/models/retention.js +26 -0
- package/dist/db/models/retention.js.map +1 -0
- package/dist/db/models/website_statistics.model.d.ts +31 -0
- package/dist/db/models/website_statistics.model.js +63 -0
- package/dist/db/models/website_statistics.model.js.map +1 -0
- package/dist/db/models/websites.model.d.ts +26 -0
- package/dist/db/models/websites.model.js +27 -0
- package/dist/db/models/websites.model.js.map +1 -0
- package/dist/db/tables/funnels.table.d.ts +31 -0
- package/dist/db/tables/funnels.table.js +54 -0
- package/dist/db/tables/funnels.table.js.map +1 -0
- package/dist/db/tables/index.d.ts +7 -0
- package/dist/db/tables/index.js +24 -0
- package/dist/db/tables/index.js.map +1 -0
- package/dist/db/tables/marketing_events.table.d.ts +15 -0
- package/dist/db/tables/marketing_events.table.js +66 -0
- package/dist/db/tables/marketing_events.table.js.map +1 -0
- package/dist/db/tables/marketing_sessions.table.d.ts +26 -0
- package/dist/db/tables/marketing_sessions.table.js +115 -0
- package/dist/db/tables/marketing_sessions.table.js.map +1 -0
- package/dist/db/tables/marketing_settings.table.d.ts +11 -0
- package/dist/db/tables/marketing_settings.table.js +51 -0
- package/dist/db/tables/marketing_settings.table.js.map +1 -0
- package/dist/db/tables/page_snapshots.table.d.ts +17 -0
- package/dist/db/tables/page_snapshots.table.js +72 -0
- package/dist/db/tables/page_snapshots.table.js.map +1 -0
- package/dist/db/tables/website_statistics.table.d.ts +14 -0
- package/dist/db/tables/website_statistics.table.js +60 -0
- package/dist/db/tables/website_statistics.table.js.map +1 -0
- package/dist/db/tables/websites.table.d.ts +13 -0
- package/dist/db/tables/websites.table.js +60 -0
- package/dist/db/tables/websites.table.js.map +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +39 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/implementations/dms-marketing/index.d.ts +2 -0
- package/dist/implementations/dms-marketing/index.js +8 -0
- package/dist/implementations/dms-marketing/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/all.d.ts +5 -0
- package/dist/pages/all.js +22 -0
- package/dist/pages/all.js.map +1 -0
- package/dist/pages/campaigns/page.d.ts +9 -0
- package/dist/pages/campaigns/page.js +27 -0
- package/dist/pages/campaigns/page.js.map +1 -0
- package/dist/pages/funnels/page.d.ts +9 -0
- package/dist/pages/funnels/page.js +27 -0
- package/dist/pages/funnels/page.js.map +1 -0
- package/dist/pages/index.d.ts +4 -0
- package/dist/pages/index.js +21 -0
- package/dist/pages/index.js.map +1 -0
- package/dist/pages/module.d.ts +1 -0
- package/dist/pages/module.js +13 -0
- package/dist/pages/module.js.map +1 -0
- package/dist/pages/overview/page.d.ts +9 -0
- package/dist/pages/overview/page.js +27 -0
- package/dist/pages/overview/page.js.map +1 -0
- package/dist/pages/pages/page.d.ts +9 -0
- package/dist/pages/pages/page.js +27 -0
- package/dist/pages/pages/page.js.map +1 -0
- package/dist/pages/settings/form.d.ts +3 -0
- package/dist/pages/settings/form.js +35 -0
- package/dist/pages/settings/form.js.map +1 -0
- package/dist/pages/settings/page.d.ts +9 -0
- package/dist/pages/settings/page.js +27 -0
- package/dist/pages/settings/page.js.map +1 -0
- package/dist/routes/collect.d.ts +7 -0
- package/dist/routes/collect.js +62 -0
- package/dist/routes/collect.js.map +1 -0
- package/dist/routes/experiments.d.ts +6 -0
- package/dist/routes/experiments.js +63 -0
- package/dist/routes/experiments.js.map +1 -0
- package/dist/routes/funnels.d.ts +16 -0
- package/dist/routes/funnels.js +44 -0
- package/dist/routes/funnels.js.map +1 -0
- package/dist/routes/index.d.ts +8 -0
- package/dist/routes/index.js +25 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/settings.d.ts +8 -0
- package/dist/routes/settings.js +50 -0
- package/dist/routes/settings.js.map +1 -0
- package/dist/routes/snapshot.d.ts +10 -0
- package/dist/routes/snapshot.js +147 -0
- package/dist/routes/snapshot.js.map +1 -0
- package/dist/routes/stats.d.ts +44 -0
- package/dist/routes/stats.js +278 -0
- package/dist/routes/stats.js.map +1 -0
- package/dist/routes/tracker.d.ts +6 -0
- package/dist/routes/tracker.js +57 -0
- package/dist/routes/tracker.js.map +1 -0
- package/dist/routes/websites.d.ts +13 -0
- package/dist/routes/websites.js +144 -0
- package/dist/routes/websites.js.map +1 -0
- package/dist/services/acquisition.d.ts +15 -0
- package/dist/services/acquisition.js +153 -0
- package/dist/services/acquisition.js.map +1 -0
- package/dist/services/bounded-cache.d.ts +14 -0
- package/dist/services/bounded-cache.js +45 -0
- package/dist/services/bounded-cache.js.map +1 -0
- package/dist/services/collect.d.ts +11 -0
- package/dist/services/collect.js +193 -0
- package/dist/services/collect.js.map +1 -0
- package/dist/services/experiment-definitions.d.ts +4 -0
- package/dist/services/experiment-definitions.js +36 -0
- package/dist/services/experiment-definitions.js.map +1 -0
- package/dist/services/experiments/assign.d.ts +5 -0
- package/dist/services/experiments/assign.js +52 -0
- package/dist/services/experiments/assign.js.map +1 -0
- package/dist/services/experiments/index.d.ts +3 -0
- package/dist/services/experiments/index.js +20 -0
- package/dist/services/experiments/index.js.map +1 -0
- package/dist/services/experiments/results.d.ts +20 -0
- package/dist/services/experiments/results.js +91 -0
- package/dist/services/experiments/results.js.map +1 -0
- package/dist/services/experiments/stats.d.ts +12 -0
- package/dist/services/experiments/stats.js +62 -0
- package/dist/services/experiments/stats.js.map +1 -0
- package/dist/services/funnels/compute.d.ts +15 -0
- package/dist/services/funnels/compute.js +68 -0
- package/dist/services/funnels/compute.js.map +1 -0
- package/dist/services/funnels/index.d.ts +2 -0
- package/dist/services/funnels/index.js +19 -0
- package/dist/services/funnels/index.js.map +1 -0
- package/dist/services/funnels/results.d.ts +14 -0
- package/dist/services/funnels/results.js +72 -0
- package/dist/services/funnels/results.js.map +1 -0
- package/dist/services/geoip.d.ts +3 -0
- package/dist/services/geoip.js +38 -0
- package/dist/services/geoip.js.map +1 -0
- package/dist/services/heatmap.d.ts +10 -0
- package/dist/services/heatmap.js +180 -0
- package/dist/services/heatmap.js.map +1 -0
- package/dist/services/index.d.ts +26 -0
- package/dist/services/index.js +43 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/ingest-guard.d.ts +11 -0
- package/dist/services/ingest-guard.js +44 -0
- package/dist/services/ingest-guard.js.map +1 -0
- package/dist/services/keyed-queue.d.ts +4 -0
- package/dist/services/keyed-queue.js +19 -0
- package/dist/services/keyed-queue.js.map +1 -0
- package/dist/services/origin-guard.d.ts +5 -0
- package/dist/services/origin-guard.js +43 -0
- package/dist/services/origin-guard.js.map +1 -0
- package/dist/services/period.d.ts +2 -0
- package/dist/services/period.js +19 -0
- package/dist/services/period.js.map +1 -0
- package/dist/services/prune.d.ts +7 -0
- package/dist/services/prune.js +49 -0
- package/dist/services/prune.js.map +1 -0
- package/dist/services/query-param.d.ts +1 -0
- package/dist/services/query-param.js +11 -0
- package/dist/services/query-param.js.map +1 -0
- package/dist/services/request-body.d.ts +2 -0
- package/dist/services/request-body.js +41 -0
- package/dist/services/request-body.js.map +1 -0
- package/dist/services/request-meta.d.ts +3 -0
- package/dist/services/request-meta.js +19 -0
- package/dist/services/request-meta.js.map +1 -0
- package/dist/services/rollup-write.d.ts +4 -0
- package/dist/services/rollup-write.js +84 -0
- package/dist/services/rollup-write.js.map +1 -0
- package/dist/services/sessionize.d.ts +23 -0
- package/dist/services/sessionize.js +78 -0
- package/dist/services/sessionize.js.map +1 -0
- package/dist/services/settings.d.ts +6 -0
- package/dist/services/settings.js +113 -0
- package/dist/services/settings.js.map +1 -0
- package/dist/services/snapshots.d.ts +24 -0
- package/dist/services/snapshots.js +139 -0
- package/dist/services/snapshots.js.map +1 -0
- package/dist/services/tenant-funnel.d.ts +3 -0
- package/dist/services/tenant-funnel.js +17 -0
- package/dist/services/tenant-funnel.js.map +1 -0
- package/dist/services/tenant-website.d.ts +5 -0
- package/dist/services/tenant-website.js +34 -0
- package/dist/services/tenant-website.js.map +1 -0
- package/dist/services/url.d.ts +10 -0
- package/dist/services/url.js +81 -0
- package/dist/services/url.js.map +1 -0
- package/dist/services/user-agent.d.ts +2 -0
- package/dist/services/user-agent.js +17 -0
- package/dist/services/user-agent.js.map +1 -0
- package/dist/services/visitor-id.d.ts +1 -0
- package/dist/services/visitor-id.js +19 -0
- package/dist/services/visitor-id.js.map +1 -0
- package/dist/services/website-cache.d.ts +3 -0
- package/dist/services/website-cache.js +22 -0
- package/dist/services/website-cache.js.map +1 -0
- package/dist/types/constants.d.ts +74 -0
- package/dist/types/constants.js +79 -0
- package/dist/types/constants.js.map +1 -0
- package/dist/types/events.d.ts +114 -0
- package/dist/types/events.js +29 -0
- package/dist/types/events.js.map +1 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +22 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/settings.d.ts +67 -0
- package/dist/types/settings.js +58 -0
- package/dist/types/settings.js.map +1 -0
- package/dist/types/snapshots.d.ts +95 -0
- package/dist/types/snapshots.js +41 -0
- package/dist/types/snapshots.js.map +1 -0
- package/dist/types/statistics.d.ts +38 -0
- package/dist/types/statistics.js +33 -0
- package/dist/types/statistics.js.map +1 -0
- package/dist/utils/experiment-type.d.ts +52 -0
- package/dist/utils/experiment-type.js +94 -0
- package/dist/utils/experiment-type.js.map +1 -0
- package/dist/utils/funnel-steps-type.d.ts +42 -0
- package/dist/utils/funnel-steps-type.js +62 -0
- package/dist/utils/funnel-steps-type.js.map +1 -0
- package/frontend-vue/.editorconfig +12 -0
- package/frontend-vue/.prettierrc +7 -0
- package/frontend-vue/app/components/CampaignsView.vue +330 -0
- package/frontend-vue/app/components/ExperimentInput.vue +128 -0
- package/frontend-vue/app/components/FunnelDetail.vue +1026 -0
- package/frontend-vue/app/components/FunnelStepsInput.vue +154 -0
- package/frontend-vue/app/components/FunnelsView.vue +493 -0
- package/frontend-vue/app/components/HeatmapCanvas.vue +116 -0
- package/frontend-vue/app/components/NoWebsiteState.vue +18 -0
- package/frontend-vue/app/components/OverviewView.vue +493 -0
- package/frontend-vue/app/components/PagePreview.vue +915 -0
- package/frontend-vue/app/components/PagesView.vue +378 -0
- package/frontend-vue/app/components/ScrollDepthOverlay.vue +158 -0
- package/frontend-vue/app/components/TextKpiCard.vue +28 -0
- package/frontend-vue/app/components/TopListTabsCard.vue +162 -0
- package/frontend-vue/app/components/TrackerSnippet.vue +31 -0
- package/frontend-vue/app/components/VariationsInput.vue +164 -0
- package/frontend-vue/app/composables/useChildId.ts +13 -0
- package/frontend-vue/app/composables/useExperiment.ts +59 -0
- package/frontend-vue/app/composables/useFunnelSteps.ts +25 -0
- package/frontend-vue/app/composables/useLatestRequest.ts +90 -0
- package/frontend-vue/app/composables/useMarketingApi.ts +420 -0
- package/frontend-vue/app/composables/useMarketingPeriod.ts +66 -0
- package/frontend-vue/app/composables/useMarketingRoutes.ts +51 -0
- package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +69 -0
- package/frontend-vue/app/composables/useMarketingWebsites.ts +80 -0
- package/frontend-vue/app/composables/useSnapshotFrame.ts +183 -0
- package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +46 -0
- package/frontend-vue/app/utils/dmsComponent.ts +18 -0
- package/frontend-vue/app/utils/heatmapPalette.ts +41 -0
- package/frontend-vue/app/utils/snapshotViewportUnits.ts +63 -0
- package/frontend-vue/dms.frontend.ts +35 -0
- package/frontend-vue/eslint.config.mjs +16 -0
- package/frontend-vue/i18n/locales/marketing-en-GB.json +382 -0
- package/frontend-vue/i18n/locales/marketing-fr-FR.json +382 -0
- package/frontend-vue/package.json +30 -0
- package/frontend-vue/pnpm-lock.yaml +5226 -0
- package/frontend-vue/pnpm-workspace.yaml +2 -0
- package/frontend-vue/tests/drafts.test.ts +54 -0
- package/frontend-vue/tests/i18n.test.ts +86 -0
- package/frontend-vue/tests/period.test.ts +35 -0
- package/frontend-vue/tests/registration.test.ts +63 -0
- package/frontend-vue/tests/routes.test.ts +31 -0
- package/frontend-vue/tests/stubs/frontend-module.ts +56 -0
- package/frontend-vue/vitest.config.ts +20 -0
- package/package.json +91 -0
- package/static/snapshot.js +497 -0
- package/static/tracker.js +503 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
useDmsRoute,
|
|
5
|
+
useDmsRouter,
|
|
6
|
+
useI18n,
|
|
7
|
+
} from '#dms/frontend-module'
|
|
8
|
+
import { useChildId } from '../composables/useChildId'
|
|
9
|
+
import { useLatestRequest } from '../composables/useLatestRequest'
|
|
10
|
+
import {
|
|
11
|
+
useMarketingApi,
|
|
12
|
+
type MarketingPages,
|
|
13
|
+
} from '../composables/useMarketingApi'
|
|
14
|
+
import {
|
|
15
|
+
MARKETING_PERIOD_PRESETS,
|
|
16
|
+
ndToPreset,
|
|
17
|
+
useMarketingPeriod,
|
|
18
|
+
} from '../composables/useMarketingPeriod'
|
|
19
|
+
import { funnelsLink, pagesLink } from '../composables/useMarketingRoutes'
|
|
20
|
+
import { useMarketingWebsites } from '../composables/useMarketingWebsites'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Tracked pages of a site: the inventory on the left, the clicks and scroll
|
|
24
|
+
* depth of the selected page on the right.
|
|
25
|
+
*
|
|
26
|
+
* The surface is deliberately keyed on nothing but a website id and a bare
|
|
27
|
+
* pathname the tracker itself reported — no route registry, no sitemap, no
|
|
28
|
+
* build manifest, no framework convention. That is what makes it hold for any
|
|
29
|
+
* tracked site, whatever it is built with.
|
|
30
|
+
*
|
|
31
|
+
* The search box is also a path field, and that is not a fallback: a day keeps
|
|
32
|
+
* at most MAX_ENTRIES_PER_TOP_MAP paths and older days keep counters only, so
|
|
33
|
+
* a page can be missing from the inventory while its clicks are perfectly
|
|
34
|
+
* queryable — the backend matches the path exactly either way.
|
|
35
|
+
*/
|
|
36
|
+
const PERIOD_SCOPE = 'dms-marketing-pages'
|
|
37
|
+
const SEARCH_DEBOUNCE_MS = 300
|
|
38
|
+
|
|
39
|
+
// Declared, not inherited as attributes: the DMS page renderer hands every
|
|
40
|
+
// component its identity, and an undeclared prop lands on the root element.
|
|
41
|
+
const props = withDefaults(
|
|
42
|
+
defineProps<{ componentId?: string, pageId?: string }>(),
|
|
43
|
+
{ componentId: 'marketing-pages', pageId: 'marketing' },
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const childId = useChildId(() => props.componentId)
|
|
47
|
+
|
|
48
|
+
const api = useMarketingApi()
|
|
49
|
+
const { t } = useI18n()
|
|
50
|
+
const route = useDmsRoute()
|
|
51
|
+
const router = useDmsRouter()
|
|
52
|
+
|
|
53
|
+
const queryParam = (key: string): string => {
|
|
54
|
+
const value = route.query[key]
|
|
55
|
+
return typeof value === 'string' ? value : ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Read once: the selector seeds its preset at init and never reactively, so a
|
|
59
|
+
// deep link's window has to be resolved before it renders.
|
|
60
|
+
const initialPreset = ndToPreset(queryParam('period'))
|
|
61
|
+
|
|
62
|
+
const {
|
|
63
|
+
websites,
|
|
64
|
+
items: websiteItems,
|
|
65
|
+
selectedId: selectedWebsiteId,
|
|
66
|
+
selected: selectedWebsite,
|
|
67
|
+
loading: loadingWebsites,
|
|
68
|
+
failed: websitesFailed,
|
|
69
|
+
load: loadWebsites,
|
|
70
|
+
create: createWebsite,
|
|
71
|
+
} = useMarketingWebsites(queryParam('website'))
|
|
72
|
+
|
|
73
|
+
// `undefined`, not null: this is what DmsMasterDetail's model is typed on.
|
|
74
|
+
const selectedPath = ref<string | undefined>(queryParam('path') || undefined)
|
|
75
|
+
const search = ref('')
|
|
76
|
+
|
|
77
|
+
const period = useMarketingPeriod(PERIOD_SCOPE)
|
|
78
|
+
|
|
79
|
+
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
|
80
|
+
|
|
81
|
+
const {
|
|
82
|
+
data: inventory,
|
|
83
|
+
loading: loadingPages,
|
|
84
|
+
failed: pagesFailed,
|
|
85
|
+
run: loadPages,
|
|
86
|
+
} = useLatestRequest<MarketingPages>(() =>
|
|
87
|
+
// Server-side there is no session to authenticate with: the fetch belongs to
|
|
88
|
+
// the client, like every other read here.
|
|
89
|
+
selectedWebsiteId.value && !import.meta.env.SSR
|
|
90
|
+
? api.listPages(selectedWebsiteId.value, period.value, search.value.trim())
|
|
91
|
+
: null,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const pages = computed(() => inventory.value?.pages ?? [])
|
|
95
|
+
const truncated = computed(() => inventory.value?.truncated ?? false)
|
|
96
|
+
const sampleRate = computed(() => inventory.value?.heatmapSampleRate ?? 0)
|
|
97
|
+
const trackerEnabled = computed(() => inventory.value?.trackerEnabled ?? true)
|
|
98
|
+
|
|
99
|
+
// Two failures, two flags: the inventory fetch clears its own error on every
|
|
100
|
+
// attempt, and must not clear the one that says the site list never loaded.
|
|
101
|
+
const failed = computed(() => websitesFailed.value || pagesFailed.value)
|
|
102
|
+
|
|
103
|
+
// Land on something rather than on an empty pane, but never override a path
|
|
104
|
+
// that was linked to or typed. Watching the answer rather than writing from
|
|
105
|
+
// the fetch keeps superseded ones out: only the newest ever reaches here.
|
|
106
|
+
watch(inventory, (data) => {
|
|
107
|
+
if (!selectedPath.value) {
|
|
108
|
+
selectedPath.value = data?.pages[0]?.path
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Retry entry point. Both fetches, in order, because either can be the one
|
|
114
|
+
* that failed: on a deep link the inventory runs even when the site list did
|
|
115
|
+
* not, and reloading the list alone leaves the selection unchanged, so the
|
|
116
|
+
* watcher never fires and the inventory is never retried.
|
|
117
|
+
*/
|
|
118
|
+
async function reload() {
|
|
119
|
+
if (websites.value.length === 0) {
|
|
120
|
+
await loadWebsites()
|
|
121
|
+
}
|
|
122
|
+
await loadPages()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
onMounted(loadWebsites)
|
|
126
|
+
|
|
127
|
+
// The watcher owns every inventory fetch, including the first: seeding it from
|
|
128
|
+
// onMounted as well would issue the initial request twice on a bare link, and
|
|
129
|
+
// not at all on a link that already names the site the list would have picked.
|
|
130
|
+
watch([selectedWebsiteId, period], ([websiteId], previous) => {
|
|
131
|
+
// Switching site invalidates the path — the same URL on another site is
|
|
132
|
+
// another page, and usually does not exist there. Only a real site-to-site
|
|
133
|
+
// change may clear: the immediate run hands multi-source watchers an EMPTY
|
|
134
|
+
// previous array (truthy — `previous &&` does not guard it), and the site
|
|
135
|
+
// list resolving undefined → id is not a switch either. Both carry a linked
|
|
136
|
+
// path that must survive.
|
|
137
|
+
const previousWebsiteId = previous?.[0]
|
|
138
|
+
if (previousWebsiteId !== undefined && websiteId !== previousWebsiteId) {
|
|
139
|
+
selectedPath.value = undefined
|
|
140
|
+
}
|
|
141
|
+
void loadPages()
|
|
142
|
+
}, { immediate: true })
|
|
143
|
+
|
|
144
|
+
watch(search, () => {
|
|
145
|
+
if (searchTimer) {
|
|
146
|
+
clearTimeout(searchTimer)
|
|
147
|
+
}
|
|
148
|
+
searchTimer = setTimeout(() => void loadPages(), SEARCH_DEBOUNCE_MS)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
onBeforeUnmount(() => {
|
|
152
|
+
if (searchTimer) {
|
|
153
|
+
clearTimeout(searchTimer)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
/** Enter inspects what was typed, whether or not the inventory lists it. */
|
|
158
|
+
function inspectTyped(): void {
|
|
159
|
+
const typed = search.value.trim()
|
|
160
|
+
if (!typed.startsWith('/')) {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
selectedPath.value = typed
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// State lives in the URL so a heatmap can be linked to. Query-only navigation
|
|
167
|
+
// does not remount the page, so this is free.
|
|
168
|
+
watch([selectedWebsiteId, selectedPath, period], () => {
|
|
169
|
+
void router.replace(
|
|
170
|
+
pagesLink({
|
|
171
|
+
website: selectedWebsiteId.value,
|
|
172
|
+
path: selectedPath.value,
|
|
173
|
+
period: period.value,
|
|
174
|
+
}),
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const pageviewsBySelected = computed(
|
|
179
|
+
() => pages.value.find(page => page.path === selectedPath.value)?.pageviews,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const listItems = computed(() =>
|
|
183
|
+
pages.value.map((page) => {
|
|
184
|
+
// Sampled PAGE LOADS, not clicks: the tracker draws once per page load,
|
|
185
|
+
// so this says whether a heatmap can exist at all — a click estimate here
|
|
186
|
+
// would contradict the real count shown beside it in the detail pane.
|
|
187
|
+
const sampled = Math.round(page.pageviews * sampleRate.value)
|
|
188
|
+
// Two counts, two plural choices: one message could only ever inflect on
|
|
189
|
+
// one of them, and the two rarely agree.
|
|
190
|
+
return {
|
|
191
|
+
value: page.path,
|
|
192
|
+
label: page.path,
|
|
193
|
+
sublabel: [
|
|
194
|
+
t('page.marketing.pages.row_hint.views', { count: page.pageviews }, page.pageviews),
|
|
195
|
+
t('page.marketing.pages.row_hint.sampled', { count: sampled }, sampled),
|
|
196
|
+
].join(' · '),
|
|
197
|
+
}
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
// --- Site-wide reset --------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const { confirm } = useConfirm()
|
|
204
|
+
const toast = useToast()
|
|
205
|
+
const resettingHeatmaps = ref(false)
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The detail pane refetches on this bump: a site-wide reset changes none of
|
|
209
|
+
* the keys it watches (site, path, period), so without it the pane would keep
|
|
210
|
+
* showing clicks that no longer exist. The inventory needs no reload — its
|
|
211
|
+
* pageview counts come from the rollup, which a click purge never touches.
|
|
212
|
+
*/
|
|
213
|
+
const heatmapRefreshToken = ref(0)
|
|
214
|
+
|
|
215
|
+
async function resetSiteHeatmaps() {
|
|
216
|
+
const site = selectedWebsite.value
|
|
217
|
+
if (!site) {
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
const confirmed = await confirm({
|
|
221
|
+
title: t('page.marketing.pages.reset.site_title'),
|
|
222
|
+
description: t('page.marketing.pages.reset.site_description', { name: site.name }),
|
|
223
|
+
confirmLabel: t('page.marketing.pages.reset.confirm'),
|
|
224
|
+
confirmColor: 'error',
|
|
225
|
+
})
|
|
226
|
+
if (!confirmed) {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
resettingHeatmaps.value = true
|
|
230
|
+
try {
|
|
231
|
+
const { deleted } = await api.resetHeatmap(site._id)
|
|
232
|
+
toast.add({
|
|
233
|
+
color: 'success',
|
|
234
|
+
title: t('page.marketing.pages.reset.done', { count: deleted }, deleted),
|
|
235
|
+
})
|
|
236
|
+
heatmapRefreshToken.value++
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
toast.add({ color: 'error', title: t('page.marketing.pages.reset.error') })
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
resettingHeatmaps.value = false
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
</script>
|
|
246
|
+
|
|
247
|
+
<template>
|
|
248
|
+
<div class="space-y-6 p-6">
|
|
249
|
+
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
250
|
+
<div>
|
|
251
|
+
<h1 class="text-xl font-semibold">{{ t('page.marketing.pages.title') }}</h1>
|
|
252
|
+
<p class="text-sm text-muted">{{ t('page.marketing.pages.subtitle') }}</p>
|
|
253
|
+
</div>
|
|
254
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
255
|
+
<USelect
|
|
256
|
+
v-if="websites.length > 1"
|
|
257
|
+
v-model="selectedWebsiteId"
|
|
258
|
+
:items="websiteItems"
|
|
259
|
+
class="w-56"
|
|
260
|
+
/>
|
|
261
|
+
<UInput
|
|
262
|
+
v-model="search"
|
|
263
|
+
icon="i-ph-magnifying-glass"
|
|
264
|
+
class="w-64"
|
|
265
|
+
:placeholder="t('page.marketing.pages.search_placeholder')"
|
|
266
|
+
@keyup.enter="inspectTyped"
|
|
267
|
+
/>
|
|
268
|
+
<DmsPeriodSelector
|
|
269
|
+
:id="PERIOD_SCOPE"
|
|
270
|
+
:component-id="childId('period')"
|
|
271
|
+
:page-id="props.pageId"
|
|
272
|
+
:default-preset="initialPreset"
|
|
273
|
+
:presets="MARKETING_PERIOD_PRESETS"
|
|
274
|
+
:comparisons="['none']"
|
|
275
|
+
:show-range-label="false"
|
|
276
|
+
/>
|
|
277
|
+
<UButton
|
|
278
|
+
v-if="selectedWebsiteId"
|
|
279
|
+
icon="i-ph-funnel"
|
|
280
|
+
color="neutral"
|
|
281
|
+
variant="ghost"
|
|
282
|
+
size="sm"
|
|
283
|
+
:to="funnelsLink({ website: selectedWebsiteId })"
|
|
284
|
+
:label="t('page.marketing.pages.to_funnels')"
|
|
285
|
+
/>
|
|
286
|
+
<UButton
|
|
287
|
+
v-if="selectedWebsiteId"
|
|
288
|
+
icon="i-ph-eraser"
|
|
289
|
+
color="neutral"
|
|
290
|
+
variant="ghost"
|
|
291
|
+
size="sm"
|
|
292
|
+
:loading="resettingHeatmaps"
|
|
293
|
+
:label="t('page.marketing.pages.reset.site_button')"
|
|
294
|
+
@click="resetSiteHeatmaps"
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<UAlert
|
|
300
|
+
v-if="!trackerEnabled"
|
|
301
|
+
icon="i-ph-plugs"
|
|
302
|
+
color="warning"
|
|
303
|
+
variant="subtle"
|
|
304
|
+
:title="t('page.marketing.pages.tracking_off.title')"
|
|
305
|
+
:description="t('page.marketing.pages.tracking_off.description')"
|
|
306
|
+
/>
|
|
307
|
+
|
|
308
|
+
<div v-if="failed" class="flex flex-col items-start gap-3">
|
|
309
|
+
<UAlert
|
|
310
|
+
color="error"
|
|
311
|
+
variant="subtle"
|
|
312
|
+
icon="i-ph-warning-circle"
|
|
313
|
+
:title="t('page.marketing.pages.error')"
|
|
314
|
+
:description="t('page.marketing.pages.error_hint')"
|
|
315
|
+
/>
|
|
316
|
+
<UButton
|
|
317
|
+
icon="i-ph-arrow-clockwise"
|
|
318
|
+
:label="t('page.marketing.pages.retry')"
|
|
319
|
+
color="neutral"
|
|
320
|
+
variant="subtle"
|
|
321
|
+
size="sm"
|
|
322
|
+
@click="reload"
|
|
323
|
+
/>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<USkeleton v-else-if="loadingWebsites" class="h-96 w-full" />
|
|
327
|
+
|
|
328
|
+
<DmsMarketingNoWebsiteState
|
|
329
|
+
v-else-if="websites.length === 0"
|
|
330
|
+
icon="i-ph-globe"
|
|
331
|
+
:title="t('page.marketing.pages.no_website.title')"
|
|
332
|
+
:description="t('page.marketing.pages.no_website.description')"
|
|
333
|
+
@create="createWebsite"
|
|
334
|
+
/>
|
|
335
|
+
|
|
336
|
+
<template v-else>
|
|
337
|
+
<UAlert
|
|
338
|
+
v-if="truncated"
|
|
339
|
+
icon="i-ph-list"
|
|
340
|
+
color="neutral"
|
|
341
|
+
variant="subtle"
|
|
342
|
+
:title="t('page.marketing.pages.list_truncated')"
|
|
343
|
+
:description="t('page.marketing.pages.list_truncated_hint')"
|
|
344
|
+
/>
|
|
345
|
+
|
|
346
|
+
<!-- An empty inventory is not an empty surface: a typed path is
|
|
347
|
+
inspectable whether or not the rollup ever listed it. -->
|
|
348
|
+
<UAlert
|
|
349
|
+
v-if="!loadingPages && pages.length === 0 && !selectedPath"
|
|
350
|
+
icon="i-ph-cursor-click"
|
|
351
|
+
:title="t('page.marketing.pages.no_pages.title')"
|
|
352
|
+
:description="t('page.marketing.pages.no_pages.description')"
|
|
353
|
+
/>
|
|
354
|
+
|
|
355
|
+
<DmsMasterDetail
|
|
356
|
+
v-else
|
|
357
|
+
v-model="selectedPath"
|
|
358
|
+
:items="listItems"
|
|
359
|
+
:list-label="t('page.marketing.pages.inventory')"
|
|
360
|
+
>
|
|
361
|
+
<DmsMarketingPagePreview
|
|
362
|
+
v-if="selectedPath"
|
|
363
|
+
:website="selectedWebsite"
|
|
364
|
+
:path="selectedPath"
|
|
365
|
+
:period="period"
|
|
366
|
+
:sample-rate="sampleRate"
|
|
367
|
+
:tracker-enabled="trackerEnabled"
|
|
368
|
+
:pageviews="pageviewsBySelected"
|
|
369
|
+
:refresh-token="heatmapRefreshToken"
|
|
370
|
+
@website-updated="loadWebsites"
|
|
371
|
+
/>
|
|
372
|
+
<div v-else class="p-8 text-center text-sm text-muted">
|
|
373
|
+
{{ t('page.marketing.pages.select_hint') }}
|
|
374
|
+
</div>
|
|
375
|
+
</DmsMasterDetail>
|
|
376
|
+
</template>
|
|
377
|
+
</div>
|
|
378
|
+
</template>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import { useI18n } from '#dms/frontend-module'
|
|
4
|
+
import { heatColorAt } from '../utils/heatmapPalette'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scroll-depth overlay of one tracked page: a vertical gradient of how many
|
|
8
|
+
* measured views saw each band of the page, reach marks at fixed shares, and
|
|
9
|
+
* a readout following the pointer.
|
|
10
|
+
*
|
|
11
|
+
* Drawn in the pane's scaled space as a sibling of the scaled surface, not
|
|
12
|
+
* inside it: depth only varies vertically, so fractions of the pane's height
|
|
13
|
+
* are fractions of the document — and the labels keep their font size at
|
|
14
|
+
* every zoom instead of shrinking with the page.
|
|
15
|
+
*
|
|
16
|
+
* `foldRatio` is where depth 0 sits. A recorded depth is a fraction of the
|
|
17
|
+
* visitor's *scrollable range*, whose zero already has the first viewport on
|
|
18
|
+
* screen; the probe viewport stands in for visitor viewports this surface
|
|
19
|
+
* cannot know. Bands are trustworthy, pixels are not. Above the fold the
|
|
20
|
+
* shade is flat — every measured view starts there.
|
|
21
|
+
*/
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
/** reached[d] = views whose max depth is ≥ d percent, d = 0..100. */
|
|
24
|
+
reached: number[]
|
|
25
|
+
/** Views that scrolled at all — the denominator of every share shown. */
|
|
26
|
+
views: number
|
|
27
|
+
/** Height of the overlay in the pane's own pixels (already scaled). */
|
|
28
|
+
height: number
|
|
29
|
+
/** 0-1 fraction of the height where depth 0 sits (the first viewport). */
|
|
30
|
+
foldRatio: number
|
|
31
|
+
}>()
|
|
32
|
+
|
|
33
|
+
const DEPTH_SCALE = 100
|
|
34
|
+
/** Gradient resolution; the distribution is monotonic, so 5% steps lose
|
|
35
|
+
* nothing a reader could see. */
|
|
36
|
+
const GRADIENT_STEP = 5
|
|
37
|
+
const OVERLAY_ALPHA = 0.35
|
|
38
|
+
/** Shares worth a labelled line — the "50% of visitors stop here" reading. */
|
|
39
|
+
const REACH_MARKS = [75, 50, 25]
|
|
40
|
+
|
|
41
|
+
const { t } = useI18n()
|
|
42
|
+
|
|
43
|
+
function reachAt(depth: number): number {
|
|
44
|
+
if (props.views <= 0) {
|
|
45
|
+
return 0
|
|
46
|
+
}
|
|
47
|
+
const clamped = Math.min(Math.max(depth, 0), DEPTH_SCALE)
|
|
48
|
+
return (props.reached[clamped] ?? 0) / props.views
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Depth percent → 0-1 fraction of the drawn height. */
|
|
52
|
+
function yRatio(depth: number): number {
|
|
53
|
+
return props.foldRatio + (depth / DEPTH_SCALE) * (1 - props.foldRatio)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const gradient = computed(() => {
|
|
57
|
+
const colorFor = (fraction: number) => heatColorAt(fraction, OVERLAY_ALPHA)
|
|
58
|
+
const stops = [`${colorFor(1)} 0%`]
|
|
59
|
+
for (let depth = 0; depth <= DEPTH_SCALE; depth += GRADIENT_STEP) {
|
|
60
|
+
stops.push(`${colorFor(reachAt(depth))} ${(yRatio(depth) * 100).toFixed(2)}%`)
|
|
61
|
+
}
|
|
62
|
+
return `linear-gradient(to bottom, ${stops.join(', ')})`
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** Deepest point at least `percent`% of the measured views got to. */
|
|
66
|
+
function depthReachedBy(percent: number): number {
|
|
67
|
+
for (let depth = DEPTH_SCALE; depth >= 0; depth--) {
|
|
68
|
+
if (reachAt(depth) >= percent / DEPTH_SCALE) {
|
|
69
|
+
return depth
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const marks = computed(() => {
|
|
76
|
+
const placed: Array<{ percent: number, y: number }> = []
|
|
77
|
+
for (const percent of REACH_MARKS) {
|
|
78
|
+
const y = yRatio(depthReachedBy(percent)) * props.height
|
|
79
|
+
// Collapsed shares (everyone stops at one depth) would stack identical
|
|
80
|
+
// lines; the strongest claim wins.
|
|
81
|
+
if (placed.every(mark => Math.abs(mark.y - y) > 1)) {
|
|
82
|
+
placed.push({ percent, y })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return placed
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const foldY = computed(() => props.foldRatio * props.height)
|
|
89
|
+
const showFold = computed(() => props.foldRatio > 0 && props.foldRatio < 1)
|
|
90
|
+
|
|
91
|
+
const hover = ref<{ y: number, percent: number } | null>(null)
|
|
92
|
+
|
|
93
|
+
function onPointerMove(event: PointerEvent): void {
|
|
94
|
+
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
|
95
|
+
if (rect.height <= 0 || props.foldRatio >= 1) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const ratio = Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
|
|
99
|
+
const depth = ratio <= props.foldRatio
|
|
100
|
+
? 0
|
|
101
|
+
: Math.round(((ratio - props.foldRatio) / (1 - props.foldRatio)) * DEPTH_SCALE)
|
|
102
|
+
hover.value = {
|
|
103
|
+
y: ratio * props.height,
|
|
104
|
+
percent: Math.round(reachAt(depth) * 100),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<template>
|
|
110
|
+
<div
|
|
111
|
+
class="absolute inset-0"
|
|
112
|
+
@pointermove="onPointerMove"
|
|
113
|
+
@pointerleave="hover = null"
|
|
114
|
+
>
|
|
115
|
+
<div class="pointer-events-none absolute inset-0" :style="{ background: gradient }" />
|
|
116
|
+
|
|
117
|
+
<div
|
|
118
|
+
v-if="showFold"
|
|
119
|
+
class="pointer-events-none absolute inset-x-0"
|
|
120
|
+
:style="{ top: `${foldY}px`, borderTop: '1px solid rgba(255, 255, 255, 0.55)' }"
|
|
121
|
+
>
|
|
122
|
+
<span
|
|
123
|
+
class="absolute left-2 top-0 -translate-y-1/2 rounded-full bg-default/90 px-2 py-0.5 text-xs text-muted shadow"
|
|
124
|
+
>
|
|
125
|
+
{{ t('page.marketing.pages.scroll.fold_line') }}
|
|
126
|
+
</span>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div
|
|
130
|
+
v-for="mark in marks"
|
|
131
|
+
:key="mark.percent"
|
|
132
|
+
class="pointer-events-none absolute inset-x-0"
|
|
133
|
+
:style="{
|
|
134
|
+
top: `${mark.y}px`,
|
|
135
|
+
borderTop: '2px dashed rgba(255, 255, 255, 0.85)',
|
|
136
|
+
filter: 'drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4))',
|
|
137
|
+
}"
|
|
138
|
+
>
|
|
139
|
+
<span
|
|
140
|
+
class="absolute right-2 top-0 -translate-y-1/2 rounded-full bg-default/90 px-2 py-0.5 text-xs font-medium text-highlighted shadow"
|
|
141
|
+
>
|
|
142
|
+
{{ t('page.marketing.pages.scroll.reach_line', { percent: mark.percent }) }}
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div
|
|
147
|
+
v-if="hover"
|
|
148
|
+
class="pointer-events-none absolute inset-x-0"
|
|
149
|
+
:style="{ top: `${hover.y}px`, borderTop: '1px solid rgba(255, 255, 255, 0.95)' }"
|
|
150
|
+
>
|
|
151
|
+
<span
|
|
152
|
+
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
|
+
>
|
|
154
|
+
{{ t('page.marketing.pages.scroll.reach_line', { percent: hover.percent }) }}
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The stat-variant DmsKpiCard look for a value that is not a plain number:
|
|
4
|
+
* dms-ui's card only formats numeric values (number / currency / percent),
|
|
5
|
+
* and a session duration reads as "1m 32s", which is none of them. Classes
|
|
6
|
+
* are copied from KpiCard's stat variant so the card sits in the same row
|
|
7
|
+
* without a seam.
|
|
8
|
+
*/
|
|
9
|
+
defineProps<{ title: string, value: string, icon: string }>()
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<DmsCard :padded="false" class="p-4 sm:p-5">
|
|
14
|
+
<div class="flex items-start justify-between gap-3">
|
|
15
|
+
<div class="min-w-0 flex-1">
|
|
16
|
+
<p class="text-dimmed truncate font-mono text-[10px] font-medium uppercase tracking-widest">
|
|
17
|
+
{{ title }}
|
|
18
|
+
</p>
|
|
19
|
+
<p class="mt-1 text-2xl font-semibold tracking-tight tabular-nums">
|
|
20
|
+
{{ value }}
|
|
21
|
+
</p>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="text-dimmed flex shrink-0 items-center justify-center">
|
|
24
|
+
<UIcon :name="icon" class="size-4" aria-hidden="true" />
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</DmsCard>
|
|
28
|
+
</template>
|