@funnelsgrove/runtime 0.4.0 → 0.5.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/dist/components/FunnelRuntimeConfigBoundary.d.ts +17 -0
- package/dist/components/FunnelRuntimeConfigBoundary.js +73 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/services/funnel-runtime-config.d.ts +36 -0
- package/dist/services/funnel-runtime-config.js +276 -0
- package/package.json +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type ResolvedFunnelRuntimeConfig } from '../services/funnel-runtime-config.js';
|
|
3
|
+
export type FunnelRuntimeConfigState = {
|
|
4
|
+
status: 'loading' | 'published' | 'fallback';
|
|
5
|
+
config: ResolvedFunnelRuntimeConfig | null;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
};
|
|
8
|
+
type FunnelRuntimeConfigBoundaryProps = {
|
|
9
|
+
funnelId: string;
|
|
10
|
+
funnelVersionId?: string;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
loadingFallback?: ReactNode;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare function FunnelRuntimeConfigBoundary({ disabled, ...props }: FunnelRuntimeConfigBoundaryProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export declare const useFunnelRuntimeConfig: () => FunnelRuntimeConfigState;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
3
|
+
var t = {};
|
|
4
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
5
|
+
t[p] = s[p];
|
|
6
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
7
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
8
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
9
|
+
t[p[i]] = s[p[i]];
|
|
10
|
+
}
|
|
11
|
+
return t;
|
|
12
|
+
};
|
|
13
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
14
|
+
import { createContext, useContext, useEffect, useMemo, useState, } from 'react';
|
|
15
|
+
import { loadFunnelRuntimeConfig, resolvePublishedFunnelRuntimeConfig, } from '../services/funnel-runtime-config.js';
|
|
16
|
+
const FunnelRuntimeConfigContext = createContext({
|
|
17
|
+
status: 'fallback',
|
|
18
|
+
config: null,
|
|
19
|
+
error: null,
|
|
20
|
+
});
|
|
21
|
+
const toError = (value) => (value instanceof Error ? value : new Error('Unable to load funnel runtime config'));
|
|
22
|
+
/**
|
|
23
|
+
* Loads the active publication once before the funnel mounts. The resolved
|
|
24
|
+
* snapshot is deeply frozen and stays fixed for the lifetime of this boundary,
|
|
25
|
+
* so one visitor can never switch experiments or prices mid-session.
|
|
26
|
+
*
|
|
27
|
+
* Missing, invalid, or temporarily unavailable publications fail open to the
|
|
28
|
+
* generated build-time config by exposing `config: null`.
|
|
29
|
+
*/
|
|
30
|
+
function ActiveFunnelRuntimeConfigBoundary({ funnelId, funnelVersionId, children, loadingFallback = null, }) {
|
|
31
|
+
const [state, setState] = useState({
|
|
32
|
+
status: 'loading',
|
|
33
|
+
config: null,
|
|
34
|
+
error: null,
|
|
35
|
+
});
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
let active = true;
|
|
38
|
+
loadFunnelRuntimeConfig(funnelId)
|
|
39
|
+
.then((published) => resolvePublishedFunnelRuntimeConfig(published, {
|
|
40
|
+
expectedFunnelVersionId: funnelVersionId,
|
|
41
|
+
}))
|
|
42
|
+
.then((config) => {
|
|
43
|
+
if (active) {
|
|
44
|
+
setState({ status: 'published', config, error: null });
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
.catch((error) => {
|
|
48
|
+
if (active) {
|
|
49
|
+
setState({ status: 'fallback', config: null, error: toError(error) });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return () => {
|
|
53
|
+
active = false;
|
|
54
|
+
};
|
|
55
|
+
}, [funnelId, funnelVersionId]);
|
|
56
|
+
const contextValue = useMemo(() => state, [state]);
|
|
57
|
+
if (state.status === 'loading') {
|
|
58
|
+
return loadingFallback;
|
|
59
|
+
}
|
|
60
|
+
return (_jsx(FunnelRuntimeConfigContext.Provider, { value: contextValue, children: children }));
|
|
61
|
+
}
|
|
62
|
+
export function FunnelRuntimeConfigBoundary(_a) {
|
|
63
|
+
var { disabled = false } = _a, props = __rest(_a, ["disabled"]);
|
|
64
|
+
if (disabled) {
|
|
65
|
+
return (_jsx(FunnelRuntimeConfigContext.Provider, { value: {
|
|
66
|
+
status: 'fallback',
|
|
67
|
+
config: null,
|
|
68
|
+
error: null,
|
|
69
|
+
}, children: props.children }));
|
|
70
|
+
}
|
|
71
|
+
return _jsx(ActiveFunnelRuntimeConfigBoundary, Object.assign({}, props));
|
|
72
|
+
}
|
|
73
|
+
export const useFunnelRuntimeConfig = () => (useContext(FunnelRuntimeConfigContext));
|
package/dist/index.d.ts
CHANGED
|
@@ -37,9 +37,11 @@ export * from './services/project-env.js';
|
|
|
37
37
|
export * from './services/public-env.js';
|
|
38
38
|
export * from './services/runtime-api.config.js';
|
|
39
39
|
export * from './services/runtime-mode.service.js';
|
|
40
|
+
export * from './services/funnel-runtime-config.js';
|
|
40
41
|
export * from './sdk/userAnswers.js';
|
|
41
42
|
export * from './components/FunnelContext.js';
|
|
42
43
|
export * from './components/FunnelEditorPanel.js';
|
|
44
|
+
export * from './components/FunnelRuntimeConfigBoundary.js';
|
|
43
45
|
export * from './components/ManageSubscriptionScreen.js';
|
|
44
46
|
export * from './components/PaymentRuntimeBoundary.js';
|
|
45
47
|
export * from './components/RuntimeDevInfoBox.js';
|
package/dist/index.js
CHANGED
|
@@ -39,10 +39,12 @@ export * from './services/project-env.js';
|
|
|
39
39
|
export * from './services/public-env.js';
|
|
40
40
|
export * from './services/runtime-api.config.js';
|
|
41
41
|
export * from './services/runtime-mode.service.js';
|
|
42
|
+
export * from './services/funnel-runtime-config.js';
|
|
42
43
|
// SDK and generic runtime UI.
|
|
43
44
|
export * from './sdk/userAnswers.js';
|
|
44
45
|
export * from './components/FunnelContext.js';
|
|
45
46
|
export * from './components/FunnelEditorPanel.js';
|
|
47
|
+
export * from './components/FunnelRuntimeConfigBoundary.js';
|
|
46
48
|
export * from './components/ManageSubscriptionScreen.js';
|
|
47
49
|
export * from './components/PaymentRuntimeBoundary.js';
|
|
48
50
|
export * from './components/RuntimeDevInfoBox.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FunnelExperimentDefinition } from '../config/funnel.experiments.types.js';
|
|
2
|
+
import type { GeneratedOfferSet } from '../runtime/offer-set-runtime.js';
|
|
3
|
+
export type FunnelRuntimeConfig = {
|
|
4
|
+
schemaVersion: number;
|
|
5
|
+
funnelId: string;
|
|
6
|
+
projectId: string;
|
|
7
|
+
plans: unknown[];
|
|
8
|
+
offerSets: unknown[];
|
|
9
|
+
experiments: unknown[];
|
|
10
|
+
};
|
|
11
|
+
export type ResolvedFunnelRuntimeConfig = {
|
|
12
|
+
revisionId: string;
|
|
13
|
+
sourceDeploymentId: string;
|
|
14
|
+
sourceVersionId: string;
|
|
15
|
+
publishedAt: string;
|
|
16
|
+
offerSets: readonly GeneratedOfferSet[];
|
|
17
|
+
experiments: readonly FunnelExperimentDefinition[];
|
|
18
|
+
};
|
|
19
|
+
export type PublishedFunnelRuntimeConfig = {
|
|
20
|
+
revisionId: string;
|
|
21
|
+
sourceDeploymentId: string;
|
|
22
|
+
sourceVersionId: string;
|
|
23
|
+
schemaVersion: number;
|
|
24
|
+
publishedAt: string;
|
|
25
|
+
config: FunnelRuntimeConfig;
|
|
26
|
+
};
|
|
27
|
+
type RuntimeConfigFetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
28
|
+
export declare const resolvePublishedFunnelRuntimeConfig: (published: PublishedFunnelRuntimeConfig, options?: {
|
|
29
|
+
expectedFunnelVersionId?: string;
|
|
30
|
+
}) => ResolvedFunnelRuntimeConfig;
|
|
31
|
+
export declare const loadFunnelRuntimeConfig: (funnelId: string, options?: {
|
|
32
|
+
fetcher?: RuntimeConfigFetcher;
|
|
33
|
+
basePath?: string;
|
|
34
|
+
}) => Promise<PublishedFunnelRuntimeConfig>;
|
|
35
|
+
export declare const clearFunnelRuntimeConfigInflight: () => void;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
const inflightByFunnelId = new Map();
|
|
2
|
+
const isRecord = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value));
|
|
3
|
+
const readString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
|
4
|
+
const readNumber = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
|
|
5
|
+
const readBoolean = (value) => (typeof value === 'boolean' ? value : null);
|
|
6
|
+
const readRecordArray = (value) => (Array.isArray(value) ? value.filter(isRecord) : []);
|
|
7
|
+
const pick = (record, ...keys) => {
|
|
8
|
+
for (const key of keys) {
|
|
9
|
+
if (record[key] !== undefined && record[key] !== null) {
|
|
10
|
+
return record[key];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
};
|
|
15
|
+
const pickString = (record, ...keys) => (readString(pick(record, ...keys)));
|
|
16
|
+
const pickNumber = (record, ...keys) => (readNumber(pick(record, ...keys)));
|
|
17
|
+
const pickBoolean = (record, ...keys) => (readBoolean(pick(record, ...keys)));
|
|
18
|
+
const deepFreeze = (value) => {
|
|
19
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
Object.freeze(value);
|
|
23
|
+
for (const nested of Object.values(value)) {
|
|
24
|
+
deepFreeze(nested);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
};
|
|
28
|
+
const getPlanById = (plans, projectBillingPlanId) => {
|
|
29
|
+
var _a;
|
|
30
|
+
return (projectBillingPlanId
|
|
31
|
+
? (_a = plans.find((plan) => pickString(plan, 'id') === projectBillingPlanId)) !== null && _a !== void 0 ? _a : null
|
|
32
|
+
: null);
|
|
33
|
+
};
|
|
34
|
+
const getProviderMapping = (plan, mode) => {
|
|
35
|
+
var _a;
|
|
36
|
+
return ((_a = readRecordArray(plan === null || plan === void 0 ? void 0 : plan.providerMappings)
|
|
37
|
+
.find((mapping) => (pickString(mapping, 'environment') === mode
|
|
38
|
+
&& pickBoolean(mapping, 'isActive', 'is_active') !== false))) !== null && _a !== void 0 ? _a : null);
|
|
39
|
+
};
|
|
40
|
+
const buildPlanModeConfig = (item, plan, mode) => {
|
|
41
|
+
var _a, _b;
|
|
42
|
+
const metadata = isRecord(item.metadata) ? item.metadata : {};
|
|
43
|
+
const metadataMappings = isRecord(metadata.providerMappingsByMode)
|
|
44
|
+
? metadata.providerMappingsByMode
|
|
45
|
+
: {};
|
|
46
|
+
const metadataMapping = isRecord(metadataMappings[mode]) ? metadataMappings[mode] : {};
|
|
47
|
+
const planMapping = getProviderMapping(plan, mode);
|
|
48
|
+
const prefix = mode === 'test' ? 'test' : 'live';
|
|
49
|
+
const providerPlanId = (pickString(item, `${prefix}ProviderPlanId`, `${prefix}_provider_plan_id`)
|
|
50
|
+
|| pickString(metadataMapping, 'providerPlanId')
|
|
51
|
+
|| (planMapping ? pickString(planMapping, 'providerPlanId', 'provider_plan_id') : null));
|
|
52
|
+
if (!providerPlanId) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const amountMinor = (_a = pickNumber(metadataMapping, 'amountCents')) !== null && _a !== void 0 ? _a : (planMapping ? pickNumber(planMapping, 'amountMinor', 'amount_minor') : null);
|
|
56
|
+
const interval = pickString(metadataMapping, 'billingInterval')
|
|
57
|
+
|| (planMapping ? pickString(planMapping, 'interval') : null);
|
|
58
|
+
const intervalCount = (_b = pickNumber(metadataMapping, 'billingIntervalCount')) !== null && _b !== void 0 ? _b : (planMapping ? pickNumber(planMapping, 'intervalCount', 'interval_count') : null);
|
|
59
|
+
const currency = planMapping ? pickString(planMapping, 'currency') : null;
|
|
60
|
+
const amountMajor = amountMinor === null ? null : amountMinor / 100;
|
|
61
|
+
const priceLabel = pickString(metadataMapping, 'priceLabel') || (amountMajor === null
|
|
62
|
+
? null
|
|
63
|
+
: new Intl.NumberFormat('en-US', {
|
|
64
|
+
style: 'currency',
|
|
65
|
+
currency: (currency === null || currency === void 0 ? void 0 : currency.toUpperCase()) || 'USD',
|
|
66
|
+
minimumFractionDigits: 2,
|
|
67
|
+
maximumFractionDigits: 2,
|
|
68
|
+
}).format(amountMajor) + (interval ? `/${interval}` : ''));
|
|
69
|
+
return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ providerPlanId }, (priceLabel ? { priceLabel } : {})), (pickString(metadataMapping, 'perDayAmount')
|
|
70
|
+
? { perDayAmount: pickString(metadataMapping, 'perDayAmount') }
|
|
71
|
+
: {})), (pickString(metadataMapping, 'perDayLabel')
|
|
72
|
+
? { perDayLabel: pickString(metadataMapping, 'perDayLabel') }
|
|
73
|
+
: {})), (amountMinor === null ? {} : { amountCents: amountMinor })), (pickString(metadataMapping, 'checkoutSummaryLabel')
|
|
74
|
+
? { checkoutSummaryLabel: pickString(metadataMapping, 'checkoutSummaryLabel') }
|
|
75
|
+
: amountMajor === null
|
|
76
|
+
? {}
|
|
77
|
+
: { checkoutSummaryLabel: `${new Intl.NumberFormat('en-US', {
|
|
78
|
+
style: 'currency',
|
|
79
|
+
currency: (currency === null || currency === void 0 ? void 0 : currency.toUpperCase()) || 'USD',
|
|
80
|
+
minimumFractionDigits: 2,
|
|
81
|
+
maximumFractionDigits: 2,
|
|
82
|
+
}).format(amountMajor)} today` })), (interval ? { billingInterval: interval } : {})), (intervalCount === null ? {} : { billingIntervalCount: intervalCount }));
|
|
83
|
+
};
|
|
84
|
+
const normalizePublishedOfferSets = (config) => {
|
|
85
|
+
const plans = readRecordArray(config.plans);
|
|
86
|
+
return readRecordArray(config.offerSets).map((offerSet) => {
|
|
87
|
+
var _a;
|
|
88
|
+
const key = pickString(offerSet, 'key');
|
|
89
|
+
if (!key) {
|
|
90
|
+
throw new Error('Published runtime offer set is missing its key');
|
|
91
|
+
}
|
|
92
|
+
const items = readRecordArray(offerSet.items).map((item) => {
|
|
93
|
+
var _a;
|
|
94
|
+
const metadata = isRecord(item.metadata) ? item.metadata : {};
|
|
95
|
+
const funnelPlanKey = pickString(item, 'funnelPlanKey', 'funnel_plan_key');
|
|
96
|
+
if (!funnelPlanKey) {
|
|
97
|
+
throw new Error(`Published runtime offer set "${key}" has an item without a plan key`);
|
|
98
|
+
}
|
|
99
|
+
const projectBillingPlanId = pickString(item, 'projectBillingPlanId', 'project_billing_plan_id');
|
|
100
|
+
const plan = getPlanById(plans, projectBillingPlanId);
|
|
101
|
+
const testMapping = buildPlanModeConfig(item, plan, 'test');
|
|
102
|
+
const liveMapping = buildPlanModeConfig(item, plan, 'live');
|
|
103
|
+
const title = pickString(item, 'title', 'displayNameOverride', 'display_name_override')
|
|
104
|
+
|| pickString(metadata, 'title', 'publicPlanName', 'public_plan_name')
|
|
105
|
+
|| (plan ? pickString(plan, 'displayName', 'display_name') : null);
|
|
106
|
+
const description = pickString(item, 'description')
|
|
107
|
+
|| pickString(metadata, 'description')
|
|
108
|
+
|| (plan ? pickString(plan, 'description') : null);
|
|
109
|
+
return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ funnelPlanKey }, (pickString(metadata, 'runtimePlanKey', 'planKey', 'billingPlanKey')
|
|
110
|
+
? { runtimePlanKey: pickString(metadata, 'runtimePlanKey', 'planKey', 'billingPlanKey') }
|
|
111
|
+
: {})), (projectBillingPlanId ? { projectBillingPlanId } : {})), (pickString(item, 'testProviderPlanId', 'test_provider_plan_id')
|
|
112
|
+
? { testProviderPlanId: pickString(item, 'testProviderPlanId', 'test_provider_plan_id') }
|
|
113
|
+
: {})), (pickString(item, 'liveProviderPlanId', 'live_provider_plan_id')
|
|
114
|
+
? { liveProviderPlanId: pickString(item, 'liveProviderPlanId', 'live_provider_plan_id') }
|
|
115
|
+
: {})), (pickString(item, 'followUpTestProviderPlanId', 'follow_up_test_provider_plan_id')
|
|
116
|
+
? { followUpTestProviderPlanId: pickString(item, 'followUpTestProviderPlanId', 'follow_up_test_provider_plan_id') }
|
|
117
|
+
: {})), (pickString(item, 'followUpLiveProviderPlanId', 'follow_up_live_provider_plan_id')
|
|
118
|
+
? { followUpLiveProviderPlanId: pickString(item, 'followUpLiveProviderPlanId', 'follow_up_live_provider_plan_id') }
|
|
119
|
+
: {})), (pickString(item, 'followUpTitle', 'follow_up_title')
|
|
120
|
+
? { followUpTitle: pickString(item, 'followUpTitle', 'follow_up_title') }
|
|
121
|
+
: {})), (pickString(item, 'followUpBillingInterval', 'follow_up_billing_interval')
|
|
122
|
+
? { followUpBillingInterval: pickString(item, 'followUpBillingInterval', 'follow_up_billing_interval') }
|
|
123
|
+
: {})), (pickNumber(item, 'followUpBillingIntervalCount', 'follow_up_billing_interval_count') === null
|
|
124
|
+
? {}
|
|
125
|
+
: { followUpBillingIntervalCount: pickNumber(item, 'followUpBillingIntervalCount', 'follow_up_billing_interval_count') })), (pickNumber(item, 'introIterations', 'intro_iterations') === null
|
|
126
|
+
? {}
|
|
127
|
+
: { introIterations: pickNumber(item, 'introIterations', 'intro_iterations') })), (pickNumber(item, 'analyticsPurchaseValue', 'analytics_purchase_value') === null
|
|
128
|
+
? {}
|
|
129
|
+
: { analyticsPurchaseValue: pickNumber(item, 'analyticsPurchaseValue', 'analytics_purchase_value') })), (title ? { title } : {})), (description ? { description } : {})), (pickString(item, 'featuredTag', 'featured_tag')
|
|
130
|
+
? { featuredTag: pickString(item, 'featuredTag', 'featured_tag') }
|
|
131
|
+
: {})), (pickString(item, 'oldPriceLabel', 'old_price_label')
|
|
132
|
+
? { oldPriceLabel: pickString(item, 'oldPriceLabel', 'old_price_label') }
|
|
133
|
+
: {})), { isDefault: (_a = pickBoolean(item, 'isDefault', 'is_default')) !== null && _a !== void 0 ? _a : false, providerMappingsByMode: Object.assign(Object.assign({}, (testMapping ? { test: testMapping } : {})), (liveMapping ? { live: liveMapping } : {})), metadata });
|
|
134
|
+
});
|
|
135
|
+
return Object.assign(Object.assign({}, (pickString(offerSet, 'id') ? { id: pickString(offerSet, 'id') } : {})), { key, isActive: (_a = pickBoolean(offerSet, 'isActive', 'is_active')) !== null && _a !== void 0 ? _a : true, metadata: isRecord(offerSet.metadata) ? offerSet.metadata : {}, items });
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
const normalizePublishedExperiments = (config, publishedAt) => readRecordArray(config.experiments).map((experiment) => {
|
|
139
|
+
const conversionConfig = isRecord(experiment.conversionConfig)
|
|
140
|
+
? experiment.conversionConfig
|
|
141
|
+
: isRecord(experiment.conversion_config)
|
|
142
|
+
? experiment.conversion_config
|
|
143
|
+
: {};
|
|
144
|
+
const id = pickString(experiment, 'posthogFlagKey', 'posthog_flag_key', 'id');
|
|
145
|
+
const name = pickString(experiment, 'name');
|
|
146
|
+
const stepId = pickString(conversionConfig, 'stepId', 'paywallStepId')
|
|
147
|
+
|| pickString(experiment, 'stepId', 'step_id');
|
|
148
|
+
const rawStatus = pickString(experiment, 'status') || 'draft';
|
|
149
|
+
const status = rawStatus === 'running' || rawStatus === 'paused' || rawStatus === 'stopped'
|
|
150
|
+
? rawStatus
|
|
151
|
+
: 'draft';
|
|
152
|
+
const type = pickString(conversionConfig, 'type') || 'step';
|
|
153
|
+
const variants = readRecordArray(experiment.variants);
|
|
154
|
+
if (!id || !name || !stepId || variants.length < 2) {
|
|
155
|
+
throw new Error('Published runtime experiment is incomplete');
|
|
156
|
+
}
|
|
157
|
+
const normalizedVariants = variants.map((variant, index) => {
|
|
158
|
+
var _a;
|
|
159
|
+
return ({
|
|
160
|
+
variantKey: pickString(variant, 'variantKey', 'variant_key', 'key')
|
|
161
|
+
|| (index === 0 ? 'control' : `variant_${String.fromCharCode(96 + index)}`),
|
|
162
|
+
label: pickString(variant, 'label') || undefined,
|
|
163
|
+
trafficPercent: (_a = pickNumber(variant, 'trafficPercent', 'traffic_percent')) !== null && _a !== void 0 ? _a : 0,
|
|
164
|
+
routeToStepId: pickString(variant, 'routeToStepId', 'route_to_step_id') || stepId,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
if (normalizedVariants.reduce((sum, variant) => sum + variant.trafficPercent, 0) !== 100) {
|
|
168
|
+
throw new Error(`Published runtime experiment "${id}" traffic must sum to 100`);
|
|
169
|
+
}
|
|
170
|
+
const launchDate = (pickString(conversionConfig, 'launchDate')
|
|
171
|
+
|| pickString(experiment, 'startedAt', 'started_at')
|
|
172
|
+
|| publishedAt).slice(0, 10);
|
|
173
|
+
if (type === 'pricing') {
|
|
174
|
+
const configuredKeys = Array.isArray(conversionConfig.offerSetKeys)
|
|
175
|
+
? conversionConfig.offerSetKeys.map(readString).filter((value) => Boolean(value))
|
|
176
|
+
: [
|
|
177
|
+
pickString(conversionConfig, 'controlOfferSetKey'),
|
|
178
|
+
pickString(conversionConfig, 'variantOfferSetKey'),
|
|
179
|
+
].filter((value) => Boolean(value));
|
|
180
|
+
if (configuredKeys.length !== normalizedVariants.length) {
|
|
181
|
+
throw new Error(`Published pricing experiment "${id}" does not map every variant to an offer set`);
|
|
182
|
+
}
|
|
183
|
+
const pricingVariants = normalizedVariants.map((variant, index) => (Object.assign(Object.assign({ variantKey: variant.variantKey }, (variant.label ? { label: variant.label } : {})), { trafficPercent: variant.trafficPercent, offerSetKey: configuredKeys[index] })));
|
|
184
|
+
return {
|
|
185
|
+
id,
|
|
186
|
+
name,
|
|
187
|
+
type: 'pricing',
|
|
188
|
+
status,
|
|
189
|
+
launchDate,
|
|
190
|
+
paywallStepId: stepId,
|
|
191
|
+
control: pricingVariants[0],
|
|
192
|
+
variant: pricingVariants[1],
|
|
193
|
+
variants: pricingVariants,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (type !== 'step' && type !== 'paywall') {
|
|
197
|
+
throw new Error(`Published runtime experiment "${id}" has an unsupported type`);
|
|
198
|
+
}
|
|
199
|
+
const routeVariants = normalizedVariants.map((variant) => (Object.assign(Object.assign({ variantKey: variant.variantKey }, (variant.label ? { label: variant.label } : {})), { trafficPercent: variant.trafficPercent, stepId: variant.routeToStepId })));
|
|
200
|
+
return {
|
|
201
|
+
id,
|
|
202
|
+
name,
|
|
203
|
+
type,
|
|
204
|
+
status,
|
|
205
|
+
launchDate,
|
|
206
|
+
stepId,
|
|
207
|
+
control: routeVariants[0],
|
|
208
|
+
variant: routeVariants[1],
|
|
209
|
+
variants: routeVariants,
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
export const resolvePublishedFunnelRuntimeConfig = (published, options = {}) => {
|
|
213
|
+
if (options.expectedFunnelVersionId
|
|
214
|
+
&& published.sourceVersionId !== options.expectedFunnelVersionId) {
|
|
215
|
+
throw new Error('Published runtime config belongs to a different funnel version');
|
|
216
|
+
}
|
|
217
|
+
return deepFreeze({
|
|
218
|
+
revisionId: published.revisionId,
|
|
219
|
+
sourceDeploymentId: published.sourceDeploymentId,
|
|
220
|
+
sourceVersionId: published.sourceVersionId,
|
|
221
|
+
publishedAt: published.publishedAt,
|
|
222
|
+
offerSets: normalizePublishedOfferSets(published.config),
|
|
223
|
+
experiments: normalizePublishedExperiments(published.config, published.publishedAt),
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
const parsePublishedRuntimeConfig = (value) => {
|
|
227
|
+
if (!isRecord(value) || !isRecord(value.config)) {
|
|
228
|
+
throw new Error('Funnel runtime config response is invalid');
|
|
229
|
+
}
|
|
230
|
+
const config = value.config;
|
|
231
|
+
if (typeof value.revisionId !== 'string'
|
|
232
|
+
|| typeof value.sourceDeploymentId !== 'string'
|
|
233
|
+
|| typeof value.sourceVersionId !== 'string'
|
|
234
|
+
|| typeof value.schemaVersion !== 'number'
|
|
235
|
+
|| typeof value.publishedAt !== 'string'
|
|
236
|
+
|| typeof config.schemaVersion !== 'number'
|
|
237
|
+
|| typeof config.funnelId !== 'string'
|
|
238
|
+
|| typeof config.projectId !== 'string'
|
|
239
|
+
|| !Array.isArray(config.plans)
|
|
240
|
+
|| !Array.isArray(config.offerSets)
|
|
241
|
+
|| !Array.isArray(config.experiments)) {
|
|
242
|
+
throw new Error('Funnel runtime config response is invalid');
|
|
243
|
+
}
|
|
244
|
+
return value;
|
|
245
|
+
};
|
|
246
|
+
export const loadFunnelRuntimeConfig = (funnelId, options = {}) => {
|
|
247
|
+
const normalizedFunnelId = funnelId.trim();
|
|
248
|
+
if (!normalizedFunnelId) {
|
|
249
|
+
return Promise.reject(new Error('funnelId is required'));
|
|
250
|
+
}
|
|
251
|
+
const cachedInflight = inflightByFunnelId.get(normalizedFunnelId);
|
|
252
|
+
if (cachedInflight) {
|
|
253
|
+
return cachedInflight;
|
|
254
|
+
}
|
|
255
|
+
const fetcher = options.fetcher || globalThis.fetch;
|
|
256
|
+
const basePath = (options.basePath || '/api/funnel-config').replace(/\/$/, '');
|
|
257
|
+
const request = fetcher(`${basePath}/${encodeURIComponent(normalizedFunnelId)}`, {
|
|
258
|
+
headers: { accept: 'application/json' },
|
|
259
|
+
}).then(async (response) => {
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
throw new Error(`Unable to load funnel runtime config (${response.status})`);
|
|
262
|
+
}
|
|
263
|
+
const published = parsePublishedRuntimeConfig(await response.json());
|
|
264
|
+
if (published.config.funnelId !== normalizedFunnelId) {
|
|
265
|
+
throw new Error('Funnel runtime config belongs to a different funnel');
|
|
266
|
+
}
|
|
267
|
+
return published;
|
|
268
|
+
}).finally(() => {
|
|
269
|
+
inflightByFunnelId.delete(normalizedFunnelId);
|
|
270
|
+
});
|
|
271
|
+
inflightByFunnelId.set(normalizedFunnelId, request);
|
|
272
|
+
return request;
|
|
273
|
+
};
|
|
274
|
+
export const clearFunnelRuntimeConfigInflight = () => {
|
|
275
|
+
inflightByFunnelId.clear();
|
|
276
|
+
};
|