@funnelsgrove/runtime 0.3.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/runtime/use-funnel-flow-controller.js +5 -20
- 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';
|
|
@@ -324,8 +324,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
|
|
|
324
324
|
const attributesRef = useRef(attributes);
|
|
325
325
|
const postHogFeatureFlagsRef = useRef({});
|
|
326
326
|
const prevStepIdRef = useRef(null);
|
|
327
|
-
const
|
|
328
|
-
const firstStepViewedVisitRef = useRef(null);
|
|
327
|
+
const firstStepViewedTrackedRef = useRef(false);
|
|
329
328
|
const firstStepClickedVisitRef = useRef(null);
|
|
330
329
|
// One controller mount is one funnel run. A full reload intentionally begins a new run.
|
|
331
330
|
const funnelCompletionClaimedRef = useRef(false);
|
|
@@ -968,7 +967,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
|
|
|
968
967
|
safeActiveStepId,
|
|
969
968
|
]);
|
|
970
969
|
useEffect(() => {
|
|
971
|
-
var _a, _b
|
|
970
|
+
var _a, _b;
|
|
972
971
|
if (safeActiveStepId !== activeStepId) {
|
|
973
972
|
logger.warn(`[FunnelFlow] Unknown or non-renderable step "${activeStepId}", falling back to "${safeActiveStepId}".`);
|
|
974
973
|
claimCurrentVisit({ type: 'exit', reason: 'replace' });
|
|
@@ -1021,10 +1020,10 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
|
|
|
1021
1020
|
featureFlags: postHogFeatureFlagsRef.current,
|
|
1022
1021
|
});
|
|
1023
1022
|
if (safeActiveStepId === defaultStepId) {
|
|
1024
|
-
const initialAttribution = takeInitialFunnelAttributionMetadata(
|
|
1025
|
-
|
|
1023
|
+
const initialAttribution = takeInitialFunnelAttributionMetadata(firstStepViewedTrackedRef.current, attributionReady, initialAttributionRef.current);
|
|
1024
|
+
firstStepViewedTrackedRef.current = initialAttribution.tracked;
|
|
1026
1025
|
if (initialAttribution.metadata) {
|
|
1027
|
-
(_b = analytics === null || analytics === void 0 ? void 0 : analytics.
|
|
1026
|
+
(_b = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _b === void 0 ? void 0 : _b.call(analytics, {
|
|
1028
1027
|
userId: currentUserIdRef.current,
|
|
1029
1028
|
environment: analyticsRuntimeMode,
|
|
1030
1029
|
stepId: renderedStepId,
|
|
@@ -1036,20 +1035,6 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
|
|
|
1036
1035
|
featureFlags: postHogFeatureFlagsRef.current,
|
|
1037
1036
|
});
|
|
1038
1037
|
}
|
|
1039
|
-
if (firstStepViewedVisitRef.current !== startIntent.visit.visitId) {
|
|
1040
|
-
firstStepViewedVisitRef.current = startIntent.visit.visitId;
|
|
1041
|
-
(_c = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _c === void 0 ? void 0 : _c.call(analytics, {
|
|
1042
|
-
userId: currentUserIdRef.current,
|
|
1043
|
-
environment: analyticsRuntimeMode,
|
|
1044
|
-
stepId: renderedStepId,
|
|
1045
|
-
stepName,
|
|
1046
|
-
stepType,
|
|
1047
|
-
stepContractVersion,
|
|
1048
|
-
occurredAt: startedAt,
|
|
1049
|
-
metadata: attributionMetadata,
|
|
1050
|
-
featureFlags: postHogFeatureFlagsRef.current,
|
|
1051
|
-
});
|
|
1052
|
-
}
|
|
1053
1038
|
}
|
|
1054
1039
|
}
|
|
1055
1040
|
}, [
|
|
@@ -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
|
+
};
|