@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,154 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import { useI18n } from '#dms/frontend-module'
|
|
4
|
+
import {
|
|
5
|
+
parseFunnelStepsValue,
|
|
6
|
+
type FunnelStepDraft,
|
|
7
|
+
} from '../composables/useFunnelSteps'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Form input for the marketing_funnel_steps DataType: an ordered list of
|
|
11
|
+
* {kind, value} steps, emitted as the array itself so the client-side zod
|
|
12
|
+
* schema and the write path both see the real type (a JSON string would be
|
|
13
|
+
* persisted raw). Bounds mirror the backend MIN/MAX_FUNNEL_STEPS.
|
|
14
|
+
*/
|
|
15
|
+
interface StepKindMeta {
|
|
16
|
+
label: string
|
|
17
|
+
placeholder: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MIN_STEPS = 1
|
|
21
|
+
const MAX_STEPS = 10
|
|
22
|
+
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
modelValue?: FunnelStepDraft[] | string | null
|
|
25
|
+
initialValue?: FunnelStepDraft[] | string | null
|
|
26
|
+
disabled?: boolean
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{ 'update:modelValue': [value: FunnelStepDraft[]] }>()
|
|
30
|
+
|
|
31
|
+
const { t } = useI18n()
|
|
32
|
+
|
|
33
|
+
// Computed so labels follow live locale switches (t() at setup would freeze).
|
|
34
|
+
const stepKindMeta = computed<Record<FunnelStepDraft['kind'], StepKindMeta>>(
|
|
35
|
+
() => ({
|
|
36
|
+
url: {
|
|
37
|
+
label: t('page.marketing.funnels.step_kind.url'),
|
|
38
|
+
placeholder: t('page.marketing.funnels.form.url_placeholder'),
|
|
39
|
+
},
|
|
40
|
+
custom: {
|
|
41
|
+
label: t('page.marketing.funnels.step_kind.custom'),
|
|
42
|
+
placeholder: t('page.marketing.funnels.form.event_placeholder'),
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const stepKindItems = computed(() =>
|
|
48
|
+
Object.entries(stepKindMeta.value).map(([value, meta]) => ({
|
|
49
|
+
label: meta.label,
|
|
50
|
+
value,
|
|
51
|
+
})),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
function emptyStep(): FunnelStepDraft {
|
|
55
|
+
return { kind: 'url', value: '' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseValue(
|
|
59
|
+
raw: FunnelStepDraft[] | string | null | undefined,
|
|
60
|
+
): FunnelStepDraft[] {
|
|
61
|
+
// Copies, not references: the drafts are mutated by the inputs below and
|
|
62
|
+
// must never write through to the prop value.
|
|
63
|
+
const steps = parseFunnelStepsValue(raw).map(step => ({ ...step }))
|
|
64
|
+
while (steps.length < MIN_STEPS) {
|
|
65
|
+
steps.push(emptyStep())
|
|
66
|
+
}
|
|
67
|
+
return steps
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const steps = ref<FunnelStepDraft[]>(
|
|
71
|
+
parseValue(props.modelValue ?? props.initialValue),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// Values are emitted untrimmed — trimming here would eat spaces mid-typing
|
|
75
|
+
// through the v-model echo; the DataType schema trims server-side.
|
|
76
|
+
function onEdit(): void {
|
|
77
|
+
emit(
|
|
78
|
+
'update:modelValue',
|
|
79
|
+
steps.value.map((step) => ({ kind: step.kind, value: step.value })),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function addStep(): void {
|
|
84
|
+
if (steps.value.length >= MAX_STEPS) {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
steps.value.push(emptyStep())
|
|
88
|
+
onEdit()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function removeStep(index: number): void {
|
|
92
|
+
if (steps.value.length <= MIN_STEPS) {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
steps.value.splice(index, 1)
|
|
96
|
+
onEdit()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
watch(
|
|
100
|
+
() => props.modelValue,
|
|
101
|
+
(value) => {
|
|
102
|
+
const parsed = parseValue(value)
|
|
103
|
+
if (JSON.stringify(parsed) !== JSON.stringify(steps.value)) {
|
|
104
|
+
steps.value = parsed
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<template>
|
|
111
|
+
<div class="flex flex-col gap-2">
|
|
112
|
+
<div
|
|
113
|
+
v-for="(step, index) in steps"
|
|
114
|
+
:key="index"
|
|
115
|
+
class="flex items-center gap-2"
|
|
116
|
+
>
|
|
117
|
+
<USelect
|
|
118
|
+
v-model="step.kind"
|
|
119
|
+
:items="stepKindItems"
|
|
120
|
+
:disabled="props.disabled"
|
|
121
|
+
class="w-32"
|
|
122
|
+
size="sm"
|
|
123
|
+
@update:model-value="onEdit"
|
|
124
|
+
/>
|
|
125
|
+
<UInput
|
|
126
|
+
v-model="step.value"
|
|
127
|
+
:placeholder="stepKindMeta[step.kind].placeholder"
|
|
128
|
+
:disabled="props.disabled"
|
|
129
|
+
class="flex-1"
|
|
130
|
+
size="sm"
|
|
131
|
+
@update:model-value="onEdit"
|
|
132
|
+
/>
|
|
133
|
+
<UButton
|
|
134
|
+
v-if="!props.disabled"
|
|
135
|
+
icon="i-ph-x"
|
|
136
|
+
color="neutral"
|
|
137
|
+
variant="ghost"
|
|
138
|
+
size="xs"
|
|
139
|
+
:disabled="steps.length <= MIN_STEPS"
|
|
140
|
+
@click="removeStep(index)"
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
<UButton
|
|
144
|
+
v-if="!props.disabled && steps.length < MAX_STEPS"
|
|
145
|
+
icon="i-ph-plus"
|
|
146
|
+
:label="t('page.marketing.funnels.form.add_step')"
|
|
147
|
+
color="neutral"
|
|
148
|
+
variant="subtle"
|
|
149
|
+
size="sm"
|
|
150
|
+
class="self-start"
|
|
151
|
+
@click="addStep"
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
</template>
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
useDmsRoute,
|
|
5
|
+
useDmsRouter,
|
|
6
|
+
useI18n,
|
|
7
|
+
} from '#dms/frontend-module'
|
|
8
|
+
import { useChildId } from '../composables/useChildId'
|
|
9
|
+
import { parseExperimentValue } from '../composables/useExperiment'
|
|
10
|
+
import { parseFunnelStepsValue } from '../composables/useFunnelSteps'
|
|
11
|
+
import { useLatestRequest } from '../composables/useLatestRequest'
|
|
12
|
+
import {
|
|
13
|
+
useMarketingApi,
|
|
14
|
+
type MarketingExperimentStatus,
|
|
15
|
+
type MarketingFunnelListItem,
|
|
16
|
+
} from '../composables/useMarketingApi'
|
|
17
|
+
import {
|
|
18
|
+
MARKETING_PERIOD_PRESETS,
|
|
19
|
+
ndToPreset,
|
|
20
|
+
useMarketingPeriod,
|
|
21
|
+
} from '../composables/useMarketingPeriod'
|
|
22
|
+
import { funnelsLink } from '../composables/useMarketingRoutes'
|
|
23
|
+
import { useMarketingWebsites } from '../composables/useMarketingWebsites'
|
|
24
|
+
import { dmsComponent } from '../utils/dmsComponent'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Conversion funnels of a site, plain or split into A/B arms: the
|
|
28
|
+
* definitions list on the left, the figure of the selected one on the right —
|
|
29
|
+
* the tracked-pages layout, for the same reason: picking what to look at is
|
|
30
|
+
* a single-selection gesture, and it, the period and the URL state belong to
|
|
31
|
+
* one surface.
|
|
32
|
+
*
|
|
33
|
+
* Definitions CRUD goes through the funnels data-api — create/edit open the
|
|
34
|
+
* generic DmsForm in a modal, delete and the lifecycle transitions sit in the
|
|
35
|
+
* detail pane; the selection lives in the URL so a funnel can be linked to.
|
|
36
|
+
*/
|
|
37
|
+
const PERIOD_SCOPE = 'dms-marketing-funnels'
|
|
38
|
+
|
|
39
|
+
// Declared, not inherited as attributes: the DMS page renderer hands every
|
|
40
|
+
// component its identity, and an undeclared prop lands on the root element.
|
|
41
|
+
const props = withDefaults(
|
|
42
|
+
defineProps<{ componentId?: string, pageId?: string }>(),
|
|
43
|
+
{ componentId: 'marketing-funnels', pageId: 'marketing' },
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const childId = useChildId(() => props.componentId)
|
|
47
|
+
|
|
48
|
+
const api = useMarketingApi()
|
|
49
|
+
const { t } = useI18n()
|
|
50
|
+
const route = useDmsRoute()
|
|
51
|
+
const router = useDmsRouter()
|
|
52
|
+
const modal = useModal()
|
|
53
|
+
const toast = useToast()
|
|
54
|
+
|
|
55
|
+
const queryParam = (key: string): string => {
|
|
56
|
+
const value = route.query[key]
|
|
57
|
+
return typeof value === 'string' ? value : ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Read once: the selector seeds its preset at init and never reactively, so a
|
|
61
|
+
// deep link's window has to be resolved before it renders.
|
|
62
|
+
const initialPreset = ndToPreset(queryParam('period'))
|
|
63
|
+
|
|
64
|
+
const {
|
|
65
|
+
websites,
|
|
66
|
+
items: websiteItems,
|
|
67
|
+
selectedId: selectedWebsiteId,
|
|
68
|
+
loading: loadingWebsites,
|
|
69
|
+
failed: websitesFailed,
|
|
70
|
+
load: loadWebsites,
|
|
71
|
+
create: createWebsite,
|
|
72
|
+
} = useMarketingWebsites(queryParam('website'))
|
|
73
|
+
|
|
74
|
+
// `undefined`, not null: this is what DmsMasterDetail's model is typed on.
|
|
75
|
+
const selectedFunnelId = ref<string | undefined>(
|
|
76
|
+
queryParam('funnel') || undefined,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const period = useMarketingPeriod(PERIOD_SCOPE)
|
|
80
|
+
|
|
81
|
+
/** Bumped after an edit so the detail recomputes an unchanged selection. */
|
|
82
|
+
const refreshToken = ref(0)
|
|
83
|
+
|
|
84
|
+
const {
|
|
85
|
+
data: funnelList,
|
|
86
|
+
loading: loadingFunnels,
|
|
87
|
+
failed: funnelsFailed,
|
|
88
|
+
run: loadFunnels,
|
|
89
|
+
} = useLatestRequest(() =>
|
|
90
|
+
// Server-side there is no session to authenticate with: the fetch belongs
|
|
91
|
+
// to the client, like every other read here.
|
|
92
|
+
selectedWebsiteId.value && !import.meta.env.SSR
|
|
93
|
+
? api.listFunnels(selectedWebsiteId.value)
|
|
94
|
+
: null,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const funnels = computed(() => funnelList.value?.results ?? [])
|
|
98
|
+
|
|
99
|
+
const selectedFunnel = computed(
|
|
100
|
+
() => funnels.value.find(funnel => funnel._id === selectedFunnelId.value) ?? null,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const selectedExperiment = computed(() =>
|
|
104
|
+
parseExperimentValue(selectedFunnel.value?.experiment),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
// Two failures, two flags: the list fetch clears its own error on every
|
|
108
|
+
// attempt, and must not clear the one that says the site list never loaded.
|
|
109
|
+
const failed = computed(() => websitesFailed.value || funnelsFailed.value)
|
|
110
|
+
|
|
111
|
+
// Land on something rather than on an empty pane. A linked funnel wins, but
|
|
112
|
+
// only while the list confirms it exists: a deleted or foreign-site id from
|
|
113
|
+
// the URL would pin the detail pane on an error no retry can clear. Watching
|
|
114
|
+
// the answer rather than writing from the fetch keeps superseded ones out:
|
|
115
|
+
// only the newest ever reaches here.
|
|
116
|
+
watch(funnelList, (data) => {
|
|
117
|
+
if (!data) {
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
const selected = selectedFunnelId.value
|
|
121
|
+
if (selected && data.results.some(funnel => funnel._id === selected)) {
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
selectedFunnelId.value = data.results[0]?._id
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Retry entry point. Both fetches, in order, because either can be the one
|
|
129
|
+
* that failed: on a deep link the list runs even when the site list did not,
|
|
130
|
+
* and reloading the site list alone leaves the selection unchanged, so the
|
|
131
|
+
* watcher never fires and the funnels are never retried.
|
|
132
|
+
*/
|
|
133
|
+
async function reload() {
|
|
134
|
+
if (websites.value.length === 0) {
|
|
135
|
+
await loadWebsites()
|
|
136
|
+
}
|
|
137
|
+
await loadFunnels()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onMounted(loadWebsites)
|
|
141
|
+
|
|
142
|
+
// The watcher owns every list fetch, including the first: definitions do not
|
|
143
|
+
// depend on the period, only on the site. Switching site invalidates the
|
|
144
|
+
// selection — only a real site-to-site change may clear it: the immediate run
|
|
145
|
+
// hands the watcher an undefined previous, and the site list resolving
|
|
146
|
+
// undefined → id is not a switch either. Both carry a linked funnel that must
|
|
147
|
+
// survive.
|
|
148
|
+
watch(selectedWebsiteId, (websiteId, previousWebsiteId) => {
|
|
149
|
+
if (previousWebsiteId !== undefined && websiteId !== previousWebsiteId) {
|
|
150
|
+
selectedFunnelId.value = undefined
|
|
151
|
+
}
|
|
152
|
+
void loadFunnels()
|
|
153
|
+
}, { immediate: true })
|
|
154
|
+
|
|
155
|
+
// State lives in the URL so a funnel can be linked to. Query-only navigation
|
|
156
|
+
// does not remount the page, so this is free.
|
|
157
|
+
watch([selectedWebsiteId, selectedFunnelId, period], () => {
|
|
158
|
+
void router.replace(
|
|
159
|
+
funnelsLink({
|
|
160
|
+
website: selectedWebsiteId.value,
|
|
161
|
+
funnel: selectedFunnelId.value,
|
|
162
|
+
period: period.value,
|
|
163
|
+
}),
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const statusLabels = computed<Record<MarketingExperimentStatus, string>>(() => ({
|
|
168
|
+
draft: t('page.marketing.funnels.status.draft'),
|
|
169
|
+
running: t('page.marketing.funnels.status.running'),
|
|
170
|
+
stopped: t('page.marketing.funnels.status.stopped'),
|
|
171
|
+
}))
|
|
172
|
+
|
|
173
|
+
const STATUS_ICONS: Record<MarketingExperimentStatus, string> = {
|
|
174
|
+
draft: 'i-ph-pencil-simple-line',
|
|
175
|
+
running: 'i-ph-play',
|
|
176
|
+
stopped: 'i-ph-stop',
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const listItems = computed(() =>
|
|
180
|
+
funnels.value.map((funnel) => {
|
|
181
|
+
const experiment = parseExperimentValue(funnel.experiment)
|
|
182
|
+
const stepCount = parseFunnelStepsValue(funnel.steps).length
|
|
183
|
+
const stepsLabel = t(
|
|
184
|
+
'page.marketing.funnels.steps_count',
|
|
185
|
+
{ count: stepCount },
|
|
186
|
+
stepCount,
|
|
187
|
+
)
|
|
188
|
+
if (!experiment) {
|
|
189
|
+
return {
|
|
190
|
+
value: funnel._id,
|
|
191
|
+
label: funnel.name,
|
|
192
|
+
sublabel: stepsLabel,
|
|
193
|
+
icon: 'i-ph-funnel',
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const status = experiment.status ?? 'draft'
|
|
197
|
+
const armCount = experiment.variations.length
|
|
198
|
+
return {
|
|
199
|
+
value: funnel._id,
|
|
200
|
+
label: funnel.name,
|
|
201
|
+
sublabel: `${statusLabels.value[status]} · ${t(
|
|
202
|
+
'page.marketing.funnels.variations_count',
|
|
203
|
+
{ count: armCount },
|
|
204
|
+
armCount,
|
|
205
|
+
)}`,
|
|
206
|
+
icon: STATUS_ICONS[status],
|
|
207
|
+
}
|
|
208
|
+
}),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// --- Definitions CRUD -------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
const FUNNELS_TABLE_BASE = '/api/marketing/tables/funnels'
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The writable field set of a funnel definition, declared for the generic
|
|
217
|
+
* DmsForm so create/edit look like every other DMS form. The website is not
|
|
218
|
+
* a field: it rides in submitDefaults — the submit only carries declared
|
|
219
|
+
* fields plus those, so the expanded relation a fetch may hydrate never
|
|
220
|
+
* reaches the write path. The experiment's status is never edited here: new
|
|
221
|
+
* rows start in draft server-side, transitions go through the detail pane.
|
|
222
|
+
*
|
|
223
|
+
* The JSON schema is not optional garnish: without one the form validates
|
|
224
|
+
* against an EMPTY zod object, and the parsed submit drops every field. Fine
|
|
225
|
+
* validation (step shapes, key slug, arm bounds) stays with the data-api.
|
|
226
|
+
*/
|
|
227
|
+
const FUNNEL_FORM_SCHEMA = {
|
|
228
|
+
type: 'object',
|
|
229
|
+
properties: {
|
|
230
|
+
name: { type: 'string', minLength: 1, maxLength: 100 },
|
|
231
|
+
steps: { type: 'array' },
|
|
232
|
+
conversionWindowHours: { type: 'number', minimum: 1, maximum: 720 },
|
|
233
|
+
experiment: { type: ['object', 'null'] },
|
|
234
|
+
},
|
|
235
|
+
required: ['name', 'steps'],
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const FUNNEL_FORM_FIELDS = [
|
|
239
|
+
{
|
|
240
|
+
id: 'name',
|
|
241
|
+
label: '$page.marketing.funnels.column.name',
|
|
242
|
+
required: true,
|
|
243
|
+
component: {
|
|
244
|
+
componentName: 'dms-input-text',
|
|
245
|
+
options: { placeholder: '$page.marketing.funnels.form.name' },
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: 'steps',
|
|
250
|
+
label: '$page.marketing.funnels.form.steps',
|
|
251
|
+
description: '$page.marketing.funnels.form.steps_description',
|
|
252
|
+
required: true,
|
|
253
|
+
component: { componentName: 'DmsMarketingFunnelStepsInput' },
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
id: 'conversionWindowHours',
|
|
257
|
+
label: '$page.marketing.funnels.form.window',
|
|
258
|
+
defaultValue: 24,
|
|
259
|
+
component: {
|
|
260
|
+
componentName: 'dms-input-number',
|
|
261
|
+
options: { min: 1, max: 720 },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: 'experiment',
|
|
266
|
+
label: '$page.marketing.funnels.form.experiment',
|
|
267
|
+
description: '$page.marketing.funnels.form.experiment_description',
|
|
268
|
+
component: { componentName: 'DmsMarketingExperimentInput' },
|
|
269
|
+
},
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
interface FunnelFormTarget {
|
|
273
|
+
title: string
|
|
274
|
+
websiteId: string
|
|
275
|
+
fetchUrl?: string
|
|
276
|
+
submitUrl: string
|
|
277
|
+
submitUrlMethod: 'POST' | 'PUT'
|
|
278
|
+
/** The saved row id, from the submit response. */
|
|
279
|
+
resolveId: (response: unknown) => string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The generic form closes through onSuccessCallback; a plain dismiss
|
|
283
|
+
* resolves the container result with undefined. */
|
|
284
|
+
function openFunnelForm(target: FunnelFormTarget): Promise<{ _id: string } | undefined> {
|
|
285
|
+
const instance = modal.open<{ _id: string } | undefined>({
|
|
286
|
+
title: target.title,
|
|
287
|
+
size: 'lg',
|
|
288
|
+
component: dmsComponent('dms-form'),
|
|
289
|
+
componentOptions: {
|
|
290
|
+
fields: FUNNEL_FORM_FIELDS,
|
|
291
|
+
schema: FUNNEL_FORM_SCHEMA,
|
|
292
|
+
fetchUrl: target.fetchUrl,
|
|
293
|
+
fetchUrlMethod: 'GET',
|
|
294
|
+
submitUrl: target.submitUrl,
|
|
295
|
+
submitUrlMethod: target.submitUrlMethod,
|
|
296
|
+
submitDefaults: { websiteId: target.websiteId },
|
|
297
|
+
onSuccessCallback: (response?: unknown) => {
|
|
298
|
+
instance.close({ _id: target.resolveId(response) })
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
return instance.result
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function openCreate(): Promise<void> {
|
|
306
|
+
const websiteId = selectedWebsiteId.value
|
|
307
|
+
if (!websiteId) {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
const saved = await openFunnelForm({
|
|
311
|
+
title: t('page.marketing.funnels.create_title'),
|
|
312
|
+
websiteId,
|
|
313
|
+
submitUrl: `${FUNNELS_TABLE_BASE}/new`,
|
|
314
|
+
submitUrlMethod: 'POST',
|
|
315
|
+
// The insert answers the generated keys, an array — not the row.
|
|
316
|
+
resolveId: response => (Array.isArray(response) ? String(response[0] ?? '') : ''),
|
|
317
|
+
})
|
|
318
|
+
if (!saved) {
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
await loadFunnels()
|
|
322
|
+
// A missing id (unexpected insert answer) keeps the current selection
|
|
323
|
+
// rather than blanking the pane.
|
|
324
|
+
if (saved._id) {
|
|
325
|
+
selectedFunnelId.value = saved._id
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The list hands the website relation expanded; the write path wants the
|
|
330
|
+
* bare id again. */
|
|
331
|
+
function bareWebsiteId(funnel: MarketingFunnelListItem): string {
|
|
332
|
+
return typeof funnel.websiteId === 'object' ? funnel.websiteId._id : funnel.websiteId
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function openEdit(): Promise<void> {
|
|
336
|
+
const funnel = selectedFunnel.value
|
|
337
|
+
if (!funnel) {
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
const saved = await openFunnelForm({
|
|
341
|
+
title: t('page.marketing.funnels.edit_title'),
|
|
342
|
+
websiteId: bareWebsiteId(funnel),
|
|
343
|
+
fetchUrl: `${FUNNELS_TABLE_BASE}/get?id=${encodeURIComponent(funnel._id)}`,
|
|
344
|
+
submitUrl: `${FUNNELS_TABLE_BASE}/edit?id=${encodeURIComponent(funnel._id)}`,
|
|
345
|
+
submitUrlMethod: 'PUT',
|
|
346
|
+
resolveId: () => funnel._id,
|
|
347
|
+
})
|
|
348
|
+
if (!saved) {
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
await loadFunnels()
|
|
352
|
+
refreshToken.value++
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Lifecycle transition from the detail pane: a full edit write, because the
|
|
356
|
+
* data-api's mandatory fields all travel on every edit. */
|
|
357
|
+
async function changeStatus(target: MarketingExperimentStatus): Promise<void> {
|
|
358
|
+
const funnel = selectedFunnel.value
|
|
359
|
+
const experiment = selectedExperiment.value
|
|
360
|
+
if (!funnel || !experiment) {
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
await api.updateFunnel(funnel._id, {
|
|
365
|
+
websiteId: bareWebsiteId(funnel),
|
|
366
|
+
name: funnel.name,
|
|
367
|
+
steps: parseFunnelStepsValue(funnel.steps),
|
|
368
|
+
conversionWindowHours: funnel.conversionWindowHours,
|
|
369
|
+
experiment: { ...experiment, status: target },
|
|
370
|
+
})
|
|
371
|
+
toast.add({
|
|
372
|
+
color: 'success',
|
|
373
|
+
title: t('page.marketing.funnels.status_updated'),
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
toast.add({
|
|
378
|
+
color: 'error',
|
|
379
|
+
title: t('page.marketing.funnels.status_error'),
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
await loadFunnels()
|
|
383
|
+
refreshToken.value++
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function onDeleted(): Promise<void> {
|
|
387
|
+
// Clearing first lets the list watcher land on the first survivor once the
|
|
388
|
+
// reload answers.
|
|
389
|
+
selectedFunnelId.value = undefined
|
|
390
|
+
await loadFunnels()
|
|
391
|
+
}
|
|
392
|
+
</script>
|
|
393
|
+
|
|
394
|
+
<template>
|
|
395
|
+
<div class="space-y-6 p-6">
|
|
396
|
+
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
397
|
+
<div>
|
|
398
|
+
<h1 class="text-xl font-semibold">{{ t('page.marketing.funnels.title') }}</h1>
|
|
399
|
+
<p class="text-sm text-muted">{{ t('page.marketing.funnels.subtitle') }}</p>
|
|
400
|
+
</div>
|
|
401
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
402
|
+
<USelect
|
|
403
|
+
v-if="websites.length > 1"
|
|
404
|
+
v-model="selectedWebsiteId"
|
|
405
|
+
:items="websiteItems"
|
|
406
|
+
class="w-56"
|
|
407
|
+
/>
|
|
408
|
+
<DmsPeriodSelector
|
|
409
|
+
:id="PERIOD_SCOPE"
|
|
410
|
+
:component-id="childId('period')"
|
|
411
|
+
:page-id="props.pageId"
|
|
412
|
+
:default-preset="initialPreset"
|
|
413
|
+
:presets="MARKETING_PERIOD_PRESETS"
|
|
414
|
+
:comparisons="['none']"
|
|
415
|
+
:show-range-label="false"
|
|
416
|
+
/>
|
|
417
|
+
<UButton
|
|
418
|
+
v-if="selectedWebsiteId"
|
|
419
|
+
icon="i-ph-plus"
|
|
420
|
+
size="sm"
|
|
421
|
+
:label="t('page.marketing.funnels.new')"
|
|
422
|
+
@click="openCreate"
|
|
423
|
+
/>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
<div v-if="failed" class="flex flex-col items-start gap-3">
|
|
428
|
+
<UAlert
|
|
429
|
+
color="error"
|
|
430
|
+
variant="subtle"
|
|
431
|
+
icon="i-ph-warning-circle"
|
|
432
|
+
:title="t('page.marketing.funnels.error')"
|
|
433
|
+
:description="t('page.marketing.funnels.error_hint')"
|
|
434
|
+
/>
|
|
435
|
+
<UButton
|
|
436
|
+
icon="i-ph-arrow-clockwise"
|
|
437
|
+
:label="t('page.marketing.funnels.retry')"
|
|
438
|
+
color="neutral"
|
|
439
|
+
variant="subtle"
|
|
440
|
+
size="sm"
|
|
441
|
+
@click="reload"
|
|
442
|
+
/>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
<USkeleton v-else-if="loadingWebsites" class="h-96 w-full" />
|
|
446
|
+
|
|
447
|
+
<DmsMarketingNoWebsiteState
|
|
448
|
+
v-else-if="websites.length === 0"
|
|
449
|
+
icon="i-ph-globe"
|
|
450
|
+
:title="t('page.marketing.funnels.no_website.title')"
|
|
451
|
+
:description="t('page.marketing.funnels.no_website.description')"
|
|
452
|
+
@create="createWebsite"
|
|
453
|
+
/>
|
|
454
|
+
|
|
455
|
+
<div
|
|
456
|
+
v-else-if="!loadingFunnels && funnels.length === 0"
|
|
457
|
+
class="flex flex-col items-start gap-3"
|
|
458
|
+
>
|
|
459
|
+
<UAlert
|
|
460
|
+
icon="i-ph-funnel"
|
|
461
|
+
:title="t('page.marketing.funnels.empty.title')"
|
|
462
|
+
:description="t('page.marketing.funnels.empty.description')"
|
|
463
|
+
/>
|
|
464
|
+
<UButton
|
|
465
|
+
icon="i-ph-plus"
|
|
466
|
+
:label="t('page.marketing.funnels.new')"
|
|
467
|
+
@click="openCreate"
|
|
468
|
+
/>
|
|
469
|
+
</div>
|
|
470
|
+
|
|
471
|
+
<DmsMasterDetail
|
|
472
|
+
v-else
|
|
473
|
+
v-model="selectedFunnelId"
|
|
474
|
+
:items="listItems"
|
|
475
|
+
:list-label="t('page.marketing.funnels.list')"
|
|
476
|
+
>
|
|
477
|
+
<DmsMarketingFunnelDetail
|
|
478
|
+
v-if="selectedFunnelId && selectedFunnel"
|
|
479
|
+
:funnel-id="selectedFunnelId"
|
|
480
|
+
:funnel-name="selectedFunnel.name"
|
|
481
|
+
:experiment="selectedExperiment"
|
|
482
|
+
:period="period"
|
|
483
|
+
:refresh-token="refreshToken"
|
|
484
|
+
@edit="openEdit"
|
|
485
|
+
@deleted="onDeleted"
|
|
486
|
+
@status-changed="changeStatus"
|
|
487
|
+
/>
|
|
488
|
+
<div v-else class="p-8 text-center text-sm text-muted">
|
|
489
|
+
{{ t('page.marketing.funnels.select_hint') }}
|
|
490
|
+
</div>
|
|
491
|
+
</DmsMasterDetail>
|
|
492
|
+
</div>
|
|
493
|
+
</template>
|