@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,1026 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import { useI18n } from '#dms/frontend-module'
|
|
4
|
+
import type {
|
|
5
|
+
ExperimentDraft,
|
|
6
|
+
ExperimentVariationDraft,
|
|
7
|
+
} from '../composables/useExperiment'
|
|
8
|
+
import { useLatestRequest } from '../composables/useLatestRequest'
|
|
9
|
+
import {
|
|
10
|
+
useMarketingApi,
|
|
11
|
+
type MarketingExperimentStatus,
|
|
12
|
+
type MarketingFunnelResults,
|
|
13
|
+
type MarketingFunnelStep,
|
|
14
|
+
type MarketingFunnelStepResult,
|
|
15
|
+
type MarketingVariationResult,
|
|
16
|
+
} from '../composables/useMarketingApi'
|
|
17
|
+
import { pagesLink } from '../composables/useMarketingRoutes'
|
|
18
|
+
|
|
19
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detail pane of the funnels master/detail: one funnel as a figure — centred
|
|
23
|
+
* bars tied by a taper carrying the drop — drawn once over the sessions that
|
|
24
|
+
* entered it, or once per arm when the funnel carries an experiment past
|
|
25
|
+
* draft, with the verdict banner (control vs best arm) above and the
|
|
26
|
+
* lifecycle actions in the header.
|
|
27
|
+
*
|
|
28
|
+
* Deletion and the status transitions are owned here (confirm included):
|
|
29
|
+
* they need nothing the parent does not already hand over. Editing is only
|
|
30
|
+
* emitted — the form needs the list row and the parent owns the list.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Bars carry their own count: below this the number would not fit inside. */
|
|
34
|
+
const MIN_BAR_PERCENT = 12
|
|
35
|
+
|
|
36
|
+
type ArmColor = 'primary' | 'warning' | 'success'
|
|
37
|
+
|
|
38
|
+
/** Literal class names: Tailwind only ships what it can read. */
|
|
39
|
+
const ARM_CLASSES: Record<ArmColor, { text: string, bar: string, taper: string }> = {
|
|
40
|
+
primary: { text: 'text-primary', bar: 'bg-primary', taper: 'bg-primary/20' },
|
|
41
|
+
warning: { text: 'text-warning', bar: 'bg-warning', taper: 'bg-warning/20' },
|
|
42
|
+
success: { text: 'text-success', bar: 'bg-success', taper: 'bg-success/20' },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const props = defineProps<{
|
|
46
|
+
funnelId: string
|
|
47
|
+
/** From the parent's list row: always names `funnelId`, while `results`
|
|
48
|
+
* may still carry the previous funnel during its (slow) fetch. */
|
|
49
|
+
funnelName: string
|
|
50
|
+
/** The row's A/B facet, parsed by the parent; null on a plain funnel. */
|
|
51
|
+
experiment: ExperimentDraft | null
|
|
52
|
+
period: string
|
|
53
|
+
/** Bumped by the parent after an edit: same id, new definition. */
|
|
54
|
+
refreshToken?: number
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
const emit = defineEmits<{
|
|
58
|
+
'edit': []
|
|
59
|
+
'deleted': [id: string]
|
|
60
|
+
'statusChanged': [status: MarketingExperimentStatus]
|
|
61
|
+
}>()
|
|
62
|
+
|
|
63
|
+
const api = useMarketingApi()
|
|
64
|
+
const { t, locale } = useI18n()
|
|
65
|
+
const { confirm } = useConfirm()
|
|
66
|
+
const toast = useToast()
|
|
67
|
+
|
|
68
|
+
// Funnel results are the slowest read in the module and the selection, the
|
|
69
|
+
// period and the post-edit refresh all retrigger them, so a superseded answer
|
|
70
|
+
// landing on the current selection is the failure mode — see useLatestRequest.
|
|
71
|
+
const {
|
|
72
|
+
data: results,
|
|
73
|
+
loading,
|
|
74
|
+
failed,
|
|
75
|
+
run: loadResults,
|
|
76
|
+
} = useLatestRequest<MarketingFunnelResults>(() =>
|
|
77
|
+
api.getFunnelResults(props.funnelId, props.period),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
watch(
|
|
81
|
+
() => [props.funnelId, props.period, props.refreshToken],
|
|
82
|
+
() => {
|
|
83
|
+
void loadResults()
|
|
84
|
+
},
|
|
85
|
+
{ immediate: true },
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const status = computed(() => props.experiment?.status ?? null)
|
|
89
|
+
|
|
90
|
+
/** The per-arm read applies once the experiment has served traffic; a draft
|
|
91
|
+
* still reads as the plain funnel it also is. */
|
|
92
|
+
const armsMode = computed(() => status.value !== null && status.value !== 'draft')
|
|
93
|
+
|
|
94
|
+
const statusMeta = computed<
|
|
95
|
+
Record<MarketingExperimentStatus, { label: string, color: 'neutral' | 'success' | 'warning' }>
|
|
96
|
+
>(() => ({
|
|
97
|
+
draft: { label: t('page.marketing.funnels.status.draft'), color: 'neutral' },
|
|
98
|
+
running: { label: t('page.marketing.funnels.status.running'), color: 'success' },
|
|
99
|
+
stopped: { label: t('page.marketing.funnels.status.stopped'), color: 'warning' },
|
|
100
|
+
}))
|
|
101
|
+
|
|
102
|
+
const variations = computed(() => props.experiment?.variations ?? [])
|
|
103
|
+
|
|
104
|
+
const totalWeight = computed(() =>
|
|
105
|
+
variations.value.reduce(
|
|
106
|
+
(sum, variation) => sum + Math.max(variation.weight || 0, 0),
|
|
107
|
+
0,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
function weightPercent(variation: ExperimentVariationDraft): number {
|
|
112
|
+
if (totalWeight.value <= 0) {
|
|
113
|
+
return Math.round(100 / Math.max(variations.value.length, 1))
|
|
114
|
+
}
|
|
115
|
+
return Math.round(
|
|
116
|
+
(Math.max(variation.weight || 0, 0) / totalWeight.value) * 100,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const totalExposed = computed(() =>
|
|
121
|
+
(results.value?.experiment?.variations ?? []).reduce(
|
|
122
|
+
(sum, variation) => sum + variation.exposedSessions,
|
|
123
|
+
0,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
function ratePercent(rate: number): string {
|
|
128
|
+
return `${(rate * 100).toFixed(1)}%`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function signedPercent(value: number): string {
|
|
132
|
+
return `${value >= 0 ? '+' : ''}${(value * 100).toFixed(1)}%`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Share of a base count; the arm's exposure or the funnel's entries. */
|
|
136
|
+
function share(sessions: number, base: number): number {
|
|
137
|
+
return base > 0 ? sessions / base : 0
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The one-line read under the title. Guarded on `loading` because `results`
|
|
142
|
+
* may still be the previous funnel's during a fetch — a stale count under a
|
|
143
|
+
* fresh name would read as this funnel's.
|
|
144
|
+
*/
|
|
145
|
+
const metaLine = computed(() => {
|
|
146
|
+
const payload = loading.value ? null : results.value
|
|
147
|
+
const experiment = props.experiment
|
|
148
|
+
if (!experiment) {
|
|
149
|
+
if (!payload) {
|
|
150
|
+
return ''
|
|
151
|
+
}
|
|
152
|
+
const steps = payload.computation.steps
|
|
153
|
+
const entered = payload.computation.totalSessions
|
|
154
|
+
const last = steps[steps.length - 1]
|
|
155
|
+
return t('page.marketing.funnels.summary', {
|
|
156
|
+
entered,
|
|
157
|
+
rate: ((last?.conversionRate ?? 0) * 100).toFixed(1),
|
|
158
|
+
}, entered)
|
|
159
|
+
}
|
|
160
|
+
const split = experiment.variations.map(variation => weightPercent(variation)).join('/')
|
|
161
|
+
if (experiment.status === 'draft') {
|
|
162
|
+
return t('page.marketing.funnels.results.meta_draft', { split })
|
|
163
|
+
}
|
|
164
|
+
const splitPart = t('page.marketing.funnels.results.meta_frozen', { split })
|
|
165
|
+
if (!payload) {
|
|
166
|
+
return splitPart
|
|
167
|
+
}
|
|
168
|
+
const exposedPart = t(
|
|
169
|
+
'page.marketing.funnels.results.exposed',
|
|
170
|
+
{ count: totalExposed.value },
|
|
171
|
+
totalExposed.value,
|
|
172
|
+
)
|
|
173
|
+
return [exposedPart, windowLabel(payload), splitPart].join(' · ')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
function shortDate(epochMs: number): string {
|
|
177
|
+
return new Date(epochMs).toLocaleDateString(locale.value, {
|
|
178
|
+
day: 'numeric',
|
|
179
|
+
month: 'short',
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Which stretch of time the arms cover. A started split is read over its own
|
|
185
|
+
* runs and not over the selected period, so the pane has to say so: the
|
|
186
|
+
* period picker moves the rest of the module and leaves these numbers alone.
|
|
187
|
+
* Past one run the count comes with it — the dates alone would read as an
|
|
188
|
+
* unbroken stretch, and the pause inside counts for nothing.
|
|
189
|
+
*/
|
|
190
|
+
function windowLabel(payload: MarketingFunnelResults): string {
|
|
191
|
+
const since = shortDate(payload.window.since)
|
|
192
|
+
const span = status.value === 'stopped'
|
|
193
|
+
? t('page.marketing.funnels.results.window_stopped', {
|
|
194
|
+
since,
|
|
195
|
+
until: shortDate(payload.window.until),
|
|
196
|
+
})
|
|
197
|
+
: t('page.marketing.funnels.results.window_running', { since })
|
|
198
|
+
const runs = props.experiment?.runs.length ?? 1
|
|
199
|
+
if (runs < 2) {
|
|
200
|
+
return span
|
|
201
|
+
}
|
|
202
|
+
return `${span} (${t('page.marketing.funnels.results.window_runs', { count: runs }, runs)})`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Every comparison unanswered for thinness: rates and bars on a sample this
|
|
207
|
+
* size would dress noise as signal, so the read degrades to raw counts.
|
|
208
|
+
* Truncation also withholds every verdict, but on a large sample — there the
|
|
209
|
+
* bars stay, under their own warning.
|
|
210
|
+
*/
|
|
211
|
+
const insufficient = computed(() => {
|
|
212
|
+
const payload = results.value
|
|
213
|
+
return payload !== null && !payload.truncated && payload.experiment !== undefined
|
|
214
|
+
&& payload.experiment.variations.every(variation => variation.significant === null)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
function goalSessions(variation: MarketingVariationResult): number {
|
|
218
|
+
const steps = variation.computation.steps
|
|
219
|
+
return steps[steps.length - 1]?.sessions ?? 0
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Arms display as positional letters (A is the control): the key is
|
|
223
|
+
* definition detail — only the tooltips still carry it. */
|
|
224
|
+
function armLetter(index: number): string {
|
|
225
|
+
return String.fromCharCode(65 + index)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Per-arm verdicts only clarify past one challenger; with a single one the
|
|
229
|
+
* verdict banner already says everything a third column would repeat. */
|
|
230
|
+
const multiArm = computed(
|
|
231
|
+
() => (results.value?.experiment?.variations.length ?? 0) > 2,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
interface ExperimentVerdict {
|
|
235
|
+
control: { key: string, label: string, rate: string }
|
|
236
|
+
best: { key: string, label: string, rate: string }
|
|
237
|
+
/** Two-tone bar split, each arm's share of the summed rates. */
|
|
238
|
+
controlWidth: number
|
|
239
|
+
bestWidth: number
|
|
240
|
+
significant: boolean
|
|
241
|
+
/** null when the control never converted — a ratio over zero. */
|
|
242
|
+
liftLabel: string | null
|
|
243
|
+
liftPositive: boolean
|
|
244
|
+
confidence: string
|
|
245
|
+
pLabel: string
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The banner's read: control against the best-converting challenger that has
|
|
250
|
+
* a p-value. null while any warning state owns the pane — truncated and
|
|
251
|
+
* insufficient both null every challenger's p-value, so they fall out here
|
|
252
|
+
* without restating those conditions.
|
|
253
|
+
*/
|
|
254
|
+
const verdict = computed<ExperimentVerdict | null>(() => {
|
|
255
|
+
const payload = results.value
|
|
256
|
+
const experiment = payload?.experiment
|
|
257
|
+
if (!payload || !experiment || payload.truncated || !armsMode.value) {
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
const controlIndex = experiment.variations.findIndex(
|
|
261
|
+
variation => variation.key === experiment.control,
|
|
262
|
+
)
|
|
263
|
+
const control = experiment.variations[controlIndex]
|
|
264
|
+
let bestIndex = -1
|
|
265
|
+
experiment.variations.forEach((variation, index) => {
|
|
266
|
+
if (variation.key === experiment.control || variation.pValue === null) {
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
if (
|
|
270
|
+
bestIndex === -1
|
|
271
|
+
|| variation.conversionRate > experiment.variations[bestIndex]!.conversionRate
|
|
272
|
+
) {
|
|
273
|
+
bestIndex = index
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
if (!control || bestIndex === -1) {
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
const best = experiment.variations[bestIndex]!
|
|
280
|
+
const pValue = best.pValue!
|
|
281
|
+
const rateSum = control.conversionRate + best.conversionRate
|
|
282
|
+
return {
|
|
283
|
+
control: {
|
|
284
|
+
key: control.key,
|
|
285
|
+
label: t('page.marketing.funnels.results.arm_control', {
|
|
286
|
+
letter: armLetter(controlIndex),
|
|
287
|
+
}),
|
|
288
|
+
rate: ratePercent(control.conversionRate),
|
|
289
|
+
},
|
|
290
|
+
best: {
|
|
291
|
+
key: best.key,
|
|
292
|
+
label: t('page.marketing.funnels.results.arm_variation', {
|
|
293
|
+
letter: armLetter(bestIndex),
|
|
294
|
+
}),
|
|
295
|
+
rate: ratePercent(best.conversionRate),
|
|
296
|
+
},
|
|
297
|
+
controlWidth: rateSum > 0 ? (control.conversionRate / rateSum) * 100 : 50,
|
|
298
|
+
bestWidth: rateSum > 0 ? (best.conversionRate / rateSum) * 100 : 50,
|
|
299
|
+
significant: best.significant === true,
|
|
300
|
+
liftLabel: best.uplift === null ? null : signedPercent(best.uplift),
|
|
301
|
+
liftPositive: (best.uplift ?? 0) >= 0,
|
|
302
|
+
confidence:
|
|
303
|
+
pValue < 0.001 ? '99.9%+' : `${((1 - pValue) * 100).toFixed(1)}%`,
|
|
304
|
+
pLabel: pValue < 0.001 ? 'p < 0.001' : `p = ${pValue.toFixed(3)}`,
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
/** One column of the figure: an arm, or the whole funnel on a plain one. */
|
|
309
|
+
interface ArmSource {
|
|
310
|
+
key: string
|
|
311
|
+
label: string
|
|
312
|
+
color: ArmColor
|
|
313
|
+
/** What every bar of the column is a share of. */
|
|
314
|
+
base: number
|
|
315
|
+
steps: MarketingFunnelStepResult[]
|
|
316
|
+
endToEnd: string | null
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
interface FigureArm {
|
|
320
|
+
key: string
|
|
321
|
+
label: string
|
|
322
|
+
color: ArmColor
|
|
323
|
+
/** Flex order: with two arms the step column sits between them. */
|
|
324
|
+
order: number
|
|
325
|
+
/** Right-hand arm of a mirrored funnel — its own columns read outwards. */
|
|
326
|
+
reversed: boolean
|
|
327
|
+
/** Share of the arm's exposed sessions still there at the goal. */
|
|
328
|
+
endToEnd: string | null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
interface FigureCell {
|
|
332
|
+
key: string
|
|
333
|
+
color: ArmColor
|
|
334
|
+
order: number
|
|
335
|
+
reversed: boolean
|
|
336
|
+
sessions: number
|
|
337
|
+
/** Bar width as a share of its column, floored so the count fits inside. */
|
|
338
|
+
width: number
|
|
339
|
+
retained: string
|
|
340
|
+
/** Trapezoid tying this bar to the next step's; null on the goal step. */
|
|
341
|
+
taper: string | null
|
|
342
|
+
/** What the next step costs this column, e.g. `−85%`. */
|
|
343
|
+
drop: string | null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface FigureRow {
|
|
347
|
+
step: MarketingFunnelStep
|
|
348
|
+
isGoal: boolean
|
|
349
|
+
kindLabel: string
|
|
350
|
+
/** The same loss in sessions, when a single column can own it. */
|
|
351
|
+
lostLabel: string | null
|
|
352
|
+
cells: FigureCell[]
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
interface Figure {
|
|
356
|
+
websiteId: string
|
|
357
|
+
arms: FigureArm[]
|
|
358
|
+
rows: FigureRow[]
|
|
359
|
+
/** Exactly two arms: the funnel mirrors around a central step column, so
|
|
360
|
+
* the same step reads across both at a glance. More arms fall back to
|
|
361
|
+
* plain columns — a mirror only has two sides. */
|
|
362
|
+
mirrored: boolean
|
|
363
|
+
/** Order of the step column itself, between the arms or before them. */
|
|
364
|
+
stepOrder: number
|
|
365
|
+
single: boolean
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function barPercent(sessions: number, base: number): number {
|
|
369
|
+
return Math.max(share(sessions, base) * 100, MIN_BAR_PERCENT)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The bars are centred, so the connector between two of them is an
|
|
373
|
+
* isosceles trapezoid over the full column width. */
|
|
374
|
+
function taperPolygon(current: number, next: number): string {
|
|
375
|
+
const top = (100 - current) / 2
|
|
376
|
+
const bottom = (100 - next) / 2
|
|
377
|
+
return `polygon(${top.toFixed(1)}% 0, ${(100 - top).toFixed(1)}% 0, ${(100 - bottom).toFixed(1)}% 100%, ${bottom.toFixed(1)}% 100%)`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function armSources(payload: MarketingFunnelResults): ArmSource[] {
|
|
381
|
+
const experiment = armsMode.value ? payload.experiment : undefined
|
|
382
|
+
if (!experiment) {
|
|
383
|
+
return [{
|
|
384
|
+
key: 'sessions',
|
|
385
|
+
label: t('page.marketing.funnels.sessions'),
|
|
386
|
+
color: 'primary',
|
|
387
|
+
base: payload.computation.totalSessions,
|
|
388
|
+
steps: payload.computation.steps,
|
|
389
|
+
endToEnd: null,
|
|
390
|
+
}]
|
|
391
|
+
}
|
|
392
|
+
// Bars sized against the arm's own exposure, not its funnel entries:
|
|
393
|
+
// entering is an outcome the variation influences, so only exposure makes
|
|
394
|
+
// arms comparable — at every step, not only at the goal.
|
|
395
|
+
return experiment.variations.map((variation, index) => {
|
|
396
|
+
const isControl = variation.key === experiment.control
|
|
397
|
+
const letter = armLetter(index)
|
|
398
|
+
return {
|
|
399
|
+
key: variation.key,
|
|
400
|
+
label: isControl
|
|
401
|
+
? t('page.marketing.funnels.results.arm_control', { letter })
|
|
402
|
+
: t('page.marketing.funnels.results.arm_variation', { letter }),
|
|
403
|
+
color: isControl ? 'warning' : 'success',
|
|
404
|
+
base: variation.exposedSessions,
|
|
405
|
+
steps: variation.computation.steps,
|
|
406
|
+
endToEnd: ratePercent(
|
|
407
|
+
share(goalSessions(variation), variation.exposedSessions),
|
|
408
|
+
),
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The funnel as one figure per column, step by step: bars tapered into the
|
|
415
|
+
* next step, which puts the drop where it happens.
|
|
416
|
+
*/
|
|
417
|
+
const figure = computed<Figure | null>(() => {
|
|
418
|
+
const payload = results.value
|
|
419
|
+
if (!payload) {
|
|
420
|
+
return null
|
|
421
|
+
}
|
|
422
|
+
const sources = armSources(payload)
|
|
423
|
+
const single = sources.length === 1
|
|
424
|
+
const mirrored = sources.length === 2
|
|
425
|
+
const armOrder = (index: number) => (mirrored ? index * 2 : index + 1)
|
|
426
|
+
const isReversed = (index: number) => mirrored && index === 1
|
|
427
|
+
const arms = sources.map((source, index) => ({
|
|
428
|
+
key: source.key,
|
|
429
|
+
label: source.label,
|
|
430
|
+
color: source.color,
|
|
431
|
+
order: armOrder(index),
|
|
432
|
+
reversed: isReversed(index),
|
|
433
|
+
endToEnd: source.endToEnd,
|
|
434
|
+
}))
|
|
435
|
+
const steps = payload.funnel.steps
|
|
436
|
+
const lastIndex = steps.length - 1
|
|
437
|
+
const rows = steps.map((step, index) => {
|
|
438
|
+
const isGoal = index === lastIndex
|
|
439
|
+
const kind = t(`page.marketing.funnels.step_kind.${step.kind}`)
|
|
440
|
+
const only = single ? sources[0]! : null
|
|
441
|
+
const lost = only && !isGoal
|
|
442
|
+
? (only.steps[index]?.sessions ?? 0) - (only.steps[index + 1]?.sessions ?? 0)
|
|
443
|
+
: 0
|
|
444
|
+
return {
|
|
445
|
+
step,
|
|
446
|
+
isGoal,
|
|
447
|
+
kindLabel: isGoal
|
|
448
|
+
? `${kind} · ${t('page.marketing.funnels.goal_suffix')}`
|
|
449
|
+
: kind,
|
|
450
|
+
lostLabel: lost > 0
|
|
451
|
+
? t('page.marketing.funnels.lost', { count: lost }, lost)
|
|
452
|
+
: null,
|
|
453
|
+
cells: sources.map((source, arm) => {
|
|
454
|
+
const sessions = source.steps[index]?.sessions ?? 0
|
|
455
|
+
const next = isGoal ? null : source.steps[index + 1]?.sessions ?? 0
|
|
456
|
+
const width = barPercent(sessions, source.base)
|
|
457
|
+
const dropped = next === null || sessions <= 0
|
|
458
|
+
? 0
|
|
459
|
+
: Math.round((1 - next / sessions) * 100)
|
|
460
|
+
return {
|
|
461
|
+
key: source.key,
|
|
462
|
+
color: source.color,
|
|
463
|
+
order: armOrder(arm),
|
|
464
|
+
reversed: isReversed(arm),
|
|
465
|
+
sessions,
|
|
466
|
+
width,
|
|
467
|
+
retained: ratePercent(share(sessions, source.base)),
|
|
468
|
+
taper: next === null
|
|
469
|
+
? null
|
|
470
|
+
: taperPolygon(width, barPercent(next, source.base)),
|
|
471
|
+
drop: next === null ? null : dropped > 0 ? `−${dropped}%` : '0%',
|
|
472
|
+
}
|
|
473
|
+
}),
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
return {
|
|
477
|
+
websiteId: payload.funnel.websiteId,
|
|
478
|
+
arms,
|
|
479
|
+
rows,
|
|
480
|
+
mirrored,
|
|
481
|
+
stepOrder: mirrored ? 1 : 0,
|
|
482
|
+
single,
|
|
483
|
+
}
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
/** `null` (thin sample) renders as "not enough data", not as a claim. */
|
|
487
|
+
function verdictOf(variation: MarketingVariationResult) {
|
|
488
|
+
if (variation.significant === null) {
|
|
489
|
+
return {
|
|
490
|
+
label: t('page.marketing.funnels.results.insufficient'),
|
|
491
|
+
color: 'neutral' as const,
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return variation.significant
|
|
495
|
+
? {
|
|
496
|
+
label: t('page.marketing.funnels.results.significant'),
|
|
497
|
+
color: 'success' as const,
|
|
498
|
+
}
|
|
499
|
+
: {
|
|
500
|
+
label: t('page.marketing.funnels.results.not_significant'),
|
|
501
|
+
color: 'neutral' as const,
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Past one challenger the banner only judges the best arm, so every other
|
|
507
|
+
* one gets its own line under the funnel — the mirrored figure has no column
|
|
508
|
+
* left to carry them.
|
|
509
|
+
*/
|
|
510
|
+
const challengers = computed(() => {
|
|
511
|
+
const experiment = results.value?.experiment
|
|
512
|
+
if (!experiment || !multiArm.value || !armsMode.value) {
|
|
513
|
+
return []
|
|
514
|
+
}
|
|
515
|
+
return experiment.variations.flatMap((variation, index) =>
|
|
516
|
+
variation.key === experiment.control
|
|
517
|
+
? []
|
|
518
|
+
: [{
|
|
519
|
+
key: variation.key,
|
|
520
|
+
label: t('page.marketing.funnels.results.arm_variation', {
|
|
521
|
+
letter: armLetter(index),
|
|
522
|
+
}),
|
|
523
|
+
lift: variation.uplift === null ? null : signedPercent(variation.uplift),
|
|
524
|
+
liftPositive: (variation.uplift ?? 0) >= 0,
|
|
525
|
+
verdict: verdictOf(variation),
|
|
526
|
+
}],
|
|
527
|
+
)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
/** draft → running ⇄ stopped; the backend enforces, this only offers. The
|
|
531
|
+
* write itself belongs to the parent — it owns the list the row lives in. */
|
|
532
|
+
const nextStatus = computed<MarketingExperimentStatus | null>(() => {
|
|
533
|
+
if (status.value === 'running') {
|
|
534
|
+
return 'stopped'
|
|
535
|
+
}
|
|
536
|
+
return status.value === null ? null : 'running'
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Stopping and resuming both ask first, for opposite reasons: the stop puts
|
|
541
|
+
* every visitor back on the control — a change to the live site, not just to
|
|
542
|
+
* the pane — while the resume reopens a reading that was final. Starting a
|
|
543
|
+
* draft asks nothing: it has nothing to undo.
|
|
544
|
+
*/
|
|
545
|
+
function confirmTransition(target: MarketingExperimentStatus): Promise<boolean> {
|
|
546
|
+
if (target === 'stopped') {
|
|
547
|
+
return confirm({
|
|
548
|
+
title: t('page.marketing.funnels.stop.title'),
|
|
549
|
+
description: t('page.marketing.funnels.stop.description'),
|
|
550
|
+
confirmLabel: t('page.marketing.funnels.stop.confirm'),
|
|
551
|
+
confirmColor: 'warning',
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
if (status.value === 'stopped') {
|
|
555
|
+
return confirm({
|
|
556
|
+
title: t('page.marketing.funnels.resume.title'),
|
|
557
|
+
description: t('page.marketing.funnels.resume.description'),
|
|
558
|
+
confirmLabel: t('page.marketing.funnels.resume.confirm'),
|
|
559
|
+
})
|
|
560
|
+
}
|
|
561
|
+
return Promise.resolve(true)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function changeStatus(): Promise<void> {
|
|
565
|
+
const target = nextStatus.value
|
|
566
|
+
if (!target || !(await confirmTransition(target))) {
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
emit('statusChanged', target)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const deleting = ref(false)
|
|
573
|
+
|
|
574
|
+
async function deleteFunnel(): Promise<void> {
|
|
575
|
+
const confirmed = await confirm({
|
|
576
|
+
title: t('page.marketing.funnels.delete.title'),
|
|
577
|
+
description: t('page.marketing.funnels.delete.description', {
|
|
578
|
+
name: props.funnelName,
|
|
579
|
+
}),
|
|
580
|
+
confirmLabel: t('page.marketing.funnels.delete.confirm'),
|
|
581
|
+
confirmColor: 'error',
|
|
582
|
+
})
|
|
583
|
+
if (!confirmed) {
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
deleting.value = true
|
|
587
|
+
try {
|
|
588
|
+
await api.deleteFunnel(props.funnelId)
|
|
589
|
+
toast.add({
|
|
590
|
+
color: 'success',
|
|
591
|
+
title: t('page.marketing.funnels.delete.done'),
|
|
592
|
+
})
|
|
593
|
+
emit('deleted', props.funnelId)
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
toast.add({
|
|
597
|
+
color: 'error',
|
|
598
|
+
title: t('page.marketing.funnels.delete.error'),
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
deleting.value = false
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Destructive, and one gesture away from Edit: it lives behind the overflow
|
|
607
|
+
* menu rather than as another button in the header. */
|
|
608
|
+
const menuItems = computed<DropdownMenuItem[][]>(() => [[
|
|
609
|
+
{
|
|
610
|
+
label: t('page.marketing.funnels.delete.button'),
|
|
611
|
+
icon: 'i-ph-trash',
|
|
612
|
+
color: 'error',
|
|
613
|
+
onSelect: () => {
|
|
614
|
+
void deleteFunnel()
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
]])
|
|
618
|
+
</script>
|
|
619
|
+
|
|
620
|
+
<template>
|
|
621
|
+
<div class="flex flex-col gap-4 p-4">
|
|
622
|
+
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
623
|
+
<div class="min-w-0">
|
|
624
|
+
<div class="flex items-center gap-2">
|
|
625
|
+
<p class="truncate font-medium text-highlighted">
|
|
626
|
+
{{ funnelName }}
|
|
627
|
+
</p>
|
|
628
|
+
<UBadge
|
|
629
|
+
v-if="status"
|
|
630
|
+
:color="statusMeta[status].color"
|
|
631
|
+
variant="subtle"
|
|
632
|
+
size="sm"
|
|
633
|
+
>
|
|
634
|
+
<span class="size-1.5 rounded-full bg-current" aria-hidden="true" />
|
|
635
|
+
{{ statusMeta[status].label }}
|
|
636
|
+
</UBadge>
|
|
637
|
+
</div>
|
|
638
|
+
<p
|
|
639
|
+
class="mt-1 truncate font-mono text-xs text-muted"
|
|
640
|
+
:title="armsMode ? t('page.marketing.funnels.locked_hint') : undefined"
|
|
641
|
+
>
|
|
642
|
+
{{ metaLine }}
|
|
643
|
+
</p>
|
|
644
|
+
</div>
|
|
645
|
+
<div class="flex items-center gap-2">
|
|
646
|
+
<UButton
|
|
647
|
+
v-if="nextStatus === 'running'"
|
|
648
|
+
:icon="status === 'stopped' ? 'i-ph-arrow-counter-clockwise' : 'i-ph-play'"
|
|
649
|
+
color="success"
|
|
650
|
+
variant="subtle"
|
|
651
|
+
size="sm"
|
|
652
|
+
:label="status === 'stopped'
|
|
653
|
+
? t('page.marketing.funnels.resume.button')
|
|
654
|
+
: t('page.marketing.funnels.start')"
|
|
655
|
+
@click="changeStatus"
|
|
656
|
+
/>
|
|
657
|
+
<UButton
|
|
658
|
+
v-else-if="nextStatus === 'stopped'"
|
|
659
|
+
icon="i-ph-stop"
|
|
660
|
+
color="warning"
|
|
661
|
+
variant="subtle"
|
|
662
|
+
size="sm"
|
|
663
|
+
:label="t('page.marketing.funnels.stop.button')"
|
|
664
|
+
@click="changeStatus"
|
|
665
|
+
/>
|
|
666
|
+
<UButton
|
|
667
|
+
icon="i-ph-pencil-simple"
|
|
668
|
+
color="neutral"
|
|
669
|
+
variant="subtle"
|
|
670
|
+
size="sm"
|
|
671
|
+
:label="t('page.marketing.funnels.edit')"
|
|
672
|
+
@click="emit('edit')"
|
|
673
|
+
/>
|
|
674
|
+
<UDropdownMenu :items="menuItems">
|
|
675
|
+
<UButton
|
|
676
|
+
icon="i-ph-dots-three"
|
|
677
|
+
color="neutral"
|
|
678
|
+
variant="subtle"
|
|
679
|
+
size="sm"
|
|
680
|
+
:loading="deleting"
|
|
681
|
+
:aria-label="t('page.marketing.funnels.more_actions')"
|
|
682
|
+
/>
|
|
683
|
+
</UDropdownMenu>
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
|
|
687
|
+
<!-- Definition summary, drafts only: running arms read their keys and
|
|
688
|
+
split off the meta line and the bars instead. -->
|
|
689
|
+
<div v-if="status === 'draft'" class="flex flex-wrap items-center gap-2">
|
|
690
|
+
<UBadge
|
|
691
|
+
v-for="(variation, index) in variations"
|
|
692
|
+
:key="variation.key"
|
|
693
|
+
:label="`${variation.key} · ${weightPercent(variation)}%`"
|
|
694
|
+
:color="index === 0 ? 'neutral' : 'primary'"
|
|
695
|
+
variant="subtle"
|
|
696
|
+
size="sm"
|
|
697
|
+
class="font-mono"
|
|
698
|
+
/>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
<!-- The verdict banner: the goal read as one two-tone bar, control
|
|
702
|
+
against the best challenger, with the numbers a decision needs.
|
|
703
|
+
Above the funnel — it is the answer, the funnel is the context. -->
|
|
704
|
+
<div
|
|
705
|
+
v-if="!failed && !loading && verdict"
|
|
706
|
+
class="mx-auto w-full max-w-3xl border-b border-default pb-4"
|
|
707
|
+
>
|
|
708
|
+
<div class="flex items-baseline gap-3 font-mono text-xs text-muted">
|
|
709
|
+
<span class="min-w-0 truncate" :title="verdict.control.key">
|
|
710
|
+
{{ verdict.control.label }}
|
|
711
|
+
<span class="font-semibold text-warning">{{ verdict.control.rate }}</span>
|
|
712
|
+
</span>
|
|
713
|
+
<span class="flex-1" />
|
|
714
|
+
<span class="min-w-0 truncate" :title="verdict.best.key">
|
|
715
|
+
<span class="font-semibold text-success">{{ verdict.best.rate }}</span>
|
|
716
|
+
{{ verdict.best.label }}
|
|
717
|
+
</span>
|
|
718
|
+
</div>
|
|
719
|
+
<div class="mt-1.5 flex h-3 overflow-hidden rounded-full bg-elevated">
|
|
720
|
+
<div
|
|
721
|
+
class="h-full bg-warning transition-all"
|
|
722
|
+
:style="{ width: `${verdict.controlWidth}%` }"
|
|
723
|
+
/>
|
|
724
|
+
<div
|
|
725
|
+
class="h-full bg-success transition-all"
|
|
726
|
+
:style="{ width: `${verdict.bestWidth}%` }"
|
|
727
|
+
/>
|
|
728
|
+
</div>
|
|
729
|
+
<div class="mt-3 flex flex-wrap gap-x-8 gap-y-2">
|
|
730
|
+
<div>
|
|
731
|
+
<p class="text-xs text-muted">
|
|
732
|
+
{{ t('page.marketing.funnels.results.verdict.lift') }}
|
|
733
|
+
</p>
|
|
734
|
+
<p
|
|
735
|
+
class="mt-0.5 text-sm font-medium"
|
|
736
|
+
:class="verdict.liftLabel === null
|
|
737
|
+
? 'text-muted'
|
|
738
|
+
: verdict.significant
|
|
739
|
+
? (verdict.liftPositive ? 'text-success' : 'text-error')
|
|
740
|
+
: 'text-warning'"
|
|
741
|
+
>
|
|
742
|
+
{{ verdict.liftLabel ?? '—' }}
|
|
743
|
+
</p>
|
|
744
|
+
</div>
|
|
745
|
+
<div>
|
|
746
|
+
<p class="text-xs text-muted">
|
|
747
|
+
{{ t('page.marketing.funnels.results.verdict.confidence') }}
|
|
748
|
+
</p>
|
|
749
|
+
<p
|
|
750
|
+
class="mt-0.5 text-sm font-medium"
|
|
751
|
+
:class="verdict.significant ? 'text-highlighted' : 'text-warning'"
|
|
752
|
+
>
|
|
753
|
+
{{ verdict.confidence }} ({{ verdict.pLabel }})
|
|
754
|
+
</p>
|
|
755
|
+
</div>
|
|
756
|
+
<div>
|
|
757
|
+
<p class="text-xs text-muted">
|
|
758
|
+
{{ t('page.marketing.funnels.results.verdict.sessions') }}
|
|
759
|
+
</p>
|
|
760
|
+
<p class="mt-0.5 text-sm font-medium text-highlighted">
|
|
761
|
+
{{ totalExposed }}
|
|
762
|
+
</p>
|
|
763
|
+
</div>
|
|
764
|
+
</div>
|
|
765
|
+
</div>
|
|
766
|
+
|
|
767
|
+
<div v-if="failed" class="flex flex-col items-start gap-3">
|
|
768
|
+
<p class="text-sm text-error">
|
|
769
|
+
{{ t('page.marketing.funnels.results_error') }}
|
|
770
|
+
</p>
|
|
771
|
+
<UButton
|
|
772
|
+
icon="i-ph-arrow-clockwise"
|
|
773
|
+
:label="t('page.marketing.funnels.retry')"
|
|
774
|
+
color="neutral"
|
|
775
|
+
variant="subtle"
|
|
776
|
+
size="sm"
|
|
777
|
+
@click="loadResults"
|
|
778
|
+
/>
|
|
779
|
+
</div>
|
|
780
|
+
<USkeleton v-else-if="loading" class="h-64 w-full" />
|
|
781
|
+
<template v-else-if="results">
|
|
782
|
+
<UAlert
|
|
783
|
+
v-if="results.truncated"
|
|
784
|
+
color="warning"
|
|
785
|
+
variant="subtle"
|
|
786
|
+
icon="i-ph-hourglass-medium"
|
|
787
|
+
:title="t('page.marketing.funnels.results.truncated.title')"
|
|
788
|
+
:description="t('page.marketing.funnels.results.truncated.description')"
|
|
789
|
+
/>
|
|
790
|
+
<UAlert
|
|
791
|
+
v-if="armsMode && results.experiment?.srm.mismatch"
|
|
792
|
+
color="warning"
|
|
793
|
+
variant="subtle"
|
|
794
|
+
icon="i-ph-scales"
|
|
795
|
+
:title="t('page.marketing.funnels.results.srm_warning.title')"
|
|
796
|
+
:description="t('page.marketing.funnels.results.srm_warning.description')"
|
|
797
|
+
/>
|
|
798
|
+
<UAlert
|
|
799
|
+
v-if="armsMode && totalExposed === 0"
|
|
800
|
+
icon="i-ph-flask"
|
|
801
|
+
:title="t('page.marketing.funnels.results.empty.title')"
|
|
802
|
+
:description="t('page.marketing.funnels.results.empty.description')"
|
|
803
|
+
/>
|
|
804
|
+
<UAlert
|
|
805
|
+
v-else-if="!armsMode && results.computation.totalSessions === 0"
|
|
806
|
+
icon="i-ph-funnel"
|
|
807
|
+
:title="t('page.marketing.funnels.no_sessions.title')"
|
|
808
|
+
:description="t('page.marketing.funnels.no_sessions.description')"
|
|
809
|
+
/>
|
|
810
|
+
<!-- No verdict anywhere: bars would dress noise as signal, so the
|
|
811
|
+
read stays raw counts until the sample can answer. -->
|
|
812
|
+
<template v-else-if="armsMode && insufficient && results.experiment">
|
|
813
|
+
<UAlert
|
|
814
|
+
icon="i-ph-hourglass"
|
|
815
|
+
:title="t('page.marketing.funnels.results.insufficient_data.title')"
|
|
816
|
+
:description="t('page.marketing.funnels.results.insufficient_data.description')"
|
|
817
|
+
/>
|
|
818
|
+
<div
|
|
819
|
+
class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-2"
|
|
820
|
+
>
|
|
821
|
+
<template v-for="(variation, index) in results.experiment.variations" :key="variation.key">
|
|
822
|
+
<span
|
|
823
|
+
class="text-right font-mono text-xs text-highlighted"
|
|
824
|
+
:title="variation.key"
|
|
825
|
+
>
|
|
826
|
+
{{ armLetter(index) }}
|
|
827
|
+
</span>
|
|
828
|
+
<p class="text-sm text-muted">
|
|
829
|
+
{{
|
|
830
|
+
t(
|
|
831
|
+
'page.marketing.funnels.results.exposed',
|
|
832
|
+
{ count: variation.exposedSessions },
|
|
833
|
+
variation.exposedSessions,
|
|
834
|
+
)
|
|
835
|
+
}}
|
|
836
|
+
·
|
|
837
|
+
{{
|
|
838
|
+
t(
|
|
839
|
+
'page.marketing.funnels.results.reached_goal',
|
|
840
|
+
{ count: goalSessions(variation) },
|
|
841
|
+
goalSessions(variation),
|
|
842
|
+
)
|
|
843
|
+
}}
|
|
844
|
+
</p>
|
|
845
|
+
</template>
|
|
846
|
+
</div>
|
|
847
|
+
</template>
|
|
848
|
+
<!-- The figure: each bar is sized against its own column's base, so
|
|
849
|
+
widths compare across columns AND down the steps, and the taper
|
|
850
|
+
between two bars carries what the step costs. Two arms face each
|
|
851
|
+
other around the step they share; column order is CSS-only, the
|
|
852
|
+
DOM stays step-then-arms. -->
|
|
853
|
+
<div v-else-if="figure" class="mx-auto w-full max-w-3xl">
|
|
854
|
+
<div
|
|
855
|
+
class="flex items-center gap-2.5 border-b border-default pb-2.5 font-mono text-[10px] tracking-wide"
|
|
856
|
+
>
|
|
857
|
+
<div class="w-44 shrink-0" :style="{ order: figure.stepOrder }" />
|
|
858
|
+
<div
|
|
859
|
+
v-for="arm in figure.arms"
|
|
860
|
+
:key="arm.key"
|
|
861
|
+
class="flex min-w-0 flex-1 items-center gap-2"
|
|
862
|
+
:class="arm.reversed ? 'flex-row-reverse' : ''"
|
|
863
|
+
:style="{ order: arm.order }"
|
|
864
|
+
>
|
|
865
|
+
<span
|
|
866
|
+
class="w-12 shrink-0 text-dimmed"
|
|
867
|
+
:class="arm.reversed ? 'text-left' : 'text-right'"
|
|
868
|
+
>
|
|
869
|
+
{{ t('page.marketing.funnels.retained') }}
|
|
870
|
+
</span>
|
|
871
|
+
<span
|
|
872
|
+
class="min-w-0 flex-1 truncate text-center"
|
|
873
|
+
:class="ARM_CLASSES[arm.color].text"
|
|
874
|
+
:title="arm.key"
|
|
875
|
+
>
|
|
876
|
+
{{ arm.label }}
|
|
877
|
+
</span>
|
|
878
|
+
</div>
|
|
879
|
+
</div>
|
|
880
|
+
|
|
881
|
+
<div
|
|
882
|
+
v-for="(row, index) in figure.rows"
|
|
883
|
+
:key="index"
|
|
884
|
+
class="flex items-start gap-2.5"
|
|
885
|
+
:class="index === 0 ? 'mt-4' : ''"
|
|
886
|
+
>
|
|
887
|
+
<div class="w-44 shrink-0" :style="{ order: figure.stepOrder }">
|
|
888
|
+
<div class="flex h-12 flex-col items-center justify-center gap-0.5">
|
|
889
|
+
<span class="flex max-w-full items-center gap-1">
|
|
890
|
+
<span
|
|
891
|
+
class="truncate font-mono text-xs font-medium"
|
|
892
|
+
:class="row.isGoal ? 'text-highlighted' : 'text-toned'"
|
|
893
|
+
:title="row.step.value"
|
|
894
|
+
>
|
|
895
|
+
{{ row.step.value }}
|
|
896
|
+
</span>
|
|
897
|
+
<!-- A url step and a tracked page are matched on the same
|
|
898
|
+
key, so the step value addresses its heatmap with no
|
|
899
|
+
extra data. -->
|
|
900
|
+
<UButton
|
|
901
|
+
v-if="row.step.kind === 'url'"
|
|
902
|
+
icon="i-ph-cursor-click"
|
|
903
|
+
color="neutral"
|
|
904
|
+
variant="ghost"
|
|
905
|
+
size="xs"
|
|
906
|
+
class="shrink-0"
|
|
907
|
+
:to="pagesLink({
|
|
908
|
+
website: figure.websiteId,
|
|
909
|
+
path: row.step.value,
|
|
910
|
+
period,
|
|
911
|
+
})"
|
|
912
|
+
:title="t('page.marketing.funnels.see_heatmap')"
|
|
913
|
+
/>
|
|
914
|
+
</span>
|
|
915
|
+
<span class="text-[10px] text-dimmed">{{ row.kindLabel }}</span>
|
|
916
|
+
</div>
|
|
917
|
+
<!-- Between two steps: the loss in sessions when one column can
|
|
918
|
+
own it, a bare connector between mirrored arms. -->
|
|
919
|
+
<div
|
|
920
|
+
v-if="!row.isGoal"
|
|
921
|
+
class="flex h-8 items-center justify-center font-mono text-[10px] text-dimmed"
|
|
922
|
+
>
|
|
923
|
+
<span v-if="row.lostLabel">{{ row.lostLabel }}</span>
|
|
924
|
+
<span v-else class="h-full w-px bg-accented" aria-hidden="true" />
|
|
925
|
+
</div>
|
|
926
|
+
</div>
|
|
927
|
+
<div
|
|
928
|
+
v-for="cell in row.cells"
|
|
929
|
+
:key="cell.key"
|
|
930
|
+
class="flex min-w-0 flex-1 items-start gap-2"
|
|
931
|
+
:class="cell.reversed ? 'flex-row-reverse' : ''"
|
|
932
|
+
:style="{ order: cell.order }"
|
|
933
|
+
>
|
|
934
|
+
<span
|
|
935
|
+
class="flex h-12 w-12 shrink-0 items-center font-mono text-[11px] text-muted"
|
|
936
|
+
:class="cell.reversed ? 'justify-start' : 'justify-end'"
|
|
937
|
+
>
|
|
938
|
+
{{ cell.retained }}
|
|
939
|
+
</span>
|
|
940
|
+
<div class="min-w-0 flex-1">
|
|
941
|
+
<!-- The count rides inside the fill (min-w-fit keeps it
|
|
942
|
+
there), so the ink inverts with the bar instead of
|
|
943
|
+
drowning on the pane. -->
|
|
944
|
+
<div
|
|
945
|
+
class="relative z-10 mx-auto flex h-12 min-w-fit items-center justify-center rounded-md px-2 transition-all"
|
|
946
|
+
:class="ARM_CLASSES[cell.color].bar"
|
|
947
|
+
:style="{ width: `${cell.width}%` }"
|
|
948
|
+
>
|
|
949
|
+
<span class="font-mono text-base font-semibold text-inverted">
|
|
950
|
+
{{ cell.sessions }}
|
|
951
|
+
</span>
|
|
952
|
+
</div>
|
|
953
|
+
<div v-if="cell.taper" class="relative -my-1 h-10">
|
|
954
|
+
<div
|
|
955
|
+
class="absolute inset-0"
|
|
956
|
+
:class="ARM_CLASSES[cell.color].taper"
|
|
957
|
+
:style="{ clipPath: cell.taper }"
|
|
958
|
+
aria-hidden="true"
|
|
959
|
+
/>
|
|
960
|
+
<div
|
|
961
|
+
class="absolute inset-0 flex items-center justify-center whitespace-nowrap font-mono text-[10px] text-muted"
|
|
962
|
+
>
|
|
963
|
+
{{ cell.drop }}
|
|
964
|
+
</div>
|
|
965
|
+
</div>
|
|
966
|
+
</div>
|
|
967
|
+
</div>
|
|
968
|
+
</div>
|
|
969
|
+
|
|
970
|
+
<div
|
|
971
|
+
v-if="!figure.single"
|
|
972
|
+
class="mt-4 flex items-center gap-2.5 border-t border-default pt-3 font-mono text-[11px]"
|
|
973
|
+
>
|
|
974
|
+
<div class="w-44 shrink-0" :style="{ order: figure.stepOrder }" />
|
|
975
|
+
<div
|
|
976
|
+
v-for="arm in figure.arms"
|
|
977
|
+
:key="arm.key"
|
|
978
|
+
class="flex min-w-0 flex-1 items-center gap-2"
|
|
979
|
+
:class="arm.reversed ? 'flex-row-reverse' : ''"
|
|
980
|
+
:style="{ order: arm.order }"
|
|
981
|
+
>
|
|
982
|
+
<span class="w-12 shrink-0" aria-hidden="true" />
|
|
983
|
+
<span
|
|
984
|
+
class="min-w-0 flex-1 text-center leading-tight"
|
|
985
|
+
:class="ARM_CLASSES[arm.color].text"
|
|
986
|
+
>
|
|
987
|
+
{{ t('page.marketing.funnels.results.end_to_end', { value: arm.endToEnd }) }}
|
|
988
|
+
</span>
|
|
989
|
+
</div>
|
|
990
|
+
</div>
|
|
991
|
+
|
|
992
|
+
<!-- Past one challenger the banner judges only the best arm. -->
|
|
993
|
+
<div
|
|
994
|
+
v-if="challengers.length > 1"
|
|
995
|
+
class="mt-4 flex flex-col gap-2 border-t border-default pt-3"
|
|
996
|
+
>
|
|
997
|
+
<div
|
|
998
|
+
v-for="challenger in challengers"
|
|
999
|
+
:key="challenger.key"
|
|
1000
|
+
class="flex items-center gap-3"
|
|
1001
|
+
>
|
|
1002
|
+
<span
|
|
1003
|
+
class="w-28 shrink-0 truncate font-mono text-xs text-success"
|
|
1004
|
+
:title="challenger.key"
|
|
1005
|
+
>
|
|
1006
|
+
{{ challenger.label }}
|
|
1007
|
+
</span>
|
|
1008
|
+
<span
|
|
1009
|
+
v-if="challenger.lift"
|
|
1010
|
+
class="w-16 shrink-0 text-xs font-medium"
|
|
1011
|
+
:class="challenger.liftPositive ? 'text-success' : 'text-error'"
|
|
1012
|
+
>
|
|
1013
|
+
{{ challenger.lift }}
|
|
1014
|
+
</span>
|
|
1015
|
+
<UBadge
|
|
1016
|
+
:label="challenger.verdict.label"
|
|
1017
|
+
:color="challenger.verdict.color"
|
|
1018
|
+
variant="subtle"
|
|
1019
|
+
size="sm"
|
|
1020
|
+
/>
|
|
1021
|
+
</div>
|
|
1022
|
+
</div>
|
|
1023
|
+
</div>
|
|
1024
|
+
</template>
|
|
1025
|
+
</div>
|
|
1026
|
+
</template>
|