@antelopejs/dms-marketing 0.3.3 → 0.4.1
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 -4
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.js +18 -3
- package/dist/index.js.map +1 -1
- package/dist/pages/acquisition/page.d.ts +9 -0
- package/dist/pages/acquisition/page.js +181 -0
- package/dist/pages/acquisition/page.js.map +1 -0
- package/dist/pages/all.d.ts +5 -1
- package/dist/pages/all.js +5 -1
- package/dist/pages/all.js.map +1 -1
- package/dist/pages/blocks.d.ts +18 -0
- package/dist/pages/blocks.js +56 -0
- package/dist/pages/blocks.js.map +1 -0
- package/dist/pages/funnel/page.d.ts +9 -0
- package/dist/pages/funnel/page.js +36 -0
- package/dist/pages/funnel/page.js.map +1 -0
- package/dist/pages/funnel-builder/page.d.ts +9 -0
- package/dist/pages/funnel-builder/page.js +29 -0
- package/dist/pages/funnel-builder/page.js.map +1 -0
- package/dist/pages/funnels/page.d.ts +1 -1
- package/dist/pages/funnels/page.js +200 -3
- package/dist/pages/funnels/page.js.map +1 -1
- package/dist/pages/index.d.ts +1 -0
- package/dist/pages/index.js +1 -0
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/{campaigns → install}/page.d.ts +3 -3
- package/dist/pages/{campaigns → install}/page.js +14 -12
- package/dist/pages/install/page.js.map +1 -0
- package/dist/pages/module.d.ts +3 -0
- package/dist/pages/module.js +30 -3
- package/dist/pages/module.js.map +1 -1
- package/dist/pages/overview/page.d.ts +1 -1
- package/dist/pages/overview/page.js +122 -3
- package/dist/pages/overview/page.js.map +1 -1
- package/dist/pages/pages/page.d.ts +1 -1
- package/dist/pages/pages/page.js +9 -4
- package/dist/pages/pages/page.js.map +1 -1
- package/dist/pages/quick-actions.d.ts +1 -0
- package/dist/pages/quick-actions.js +25 -0
- package/dist/pages/quick-actions.js.map +1 -0
- package/dist/pages/settings/form.d.ts +2 -1
- package/dist/pages/settings/form.js +62 -20
- package/dist/pages/settings/form.js.map +1 -1
- package/dist/pages/settings/page.d.ts +4 -1
- package/dist/pages/settings/page.js +50 -4
- package/dist/pages/settings/page.js.map +1 -1
- package/dist/pages/websites/page.d.ts +11 -0
- package/dist/pages/websites/page.js +62 -0
- package/dist/pages/websites/page.js.map +1 -0
- package/dist/routes/blocks.d.ts +50 -0
- package/dist/routes/blocks.js +269 -0
- package/dist/routes/blocks.js.map +1 -0
- package/dist/routes/collect.d.ts +1 -2
- package/dist/routes/collect.js +19 -12
- package/dist/routes/collect.js.map +1 -1
- package/dist/routes/context.d.ts +8 -0
- package/dist/routes/context.js +54 -0
- package/dist/routes/context.js.map +1 -0
- package/dist/routes/experiments.js +2 -1
- package/dist/routes/experiments.js.map +1 -1
- package/dist/routes/funnels.d.ts +21 -3
- package/dist/routes/funnels.js +150 -7
- package/dist/routes/funnels.js.map +1 -1
- package/dist/routes/index.d.ts +2 -0
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/settings.d.ts +6 -1
- package/dist/routes/settings.js +65 -4
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/snapshot.d.ts +1 -1
- package/dist/routes/snapshot.js +24 -25
- package/dist/routes/snapshot.js.map +1 -1
- package/dist/routes/stats.d.ts +14 -23
- package/dist/routes/stats.js +73 -97
- package/dist/routes/stats.js.map +1 -1
- package/dist/routes/tracker.js +6 -4
- package/dist/routes/tracker.js.map +1 -1
- package/dist/routes/websites.d.ts +4 -0
- package/dist/routes/websites.js +111 -12
- package/dist/routes/websites.js.map +1 -1
- package/dist/services/acquisition-report.d.ts +62 -0
- package/dist/services/acquisition-report.js +181 -0
- package/dist/services/acquisition-report.js.map +1 -0
- package/dist/services/acquisition.d.ts +9 -3
- package/dist/services/acquisition.js +35 -5
- package/dist/services/acquisition.js.map +1 -1
- package/dist/services/catalog.d.ts +3 -0
- package/dist/services/catalog.js +17 -0
- package/dist/services/catalog.js.map +1 -0
- package/dist/services/collect.d.ts +1 -2
- package/dist/services/collect.js +49 -24
- package/dist/services/collect.js.map +1 -1
- package/dist/services/composed.d.ts +9 -0
- package/dist/services/composed.js +62 -0
- package/dist/services/composed.js.map +1 -0
- package/dist/services/context.d.ts +20 -0
- package/dist/services/context.js +76 -0
- package/dist/services/context.js.map +1 -0
- package/dist/services/dashboard.d.ts +74 -0
- package/dist/services/dashboard.js +343 -0
- package/dist/services/dashboard.js.map +1 -0
- package/dist/services/experiments/results.d.ts +12 -1
- package/dist/services/experiments/results.js +19 -2
- package/dist/services/experiments/results.js.map +1 -1
- package/dist/services/experiments/stats.d.ts +7 -1
- package/dist/services/experiments/stats.js +43 -5
- package/dist/services/experiments/stats.js.map +1 -1
- package/dist/services/funnel-report.d.ts +64 -0
- package/dist/services/funnel-report.js +280 -0
- package/dist/services/funnel-report.js.map +1 -0
- package/dist/services/funnels/compute.d.ts +2 -0
- package/dist/services/funnels/compute.js +25 -13
- package/dist/services/funnels/compute.js.map +1 -1
- package/dist/services/funnels/results.js +4 -1
- package/dist/services/funnels/results.js.map +1 -1
- package/dist/services/funnels/step-path.d.ts +4 -0
- package/dist/services/funnels/step-path.js +47 -0
- package/dist/services/funnels/step-path.js.map +1 -0
- package/dist/services/funnels-table.d.ts +31 -0
- package/dist/services/funnels-table.js +230 -0
- package/dist/services/funnels-table.js.map +1 -0
- package/dist/services/heatmap.d.ts +15 -3
- package/dist/services/heatmap.js +54 -11
- package/dist/services/heatmap.js.map +1 -1
- package/dist/services/hourly-traffic.d.ts +4 -0
- package/dist/services/hourly-traffic.js +35 -0
- package/dist/services/hourly-traffic.js.map +1 -0
- package/dist/services/origin-guard.d.ts +9 -1
- package/dist/services/origin-guard.js +46 -1
- package/dist/services/origin-guard.js.map +1 -1
- package/dist/services/period.d.ts +26 -1
- package/dist/services/period.js +99 -9
- package/dist/services/period.js.map +1 -1
- package/dist/services/prune.d.ts +6 -0
- package/dist/services/prune.js +35 -0
- package/dist/services/prune.js.map +1 -1
- package/dist/services/request-body.d.ts +1 -2
- package/dist/services/request-body.js +10 -36
- package/dist/services/request-body.js.map +1 -1
- package/dist/services/request-meta.d.ts +0 -1
- package/dist/services/request-meta.js +0 -11
- package/dist/services/request-meta.js.map +1 -1
- package/dist/services/rollups.d.ts +25 -0
- package/dist/services/rollups.js +102 -0
- package/dist/services/rollups.js.map +1 -0
- package/dist/services/sessionize.d.ts +5 -1
- package/dist/services/sessionize.js +52 -10
- package/dist/services/sessionize.js.map +1 -1
- package/dist/services/settings.d.ts +41 -2
- package/dist/services/settings.js +219 -6
- package/dist/services/settings.js.map +1 -1
- package/dist/services/snapshots.d.ts +3 -2
- package/dist/services/snapshots.js +110 -9
- package/dist/services/snapshots.js.map +1 -1
- package/dist/services/tenant-website.js +1 -0
- package/dist/services/tenant-website.js.map +1 -1
- package/dist/services/url.js +12 -4
- package/dist/services/url.js.map +1 -1
- package/dist/services/website-hosts.d.ts +15 -0
- package/dist/services/website-hosts.js +67 -0
- package/dist/services/website-hosts.js.map +1 -0
- package/dist/services/website-report.d.ts +40 -0
- package/dist/services/website-report.js +84 -0
- package/dist/services/website-report.js.map +1 -0
- package/dist/types/constants.d.ts +14 -2
- package/dist/types/constants.js +17 -6
- package/dist/types/constants.js.map +1 -1
- package/dist/types/events.d.ts +221 -68
- package/dist/types/events.js +60 -11
- package/dist/types/events.js.map +1 -1
- package/dist/types/snapshots.d.ts +6 -0
- package/dist/types/snapshots.js +2 -0
- package/dist/types/snapshots.js.map +1 -1
- package/dist/types/statistics.d.ts +2 -1
- package/dist/types/statistics.js +9 -2
- package/dist/types/statistics.js.map +1 -1
- package/dist/utils/experiment-type.js +1 -1
- package/dist/utils/experiment-type.js.map +1 -1
- package/dist/utils/funnel-steps-type.d.ts +21 -3
- package/dist/utils/funnel-steps-type.js +7 -2
- package/dist/utils/funnel-steps-type.js.map +1 -1
- package/frontend-vue/app/components/ChannelsCard.vue +265 -0
- package/frontend-vue/app/components/Context.vue +479 -0
- package/frontend-vue/app/components/ExperimentInput.vue +3 -1
- package/frontend-vue/app/components/ExperimentReport.vue +736 -0
- package/frontend-vue/app/components/FirstRun.vue +60 -0
- package/frontend-vue/app/components/FunnelBuilder.vue +1595 -0
- package/frontend-vue/app/components/FunnelFigure.vue +640 -0
- package/frontend-vue/app/components/FunnelReport.vue +532 -0
- package/frontend-vue/app/components/FunnelStepsInput.vue +3 -1
- package/frontend-vue/app/components/FunnelTemplates.vue +161 -0
- package/frontend-vue/app/components/HeatmapCanvas.vue +11 -4
- package/frontend-vue/app/components/InstallGuide.vue +753 -0
- package/frontend-vue/app/components/PagePreview.vue +889 -416
- package/frontend-vue/app/components/PagesExplorer.vue +552 -0
- package/frontend-vue/app/components/ScrollDepthOverlay.vue +48 -20
- package/frontend-vue/app/components/TopListTabs.vue +317 -0
- package/frontend-vue/app/components/TrackerSnippet.vue +50 -16
- package/frontend-vue/app/components/VariationsInput.vue +15 -5
- package/frontend-vue/app/components/WebsitesGrid.vue +799 -0
- package/frontend-vue/app/composables/useContextLink.ts +16 -0
- package/frontend-vue/app/composables/useExperiment.ts +1 -2
- package/frontend-vue/app/composables/useFunnelSteps.ts +1 -2
- package/frontend-vue/app/composables/useLatestRequest.ts +2 -4
- package/frontend-vue/app/composables/useMarketingApi.ts +298 -133
- package/frontend-vue/app/composables/useMarketingContext.ts +55 -0
- package/frontend-vue/app/composables/useMarketingRoutes.ts +80 -27
- package/frontend-vue/app/composables/useSnapshotFrame.ts +26 -4
- package/frontend-vue/app/constants.ts +22 -0
- package/frontend-vue/app/plugins/funnel-steps-data-type.client.ts +2 -1
- package/frontend-vue/app/utils/channels.ts +37 -0
- package/frontend-vue/app/utils/format.ts +132 -0
- package/frontend-vue/app/utils/funnel-draft.ts +264 -0
- package/frontend-vue/app/utils/heatmap.ts +82 -0
- package/frontend-vue/app/utils/heatmapPalette.ts +10 -0
- package/frontend-vue/app/utils/host-conflict.ts +48 -0
- package/frontend-vue/app/utils/sample-size.ts +17 -0
- package/frontend-vue/app/utils/snapshotViewportUnits.ts +15 -5
- package/frontend-vue/app/utils/snippet.ts +53 -0
- package/frontend-vue/app/utils/step-path.ts +32 -0
- package/frontend-vue/dms.frontend.ts +4 -3
- package/frontend-vue/i18n/locales/marketing-en-GB.json +1083 -316
- package/frontend-vue/i18n/locales/marketing-fr-FR.json +1087 -320
- package/frontend-vue/package.json +1 -2
- package/frontend-vue/pnpm-lock.yaml +51 -19
- 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 +17 -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/frontend-vue/vitest.config.ts +3 -1
- package/package.json +10 -8
- 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
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Links between the module's surfaces.
|
|
3
3
|
*
|
|
4
|
-
* The `/modules/marketing` prefix comes from the
|
|
5
|
-
* a DMS module
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The `/modules/marketing` prefix comes from the pages being registered under
|
|
5
|
+
* a DMS module. The context bar reads the website and the period from the
|
|
6
|
+
* page URL, the reader's saved ones standing in for what it leaves out: a
|
|
7
|
+
* link followed from an analytics page carries the page's own, or it would
|
|
8
|
+
* open on whatever another tab saved last: {@link withWebsite} for the
|
|
9
|
+
* website, the DMS `withPeriodQuery` for the period selector's keys.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
const MODULE_BASE = '/modules/marketing'
|
|
11
13
|
|
|
12
|
-
interface PagesLink {
|
|
13
|
-
website?: string | null
|
|
14
|
-
path?: string | null
|
|
15
|
-
period?: string | null
|
|
16
|
-
}
|
|
17
|
-
|
|
18
14
|
/** Query without the empty keys — a bare link must stay bare. */
|
|
19
|
-
function withQuery(
|
|
15
|
+
function withQuery(
|
|
16
|
+
base: string,
|
|
17
|
+
query: Record<string, string | null | undefined>,
|
|
18
|
+
): string {
|
|
20
19
|
const params = new URLSearchParams()
|
|
21
20
|
for (const [key, value] of Object.entries(query)) {
|
|
22
21
|
if (value) {
|
|
@@ -27,28 +26,82 @@ function withQuery(base: string, query: Record<string, string | null | undefined
|
|
|
27
26
|
return suffix ? `${base}?${suffix}` : base
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
/** The
|
|
31
|
-
export function
|
|
32
|
-
return withQuery(`${MODULE_BASE}/
|
|
29
|
+
/** The overview, on `website` when given. */
|
|
30
|
+
export function overviewLink(website?: string | null): string {
|
|
31
|
+
return withQuery(`${MODULE_BASE}/overview`, { website })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Pages & heatmaps, optionally on a given path. */
|
|
35
|
+
export function pagesLink(path?: string | null): string {
|
|
36
|
+
return withQuery(`${MODULE_BASE}/pages`, { path })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function acquisitionLink(): string {
|
|
40
|
+
return `${MODULE_BASE}/acquisition`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function funnelsLink(): string {
|
|
44
|
+
return `${MODULE_BASE}/funnels`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One funnel's report (an A/B test's when it carries one). */
|
|
48
|
+
export function funnelLink(id: string): string {
|
|
49
|
+
return withQuery(`${MODULE_BASE}/funnel`, { id })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface BuilderOptions {
|
|
53
|
+
/** Open with the A/B section switched on. */
|
|
54
|
+
split?: boolean
|
|
55
|
+
/** Prefill the steps of a new funnel (a template). */
|
|
56
|
+
steps?: { kind: 'url' | 'custom'; value: string }[]
|
|
57
|
+
/** Start a new funnel as a copy of this one (Duplicate). */
|
|
58
|
+
copy?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The builder: a new funnel, or the one `id` names. */
|
|
62
|
+
export function funnelBuilderLink(
|
|
63
|
+
id?: string | null,
|
|
64
|
+
options: BuilderOptions = {},
|
|
65
|
+
): string {
|
|
66
|
+
return withQuery(`${MODULE_BASE}/funnel-builder`, {
|
|
67
|
+
id,
|
|
68
|
+
split: options.split ? '1' : null,
|
|
69
|
+
steps: options.steps ? JSON.stringify(options.steps) : null,
|
|
70
|
+
copy: options.copy,
|
|
71
|
+
})
|
|
33
72
|
}
|
|
34
73
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
period?: string | null
|
|
74
|
+
export function websitesLink(): string {
|
|
75
|
+
return `${MODULE_BASE}/websites`
|
|
38
76
|
}
|
|
39
77
|
|
|
40
|
-
/** The
|
|
41
|
-
export function
|
|
42
|
-
return withQuery(`${MODULE_BASE}/
|
|
78
|
+
/** The install guide: a new website, or the one `website` names. */
|
|
79
|
+
export function installLink(website?: string | null): string {
|
|
80
|
+
return withQuery(`${MODULE_BASE}/install`, { website })
|
|
43
81
|
}
|
|
44
82
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
funnel?: string | null
|
|
48
|
-
period?: string | null
|
|
83
|
+
export function settingsLink(): string {
|
|
84
|
+
return `${MODULE_BASE}/settings`
|
|
49
85
|
}
|
|
50
86
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
87
|
+
/**
|
|
88
|
+
* `link` on the website of the page it is followed from (`query`, that page's
|
|
89
|
+
* route query), unless the link names one itself. `useContextLink` adds the
|
|
90
|
+
* period through the DMS `withPeriodQuery`.
|
|
91
|
+
*/
|
|
92
|
+
export function withWebsite(
|
|
93
|
+
link: string,
|
|
94
|
+
query: Record<string, unknown>,
|
|
95
|
+
): string {
|
|
96
|
+
const website = query.website
|
|
97
|
+
if (typeof website !== 'string' || !website) {
|
|
98
|
+
return link
|
|
99
|
+
}
|
|
100
|
+
const [path, search = ''] = link.split('?')
|
|
101
|
+
const params = new URLSearchParams(search)
|
|
102
|
+
if (params.has('website')) {
|
|
103
|
+
return link
|
|
104
|
+
}
|
|
105
|
+
params.set('website', website)
|
|
106
|
+
return `${path}?${params.toString()}`
|
|
54
107
|
}
|
|
@@ -46,6 +46,27 @@ export interface AnchorBox extends AnchorRequest {
|
|
|
46
46
|
y: number
|
|
47
47
|
width: number
|
|
48
48
|
height: number
|
|
49
|
+
/** What the element reads, for the "Most clicked" list; empty on an image. */
|
|
50
|
+
text: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ANCHOR_TEXT_LENGTH = 40
|
|
54
|
+
|
|
55
|
+
function readableText(element: Element): string {
|
|
56
|
+
const text = (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
57
|
+
// An image, an icon or a picture reads through its own alternative, or the
|
|
58
|
+
// one of the image inside it.
|
|
59
|
+
const label =
|
|
60
|
+
text ||
|
|
61
|
+
element.getAttribute('aria-label') ||
|
|
62
|
+
element.getAttribute('alt') ||
|
|
63
|
+
element.getAttribute('title') ||
|
|
64
|
+
element.querySelector('img[alt]')?.getAttribute('alt') ||
|
|
65
|
+
element.querySelector('svg title')?.textContent?.trim() ||
|
|
66
|
+
''
|
|
67
|
+
return label.length > ANCHOR_TEXT_LENGTH
|
|
68
|
+
? `${label.slice(0, ANCHOR_TEXT_LENGTH - 1)}…`
|
|
69
|
+
: label
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
export function useSnapshotFrame(frame: Ref<HTMLIFrameElement | null>) {
|
|
@@ -143,12 +164,12 @@ export function useSnapshotFrame(frame: Ref<HTMLIFrameElement | null>) {
|
|
|
143
164
|
let matches: NodeListOf<Element>
|
|
144
165
|
try {
|
|
145
166
|
matches = doc.querySelectorAll(anchor.selector)
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
167
|
+
} catch {
|
|
148
168
|
continue
|
|
149
169
|
}
|
|
150
|
-
const
|
|
151
|
-
|
|
170
|
+
const element = matches[anchor.nth]
|
|
171
|
+
const box = element?.getBoundingClientRect()
|
|
172
|
+
if (!element || !box || box.width <= 0 || box.height <= 0) {
|
|
152
173
|
continue
|
|
153
174
|
}
|
|
154
175
|
boxes.push({
|
|
@@ -157,6 +178,7 @@ export function useSnapshotFrame(frame: Ref<HTMLIFrameElement | null>) {
|
|
|
157
178
|
y: box.top + view.scrollY,
|
|
158
179
|
width: box.width,
|
|
159
180
|
height: box.height,
|
|
181
|
+
text: readableText(element),
|
|
160
182
|
})
|
|
161
183
|
}
|
|
162
184
|
return boxes
|
|
@@ -8,3 +8,25 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export const MS_PER_DAY = 86_400_000
|
|
11
|
+
|
|
12
|
+
/** A website's name and domain caps (MAX_NAME_LENGTH, MAX_DOMAIN_LENGTH on the server). */
|
|
13
|
+
export const MAX_WEBSITE_NAME_LENGTH = 100
|
|
14
|
+
export const MAX_WEBSITE_DOMAIN_LENGTH = 253
|
|
15
|
+
|
|
16
|
+
/** Longest period the routes read (MAX_QUERY_PERIOD_DAYS on the server). */
|
|
17
|
+
export const MAX_PERIOD_DAYS = 90
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The id of the context bar's period selector: the scope the analytics blocks
|
|
21
|
+
* bind to and the prefix of its keys in the page URL (`?marketing.period=`).
|
|
22
|
+
* MARKETING_PERIOD_SCOPE on the server.
|
|
23
|
+
*/
|
|
24
|
+
export const PERIOD_SCOPE = 'marketing'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Exactly `digits` decimals, for the DMS `formatPercentage`: a rate keeps
|
|
28
|
+
* its width from one row to the next.
|
|
29
|
+
*/
|
|
30
|
+
export function fractionDigits(digits: number): Intl.NumberFormatOptions {
|
|
31
|
+
return { minimumFractionDigits: digits, maximumFractionDigits: digits }
|
|
32
|
+
}
|
|
@@ -21,7 +21,8 @@ const FunnelStepsDisplay = defineComponent({
|
|
|
21
21
|
name: 'FunnelStepsDisplay',
|
|
22
22
|
props: {
|
|
23
23
|
modelValue: {
|
|
24
|
-
type: [Array, String] as unknown as () =>
|
|
24
|
+
type: [Array, String] as unknown as () =>
|
|
25
|
+
FunnelStepDraft[] | string | null,
|
|
25
26
|
default: null,
|
|
26
27
|
},
|
|
27
28
|
},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Icon and bar colour of each acquisition channel, in one place. */
|
|
2
|
+
export interface ChannelStyle {
|
|
3
|
+
icon: string
|
|
4
|
+
bar: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const NEUTRAL_STYLE: ChannelStyle = {
|
|
8
|
+
icon: 'i-ph-question',
|
|
9
|
+
bar: 'bg-(--ui-border-accented)',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A categorical palette: six hues told apart in both themes, none of them the
|
|
13
|
+
// green and orange that mean "good" and "watch out" elsewhere in the module.
|
|
14
|
+
const CHANNEL_STYLES: Record<string, ChannelStyle> = {
|
|
15
|
+
direct: { icon: 'i-ph-arrow-elbow-down-right', bar: 'bg-primary' },
|
|
16
|
+
organic: {
|
|
17
|
+
icon: 'i-ph-magnifying-glass',
|
|
18
|
+
bar: 'bg-blue-600 dark:bg-blue-400',
|
|
19
|
+
},
|
|
20
|
+
email: {
|
|
21
|
+
icon: 'i-ph-envelope-simple',
|
|
22
|
+
bar: 'bg-violet-600 dark:bg-violet-400',
|
|
23
|
+
},
|
|
24
|
+
referral: { icon: 'i-ph-link', bar: 'bg-amber-500 dark:bg-amber-300' },
|
|
25
|
+
social: {
|
|
26
|
+
icon: 'i-ph-share-network',
|
|
27
|
+
bar: 'bg-pink-600 dark:bg-pink-400',
|
|
28
|
+
},
|
|
29
|
+
paid: {
|
|
30
|
+
icon: 'i-ph-currency-eur',
|
|
31
|
+
bar: 'bg-slate-700 dark:bg-slate-300',
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function channelStyle(channel: string): ChannelStyle {
|
|
36
|
+
return CHANNEL_STYLES[channel] ?? NEUTRAL_STYLE
|
|
37
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale formatting shared by the module's blocks. Pure functions of their
|
|
3
|
+
* inputs, so the blocks stay thin and the rules stay testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fractionDigits } from '../constants'
|
|
7
|
+
|
|
8
|
+
const SECONDS_PER_MINUTE = 60
|
|
9
|
+
const SECONDS_PER_HOUR = 3600
|
|
10
|
+
const MS_PER_SECOND = 1000
|
|
11
|
+
const INITIALS_LENGTH = 2
|
|
12
|
+
|
|
13
|
+
export function formatNumber(
|
|
14
|
+
value: number,
|
|
15
|
+
locale: string,
|
|
16
|
+
digits = 0,
|
|
17
|
+
): string {
|
|
18
|
+
return new Intl.NumberFormat(locale, {
|
|
19
|
+
minimumFractionDigits: digits,
|
|
20
|
+
maximumFractionDigits: digits,
|
|
21
|
+
}).format(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "2m 48s", the way analytics tools write a visit length. */
|
|
25
|
+
export function formatDuration(seconds: number): string {
|
|
26
|
+
const total = Math.max(0, Math.round(seconds))
|
|
27
|
+
const hours = Math.floor(total / SECONDS_PER_HOUR)
|
|
28
|
+
const minutes = Math.floor((total % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE)
|
|
29
|
+
const rest = total % SECONDS_PER_MINUTE
|
|
30
|
+
if (hours > 0) {
|
|
31
|
+
return `${hours}h ${minutes}m`
|
|
32
|
+
}
|
|
33
|
+
return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A change in percent under half a point rounds to "0 %": it reads as no
|
|
38
|
+
* change, whatever its sign.
|
|
39
|
+
*/
|
|
40
|
+
const FLAT_PERCENT_CHANGE = 0.5
|
|
41
|
+
|
|
42
|
+
/** Which way a change in percent (+12.5 for +12.5 %) moved, flat when it rounds to 0 %. */
|
|
43
|
+
export function percentChangeTrend(delta: number): 'up' | 'down' | 'flat' {
|
|
44
|
+
if (Math.abs(delta) < FLAT_PERCENT_CHANGE) {
|
|
45
|
+
return 'flat'
|
|
46
|
+
}
|
|
47
|
+
return delta > 0 ? 'up' : 'down'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Signed difference with its unit: "+8s", "−2.3 pt", "+0.1". */
|
|
51
|
+
export function formatSignedDelta(
|
|
52
|
+
value: number,
|
|
53
|
+
unit: 'points' | 'seconds' | 'number' | 'percent',
|
|
54
|
+
locale: string,
|
|
55
|
+
): string {
|
|
56
|
+
const rounded = Number(value.toFixed(unit === 'seconds' ? 0 : 1))
|
|
57
|
+
const sign = rounded > 0 ? '+' : rounded < 0 ? '−' : '±'
|
|
58
|
+
const magnitude = Math.abs(rounded)
|
|
59
|
+
const formatters: Record<typeof unit, () => string> = {
|
|
60
|
+
points: () => `${formatNumber(magnitude, locale, 1)} pt`,
|
|
61
|
+
seconds: () => formatDuration(magnitude),
|
|
62
|
+
number: () => formatNumber(magnitude, locale, 1),
|
|
63
|
+
percent: () =>
|
|
64
|
+
String(formatPercentage(magnitude / 100, locale, fractionDigits(1))),
|
|
65
|
+
}
|
|
66
|
+
return `${sign}${formatters[unit]()}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const RELATIVE_STEPS: [Intl.RelativeTimeFormatUnit, number][] = [
|
|
70
|
+
['second', SECONDS_PER_MINUTE],
|
|
71
|
+
['minute', SECONDS_PER_MINUTE],
|
|
72
|
+
['hour', 24],
|
|
73
|
+
['day', 30],
|
|
74
|
+
['month', 12],
|
|
75
|
+
['year', Number.POSITIVE_INFINITY],
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
/** "12 seconds ago", "4 minutes ago" — the age of the last event. */
|
|
79
|
+
export function formatRelativeTime(
|
|
80
|
+
at: number,
|
|
81
|
+
now: number,
|
|
82
|
+
locale: string,
|
|
83
|
+
): string {
|
|
84
|
+
let value = Math.round((at - now) / MS_PER_SECOND)
|
|
85
|
+
const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
|
|
86
|
+
for (const [unit, size] of RELATIVE_STEPS) {
|
|
87
|
+
if (Math.abs(value) < size) {
|
|
88
|
+
return format.format(value, unit)
|
|
89
|
+
}
|
|
90
|
+
value = Math.round(value / size)
|
|
91
|
+
}
|
|
92
|
+
return format.format(value, 'year')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** "Aug 31 – Sep 29, 2026", the resolved range of the period. */
|
|
96
|
+
export function formatDateRange(from: Date, to: Date, locale: string): string {
|
|
97
|
+
return new Intl.DateTimeFormat(locale, {
|
|
98
|
+
month: 'short',
|
|
99
|
+
day: 'numeric',
|
|
100
|
+
year: 'numeric',
|
|
101
|
+
}).formatRange(from, to)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function formatShortDate(at: number | Date, locale: string): string {
|
|
105
|
+
return new Intl.DateTimeFormat(locale, {
|
|
106
|
+
month: 'short',
|
|
107
|
+
day: 'numeric',
|
|
108
|
+
}).format(at)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** "AS" for "Acme shop": the avatar of a website. */
|
|
112
|
+
export function initials(name: string): string {
|
|
113
|
+
const words = name.trim().split(/\s+/).filter(Boolean)
|
|
114
|
+
const letters =
|
|
115
|
+
words.length > 1
|
|
116
|
+
? words.slice(0, INITIALS_LENGTH).map((word) => word[0])
|
|
117
|
+
: [...(words[0] ?? '?')].slice(0, INITIALS_LENGTH)
|
|
118
|
+
return letters.join('').toUpperCase()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Region and language names, falling back to the raw code on a bad one. */
|
|
122
|
+
export function displayName(
|
|
123
|
+
code: string,
|
|
124
|
+
type: 'region' | 'language',
|
|
125
|
+
locale: string,
|
|
126
|
+
): string {
|
|
127
|
+
try {
|
|
128
|
+
return new Intl.DisplayNames([locale], { type }).of(code) ?? code
|
|
129
|
+
} catch {
|
|
130
|
+
return code
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { sampleSizePerArm } from './sample-size'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The rules of a funnel draft, as the builder checks them before a save.
|
|
5
|
+
*
|
|
6
|
+
* They mirror the backend's (src/types/constants.ts, src/utils/funnel-steps-type.ts
|
|
7
|
+
* and src/utils/experiment-type.ts): what passes here passes there, so a
|
|
8
|
+
* refusal shows under its field before the request instead of after it.
|
|
9
|
+
*/
|
|
10
|
+
export const MAX_NAME_LENGTH = 100
|
|
11
|
+
export const MAX_STEPS = 10
|
|
12
|
+
export const MAX_STEP_VALUE_LENGTH = 500
|
|
13
|
+
export const MAX_WINDOW_HOURS = 720
|
|
14
|
+
export const MIN_VARIATIONS = 2
|
|
15
|
+
export const MAX_VARIATIONS = 8
|
|
16
|
+
export const MAX_EXPERIMENT_KEY_LENGTH = 64
|
|
17
|
+
|
|
18
|
+
/** The slug page code passes to `dmsMarketing.variation(…)`, both for the
|
|
19
|
+
* experiment and its arms: it starts with a letter or a digit. */
|
|
20
|
+
const EXPERIMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
|
|
21
|
+
|
|
22
|
+
export function isExperimentKey(key: string): boolean {
|
|
23
|
+
return (
|
|
24
|
+
key.length <= MAX_EXPERIMENT_KEY_LENGTH && EXPERIMENT_KEY_PATTERN.test(key)
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StepValue {
|
|
29
|
+
kind: 'url' | 'custom'
|
|
30
|
+
value: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface VariationValue {
|
|
34
|
+
key: string
|
|
35
|
+
/** A number, or '' while its input is empty. */
|
|
36
|
+
weight: number | string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FunnelDraftValues {
|
|
40
|
+
name: string
|
|
41
|
+
websiteId: string
|
|
42
|
+
steps: StepValue[]
|
|
43
|
+
windowHours: number | string
|
|
44
|
+
split: boolean
|
|
45
|
+
experimentKey: string
|
|
46
|
+
variations: VariationValue[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The request body keys, which also name the fields the server's errors
|
|
50
|
+
* land on. `variations` has no key of its own: it is the experiment's arms. */
|
|
51
|
+
export type DraftField =
|
|
52
|
+
| 'name'
|
|
53
|
+
| 'websiteId'
|
|
54
|
+
| 'steps'
|
|
55
|
+
| 'conversionWindowHours'
|
|
56
|
+
| 'experiment'
|
|
57
|
+
| 'variations'
|
|
58
|
+
|
|
59
|
+
export interface DraftIssue {
|
|
60
|
+
/** i18n key of the message, under page.marketing.builder.errors. */
|
|
61
|
+
message: string
|
|
62
|
+
params?: Record<string, unknown>
|
|
63
|
+
/** The step or variation row the issue names. */
|
|
64
|
+
index?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isStep(step: unknown): step is StepValue {
|
|
68
|
+
const candidate = step as Partial<StepValue> | null
|
|
69
|
+
return (
|
|
70
|
+
typeof candidate === 'object' &&
|
|
71
|
+
candidate !== null &&
|
|
72
|
+
(candidate.kind === 'url' || candidate.kind === 'custom') &&
|
|
73
|
+
typeof candidate.value === 'string'
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The steps of a `?steps=` template link a funnel can hold. A link is typed
|
|
79
|
+
* or edited by hand: anything malformed is dropped, never thrown.
|
|
80
|
+
*/
|
|
81
|
+
export function templateSteps(raw: unknown): StepValue[] {
|
|
82
|
+
if (typeof raw !== 'string') {
|
|
83
|
+
return []
|
|
84
|
+
}
|
|
85
|
+
let parsed: unknown
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(raw)
|
|
88
|
+
} catch {
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
if (!Array.isArray(parsed)) {
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
return parsed
|
|
95
|
+
.filter(isStep)
|
|
96
|
+
.map((step) => ({ kind: step.kind, value: step.value.trim() }))
|
|
97
|
+
.filter(
|
|
98
|
+
(step) => step.value !== '' && step.value.length <= MAX_STEP_VALUE_LENGTH,
|
|
99
|
+
)
|
|
100
|
+
.slice(0, MAX_STEPS)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether a window is an hour count the server stores as is. */
|
|
104
|
+
export function isWindowHours(hours: number | string): hours is number {
|
|
105
|
+
return (
|
|
106
|
+
typeof hours === 'number' &&
|
|
107
|
+
Number.isInteger(hours) &&
|
|
108
|
+
hours >= 1 &&
|
|
109
|
+
hours <= MAX_WINDOW_HOURS
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isWeight(weight: number | string): weight is number {
|
|
114
|
+
return typeof weight === 'number' && Number.isFinite(weight) && weight >= 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stepIssue(steps: StepValue[]): DraftIssue | undefined {
|
|
118
|
+
const index = steps.findIndex(
|
|
119
|
+
(step) => step.value.trim().length > MAX_STEP_VALUE_LENGTH,
|
|
120
|
+
)
|
|
121
|
+
if (index >= 0) {
|
|
122
|
+
return {
|
|
123
|
+
message: 'step_length',
|
|
124
|
+
params: { index: index + 1, max: MAX_STEP_VALUE_LENGTH },
|
|
125
|
+
index,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!steps.some((step) => step.value.trim() !== '')) {
|
|
129
|
+
return { message: 'steps', index: 0 }
|
|
130
|
+
}
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function variationIssue(variations: VariationValue[]): DraftIssue | undefined {
|
|
135
|
+
const keys = variations.map((arm) => arm.key.trim())
|
|
136
|
+
const badKey = keys.findIndex(
|
|
137
|
+
(key, index) => !isExperimentKey(key) || keys.indexOf(key) !== index,
|
|
138
|
+
)
|
|
139
|
+
if (badKey >= 0) {
|
|
140
|
+
return {
|
|
141
|
+
message: 'variations',
|
|
142
|
+
params: { max: MAX_EXPERIMENT_KEY_LENGTH },
|
|
143
|
+
index: badKey,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const badWeight = variations.findIndex((arm) => !isWeight(arm.weight))
|
|
147
|
+
const total = variations.reduce(
|
|
148
|
+
(sum, arm) => sum + (isWeight(arm.weight) ? arm.weight : 0),
|
|
149
|
+
0,
|
|
150
|
+
)
|
|
151
|
+
if (badWeight >= 0 || total <= 0) {
|
|
152
|
+
return { message: 'weights', index: Math.max(badWeight, 0) }
|
|
153
|
+
}
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The first issue of each field, in the order the form shows them. A split
|
|
159
|
+
* that has started keeps its stored key and arms, so they are not checked.
|
|
160
|
+
*/
|
|
161
|
+
export function draftIssues(
|
|
162
|
+
draft: FunnelDraftValues,
|
|
163
|
+
frozen: boolean,
|
|
164
|
+
): Partial<Record<DraftField, DraftIssue>> {
|
|
165
|
+
const issues: Partial<Record<DraftField, DraftIssue>> = {}
|
|
166
|
+
const name = draft.name.trim()
|
|
167
|
+
if (!name) {
|
|
168
|
+
issues.name = { message: 'name' }
|
|
169
|
+
} else if (name.length > MAX_NAME_LENGTH) {
|
|
170
|
+
issues.name = { message: 'name_length', params: { max: MAX_NAME_LENGTH } }
|
|
171
|
+
}
|
|
172
|
+
if (!draft.websiteId) {
|
|
173
|
+
issues.websiteId = { message: 'website' }
|
|
174
|
+
}
|
|
175
|
+
const steps = stepIssue(draft.steps)
|
|
176
|
+
if (steps) {
|
|
177
|
+
issues.steps = steps
|
|
178
|
+
}
|
|
179
|
+
if (!isWindowHours(draft.windowHours)) {
|
|
180
|
+
issues.conversionWindowHours = {
|
|
181
|
+
message: 'window',
|
|
182
|
+
params: { max: MAX_WINDOW_HOURS },
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (draft.split && !frozen) {
|
|
186
|
+
if (!isExperimentKey(draft.experimentKey.trim())) {
|
|
187
|
+
issues.experiment = {
|
|
188
|
+
message: 'key',
|
|
189
|
+
params: { max: MAX_EXPERIMENT_KEY_LENGTH },
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const variations = variationIssue(draft.variations)
|
|
193
|
+
if (variations) {
|
|
194
|
+
issues.variations = variations
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return issues
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Entered sessions a split needs before a relative `lift` on `rate` can be
|
|
202
|
+
* told from noise: each arm needs `sampleSizePerArm`, and the arm with the
|
|
203
|
+
* smallest share of the traffic is the one that takes longest to get it.
|
|
204
|
+
* Null without two arms that receive traffic.
|
|
205
|
+
*/
|
|
206
|
+
export function splitSampleSize(
|
|
207
|
+
rate: number,
|
|
208
|
+
lift: number,
|
|
209
|
+
weights: (number | string)[],
|
|
210
|
+
): number | null {
|
|
211
|
+
const served = weights.filter(
|
|
212
|
+
(weight): weight is number => isWeight(weight) && weight > 0,
|
|
213
|
+
)
|
|
214
|
+
if (rate <= 0 || served.length < MIN_VARIATIONS) {
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
const total = served.reduce((sum, weight) => sum + weight, 0)
|
|
218
|
+
const smallestShare = Math.min(...served) / total
|
|
219
|
+
return sampleSizePerArm(rate, lift) / smallestShare
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The comments of the snippet, in the reader's language. */
|
|
223
|
+
export interface VariationSnippetComments {
|
|
224
|
+
show: (variation: string) => string
|
|
225
|
+
keep: (control: string) => string
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const ENGLISH_COMMENTS: VariationSnippetComments = {
|
|
229
|
+
show: (variation) => `show ${variation}`,
|
|
230
|
+
keep: (control) => `null or "${control}": keep the current page`,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The snippet comments read from the locale files. */
|
|
234
|
+
export function localizedSnippetComments(
|
|
235
|
+
t: (key: string, named: Record<string, unknown>) => string,
|
|
236
|
+
): VariationSnippetComments {
|
|
237
|
+
return {
|
|
238
|
+
show: (variation) =>
|
|
239
|
+
t('page.marketing.experiment.code_show', { variation }),
|
|
240
|
+
keep: (control) => t('page.marketing.experiment.code_keep', { control }),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The lines page code needs to serve an arm. Promise-based rather than a
|
|
246
|
+
* top-level `await`, which a classic `<script>` refuses.
|
|
247
|
+
*/
|
|
248
|
+
export function variationSnippet(
|
|
249
|
+
key: string,
|
|
250
|
+
control: string,
|
|
251
|
+
challengers: string[],
|
|
252
|
+
comments: VariationSnippetComments = ENGLISH_COMMENTS,
|
|
253
|
+
): string {
|
|
254
|
+
return [
|
|
255
|
+
`window.dmsMarketing?.variation("${key}").then((arm) => {`,
|
|
256
|
+
...challengers.flatMap((challenger, index) => [
|
|
257
|
+
` ${index === 0 ? 'if' : '} else if'} (arm === "${challenger}") {`,
|
|
258
|
+
` // ${comments.show(challenger)}`,
|
|
259
|
+
]),
|
|
260
|
+
' }',
|
|
261
|
+
` // ${comments.keep(control)}`,
|
|
262
|
+
'});',
|
|
263
|
+
].join('\n')
|
|
264
|
+
}
|