@antelopejs/dms-marketing 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -15
- package/dist/config.d.ts +1 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/data-api/funnels.d.ts +2 -0
- package/dist/data-api/funnels.js +8 -1
- package/dist/data-api/funnels.js.map +1 -1
- package/dist/db/models/funnels.model.d.ts +2 -0
- package/dist/db/models/funnels.model.js +9 -0
- package/dist/db/models/funnels.model.js.map +1 -1
- package/dist/db/models/index.d.ts +1 -0
- package/dist/db/models/index.js +1 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/marketing_events.model.d.ts +22 -0
- package/dist/db/models/marketing_events.model.js +70 -5
- package/dist/db/models/marketing_events.model.js.map +1 -1
- package/dist/db/models/marketing_preferences.model.d.ts +25 -0
- package/dist/db/models/marketing_preferences.model.js +29 -0
- package/dist/db/models/marketing_preferences.model.js.map +1 -0
- package/dist/db/models/marketing_sessions.model.d.ts +2 -1
- package/dist/db/models/marketing_sessions.model.js +11 -1
- package/dist/db/models/marketing_sessions.model.js.map +1 -1
- package/dist/db/models/page_snapshots.model.d.ts +2 -1
- package/dist/db/models/page_snapshots.model.js +7 -7
- package/dist/db/models/page_snapshots.model.js.map +1 -1
- package/dist/db/models/website_statistics.model.d.ts +4 -1
- package/dist/db/models/website_statistics.model.js +15 -1
- package/dist/db/models/website_statistics.model.js.map +1 -1
- package/dist/db/tables/index.d.ts +1 -0
- package/dist/db/tables/index.js +1 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/marketing_preferences.table.d.ts +6 -0
- package/dist/db/tables/marketing_preferences.table.js +31 -0
- package/dist/db/tables/marketing_preferences.table.js.map +1 -0
- package/dist/hooks/index.js +8 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.js +18 -2
- package/dist/index.js.map +1 -1
- package/dist/pages/acquisition/page.d.ts +9 -0
- package/dist/pages/acquisition/page.js +181 -0
- package/dist/pages/acquisition/page.js.map +1 -0
- package/dist/pages/all.d.ts +5 -1
- package/dist/pages/all.js +5 -1
- package/dist/pages/all.js.map +1 -1
- package/dist/pages/blocks.d.ts +18 -0
- package/dist/pages/blocks.js +56 -0
- package/dist/pages/blocks.js.map +1 -0
- package/dist/pages/funnel/page.d.ts +9 -0
- package/dist/pages/funnel/page.js +36 -0
- package/dist/pages/funnel/page.js.map +1 -0
- package/dist/pages/funnel-builder/page.d.ts +9 -0
- package/dist/pages/funnel-builder/page.js +29 -0
- package/dist/pages/funnel-builder/page.js.map +1 -0
- package/dist/pages/funnels/page.d.ts +1 -1
- package/dist/pages/funnels/page.js +200 -3
- package/dist/pages/funnels/page.js.map +1 -1
- package/dist/pages/index.d.ts +1 -0
- package/dist/pages/index.js +1 -0
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/{campaigns → install}/page.d.ts +3 -3
- package/dist/pages/{campaigns → install}/page.js +14 -12
- package/dist/pages/install/page.js.map +1 -0
- package/dist/pages/module.d.ts +3 -0
- package/dist/pages/module.js +30 -3
- package/dist/pages/module.js.map +1 -1
- package/dist/pages/overview/page.d.ts +1 -1
- package/dist/pages/overview/page.js +122 -3
- package/dist/pages/overview/page.js.map +1 -1
- package/dist/pages/pages/page.d.ts +1 -1
- package/dist/pages/pages/page.js +9 -4
- package/dist/pages/pages/page.js.map +1 -1
- package/dist/pages/quick-actions.d.ts +1 -0
- package/dist/pages/quick-actions.js +25 -0
- package/dist/pages/quick-actions.js.map +1 -0
- package/dist/pages/settings/form.d.ts +2 -1
- package/dist/pages/settings/form.js +62 -20
- package/dist/pages/settings/form.js.map +1 -1
- package/dist/pages/settings/page.d.ts +4 -1
- package/dist/pages/settings/page.js +50 -4
- package/dist/pages/settings/page.js.map +1 -1
- package/dist/pages/websites/page.d.ts +11 -0
- package/dist/pages/websites/page.js +62 -0
- package/dist/pages/websites/page.js.map +1 -0
- package/dist/routes/blocks.d.ts +50 -0
- package/dist/routes/blocks.js +269 -0
- package/dist/routes/blocks.js.map +1 -0
- package/dist/routes/collect.d.ts +1 -2
- package/dist/routes/collect.js +19 -12
- package/dist/routes/collect.js.map +1 -1
- package/dist/routes/context.d.ts +8 -0
- package/dist/routes/context.js +54 -0
- package/dist/routes/context.js.map +1 -0
- package/dist/routes/experiments.js +2 -1
- package/dist/routes/experiments.js.map +1 -1
- package/dist/routes/funnels.d.ts +21 -3
- package/dist/routes/funnels.js +150 -7
- package/dist/routes/funnels.js.map +1 -1
- package/dist/routes/index.d.ts +2 -0
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/settings.d.ts +6 -1
- package/dist/routes/settings.js +65 -4
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/snapshot.d.ts +1 -1
- package/dist/routes/snapshot.js +24 -25
- package/dist/routes/snapshot.js.map +1 -1
- package/dist/routes/stats.d.ts +14 -23
- package/dist/routes/stats.js +73 -97
- package/dist/routes/stats.js.map +1 -1
- package/dist/routes/tracker.js +6 -4
- package/dist/routes/tracker.js.map +1 -1
- package/dist/routes/websites.d.ts +4 -0
- package/dist/routes/websites.js +111 -12
- package/dist/routes/websites.js.map +1 -1
- package/dist/services/acquisition-report.d.ts +62 -0
- package/dist/services/acquisition-report.js +181 -0
- package/dist/services/acquisition-report.js.map +1 -0
- package/dist/services/acquisition.d.ts +9 -3
- package/dist/services/acquisition.js +35 -5
- package/dist/services/acquisition.js.map +1 -1
- package/dist/services/catalog.d.ts +3 -0
- package/dist/services/catalog.js +17 -0
- package/dist/services/catalog.js.map +1 -0
- package/dist/services/collect.d.ts +1 -2
- package/dist/services/collect.js +49 -24
- package/dist/services/collect.js.map +1 -1
- package/dist/services/composed.d.ts +9 -0
- package/dist/services/composed.js +62 -0
- package/dist/services/composed.js.map +1 -0
- package/dist/services/context.d.ts +20 -0
- package/dist/services/context.js +76 -0
- package/dist/services/context.js.map +1 -0
- package/dist/services/dashboard.d.ts +74 -0
- package/dist/services/dashboard.js +343 -0
- package/dist/services/dashboard.js.map +1 -0
- package/dist/services/experiments/results.d.ts +12 -1
- package/dist/services/experiments/results.js +19 -2
- package/dist/services/experiments/results.js.map +1 -1
- package/dist/services/experiments/stats.d.ts +7 -1
- package/dist/services/experiments/stats.js +43 -5
- package/dist/services/experiments/stats.js.map +1 -1
- package/dist/services/funnel-report.d.ts +64 -0
- package/dist/services/funnel-report.js +280 -0
- package/dist/services/funnel-report.js.map +1 -0
- package/dist/services/funnels/compute.d.ts +2 -0
- package/dist/services/funnels/compute.js +25 -13
- package/dist/services/funnels/compute.js.map +1 -1
- package/dist/services/funnels/results.js +4 -1
- package/dist/services/funnels/results.js.map +1 -1
- package/dist/services/funnels/step-path.d.ts +4 -0
- package/dist/services/funnels/step-path.js +47 -0
- package/dist/services/funnels/step-path.js.map +1 -0
- package/dist/services/funnels-table.d.ts +31 -0
- package/dist/services/funnels-table.js +230 -0
- package/dist/services/funnels-table.js.map +1 -0
- package/dist/services/heatmap.d.ts +15 -3
- package/dist/services/heatmap.js +54 -11
- package/dist/services/heatmap.js.map +1 -1
- package/dist/services/hourly-traffic.d.ts +4 -0
- package/dist/services/hourly-traffic.js +35 -0
- package/dist/services/hourly-traffic.js.map +1 -0
- package/dist/services/origin-guard.d.ts +9 -1
- package/dist/services/origin-guard.js +46 -1
- package/dist/services/origin-guard.js.map +1 -1
- package/dist/services/period.d.ts +26 -1
- package/dist/services/period.js +99 -9
- package/dist/services/period.js.map +1 -1
- package/dist/services/prune.d.ts +6 -0
- package/dist/services/prune.js +35 -0
- package/dist/services/prune.js.map +1 -1
- package/dist/services/request-body.d.ts +1 -2
- package/dist/services/request-body.js +10 -36
- package/dist/services/request-body.js.map +1 -1
- package/dist/services/request-meta.d.ts +0 -1
- package/dist/services/request-meta.js +0 -11
- package/dist/services/request-meta.js.map +1 -1
- package/dist/services/rollups.d.ts +25 -0
- package/dist/services/rollups.js +102 -0
- package/dist/services/rollups.js.map +1 -0
- package/dist/services/sessionize.d.ts +5 -1
- package/dist/services/sessionize.js +52 -10
- package/dist/services/sessionize.js.map +1 -1
- package/dist/services/settings.d.ts +41 -2
- package/dist/services/settings.js +219 -6
- package/dist/services/settings.js.map +1 -1
- package/dist/services/snapshots.d.ts +3 -2
- package/dist/services/snapshots.js +110 -9
- package/dist/services/snapshots.js.map +1 -1
- package/dist/services/tenant-website.js +1 -0
- package/dist/services/tenant-website.js.map +1 -1
- package/dist/services/url.js +12 -4
- package/dist/services/url.js.map +1 -1
- package/dist/services/website-hosts.d.ts +15 -0
- package/dist/services/website-hosts.js +67 -0
- package/dist/services/website-hosts.js.map +1 -0
- package/dist/services/website-report.d.ts +40 -0
- package/dist/services/website-report.js +84 -0
- package/dist/services/website-report.js.map +1 -0
- package/dist/types/constants.d.ts +14 -2
- package/dist/types/constants.js +17 -6
- package/dist/types/constants.js.map +1 -1
- package/dist/types/events.d.ts +221 -68
- package/dist/types/events.js +60 -11
- package/dist/types/events.js.map +1 -1
- package/dist/types/snapshots.d.ts +6 -0
- package/dist/types/snapshots.js +2 -0
- package/dist/types/snapshots.js.map +1 -1
- package/dist/types/statistics.d.ts +2 -1
- package/dist/types/statistics.js +9 -2
- package/dist/types/statistics.js.map +1 -1
- package/dist/utils/experiment-type.js +1 -1
- package/dist/utils/experiment-type.js.map +1 -1
- package/dist/utils/funnel-steps-type.d.ts +21 -3
- package/dist/utils/funnel-steps-type.js +7 -2
- package/dist/utils/funnel-steps-type.js.map +1 -1
- package/frontend-vue/app/components/ChannelsCard.vue +265 -0
- package/frontend-vue/app/components/Context.vue +479 -0
- package/frontend-vue/app/components/ExperimentInput.vue +3 -1
- package/frontend-vue/app/components/ExperimentReport.vue +736 -0
- package/frontend-vue/app/components/FirstRun.vue +60 -0
- package/frontend-vue/app/components/FunnelBuilder.vue +1595 -0
- package/frontend-vue/app/components/FunnelFigure.vue +640 -0
- package/frontend-vue/app/components/FunnelReport.vue +532 -0
- package/frontend-vue/app/components/FunnelStepsInput.vue +3 -1
- package/frontend-vue/app/components/FunnelTemplates.vue +161 -0
- package/frontend-vue/app/components/HeatmapCanvas.vue +11 -4
- package/frontend-vue/app/components/InstallGuide.vue +753 -0
- package/frontend-vue/app/components/PagePreview.vue +889 -416
- package/frontend-vue/app/components/PagesExplorer.vue +552 -0
- package/frontend-vue/app/components/ScrollDepthOverlay.vue +48 -20
- package/frontend-vue/app/components/TopListTabs.vue +317 -0
- package/frontend-vue/app/components/TrackerSnippet.vue +50 -16
- package/frontend-vue/app/components/VariationsInput.vue +15 -5
- package/frontend-vue/app/components/WebsitesGrid.vue +799 -0
- package/frontend-vue/app/composables/useContextLink.ts +16 -0
- package/frontend-vue/app/composables/useExperiment.ts +1 -2
- package/frontend-vue/app/composables/useFunnelSteps.ts +1 -2
- package/frontend-vue/app/composables/useLatestRequest.ts +2 -4
- package/frontend-vue/app/composables/useMarketingApi.ts +298 -133
- package/frontend-vue/app/composables/useMarketingContext.ts +55 -0
- package/frontend-vue/app/composables/useMarketingRoutes.ts +80 -27
- package/frontend-vue/app/composables/useSnapshotFrame.ts +26 -4
- package/frontend-vue/app/constants.ts +22 -0
- package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +2 -1
- package/frontend-vue/app/utils/channels.ts +37 -0
- package/frontend-vue/app/utils/format.ts +132 -0
- package/frontend-vue/app/utils/funnel-draft.ts +264 -0
- package/frontend-vue/app/utils/heatmap.ts +82 -0
- package/frontend-vue/app/utils/heatmapPalette.ts +10 -0
- package/frontend-vue/app/utils/host-conflict.ts +48 -0
- package/frontend-vue/app/utils/sample-size.ts +17 -0
- package/frontend-vue/app/utils/snapshotViewportUnits.ts +15 -5
- package/frontend-vue/app/utils/snippet.ts +53 -0
- package/frontend-vue/app/utils/step-path.ts +32 -0
- package/frontend-vue/dms.frontend.ts +4 -3
- package/frontend-vue/i18n/locales/marketing-en-GB.json +1083 -316
- package/frontend-vue/i18n/locales/marketing-fr-FR.json +1087 -320
- package/frontend-vue/package.json +3 -0
- package/frontend-vue/tests/drafts.test.ts +3 -3
- package/frontend-vue/tests/format.test.ts +73 -0
- package/frontend-vue/tests/funnel-draft.test.ts +194 -0
- package/frontend-vue/tests/heatmap.test.ts +32 -0
- package/frontend-vue/tests/i18n.test.ts +87 -4
- package/frontend-vue/tests/latest-request.test.ts +6 -3
- package/frontend-vue/tests/locale-typography.test.ts +115 -0
- package/frontend-vue/tests/registration.test.ts +21 -11
- package/frontend-vue/tests/routes.test.ts +67 -7
- package/frontend-vue/tests/server-parity.test.ts +77 -0
- package/frontend-vue/tests/stubs/frontend-module.ts +18 -3
- package/package.json +9 -7
- package/static/snapshot.js +46 -16
- package/static/tracker.js +139 -48
- package/dist/pages/campaigns/page.js.map +0 -1
- package/frontend-vue/app/components/CampaignsView.vue +0 -330
- package/frontend-vue/app/components/FunnelDetail.vue +0 -1026
- package/frontend-vue/app/components/FunnelsView.vue +0 -493
- package/frontend-vue/app/components/NoWebsiteState.vue +0 -18
- package/frontend-vue/app/components/OverviewView.vue +0 -493
- package/frontend-vue/app/components/PagesView.vue +0 -391
- package/frontend-vue/app/components/TextKpiCard.vue +0 -28
- package/frontend-vue/app/components/TopListTabsCard.vue +0 -162
- package/frontend-vue/app/composables/useChildId.ts +0 -13
- package/frontend-vue/app/composables/useMarketingPeriod.ts +0 -65
- package/frontend-vue/app/composables/useMarketingWebsiteForm.ts +0 -69
- package/frontend-vue/app/composables/useMarketingWebsites.ts +0 -80
- package/frontend-vue/app/utils/dmsComponent.ts +0 -18
- package/frontend-vue/tests/period.test.ts +0 -35
|
@@ -0,0 +1,1595 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
|
|
4
|
+
import { parseExperimentValue } from '../composables/useExperiment'
|
|
5
|
+
import { parseFunnelStepsValue } from '../composables/useFunnelSteps'
|
|
6
|
+
import { useLatestRequest } from '../composables/useLatestRequest'
|
|
7
|
+
import {
|
|
8
|
+
useMarketingApi,
|
|
9
|
+
type MarketingExperiment,
|
|
10
|
+
type MarketingFunnelPreview,
|
|
11
|
+
type MarketingFunnelStep,
|
|
12
|
+
type MarketingFunnelSuggestions,
|
|
13
|
+
} from '../composables/useMarketingApi'
|
|
14
|
+
import type { ContextWebsite } from '../composables/useMarketingContext'
|
|
15
|
+
import {
|
|
16
|
+
funnelBuilderLink,
|
|
17
|
+
funnelLink,
|
|
18
|
+
funnelsLink,
|
|
19
|
+
} from '../composables/useMarketingRoutes'
|
|
20
|
+
import { useContextLink } from '../composables/useContextLink'
|
|
21
|
+
import {
|
|
22
|
+
draftIssues,
|
|
23
|
+
isWindowHours,
|
|
24
|
+
MAX_EXPERIMENT_KEY_LENGTH,
|
|
25
|
+
MAX_NAME_LENGTH,
|
|
26
|
+
MAX_STEP_VALUE_LENGTH,
|
|
27
|
+
MAX_STEPS,
|
|
28
|
+
MAX_VARIATIONS,
|
|
29
|
+
MAX_WINDOW_HOURS,
|
|
30
|
+
MIN_VARIATIONS,
|
|
31
|
+
splitSampleSize,
|
|
32
|
+
templateSteps,
|
|
33
|
+
localizedSnippetComments,
|
|
34
|
+
variationSnippet,
|
|
35
|
+
type DraftField,
|
|
36
|
+
type FunnelDraftValues,
|
|
37
|
+
type StepValue,
|
|
38
|
+
} from '../utils/funnel-draft'
|
|
39
|
+
import { formatNumber } from '../utils/format'
|
|
40
|
+
import { normalizeFunnelStepPath } from '../utils/step-path'
|
|
41
|
+
import { fractionDigits } from '../constants'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The funnel builder (`?id=` to edit, `?from=` to start from a copy of a
|
|
45
|
+
* funnel, `?split=1` to open the A/B section, `?steps=` to start from a
|
|
46
|
+
* template). Steps are picked from the pages and events the site already
|
|
47
|
+
* tracks, with their counts; the preview scores the draft over the last 30
|
|
48
|
+
* days while it is edited. Follows the DMS form contract — unsaved changes
|
|
49
|
+
* against a baseline, a guard before leaving, errors under their field — and
|
|
50
|
+
* writes through the funnels data API, which owns the experiment lifecycle.
|
|
51
|
+
*/
|
|
52
|
+
const props = withDefaults(
|
|
53
|
+
defineProps<{ componentId?: string; pageId?: string }>(),
|
|
54
|
+
{
|
|
55
|
+
componentId: undefined,
|
|
56
|
+
pageId: undefined,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
interface StepDraft extends StepValue {
|
|
61
|
+
uid: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface VariationDraft {
|
|
65
|
+
uid: number
|
|
66
|
+
key: string
|
|
67
|
+
weight: number | string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A funnel is matched within one session, which ends after 30 minutes
|
|
71
|
+
// without activity: a window of days would change nothing (FB-11).
|
|
72
|
+
const WINDOW_PRESETS = [1, 24]
|
|
73
|
+
const PREVIEW_DEBOUNCE_MS = 450
|
|
74
|
+
const SUGGESTIONS_SHOWN = 6
|
|
75
|
+
const MINIMUM_DETECTABLE_LIFT = 0.2
|
|
76
|
+
const LETTER_A = 65
|
|
77
|
+
const PERCENT = 100
|
|
78
|
+
const MS_PER_HOUR = 3_600_000
|
|
79
|
+
const HTTP_NOT_FOUND = 404
|
|
80
|
+
/** Below this, months read "under 0.1". */
|
|
81
|
+
const MIN_MONTHS_SHOWN = 0.1
|
|
82
|
+
|
|
83
|
+
void props
|
|
84
|
+
|
|
85
|
+
const { t, locale } = useI18n()
|
|
86
|
+
const route = useDmsRoute()
|
|
87
|
+
const router = useDmsRouter()
|
|
88
|
+
const api = useMarketingApi()
|
|
89
|
+
const toast = useToast()
|
|
90
|
+
const { confirm } = useConfirm()
|
|
91
|
+
|
|
92
|
+
function queryParam(key: string): string {
|
|
93
|
+
const value = route.query[key]
|
|
94
|
+
return typeof value === 'string' ? value : ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const funnelId = queryParam('id')
|
|
98
|
+
const sourceId = funnelId ? '' : queryParam('copy')
|
|
99
|
+
const isEdit = funnelId !== ''
|
|
100
|
+
|
|
101
|
+
let nextUid = 1
|
|
102
|
+
const uid = () => nextUid++
|
|
103
|
+
|
|
104
|
+
const name = ref('')
|
|
105
|
+
const websiteId = ref<string>('')
|
|
106
|
+
const steps = ref<StepDraft[]>([{ uid: uid(), kind: 'url', value: '' }])
|
|
107
|
+
const windowHours = ref<number | string>(24)
|
|
108
|
+
const splitEnabled = ref(route.query.split === '1')
|
|
109
|
+
const experimentKey = ref('')
|
|
110
|
+
const variations = ref<VariationDraft[]>([
|
|
111
|
+
{ uid: uid(), key: 'control', weight: 1 },
|
|
112
|
+
{ uid: uid(), key: '', weight: 1 },
|
|
113
|
+
])
|
|
114
|
+
const original = ref<MarketingExperiment | null>(null)
|
|
115
|
+
/** The goal a started test was saved with, to warn before it changes. */
|
|
116
|
+
const savedObjective = ref('')
|
|
117
|
+
const loading = ref(true)
|
|
118
|
+
const loadFailed = ref<'not_found' | 'error' | null>(null)
|
|
119
|
+
const saving = ref(false)
|
|
120
|
+
|
|
121
|
+
/** A started split keeps its key and arms: changing them would reassign visitors. */
|
|
122
|
+
const frozen = computed(
|
|
123
|
+
() => original.value !== null && original.value.status !== 'draft',
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// --- Form values, baseline and leave guard -------------------------------------
|
|
127
|
+
|
|
128
|
+
function values(): FunnelDraftValues {
|
|
129
|
+
return {
|
|
130
|
+
name: name.value,
|
|
131
|
+
websiteId: websiteId.value,
|
|
132
|
+
steps: steps.value.map(({ kind, value }) => ({ kind, value })),
|
|
133
|
+
windowHours: windowHours.value,
|
|
134
|
+
split: splitEnabled.value,
|
|
135
|
+
experimentKey: experimentKey.value,
|
|
136
|
+
variations: variations.value.map(({ key, weight }) => ({ key, weight })),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function applyValues(next: FunnelDraftValues): void {
|
|
141
|
+
name.value = next.name
|
|
142
|
+
websiteId.value = next.websiteId
|
|
143
|
+
steps.value = next.steps.map((step) => ({ uid: uid(), ...step }))
|
|
144
|
+
windowHours.value = next.windowHours
|
|
145
|
+
splitEnabled.value = next.split
|
|
146
|
+
experimentKey.value = next.experimentKey
|
|
147
|
+
variations.value = next.variations.map((arm) => ({ uid: uid(), ...arm }))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { dirty, baseline, markClean } = useFormDirty(values)
|
|
151
|
+
const { confirmLeave } = useUnsavedChanges({ dirty })
|
|
152
|
+
|
|
153
|
+
// Errors land under their field: the client's checks on a save attempt, and
|
|
154
|
+
// the server's — its codes name the field they belong to.
|
|
155
|
+
const STEP_CONTROL_PREFIX = 'funnel-step-'
|
|
156
|
+
const VARIATION_CONTROL_PREFIX = 'funnel-variation-'
|
|
157
|
+
const flaggedStep = ref<number | null>(null)
|
|
158
|
+
const flaggedVariation = ref<number | null>(null)
|
|
159
|
+
|
|
160
|
+
/** The flagged row's input, else the first row's (a server error names no row). */
|
|
161
|
+
function rowControl(prefix: string, flagged: number | null, first?: number) {
|
|
162
|
+
return document.getElementById(`${prefix}${flagged ?? first}`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const fieldErrors = useFieldErrors<DraftField>({
|
|
166
|
+
fields: {
|
|
167
|
+
name: 'funnel-name',
|
|
168
|
+
websiteId: 'funnel-website',
|
|
169
|
+
steps: () =>
|
|
170
|
+
rowControl(STEP_CONTROL_PREFIX, flaggedStep.value, steps.value[0]?.uid),
|
|
171
|
+
conversionWindowHours: 'funnel-window',
|
|
172
|
+
experiment: 'funnel-experiment-key',
|
|
173
|
+
variations: () =>
|
|
174
|
+
rowControl(
|
|
175
|
+
VARIATION_CONTROL_PREFIX,
|
|
176
|
+
flaggedVariation.value,
|
|
177
|
+
variations.value[0]?.uid,
|
|
178
|
+
),
|
|
179
|
+
},
|
|
180
|
+
codes: {
|
|
181
|
+
'$page.marketing.errors.invalid_steps': 'steps',
|
|
182
|
+
'$page.marketing.errors.invalid_experiment_key': 'experiment',
|
|
183
|
+
'$page.marketing.errors.duplicate_experiment_key': 'experiment',
|
|
184
|
+
'$page.marketing.errors.experiment_locked': 'experiment',
|
|
185
|
+
'$page.marketing.errors.invalid_variations': 'variations',
|
|
186
|
+
'$page.marketing.errors.website_not_found': 'websiteId',
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
function clearStepError(): void {
|
|
191
|
+
flaggedStep.value = null
|
|
192
|
+
fieldErrors.clear('steps')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function clearVariationError(): void {
|
|
196
|
+
flaggedVariation.value = null
|
|
197
|
+
fieldErrors.clear('variations')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- Loading -------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/** A stored definition as the form holds it. */
|
|
203
|
+
async function fetchDefinition(id: string) {
|
|
204
|
+
const row = await api.getFunnel(id)
|
|
205
|
+
const experiment = parseExperimentValue(row.experiment)
|
|
206
|
+
const draft: FunnelDraftValues = {
|
|
207
|
+
name: row.name,
|
|
208
|
+
websiteId:
|
|
209
|
+
typeof row.websiteId === 'string' ? row.websiteId : row.websiteId._id,
|
|
210
|
+
steps: parseFunnelStepsValue(row.steps),
|
|
211
|
+
windowHours: row.conversionWindowHours,
|
|
212
|
+
split: experiment !== null,
|
|
213
|
+
experimentKey: experiment?.key ?? '',
|
|
214
|
+
variations: experiment?.variations ?? values().variations,
|
|
215
|
+
}
|
|
216
|
+
const stored: MarketingExperiment | null = experiment && {
|
|
217
|
+
key: experiment.key,
|
|
218
|
+
status: experiment.status ?? 'draft',
|
|
219
|
+
variations: experiment.variations,
|
|
220
|
+
runs: experiment.runs,
|
|
221
|
+
}
|
|
222
|
+
return { draft, experiment: stored }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function objectiveOf(draft: FunnelDraftValues): string {
|
|
226
|
+
const kept = draft.steps
|
|
227
|
+
.map((step) => ({ kind: step.kind, value: step.value.trim() }))
|
|
228
|
+
.filter((step) => step.value !== '')
|
|
229
|
+
return JSON.stringify([kept, draft.windowHours])
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function loadExisting(): Promise<void> {
|
|
233
|
+
const { draft, experiment } = await fetchDefinition(funnelId)
|
|
234
|
+
applyValues(draft)
|
|
235
|
+
original.value = experiment
|
|
236
|
+
savedObjective.value = objectiveOf(draft)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The copy a Duplicate opened on, what Discard returns to. */
|
|
240
|
+
const copied = ref<FunnelDraftValues | null>(null)
|
|
241
|
+
|
|
242
|
+
/** Duplicate: the copy is a new draft, unsaved until the user saves it — its
|
|
243
|
+
* experiment key must change, keys being unique per site. */
|
|
244
|
+
async function loadCopy(): Promise<void> {
|
|
245
|
+
const { draft } = await fetchDefinition(sourceId)
|
|
246
|
+
applyValues({
|
|
247
|
+
...draft,
|
|
248
|
+
name: t('page.marketing.builder.copy_name', { name: draft.name }).slice(
|
|
249
|
+
0,
|
|
250
|
+
MAX_NAME_LENGTH,
|
|
251
|
+
),
|
|
252
|
+
experimentKey: '',
|
|
253
|
+
})
|
|
254
|
+
copied.value = values()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The builder sits outside the context bar: it reads the website list itself,
|
|
258
|
+
// and starts a new funnel on the website of the page it was opened from, else
|
|
259
|
+
// on the member's selection.
|
|
260
|
+
const websites = ref<ContextWebsite[]>([])
|
|
261
|
+
|
|
262
|
+
async function loadWebsites(): Promise<void> {
|
|
263
|
+
const payload = await api.getContext()
|
|
264
|
+
websites.value = payload.websites
|
|
265
|
+
const linked = queryParam('website')
|
|
266
|
+
websiteId.value ||=
|
|
267
|
+
(payload.websites.some((site) => site.id === linked) ? linked : null) ??
|
|
268
|
+
payload.selectedId ??
|
|
269
|
+
''
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function load(): Promise<void> {
|
|
273
|
+
loading.value = true
|
|
274
|
+
loadFailed.value = null
|
|
275
|
+
try {
|
|
276
|
+
// The funnel first: its site then wins over the context's, and the
|
|
277
|
+
// suggestions are read once, for the right site.
|
|
278
|
+
if (isEdit) {
|
|
279
|
+
await loadExisting()
|
|
280
|
+
}
|
|
281
|
+
await loadWebsites().catch(() => undefined)
|
|
282
|
+
const template = templateSteps(route.query.steps)
|
|
283
|
+
if (!isEdit && template.length > 0) {
|
|
284
|
+
steps.value = template.map((step) => ({ uid: uid(), ...step }))
|
|
285
|
+
}
|
|
286
|
+
// The starting point: what the page opened on. A copy is applied after
|
|
287
|
+
// it, so it counts as a change to save.
|
|
288
|
+
markClean()
|
|
289
|
+
if (sourceId) {
|
|
290
|
+
await loadCopy()
|
|
291
|
+
}
|
|
292
|
+
} catch (error) {
|
|
293
|
+
loadFailed.value =
|
|
294
|
+
apiErrorStatus(error) === HTTP_NOT_FOUND ? 'not_found' : 'error'
|
|
295
|
+
} finally {
|
|
296
|
+
loading.value = false
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
onMounted(load)
|
|
301
|
+
|
|
302
|
+
// --- Suggestions and preview -------------------------------------------------
|
|
303
|
+
|
|
304
|
+
const suggestions = ref<MarketingFunnelSuggestions>({ pages: [], events: [] })
|
|
305
|
+
|
|
306
|
+
async function loadSuggestions(): Promise<void> {
|
|
307
|
+
if (!websiteId.value) {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
suggestions.value = await api.getFunnelSuggestions(websiteId.value)
|
|
312
|
+
} catch {
|
|
313
|
+
suggestions.value = { pages: [], events: [] }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Fires once the site is known (the context, or the funnel being edited).
|
|
318
|
+
watch(websiteId, () => void loadSuggestions())
|
|
319
|
+
|
|
320
|
+
const focusedStep = ref<number | null>(null)
|
|
321
|
+
/** The step whose list Escape closed, until it is typed in again. */
|
|
322
|
+
const dismissedStep = ref<number | null>(null)
|
|
323
|
+
const activeOption = ref(-1)
|
|
324
|
+
|
|
325
|
+
function matchesFor(step: StepDraft) {
|
|
326
|
+
const pool =
|
|
327
|
+
step.kind === 'url' ? suggestions.value.pages : suggestions.value.events
|
|
328
|
+
const needle = step.value.trim().toLowerCase()
|
|
329
|
+
return pool
|
|
330
|
+
.filter((entry) => !needle || entry.value.toLowerCase().includes(needle))
|
|
331
|
+
.filter((entry) => entry.value !== step.value)
|
|
332
|
+
.slice(0, SUGGESTIONS_SHOWN)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function listOpen(step: StepDraft): boolean {
|
|
336
|
+
return (
|
|
337
|
+
focusedStep.value === step.uid &&
|
|
338
|
+
dismissedStep.value !== step.uid &&
|
|
339
|
+
matchesFor(step).length > 0
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function focusStep(step: StepDraft): void {
|
|
344
|
+
focusedStep.value = step.uid
|
|
345
|
+
activeOption.value = -1
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const BLUR_DELAY_MS = 150
|
|
349
|
+
|
|
350
|
+
/** Late enough for a click on a suggestion to land first. */
|
|
351
|
+
function blurStep(step: StepDraft): void {
|
|
352
|
+
setTimeout(() => {
|
|
353
|
+
if (focusedStep.value === step.uid) {
|
|
354
|
+
focusedStep.value = null
|
|
355
|
+
}
|
|
356
|
+
}, BLUR_DELAY_MS)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function typeStep(): void {
|
|
360
|
+
dismissedStep.value = null
|
|
361
|
+
activeOption.value = -1
|
|
362
|
+
clearStepError()
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function pick(step: StepDraft, value: string): void {
|
|
366
|
+
step.value = value
|
|
367
|
+
dismissedStep.value = step.uid
|
|
368
|
+
activeOption.value = -1
|
|
369
|
+
clearStepError()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The combobox keys: arrows move through the list, Enter picks, Escape closes. */
|
|
373
|
+
function onStepKeydown(event: KeyboardEvent, step: StepDraft): void {
|
|
374
|
+
if (!listOpen(step)) {
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
const count = matchesFor(step).length
|
|
378
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
379
|
+
event.preventDefault()
|
|
380
|
+
const offset = event.key === 'ArrowDown' ? 1 : -1
|
|
381
|
+
activeOption.value = (activeOption.value + offset + count) % count
|
|
382
|
+
} else if (event.key === 'Enter' && activeOption.value >= 0) {
|
|
383
|
+
event.preventDefault()
|
|
384
|
+
pick(step, matchesFor(step)[activeOption.value]!.value)
|
|
385
|
+
} else if (event.key === 'Escape') {
|
|
386
|
+
event.preventDefault()
|
|
387
|
+
dismissedStep.value = step.uid
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function optionId(step: StepDraft, index: number): string {
|
|
392
|
+
return `${STEP_CONTROL_PREFIX}${step.uid}-option-${index}`
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const validSteps = computed(() =>
|
|
396
|
+
steps.value
|
|
397
|
+
.map((step) => ({ kind: step.kind, value: step.value.trim() }))
|
|
398
|
+
.filter((step) => step.value !== ''),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
/** Where each filled step sits in the preview, which skips the empty ones —
|
|
402
|
+
* by position, so a step repeated later reads its own count. */
|
|
403
|
+
const previewIndex = computed(() => {
|
|
404
|
+
const index = new Map<number, number>()
|
|
405
|
+
for (const step of steps.value) {
|
|
406
|
+
if (step.value.trim() !== '') {
|
|
407
|
+
index.set(step.uid, index.size)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return index
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
/** The preview scores what the server would accept, nothing else. */
|
|
414
|
+
const previewable = computed(
|
|
415
|
+
() =>
|
|
416
|
+
isWindowHours(windowHours.value) &&
|
|
417
|
+
validSteps.value.every(
|
|
418
|
+
(step) => step.value.length <= MAX_STEP_VALUE_LENGTH,
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
const {
|
|
423
|
+
data: preview,
|
|
424
|
+
loading: previewing,
|
|
425
|
+
failed: previewFailed,
|
|
426
|
+
run: runPreview,
|
|
427
|
+
} = useLatestRequest<MarketingFunnelPreview>(() =>
|
|
428
|
+
websiteId.value && validSteps.value.length > 0 && previewable.value
|
|
429
|
+
? api.previewFunnel(
|
|
430
|
+
websiteId.value,
|
|
431
|
+
validSteps.value,
|
|
432
|
+
windowHours.value as number,
|
|
433
|
+
)
|
|
434
|
+
: null,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
let previewTimer: ReturnType<typeof setTimeout> | null = null
|
|
438
|
+
|
|
439
|
+
watch(
|
|
440
|
+
[validSteps, windowHours, websiteId],
|
|
441
|
+
() => {
|
|
442
|
+
if (previewTimer) {
|
|
443
|
+
clearTimeout(previewTimer)
|
|
444
|
+
}
|
|
445
|
+
previewTimer = setTimeout(() => void runPreview(), PREVIEW_DEBOUNCE_MS)
|
|
446
|
+
},
|
|
447
|
+
{ deep: true, immediate: true },
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
onBeforeUnmount(() => previewTimer && clearTimeout(previewTimer))
|
|
451
|
+
|
|
452
|
+
function previewCount(step: StepDraft): number | null {
|
|
453
|
+
const index = previewIndex.value.get(step.uid)
|
|
454
|
+
return index === undefined
|
|
455
|
+
? null
|
|
456
|
+
: (preview.value?.computation.steps[index]?.sessions ?? null)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const previewRows = computed(() => preview.value?.computation.steps ?? [])
|
|
460
|
+
|
|
461
|
+
/** Under a step: the path a pasted URL is matched as, or a step the site
|
|
462
|
+
* never sent over the preview's 30 days (unless the read hit its cap). */
|
|
463
|
+
function stepHint(step: StepDraft): { text: string; warning: boolean } | null {
|
|
464
|
+
const value = step.value.trim()
|
|
465
|
+
if (!value) {
|
|
466
|
+
return null
|
|
467
|
+
}
|
|
468
|
+
const index = previewIndex.value.get(step.uid)
|
|
469
|
+
const row = index === undefined ? undefined : previewRows.value[index]
|
|
470
|
+
if (
|
|
471
|
+
row &&
|
|
472
|
+
row.matches === 0 &&
|
|
473
|
+
(preview.value?.computation.events ?? 0) > 0 &&
|
|
474
|
+
!preview.value?.truncated
|
|
475
|
+
) {
|
|
476
|
+
return { text: t('page.marketing.builder.step_unseen'), warning: true }
|
|
477
|
+
}
|
|
478
|
+
if (step.kind === 'url') {
|
|
479
|
+
const path = normalizeFunnelStepPath(value)
|
|
480
|
+
if (path !== value) {
|
|
481
|
+
return {
|
|
482
|
+
text: t('page.marketing.builder.step_matched_as', { path }),
|
|
483
|
+
warning: false,
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return null
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const entered = computed(() => previewRows.value[0]?.sessions ?? 0)
|
|
491
|
+
const converted = computed(() => previewRows.value.at(-1)?.sessions ?? 0)
|
|
492
|
+
const endToEnd = computed(() =>
|
|
493
|
+
entered.value > 0 ? (converted.value / entered.value) * PERCENT : null,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
/** Share of the previous step's sessions that reached this one. */
|
|
497
|
+
function stepRate(index: number): string | null {
|
|
498
|
+
const before = previewRows.value[index - 1]?.sessions ?? 0
|
|
499
|
+
if (index === 0 || before <= 0) {
|
|
500
|
+
return null
|
|
501
|
+
}
|
|
502
|
+
return String(
|
|
503
|
+
formatPercentage(
|
|
504
|
+
previewRows.value[index]!.sessions / before,
|
|
505
|
+
locale.value,
|
|
506
|
+
fractionDigits(0),
|
|
507
|
+
),
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** How long the split would need, at the traffic the preview measured (the
|
|
512
|
+
* preview covers 30 days, so its entered sessions are a month's). Moot once
|
|
513
|
+
* the test has started: the arms can no longer change. */
|
|
514
|
+
const sampleHint = computed(() => {
|
|
515
|
+
const rate = entered.value > 0 ? converted.value / entered.value : 0
|
|
516
|
+
const total = splitSampleSize(
|
|
517
|
+
rate,
|
|
518
|
+
MINIMUM_DETECTABLE_LIFT,
|
|
519
|
+
variations.value.map((arm) => arm.weight),
|
|
520
|
+
)
|
|
521
|
+
if (frozen.value || total === null) {
|
|
522
|
+
return null
|
|
523
|
+
}
|
|
524
|
+
const months = total / entered.value
|
|
525
|
+
const duration =
|
|
526
|
+
months < MIN_MONTHS_SHOWN
|
|
527
|
+
? t('page.marketing.builder.months_under', {
|
|
528
|
+
months: formatNumber(MIN_MONTHS_SHOWN, locale.value, 1),
|
|
529
|
+
})
|
|
530
|
+
: t(
|
|
531
|
+
'page.marketing.builder.months',
|
|
532
|
+
{
|
|
533
|
+
months: formatNumber(months, locale.value, months < 1 ? 1 : 0),
|
|
534
|
+
},
|
|
535
|
+
months <= 1 ? 1 : 2,
|
|
536
|
+
)
|
|
537
|
+
return {
|
|
538
|
+
total: formatNumber(Math.round(total), locale.value),
|
|
539
|
+
rate: formatPercentage(rate, locale.value, fractionDigits(1)),
|
|
540
|
+
duration,
|
|
541
|
+
tooSmall: months > 3,
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
// --- Editing -------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
function addStep(kind: MarketingFunnelStep['kind'] = 'url', value = ''): void {
|
|
548
|
+
clearStepError()
|
|
549
|
+
const empty = steps.value.find((step) => step.value.trim() === '')
|
|
550
|
+
if (value && empty) {
|
|
551
|
+
empty.kind = kind
|
|
552
|
+
empty.value = value
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
if (steps.value.length < MAX_STEPS) {
|
|
556
|
+
steps.value.push({ uid: uid(), kind, value })
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function removeStep(index: number): void {
|
|
561
|
+
clearStepError()
|
|
562
|
+
steps.value.splice(index, 1)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function moveStep(index: number, offset: number): void {
|
|
566
|
+
const target = index + offset
|
|
567
|
+
if (target < 0 || target >= steps.value.length) {
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
const [step] = steps.value.splice(index, 1)
|
|
571
|
+
steps.value.splice(target, 0, step!)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const windowItems = computed(() =>
|
|
575
|
+
WINDOW_PRESETS.map((hours) => ({
|
|
576
|
+
value: hours,
|
|
577
|
+
label: t(`page.marketing.builder.window_presets.${hours}`),
|
|
578
|
+
})),
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
const kindItems = computed(() => [
|
|
582
|
+
{
|
|
583
|
+
value: 'url',
|
|
584
|
+
label: t('page.marketing.funnel.kind.url'),
|
|
585
|
+
icon: 'i-ph-browser',
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
value: 'custom',
|
|
589
|
+
label: t('page.marketing.funnel.kind.custom'),
|
|
590
|
+
icon: 'i-ph-lightning',
|
|
591
|
+
},
|
|
592
|
+
])
|
|
593
|
+
|
|
594
|
+
function servedWeight(arm: VariationDraft): number {
|
|
595
|
+
return typeof arm.weight === 'number' && arm.weight > 0 ? arm.weight : 0
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const totalWeight = computed(() =>
|
|
599
|
+
variations.value.reduce((sum, arm) => sum + servedWeight(arm), 0),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
function share(arm: VariationDraft): number {
|
|
603
|
+
return totalWeight.value > 0
|
|
604
|
+
? (servedWeight(arm) / totalWeight.value) * PERCENT
|
|
605
|
+
: 0
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function addVariation(): void {
|
|
609
|
+
clearVariationError()
|
|
610
|
+
if (variations.value.length < MAX_VARIATIONS) {
|
|
611
|
+
variations.value.push({ uid: uid(), key: '', weight: 1 })
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function removeVariation(index: number): void {
|
|
616
|
+
clearVariationError()
|
|
617
|
+
variations.value.splice(index, 1)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function letter(index: number): string {
|
|
621
|
+
return String.fromCharCode(LETTER_A + index)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const firstUrlStep = computed(
|
|
625
|
+
() => validSteps.value.find((step) => step.kind === 'url')?.value ?? '/',
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
const snippetComments = computed(() => localizedSnippetComments(t))
|
|
629
|
+
|
|
630
|
+
const snippet = computed(() =>
|
|
631
|
+
variationSnippet(
|
|
632
|
+
experimentKey.value.trim() || 'experiment-key',
|
|
633
|
+
variations.value[0]?.key.trim() || 'control',
|
|
634
|
+
[variations.value[1]?.key.trim() || 'variation'],
|
|
635
|
+
snippetComments.value,
|
|
636
|
+
),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
/** The steps or the window of a started test differ from the saved ones:
|
|
640
|
+
* saving re-scores the whole test against the new goal. */
|
|
641
|
+
const objectiveChanged = computed(
|
|
642
|
+
() => frozen.value && objectiveOf(values()) !== savedObjective.value,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
// --- Validation and save -------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
async function showDraftIssues(): Promise<boolean> {
|
|
648
|
+
const issues = draftIssues(values(), frozen.value)
|
|
649
|
+
const fields = Object.keys(issues) as DraftField[]
|
|
650
|
+
if (fields.length === 0) {
|
|
651
|
+
return false
|
|
652
|
+
}
|
|
653
|
+
fieldErrors.clear()
|
|
654
|
+
flaggedStep.value =
|
|
655
|
+
issues.steps?.index === undefined
|
|
656
|
+
? null
|
|
657
|
+
: (steps.value[issues.steps.index]?.uid ?? null)
|
|
658
|
+
flaggedVariation.value =
|
|
659
|
+
issues.variations?.index === undefined
|
|
660
|
+
? null
|
|
661
|
+
: (variations.value[issues.variations.index]?.uid ?? null)
|
|
662
|
+
for (const field of fields) {
|
|
663
|
+
const issue = issues[field]!
|
|
664
|
+
fieldErrors.errors[field] = t(
|
|
665
|
+
`page.marketing.builder.errors.${issue.message}`,
|
|
666
|
+
issue.params ?? {},
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
await fieldErrors.focus(fields[0]!)
|
|
670
|
+
return true
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function experimentPayload(): MarketingExperiment | null {
|
|
674
|
+
if (!splitEnabled.value) {
|
|
675
|
+
return null
|
|
676
|
+
}
|
|
677
|
+
if (frozen.value && original.value) {
|
|
678
|
+
return original.value
|
|
679
|
+
}
|
|
680
|
+
return {
|
|
681
|
+
key: experimentKey.value.trim(),
|
|
682
|
+
status: original.value?.status ?? 'draft',
|
|
683
|
+
variations: variations.value.map((arm) => ({
|
|
684
|
+
key: arm.key.trim(),
|
|
685
|
+
weight: arm.weight as number,
|
|
686
|
+
})),
|
|
687
|
+
runs: original.value?.runs ?? [],
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function confirmObjectiveChange(): Promise<boolean> {
|
|
692
|
+
return confirm({
|
|
693
|
+
title: t('page.marketing.builder.objective_confirm.title'),
|
|
694
|
+
description: t('page.marketing.builder.objective_confirm.description'),
|
|
695
|
+
confirmLabel: t('page.marketing.builder.objective_confirm.confirm'),
|
|
696
|
+
cancelLabel: t('page.marketing.builder.objective_confirm.cancel'),
|
|
697
|
+
color: 'warning',
|
|
698
|
+
icon: 'i-ph-warning',
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function save(): Promise<void> {
|
|
703
|
+
// Several clicks in one tick would each create a funnel.
|
|
704
|
+
if (saving.value) {
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
saving.value = true
|
|
708
|
+
try {
|
|
709
|
+
if (await showDraftIssues()) {
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
if (objectiveChanged.value && !(await confirmObjectiveChange())) {
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
const input = {
|
|
716
|
+
websiteId: websiteId.value,
|
|
717
|
+
name: name.value.trim(),
|
|
718
|
+
steps: validSteps.value,
|
|
719
|
+
conversionWindowHours: windowHours.value as number,
|
|
720
|
+
experiment: experimentPayload(),
|
|
721
|
+
}
|
|
722
|
+
const id = isEdit ? funnelId : (await api.createFunnel(input))[0]
|
|
723
|
+
if (isEdit) {
|
|
724
|
+
await api.updateFunnel(funnelId, input)
|
|
725
|
+
}
|
|
726
|
+
// Clean before leaving, or the leave guard would ask.
|
|
727
|
+
markClean()
|
|
728
|
+
toast.add({
|
|
729
|
+
color: 'success',
|
|
730
|
+
title: t('page.marketing.builder.saved'),
|
|
731
|
+
})
|
|
732
|
+
await router.push(inContext(id ? funnelLink(id) : funnelsLink()))
|
|
733
|
+
} catch (error) {
|
|
734
|
+
await fieldErrors.handleApiError(error, {
|
|
735
|
+
toastTitle: 'page.marketing.builder.save_error',
|
|
736
|
+
})
|
|
737
|
+
} finally {
|
|
738
|
+
saving.value = false
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Back to what the page opened on: the copy itself for a Duplicate, which
|
|
743
|
+
* stays a change to save. */
|
|
744
|
+
function discard(): void {
|
|
745
|
+
clearStepError()
|
|
746
|
+
clearVariationError()
|
|
747
|
+
fieldErrors.clear()
|
|
748
|
+
applyValues(copied.value ?? baseline.value)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** A link out of the builder, on the website and period it was opened from. */
|
|
752
|
+
const inContext = useContextLink()
|
|
753
|
+
|
|
754
|
+
const backLink = computed(() => {
|
|
755
|
+
if (isEdit) {
|
|
756
|
+
return inContext(funnelLink(funnelId))
|
|
757
|
+
}
|
|
758
|
+
return inContext(sourceId ? funnelLink(sourceId) : funnelsLink())
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
/** `remount` for another builder URL: the same page otherwise stays
|
|
762
|
+
* mounted on the funnel it opened with. */
|
|
763
|
+
async function leave(href: string, remount = false): Promise<void> {
|
|
764
|
+
if (await confirmLeave()) {
|
|
765
|
+
await router.push(href, remount ? { preserveState: false } : undefined)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const websiteItems = computed(() =>
|
|
770
|
+
websites.value.map((site) => ({
|
|
771
|
+
value: site.id,
|
|
772
|
+
label: `${site.name} · ${site.domain}`,
|
|
773
|
+
})),
|
|
774
|
+
)
|
|
775
|
+
</script>
|
|
776
|
+
|
|
777
|
+
<template>
|
|
778
|
+
<div class="flex flex-col gap-4">
|
|
779
|
+
<header class="flex items-start gap-4">
|
|
780
|
+
<UButton
|
|
781
|
+
icon="i-ph-arrow-left"
|
|
782
|
+
color="neutral"
|
|
783
|
+
variant="ghost"
|
|
784
|
+
:aria-label="t('page.marketing.funnel.back')"
|
|
785
|
+
@click="leave(backLink)"
|
|
786
|
+
/>
|
|
787
|
+
<div class="min-w-0 flex-1">
|
|
788
|
+
<h1 class="text-2xl font-semibold break-words text-highlighted">
|
|
789
|
+
{{
|
|
790
|
+
loadFailed
|
|
791
|
+
? t('page.marketing.builder.page_title')
|
|
792
|
+
: isEdit
|
|
793
|
+
? t('page.marketing.builder.edit_title', { name: name || '…' })
|
|
794
|
+
: t('page.marketing.builder.title')
|
|
795
|
+
}}
|
|
796
|
+
</h1>
|
|
797
|
+
<p v-if="!loadFailed" class="mt-1 max-w-3xl text-sm text-muted">
|
|
798
|
+
{{ t('page.marketing.builder.subtitle') }}
|
|
799
|
+
</p>
|
|
800
|
+
</div>
|
|
801
|
+
<UButton
|
|
802
|
+
v-if="isEdit && !loading && !loadFailed"
|
|
803
|
+
icon="i-ph-copy"
|
|
804
|
+
color="neutral"
|
|
805
|
+
variant="outline"
|
|
806
|
+
:label="t('page.marketing.builder.duplicate')"
|
|
807
|
+
@click="
|
|
808
|
+
leave(inContext(funnelBuilderLink(null, { copy: funnelId })), true)
|
|
809
|
+
"
|
|
810
|
+
/>
|
|
811
|
+
</header>
|
|
812
|
+
|
|
813
|
+
<div v-if="loading" class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
|
814
|
+
<USkeleton class="h-[36rem] rounded-xl" />
|
|
815
|
+
<USkeleton class="h-72 rounded-xl" />
|
|
816
|
+
</div>
|
|
817
|
+
|
|
818
|
+
<DmsEmptyState
|
|
819
|
+
v-else-if="loadFailed"
|
|
820
|
+
variant="error"
|
|
821
|
+
class="dms-card"
|
|
822
|
+
:title="
|
|
823
|
+
t(
|
|
824
|
+
loadFailed === 'not_found'
|
|
825
|
+
? 'page.marketing.errors.funnel_not_found'
|
|
826
|
+
: 'page.marketing.builder.load_error',
|
|
827
|
+
)
|
|
828
|
+
"
|
|
829
|
+
>
|
|
830
|
+
<template #actions>
|
|
831
|
+
<UButton
|
|
832
|
+
v-if="loadFailed === 'error'"
|
|
833
|
+
size="sm"
|
|
834
|
+
color="neutral"
|
|
835
|
+
variant="outline"
|
|
836
|
+
icon="i-ph-arrow-clockwise"
|
|
837
|
+
:label="t('page.marketing.common.retry')"
|
|
838
|
+
@click="load"
|
|
839
|
+
/>
|
|
840
|
+
<UButton
|
|
841
|
+
size="sm"
|
|
842
|
+
color="neutral"
|
|
843
|
+
variant="outline"
|
|
844
|
+
icon="i-ph-arrow-left"
|
|
845
|
+
:label="t('page.marketing.funnel.back')"
|
|
846
|
+
@click="router.push(inContext(funnelsLink()))"
|
|
847
|
+
/>
|
|
848
|
+
</template>
|
|
849
|
+
</DmsEmptyState>
|
|
850
|
+
|
|
851
|
+
<div
|
|
852
|
+
v-else
|
|
853
|
+
class="grid grid-cols-1 items-start gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]"
|
|
854
|
+
>
|
|
855
|
+
<div class="dms-card flex min-w-0 flex-col divide-y divide-default p-0">
|
|
856
|
+
<section class="flex flex-col gap-4 p-5">
|
|
857
|
+
<h2
|
|
858
|
+
class="flex items-center gap-2 text-sm font-semibold text-highlighted"
|
|
859
|
+
>
|
|
860
|
+
<span class="font-mono text-xs text-primary">01</span>
|
|
861
|
+
{{ t('page.marketing.builder.sections.basics') }}
|
|
862
|
+
</h2>
|
|
863
|
+
<div class="grid gap-4 md:grid-cols-2">
|
|
864
|
+
<DmsFieldRow
|
|
865
|
+
layout="stack"
|
|
866
|
+
spacing="list"
|
|
867
|
+
:inset="false"
|
|
868
|
+
label="$page.marketing.builder.name"
|
|
869
|
+
label-for="funnel-name"
|
|
870
|
+
required
|
|
871
|
+
>
|
|
872
|
+
<UInput
|
|
873
|
+
id="funnel-name"
|
|
874
|
+
v-model="name"
|
|
875
|
+
class="w-full"
|
|
876
|
+
:maxlength="MAX_NAME_LENGTH"
|
|
877
|
+
aria-required="true"
|
|
878
|
+
:placeholder="t('page.marketing.builder.name_placeholder')"
|
|
879
|
+
v-bind="fieldErrors.aria('name')"
|
|
880
|
+
@update:model-value="fieldErrors.clear('name')"
|
|
881
|
+
/>
|
|
882
|
+
<DmsFieldError
|
|
883
|
+
:id="fieldErrors.errorId('name')"
|
|
884
|
+
:message="fieldErrors.errors.name"
|
|
885
|
+
/>
|
|
886
|
+
</DmsFieldRow>
|
|
887
|
+
<DmsFieldRow
|
|
888
|
+
layout="stack"
|
|
889
|
+
spacing="list"
|
|
890
|
+
:inset="false"
|
|
891
|
+
label="$page.marketing.builder.website"
|
|
892
|
+
:description="
|
|
893
|
+
isEdit && frozen
|
|
894
|
+
? '$page.marketing.builder.website_locked'
|
|
895
|
+
: undefined
|
|
896
|
+
"
|
|
897
|
+
label-for="funnel-website"
|
|
898
|
+
required
|
|
899
|
+
>
|
|
900
|
+
<USelect
|
|
901
|
+
id="funnel-website"
|
|
902
|
+
v-model="websiteId"
|
|
903
|
+
class="w-full"
|
|
904
|
+
:items="websiteItems"
|
|
905
|
+
:disabled="isEdit && frozen"
|
|
906
|
+
required
|
|
907
|
+
v-bind="fieldErrors.aria('websiteId')"
|
|
908
|
+
@update:model-value="fieldErrors.clear('websiteId')"
|
|
909
|
+
/>
|
|
910
|
+
<DmsFieldError
|
|
911
|
+
:id="fieldErrors.errorId('websiteId')"
|
|
912
|
+
:message="fieldErrors.errors.websiteId"
|
|
913
|
+
/>
|
|
914
|
+
</DmsFieldRow>
|
|
915
|
+
</div>
|
|
916
|
+
</section>
|
|
917
|
+
|
|
918
|
+
<section class="flex flex-col gap-3 p-5">
|
|
919
|
+
<div
|
|
920
|
+
class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
|
|
921
|
+
>
|
|
922
|
+
<h2
|
|
923
|
+
class="flex items-center gap-2 text-sm font-semibold text-highlighted"
|
|
924
|
+
>
|
|
925
|
+
<span class="font-mono text-xs text-primary">02</span>
|
|
926
|
+
<span>
|
|
927
|
+
{{ t('page.marketing.builder.sections.steps') }}
|
|
928
|
+
<span class="text-error" aria-hidden="true">*</span>
|
|
929
|
+
</span>
|
|
930
|
+
</h2>
|
|
931
|
+
<span class="text-xs text-dimmed">
|
|
932
|
+
{{ t('page.marketing.builder.steps_hint', { max: MAX_STEPS }) }}
|
|
933
|
+
</span>
|
|
934
|
+
</div>
|
|
935
|
+
<DmsBanner
|
|
936
|
+
v-if="objectiveChanged"
|
|
937
|
+
tone="warning"
|
|
938
|
+
size="sm"
|
|
939
|
+
icon="i-ph-warning"
|
|
940
|
+
:title="t('page.marketing.builder.objective_title')"
|
|
941
|
+
:description="t('page.marketing.builder.objective_description')"
|
|
942
|
+
/>
|
|
943
|
+
<ol class="flex flex-col gap-2">
|
|
944
|
+
<li
|
|
945
|
+
v-for="(step, index) in steps"
|
|
946
|
+
:key="step.uid"
|
|
947
|
+
class="relative grid grid-cols-[auto_1.75rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-lg border p-2 md:grid-cols-[auto_1.75rem_9rem_minmax(0,1fr)_auto_auto]"
|
|
948
|
+
:class="
|
|
949
|
+
flaggedStep === step.uid
|
|
950
|
+
? 'border-error'
|
|
951
|
+
: focusedStep === step.uid
|
|
952
|
+
? 'border-primary'
|
|
953
|
+
: 'border-default'
|
|
954
|
+
"
|
|
955
|
+
>
|
|
956
|
+
<span class="flex flex-col">
|
|
957
|
+
<UButton
|
|
958
|
+
icon="i-ph-caret-up"
|
|
959
|
+
size="xs"
|
|
960
|
+
color="neutral"
|
|
961
|
+
variant="ghost"
|
|
962
|
+
:disabled="index === 0"
|
|
963
|
+
:aria-label="
|
|
964
|
+
t('page.marketing.builder.move_up_step', { n: index + 1 })
|
|
965
|
+
"
|
|
966
|
+
@click="moveStep(index, -1)"
|
|
967
|
+
/>
|
|
968
|
+
<UButton
|
|
969
|
+
icon="i-ph-caret-down"
|
|
970
|
+
size="xs"
|
|
971
|
+
color="neutral"
|
|
972
|
+
variant="ghost"
|
|
973
|
+
:disabled="index === steps.length - 1"
|
|
974
|
+
:aria-label="
|
|
975
|
+
t('page.marketing.builder.move_down_step', { n: index + 1 })
|
|
976
|
+
"
|
|
977
|
+
@click="moveStep(index, 1)"
|
|
978
|
+
/>
|
|
979
|
+
</span>
|
|
980
|
+
<span
|
|
981
|
+
class="grid size-7 place-items-center rounded-md font-mono text-xs"
|
|
982
|
+
:class="
|
|
983
|
+
index === steps.length - 1 && steps.length > 1
|
|
984
|
+
? 'bg-primary text-inverted'
|
|
985
|
+
: 'bg-elevated text-toned'
|
|
986
|
+
"
|
|
987
|
+
>
|
|
988
|
+
<UIcon
|
|
989
|
+
v-if="index === steps.length - 1 && steps.length > 1"
|
|
990
|
+
name="i-ph-flag-checkered"
|
|
991
|
+
class="size-3.5"
|
|
992
|
+
/>
|
|
993
|
+
<template v-else>{{ index + 1 }}</template>
|
|
994
|
+
</span>
|
|
995
|
+
<USelect
|
|
996
|
+
v-model="step.kind"
|
|
997
|
+
:items="kindItems"
|
|
998
|
+
size="sm"
|
|
999
|
+
:aria-label="
|
|
1000
|
+
t('page.marketing.builder.step_kind_label', { n: index + 1 })
|
|
1001
|
+
"
|
|
1002
|
+
@update:model-value="clearStepError"
|
|
1003
|
+
/>
|
|
1004
|
+
<div
|
|
1005
|
+
class="relative order-last col-span-full md:order-none md:col-span-1"
|
|
1006
|
+
>
|
|
1007
|
+
<UInput
|
|
1008
|
+
:id="`${STEP_CONTROL_PREFIX}${step.uid}`"
|
|
1009
|
+
v-model="step.value"
|
|
1010
|
+
size="sm"
|
|
1011
|
+
class="w-full font-mono"
|
|
1012
|
+
:maxlength="MAX_STEP_VALUE_LENGTH"
|
|
1013
|
+
:placeholder="
|
|
1014
|
+
step.kind === 'url' ? '/pricing' : 'signup_submitted'
|
|
1015
|
+
"
|
|
1016
|
+
role="combobox"
|
|
1017
|
+
aria-autocomplete="list"
|
|
1018
|
+
:aria-label="
|
|
1019
|
+
t('page.marketing.builder.step_value_label', {
|
|
1020
|
+
n: index + 1,
|
|
1021
|
+
})
|
|
1022
|
+
"
|
|
1023
|
+
:aria-expanded="listOpen(step)"
|
|
1024
|
+
:aria-controls="`${STEP_CONTROL_PREFIX}${step.uid}-list`"
|
|
1025
|
+
:aria-activedescendant="
|
|
1026
|
+
listOpen(step) && activeOption >= 0
|
|
1027
|
+
? optionId(step, activeOption)
|
|
1028
|
+
: undefined
|
|
1029
|
+
"
|
|
1030
|
+
v-bind="
|
|
1031
|
+
flaggedStep === step.uid ? fieldErrors.aria('steps') : {}
|
|
1032
|
+
"
|
|
1033
|
+
@focus="focusStep(step)"
|
|
1034
|
+
@blur="blurStep(step)"
|
|
1035
|
+
@keydown="onStepKeydown($event, step)"
|
|
1036
|
+
@update:model-value="typeStep"
|
|
1037
|
+
/>
|
|
1038
|
+
<div
|
|
1039
|
+
v-if="listOpen(step)"
|
|
1040
|
+
class="absolute inset-x-0 top-full z-20 mt-1 overflow-hidden rounded-lg border border-default bg-default shadow-lg"
|
|
1041
|
+
>
|
|
1042
|
+
<div class="px-3 pt-2 pb-1">
|
|
1043
|
+
<DmsEyebrow
|
|
1044
|
+
:label="
|
|
1045
|
+
t(
|
|
1046
|
+
step.kind === 'url'
|
|
1047
|
+
? 'page.marketing.builder.tracked_pages'
|
|
1048
|
+
: 'page.marketing.builder.tracked_events',
|
|
1049
|
+
)
|
|
1050
|
+
"
|
|
1051
|
+
/>
|
|
1052
|
+
</div>
|
|
1053
|
+
<ul
|
|
1054
|
+
:id="`${STEP_CONTROL_PREFIX}${step.uid}-list`"
|
|
1055
|
+
role="listbox"
|
|
1056
|
+
>
|
|
1057
|
+
<li
|
|
1058
|
+
v-for="(match, matchIndex) in matchesFor(step)"
|
|
1059
|
+
:id="optionId(step, matchIndex)"
|
|
1060
|
+
:key="match.value"
|
|
1061
|
+
role="option"
|
|
1062
|
+
:aria-selected="activeOption === matchIndex"
|
|
1063
|
+
class="flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-left hover:bg-elevated"
|
|
1064
|
+
:class="{ 'bg-elevated': activeOption === matchIndex }"
|
|
1065
|
+
@mousedown.prevent="pick(step, match.value)"
|
|
1066
|
+
>
|
|
1067
|
+
<UIcon
|
|
1068
|
+
:name="
|
|
1069
|
+
step.kind === 'url'
|
|
1070
|
+
? 'i-ph-browser'
|
|
1071
|
+
: 'i-ph-lightning'
|
|
1072
|
+
"
|
|
1073
|
+
class="size-3.5 text-dimmed"
|
|
1074
|
+
/>
|
|
1075
|
+
<span
|
|
1076
|
+
class="flex-1 truncate font-mono text-[13px] text-highlighted"
|
|
1077
|
+
>
|
|
1078
|
+
{{ match.value }}
|
|
1079
|
+
</span>
|
|
1080
|
+
<span class="font-mono text-[11px] text-dimmed">
|
|
1081
|
+
{{
|
|
1082
|
+
t(
|
|
1083
|
+
step.kind === 'url'
|
|
1084
|
+
? 'page.marketing.builder.views'
|
|
1085
|
+
: 'page.marketing.builder.triggers',
|
|
1086
|
+
{ count: formatNumber(match.count, locale) },
|
|
1087
|
+
match.count,
|
|
1088
|
+
)
|
|
1089
|
+
}}
|
|
1090
|
+
</span>
|
|
1091
|
+
</li>
|
|
1092
|
+
</ul>
|
|
1093
|
+
</div>
|
|
1094
|
+
<p
|
|
1095
|
+
v-if="stepHint(step)"
|
|
1096
|
+
class="mt-1 flex items-center gap-1 text-xs"
|
|
1097
|
+
:class="
|
|
1098
|
+
stepHint(step)!.warning ? 'text-warning' : 'text-dimmed'
|
|
1099
|
+
"
|
|
1100
|
+
>
|
|
1101
|
+
<UIcon
|
|
1102
|
+
:name="
|
|
1103
|
+
stepHint(step)!.warning ? 'i-ph-warning' : 'i-ph-info'
|
|
1104
|
+
"
|
|
1105
|
+
class="size-3.5 shrink-0"
|
|
1106
|
+
/>
|
|
1107
|
+
{{ stepHint(step)!.text }}
|
|
1108
|
+
</p>
|
|
1109
|
+
</div>
|
|
1110
|
+
<span
|
|
1111
|
+
class="text-right font-mono text-[11px] text-muted md:min-w-20"
|
|
1112
|
+
>
|
|
1113
|
+
<template v-if="previewCount(step) !== null">
|
|
1114
|
+
<span class="font-semibold text-highlighted">
|
|
1115
|
+
{{ formatNumber(previewCount(step)!, locale) }}
|
|
1116
|
+
</span>
|
|
1117
|
+
{{ t('page.marketing.builder.sessions_short') }}
|
|
1118
|
+
</template>
|
|
1119
|
+
</span>
|
|
1120
|
+
<UButton
|
|
1121
|
+
icon="i-ph-x"
|
|
1122
|
+
size="xs"
|
|
1123
|
+
color="neutral"
|
|
1124
|
+
variant="ghost"
|
|
1125
|
+
:disabled="steps.length === 1"
|
|
1126
|
+
:aria-label="
|
|
1127
|
+
t('page.marketing.builder.remove_step_n', { n: index + 1 })
|
|
1128
|
+
"
|
|
1129
|
+
@click="removeStep(index)"
|
|
1130
|
+
/>
|
|
1131
|
+
</li>
|
|
1132
|
+
</ol>
|
|
1133
|
+
<DmsFieldError
|
|
1134
|
+
:id="fieldErrors.errorId('steps')"
|
|
1135
|
+
:message="fieldErrors.errors.steps"
|
|
1136
|
+
/>
|
|
1137
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
1138
|
+
<UButton
|
|
1139
|
+
icon="i-ph-plus"
|
|
1140
|
+
size="sm"
|
|
1141
|
+
color="neutral"
|
|
1142
|
+
variant="outline"
|
|
1143
|
+
:disabled="steps.length >= MAX_STEPS"
|
|
1144
|
+
:label="t('page.marketing.builder.add_step')"
|
|
1145
|
+
@click="addStep()"
|
|
1146
|
+
/>
|
|
1147
|
+
<span class="text-xs text-dimmed">
|
|
1148
|
+
{{ t('page.marketing.builder.goal_hint') }}
|
|
1149
|
+
</span>
|
|
1150
|
+
</div>
|
|
1151
|
+
</section>
|
|
1152
|
+
|
|
1153
|
+
<section class="flex flex-col gap-3 p-5">
|
|
1154
|
+
<div
|
|
1155
|
+
class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
|
|
1156
|
+
>
|
|
1157
|
+
<h2
|
|
1158
|
+
class="flex items-center gap-2 text-sm font-semibold text-highlighted"
|
|
1159
|
+
>
|
|
1160
|
+
<span class="font-mono text-xs text-primary">03</span>
|
|
1161
|
+
{{ t('page.marketing.builder.sections.window') }}
|
|
1162
|
+
</h2>
|
|
1163
|
+
<span class="text-xs text-dimmed">
|
|
1164
|
+
{{ t('page.marketing.builder.window_hint') }}
|
|
1165
|
+
</span>
|
|
1166
|
+
</div>
|
|
1167
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
1168
|
+
<DmsSegmented
|
|
1169
|
+
v-model="windowHours"
|
|
1170
|
+
:items="windowItems"
|
|
1171
|
+
variant="mono"
|
|
1172
|
+
size="sm"
|
|
1173
|
+
@update:model-value="fieldErrors.clear('conversionWindowHours')"
|
|
1174
|
+
/>
|
|
1175
|
+
<span class="text-xs text-dimmed">
|
|
1176
|
+
{{ t('page.marketing.builder.or') }}
|
|
1177
|
+
</span>
|
|
1178
|
+
<UInput
|
|
1179
|
+
id="funnel-window"
|
|
1180
|
+
v-model.number="windowHours"
|
|
1181
|
+
type="number"
|
|
1182
|
+
:min="1"
|
|
1183
|
+
:max="MAX_WINDOW_HOURS"
|
|
1184
|
+
:step="1"
|
|
1185
|
+
size="sm"
|
|
1186
|
+
class="w-28"
|
|
1187
|
+
:aria-label="t('page.marketing.builder.window_label')"
|
|
1188
|
+
v-bind="fieldErrors.aria('conversionWindowHours')"
|
|
1189
|
+
@update:model-value="fieldErrors.clear('conversionWindowHours')"
|
|
1190
|
+
>
|
|
1191
|
+
<template #trailing>
|
|
1192
|
+
<span class="text-xs text-dimmed">
|
|
1193
|
+
{{ t('page.marketing.builder.hours') }}
|
|
1194
|
+
</span>
|
|
1195
|
+
</template>
|
|
1196
|
+
</UInput>
|
|
1197
|
+
<span class="text-xs text-dimmed">
|
|
1198
|
+
{{
|
|
1199
|
+
t('page.marketing.builder.window_max', {
|
|
1200
|
+
max: MAX_WINDOW_HOURS,
|
|
1201
|
+
})
|
|
1202
|
+
}}
|
|
1203
|
+
</span>
|
|
1204
|
+
</div>
|
|
1205
|
+
<DmsFieldError
|
|
1206
|
+
:id="fieldErrors.errorId('conversionWindowHours')"
|
|
1207
|
+
:message="fieldErrors.errors.conversionWindowHours"
|
|
1208
|
+
/>
|
|
1209
|
+
</section>
|
|
1210
|
+
|
|
1211
|
+
<section class="flex flex-col gap-3 p-5">
|
|
1212
|
+
<div
|
|
1213
|
+
class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
|
|
1214
|
+
>
|
|
1215
|
+
<h2
|
|
1216
|
+
class="flex items-center gap-2 text-sm font-semibold text-highlighted"
|
|
1217
|
+
>
|
|
1218
|
+
<span class="font-mono text-xs text-primary">04</span>
|
|
1219
|
+
{{ t('page.marketing.builder.sections.split') }}
|
|
1220
|
+
</h2>
|
|
1221
|
+
<span class="text-xs text-dimmed">
|
|
1222
|
+
{{ t('page.marketing.builder.optional') }}
|
|
1223
|
+
</span>
|
|
1224
|
+
</div>
|
|
1225
|
+
<div
|
|
1226
|
+
class="flex items-start gap-3 rounded-lg border border-default p-4"
|
|
1227
|
+
>
|
|
1228
|
+
<DmsIconWell icon="i-ph-flask" tone="primary" size="md" />
|
|
1229
|
+
<span class="min-w-0 flex-1">
|
|
1230
|
+
<span class="block text-sm font-semibold text-highlighted">
|
|
1231
|
+
{{ t('page.marketing.builder.split_title') }}
|
|
1232
|
+
</span>
|
|
1233
|
+
<span class="block text-xs text-muted">
|
|
1234
|
+
{{ t('page.marketing.builder.split_description') }}
|
|
1235
|
+
</span>
|
|
1236
|
+
</span>
|
|
1237
|
+
<USwitch
|
|
1238
|
+
v-model="splitEnabled"
|
|
1239
|
+
:disabled="frozen"
|
|
1240
|
+
:aria-label="t('page.marketing.builder.split_title')"
|
|
1241
|
+
/>
|
|
1242
|
+
</div>
|
|
1243
|
+
<template v-if="splitEnabled">
|
|
1244
|
+
<DmsBanner
|
|
1245
|
+
v-if="frozen"
|
|
1246
|
+
tone="info"
|
|
1247
|
+
size="sm"
|
|
1248
|
+
icon="i-ph-lock-simple"
|
|
1249
|
+
:title="t('page.marketing.builder.frozen_title')"
|
|
1250
|
+
:description="t('page.marketing.builder.frozen_description')"
|
|
1251
|
+
/>
|
|
1252
|
+
<DmsFieldRow
|
|
1253
|
+
layout="stack"
|
|
1254
|
+
spacing="list"
|
|
1255
|
+
:inset="false"
|
|
1256
|
+
label="$page.marketing.builder.key"
|
|
1257
|
+
description="$page.marketing.builder.key_hint"
|
|
1258
|
+
label-for="funnel-experiment-key"
|
|
1259
|
+
required
|
|
1260
|
+
>
|
|
1261
|
+
<UInput
|
|
1262
|
+
id="funnel-experiment-key"
|
|
1263
|
+
v-model="experimentKey"
|
|
1264
|
+
class="w-full max-w-72 font-mono"
|
|
1265
|
+
placeholder="sale-hero"
|
|
1266
|
+
:maxlength="MAX_EXPERIMENT_KEY_LENGTH"
|
|
1267
|
+
:disabled="frozen"
|
|
1268
|
+
aria-required="true"
|
|
1269
|
+
v-bind="fieldErrors.aria('experiment')"
|
|
1270
|
+
@update:model-value="fieldErrors.clear('experiment')"
|
|
1271
|
+
/>
|
|
1272
|
+
<DmsFieldError
|
|
1273
|
+
:id="fieldErrors.errorId('experiment')"
|
|
1274
|
+
:message="fieldErrors.errors.experiment"
|
|
1275
|
+
/>
|
|
1276
|
+
</DmsFieldRow>
|
|
1277
|
+
<div class="flex flex-wrap items-center justify-between gap-x-3">
|
|
1278
|
+
<span class="text-sm font-medium text-highlighted">
|
|
1279
|
+
{{ t('page.marketing.builder.variations') }}
|
|
1280
|
+
</span>
|
|
1281
|
+
<span class="text-xs text-dimmed">
|
|
1282
|
+
{{
|
|
1283
|
+
t('page.marketing.builder.variations_hint', {
|
|
1284
|
+
min: MIN_VARIATIONS,
|
|
1285
|
+
max: MAX_VARIATIONS,
|
|
1286
|
+
})
|
|
1287
|
+
}}
|
|
1288
|
+
</span>
|
|
1289
|
+
</div>
|
|
1290
|
+
<ul class="flex flex-col gap-2">
|
|
1291
|
+
<li
|
|
1292
|
+
v-for="(arm, index) in variations"
|
|
1293
|
+
:key="arm.uid"
|
|
1294
|
+
class="grid grid-cols-[1.5rem_minmax(0,1fr)_6rem_3rem_auto] items-center gap-2 sm:grid-cols-[1.5rem_minmax(0,1fr)_6rem_5rem_3rem_auto]"
|
|
1295
|
+
>
|
|
1296
|
+
<span
|
|
1297
|
+
class="grid size-6 place-items-center rounded font-mono text-[11px] font-semibold"
|
|
1298
|
+
:class="
|
|
1299
|
+
index === 0
|
|
1300
|
+
? 'bg-elevated text-toned'
|
|
1301
|
+
: 'bg-primary/15 text-(--ui-color-primary-800) dark:text-primary'
|
|
1302
|
+
"
|
|
1303
|
+
>
|
|
1304
|
+
{{ letter(index) }}
|
|
1305
|
+
</span>
|
|
1306
|
+
<UInput
|
|
1307
|
+
:id="`${VARIATION_CONTROL_PREFIX}${arm.uid}`"
|
|
1308
|
+
v-model="arm.key"
|
|
1309
|
+
size="sm"
|
|
1310
|
+
class="font-mono"
|
|
1311
|
+
:maxlength="MAX_EXPERIMENT_KEY_LENGTH"
|
|
1312
|
+
:placeholder="index === 0 ? 'control' : 'video-hero'"
|
|
1313
|
+
:disabled="frozen"
|
|
1314
|
+
:aria-label="
|
|
1315
|
+
t('page.marketing.builder.variation_key_label', {
|
|
1316
|
+
letter: letter(index),
|
|
1317
|
+
})
|
|
1318
|
+
"
|
|
1319
|
+
v-bind="
|
|
1320
|
+
flaggedVariation === arm.uid
|
|
1321
|
+
? fieldErrors.aria('variations')
|
|
1322
|
+
: {}
|
|
1323
|
+
"
|
|
1324
|
+
@update:model-value="clearVariationError"
|
|
1325
|
+
/>
|
|
1326
|
+
<UInput
|
|
1327
|
+
v-model.number="arm.weight"
|
|
1328
|
+
type="number"
|
|
1329
|
+
:min="0"
|
|
1330
|
+
size="sm"
|
|
1331
|
+
:disabled="frozen"
|
|
1332
|
+
:aria-label="
|
|
1333
|
+
t('page.marketing.builder.variation_weight_label', {
|
|
1334
|
+
letter: letter(index),
|
|
1335
|
+
})
|
|
1336
|
+
"
|
|
1337
|
+
@update:model-value="clearVariationError"
|
|
1338
|
+
>
|
|
1339
|
+
<template #trailing>
|
|
1340
|
+
<span class="text-xs text-dimmed">
|
|
1341
|
+
{{ t('page.marketing.builder.weight') }}
|
|
1342
|
+
</span>
|
|
1343
|
+
</template>
|
|
1344
|
+
</UInput>
|
|
1345
|
+
<span
|
|
1346
|
+
class="hidden h-1.5 overflow-hidden rounded-full bg-elevated sm:block"
|
|
1347
|
+
>
|
|
1348
|
+
<span
|
|
1349
|
+
class="block h-full rounded-full"
|
|
1350
|
+
:class="
|
|
1351
|
+
index === 0 ? 'bg-(--ui-text-dimmed)' : 'bg-primary'
|
|
1352
|
+
"
|
|
1353
|
+
:style="{ width: `${share(arm)}%` }"
|
|
1354
|
+
/>
|
|
1355
|
+
</span>
|
|
1356
|
+
<span
|
|
1357
|
+
class="text-right font-mono text-xs font-semibold text-highlighted"
|
|
1358
|
+
>
|
|
1359
|
+
{{
|
|
1360
|
+
formatPercentage(
|
|
1361
|
+
share(arm) / 100,
|
|
1362
|
+
locale,
|
|
1363
|
+
fractionDigits(0),
|
|
1364
|
+
)
|
|
1365
|
+
}}
|
|
1366
|
+
</span>
|
|
1367
|
+
<UButton
|
|
1368
|
+
icon="i-ph-x"
|
|
1369
|
+
size="xs"
|
|
1370
|
+
color="neutral"
|
|
1371
|
+
variant="ghost"
|
|
1372
|
+
:disabled="frozen || variations.length <= MIN_VARIATIONS"
|
|
1373
|
+
:aria-label="
|
|
1374
|
+
t('page.marketing.builder.remove_variation_n', {
|
|
1375
|
+
letter: letter(index),
|
|
1376
|
+
})
|
|
1377
|
+
"
|
|
1378
|
+
@click="removeVariation(index)"
|
|
1379
|
+
/>
|
|
1380
|
+
</li>
|
|
1381
|
+
</ul>
|
|
1382
|
+
<DmsFieldError
|
|
1383
|
+
:id="fieldErrors.errorId('variations')"
|
|
1384
|
+
:message="fieldErrors.errors.variations"
|
|
1385
|
+
/>
|
|
1386
|
+
<UButton
|
|
1387
|
+
v-if="!frozen"
|
|
1388
|
+
icon="i-ph-plus"
|
|
1389
|
+
size="sm"
|
|
1390
|
+
color="neutral"
|
|
1391
|
+
variant="ghost"
|
|
1392
|
+
class="self-start"
|
|
1393
|
+
:disabled="variations.length >= MAX_VARIATIONS"
|
|
1394
|
+
:label="t('page.marketing.builder.add_variation')"
|
|
1395
|
+
@click="addVariation"
|
|
1396
|
+
/>
|
|
1397
|
+
<div class="overflow-hidden rounded-lg border border-default">
|
|
1398
|
+
<div
|
|
1399
|
+
class="flex items-center justify-between gap-2 border-b border-default px-3 py-1.5"
|
|
1400
|
+
>
|
|
1401
|
+
<DmsEyebrow
|
|
1402
|
+
class="min-w-0 truncate"
|
|
1403
|
+
:label="
|
|
1404
|
+
t('page.marketing.builder.snippet_title', {
|
|
1405
|
+
path: firstUrlStep,
|
|
1406
|
+
})
|
|
1407
|
+
"
|
|
1408
|
+
/>
|
|
1409
|
+
<DmsCopyButton :value="snippet" />
|
|
1410
|
+
</div>
|
|
1411
|
+
<pre
|
|
1412
|
+
class="overflow-x-auto px-3 py-2.5 font-mono text-xs leading-relaxed text-toned"
|
|
1413
|
+
>{{ snippet }}</pre>
|
|
1414
|
+
</div>
|
|
1415
|
+
<DmsBanner
|
|
1416
|
+
v-if="!frozen"
|
|
1417
|
+
tone="primary"
|
|
1418
|
+
size="sm"
|
|
1419
|
+
icon="i-ph-lock-simple-open"
|
|
1420
|
+
:title="t('page.marketing.builder.draft_title')"
|
|
1421
|
+
:description="t('page.marketing.builder.draft_description')"
|
|
1422
|
+
/>
|
|
1423
|
+
</template>
|
|
1424
|
+
</section>
|
|
1425
|
+
|
|
1426
|
+
<div class="px-5 py-3">
|
|
1427
|
+
<DmsFormRequiredLegend />
|
|
1428
|
+
</div>
|
|
1429
|
+
</div>
|
|
1430
|
+
|
|
1431
|
+
<aside class="flex min-w-0 flex-col gap-4 lg:sticky lg:top-4">
|
|
1432
|
+
<section class="dms-card p-0">
|
|
1433
|
+
<header
|
|
1434
|
+
class="flex items-center justify-between border-b border-default px-4 py-3"
|
|
1435
|
+
>
|
|
1436
|
+
<DmsEyebrow :label="t('page.marketing.builder.preview')" />
|
|
1437
|
+
<UIcon
|
|
1438
|
+
v-if="previewing"
|
|
1439
|
+
name="i-ph-circle-notch"
|
|
1440
|
+
class="size-4 animate-spin text-dimmed"
|
|
1441
|
+
/>
|
|
1442
|
+
</header>
|
|
1443
|
+
<div class="flex flex-col gap-2 px-4 py-3">
|
|
1444
|
+
<p
|
|
1445
|
+
v-if="validSteps.length > 0 && !previewable"
|
|
1446
|
+
class="py-4 text-center text-sm text-muted"
|
|
1447
|
+
>
|
|
1448
|
+
{{ t('page.marketing.builder.preview_invalid') }}
|
|
1449
|
+
</p>
|
|
1450
|
+
<p
|
|
1451
|
+
v-else-if="previewFailed"
|
|
1452
|
+
class="py-4 text-center text-sm text-muted"
|
|
1453
|
+
>
|
|
1454
|
+
{{ t('page.marketing.builder.preview_error') }}
|
|
1455
|
+
</p>
|
|
1456
|
+
<p
|
|
1457
|
+
v-else-if="previewRows.length === 0"
|
|
1458
|
+
class="py-4 text-center text-sm text-muted"
|
|
1459
|
+
>
|
|
1460
|
+
{{ t('page.marketing.builder.preview_empty') }}
|
|
1461
|
+
</p>
|
|
1462
|
+
<template v-else>
|
|
1463
|
+
<div
|
|
1464
|
+
v-for="(row, index) in previewRows"
|
|
1465
|
+
:key="index"
|
|
1466
|
+
class="flex flex-col gap-1"
|
|
1467
|
+
>
|
|
1468
|
+
<div class="flex items-center gap-2 text-xs">
|
|
1469
|
+
<span class="w-4 shrink-0 font-mono text-[11px] text-dimmed">
|
|
1470
|
+
{{ index + 1 }}
|
|
1471
|
+
</span>
|
|
1472
|
+
<span
|
|
1473
|
+
class="min-w-0 flex-1 truncate font-mono text-toned"
|
|
1474
|
+
:title="row.step.value"
|
|
1475
|
+
>
|
|
1476
|
+
{{ row.step.value }}
|
|
1477
|
+
</span>
|
|
1478
|
+
<span
|
|
1479
|
+
v-if="stepRate(index)"
|
|
1480
|
+
class="shrink-0 font-mono text-[11px] text-dimmed"
|
|
1481
|
+
:title="t('page.marketing.builder.step_rate')"
|
|
1482
|
+
>
|
|
1483
|
+
{{ stepRate(index) }}
|
|
1484
|
+
</span>
|
|
1485
|
+
<span
|
|
1486
|
+
class="w-14 shrink-0 text-right font-mono font-semibold text-highlighted"
|
|
1487
|
+
>
|
|
1488
|
+
{{ formatNumber(row.sessions, locale) }}
|
|
1489
|
+
</span>
|
|
1490
|
+
</div>
|
|
1491
|
+
<span
|
|
1492
|
+
class="ms-6 h-1.5 overflow-hidden rounded-full bg-elevated"
|
|
1493
|
+
>
|
|
1494
|
+
<span
|
|
1495
|
+
class="block h-full rounded-full bg-primary"
|
|
1496
|
+
:style="{
|
|
1497
|
+
width: `${entered > 0 ? (row.sessions / entered) * 100 : 0}%`,
|
|
1498
|
+
}"
|
|
1499
|
+
/>
|
|
1500
|
+
</span>
|
|
1501
|
+
</div>
|
|
1502
|
+
<div
|
|
1503
|
+
class="mt-2 flex items-center justify-between border-t border-default pt-2"
|
|
1504
|
+
>
|
|
1505
|
+
<span class="text-sm text-muted">
|
|
1506
|
+
{{ t('page.marketing.builder.end_to_end') }}
|
|
1507
|
+
</span>
|
|
1508
|
+
<span class="font-mono text-sm font-semibold text-highlighted">
|
|
1509
|
+
{{
|
|
1510
|
+
endToEnd === null
|
|
1511
|
+
? '—'
|
|
1512
|
+
: formatPercentage(
|
|
1513
|
+
endToEnd / 100,
|
|
1514
|
+
locale,
|
|
1515
|
+
fractionDigits(1),
|
|
1516
|
+
)
|
|
1517
|
+
}}
|
|
1518
|
+
</span>
|
|
1519
|
+
</div>
|
|
1520
|
+
</template>
|
|
1521
|
+
<p
|
|
1522
|
+
v-if="splitEnabled && sampleHint"
|
|
1523
|
+
class="mt-1 flex gap-1.5 text-xs"
|
|
1524
|
+
:class="sampleHint.tooSmall ? 'text-warning' : 'text-muted'"
|
|
1525
|
+
>
|
|
1526
|
+
<UIcon name="i-ph-info" class="mt-0.5 size-3.5 shrink-0" />
|
|
1527
|
+
{{ t('page.marketing.builder.sample_hint', sampleHint) }}
|
|
1528
|
+
</p>
|
|
1529
|
+
</div>
|
|
1530
|
+
</section>
|
|
1531
|
+
|
|
1532
|
+
<section class="dms-card p-0">
|
|
1533
|
+
<header class="border-b border-default px-4 py-3">
|
|
1534
|
+
<DmsEyebrow :label="t('page.marketing.builder.events_title')" />
|
|
1535
|
+
</header>
|
|
1536
|
+
<ul class="divide-y divide-default">
|
|
1537
|
+
<li
|
|
1538
|
+
v-for="event in suggestions.events.slice(0, 6)"
|
|
1539
|
+
:key="event.value"
|
|
1540
|
+
>
|
|
1541
|
+
<button
|
|
1542
|
+
type="button"
|
|
1543
|
+
class="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-elevated/60"
|
|
1544
|
+
:aria-label="
|
|
1545
|
+
t('page.marketing.builder.add_event_step', {
|
|
1546
|
+
name: event.value,
|
|
1547
|
+
})
|
|
1548
|
+
"
|
|
1549
|
+
@click="addStep('custom', event.value)"
|
|
1550
|
+
>
|
|
1551
|
+
<span
|
|
1552
|
+
class="flex-1 truncate font-mono text-[13px] text-highlighted"
|
|
1553
|
+
>
|
|
1554
|
+
{{ event.value }}
|
|
1555
|
+
</span>
|
|
1556
|
+
<span class="font-mono text-[11px] text-dimmed">
|
|
1557
|
+
{{ formatNumber(event.count, locale) }}
|
|
1558
|
+
</span>
|
|
1559
|
+
</button>
|
|
1560
|
+
</li>
|
|
1561
|
+
<li
|
|
1562
|
+
v-if="suggestions.events.length === 0"
|
|
1563
|
+
class="px-4 py-4 text-center text-sm text-muted"
|
|
1564
|
+
>
|
|
1565
|
+
{{ t('page.marketing.builder.no_events') }}
|
|
1566
|
+
</li>
|
|
1567
|
+
</ul>
|
|
1568
|
+
<footer
|
|
1569
|
+
class="flex items-center gap-1.5 border-t border-default px-4 py-2.5 font-mono text-[11px] text-dimmed"
|
|
1570
|
+
>
|
|
1571
|
+
<UIcon name="i-ph-code" class="size-3.5 shrink-0" />
|
|
1572
|
+
<span class="min-w-0 break-words">
|
|
1573
|
+
{{ t('page.marketing.builder.events_hint') }}
|
|
1574
|
+
</span>
|
|
1575
|
+
</footer>
|
|
1576
|
+
</section>
|
|
1577
|
+
</aside>
|
|
1578
|
+
</div>
|
|
1579
|
+
|
|
1580
|
+
<DmsSaveBar
|
|
1581
|
+
v-if="!loading && !loadFailed"
|
|
1582
|
+
:dirty="dirty"
|
|
1583
|
+
:saving="saving"
|
|
1584
|
+
:save-label="
|
|
1585
|
+
isEdit
|
|
1586
|
+
? '$page.marketing.common.save_changes'
|
|
1587
|
+
: '$page.marketing.builder.save'
|
|
1588
|
+
"
|
|
1589
|
+
cancellable
|
|
1590
|
+
@save="save"
|
|
1591
|
+
@discard="discard"
|
|
1592
|
+
@cancel="leave(backLink)"
|
|
1593
|
+
/>
|
|
1594
|
+
</div>
|
|
1595
|
+
</template>
|