@funnelsgrove/runtime 0.1.0 → 0.1.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.
Files changed (62) hide show
  1. package/README.md +20 -1
  2. package/dist/components/FunnelContext.d.ts +5 -2
  3. package/dist/components/FunnelContext.js +3 -0
  4. package/dist/components/FunnelEditorPanel.d.ts +3 -5
  5. package/dist/components/FunnelEditorPanel.js +3 -3
  6. package/dist/components/ManageSubscriptionScreen.d.ts +51 -0
  7. package/dist/components/ManageSubscriptionScreen.js +349 -0
  8. package/dist/components/RuntimeDevInfoBox.d.ts +23 -0
  9. package/dist/components/RuntimeDevInfoBox.js +363 -0
  10. package/dist/components/SubscriptionHandoffScreen.d.ts +31 -0
  11. package/dist/components/SubscriptionHandoffScreen.js +338 -0
  12. package/dist/config/builder-preview.protocol.d.ts +73 -0
  13. package/dist/config/builder-preview.protocol.js +3 -0
  14. package/dist/config/env.config.d.ts +44 -0
  15. package/dist/config/env.config.js +161 -0
  16. package/dist/config/font-config.d.ts +14 -0
  17. package/dist/config/font-config.js +101 -0
  18. package/dist/config/funnel-theme.d.ts +61 -10
  19. package/dist/config/funnel-theme.js +355 -35
  20. package/dist/config/funnel.manifest.types.d.ts +13 -7
  21. package/dist/content/step-content.d.ts +130 -0
  22. package/dist/content/step-content.js +381 -0
  23. package/dist/index.d.ts +33 -21
  24. package/dist/index.js +33 -21
  25. package/dist/runtime/browser-helpers.d.ts +1 -0
  26. package/dist/runtime/browser-helpers.js +14 -0
  27. package/dist/runtime/experiment-assignment.d.ts +13 -4
  28. package/dist/runtime/experiment-assignment.js +9 -27
  29. package/dist/runtime/funnel-attribution.d.ts +18 -0
  30. package/dist/runtime/funnel-attribution.js +226 -0
  31. package/dist/runtime/funnel-flow.d.ts +9 -10
  32. package/dist/runtime/funnel-flow.js +4 -18
  33. package/dist/runtime/funnel-manifest.validation.d.ts +1 -1
  34. package/dist/runtime/funnel-manifest.validation.js +2 -6
  35. package/dist/runtime/funnel-runtime.d.ts +2 -3
  36. package/dist/runtime/funnel-runtime.js +6 -13
  37. package/dist/runtime/posthog-flags.d.ts +30 -0
  38. package/dist/runtime/posthog-flags.js +71 -0
  39. package/dist/runtime/preview-bridge.d.ts +13 -3
  40. package/dist/runtime/preview-bridge.js +96 -4
  41. package/dist/runtime/preview-definition-overrides.d.ts +20 -0
  42. package/dist/runtime/preview-definition-overrides.js +148 -0
  43. package/dist/runtime/route-resolver.d.ts +2 -3
  44. package/dist/runtime/route-resolver.js +15 -26
  45. package/dist/runtime/subscription-handoff.d.ts +32 -0
  46. package/dist/runtime/subscription-handoff.js +113 -0
  47. package/dist/runtime/use-funnel-flow-controller.d.ts +19 -10
  48. package/dist/runtime/use-funnel-flow-controller.js +190 -159
  49. package/dist/sdk/userAnswers.d.ts +2 -2
  50. package/dist/services/api.service.d.ts +21 -4
  51. package/dist/services/api.service.js +165 -35
  52. package/dist/services/funnel-state.service.d.ts +8 -0
  53. package/dist/services/funnel-state.service.js +44 -0
  54. package/dist/services/preview-frame.service.d.ts +2 -2
  55. package/dist/services/preview-frame.service.js +2 -2
  56. package/dist/services/public-env.d.ts +69 -0
  57. package/dist/services/public-env.js +105 -0
  58. package/dist/services/runtime-api.config.d.ts +5 -0
  59. package/dist/services/runtime-api.config.js +12 -7
  60. package/dist/services/runtime-mode.service.d.ts +3 -0
  61. package/dist/services/runtime-mode.service.js +142 -4
  62. package/package.json +8 -2
@@ -1,4 +1,13 @@
1
- import type { FunnelManifestExperiment } from '../config/funnel.manifest.types';
2
- export declare const getExperimentAssignmentStorageKey: (funnelId: string, userId: string, experimentId: string) => string;
3
- export declare const getExperimentAssignmentAttributeKey: (experimentId: string) => string;
4
- export declare const chooseAssignedVariantId: (experiment: FunnelManifestExperiment, userId: string, existingVariantId?: string | null) => string | null;
1
+ export type ManifestVariant = {
2
+ variantKey: string;
3
+ routeToStepId: string;
4
+ };
5
+ export type ResolveInput = {
6
+ experimentId: string;
7
+ variants: readonly ManifestVariant[];
8
+ };
9
+ export type Assignment = {
10
+ variantKey: string;
11
+ routeToStepId: string;
12
+ };
13
+ export declare const resolveExperimentAssignment: (input: ResolveInput) => Assignment | null;
@@ -1,32 +1,14 @@
1
- const hashToBucket = (input) => {
2
- let hash = 0;
3
- for (let index = 0; index < input.length; index += 1) {
4
- hash = (hash * 31 + input.charCodeAt(index)) % 104729;
5
- }
6
- return Math.abs(hash % 100);
7
- };
8
- export const getExperimentAssignmentStorageKey = (funnelId, userId, experimentId) => {
9
- return `funnel:experiment:${funnelId}:${userId}:${experimentId}`;
10
- };
11
- export const getExperimentAssignmentAttributeKey = (experimentId) => {
12
- return `experiment.${experimentId}.variant`;
13
- };
14
- export const chooseAssignedVariantId = (experiment, userId, existingVariantId) => {
1
+ import { resolveExperimentVariant } from './posthog-flags.js';
2
+ export const resolveExperimentAssignment = (input) => {
15
3
  var _a;
16
- if (!userId.trim()) {
4
+ if (input.variants.length === 0) {
17
5
  return null;
18
6
  }
19
- const variantsById = new Map(experiment.variants.map((variant) => [variant.id, variant]));
20
- if (existingVariantId && variantsById.has(existingVariantId)) {
21
- return existingVariantId;
22
- }
23
- const bucket = hashToBucket(`${experiment.experimentId}:${userId}`);
24
- let cumulative = 0;
25
- for (const variant of experiment.variants) {
26
- cumulative += Number.isFinite(variant.trafficPercent) ? variant.trafficPercent : 0;
27
- if (bucket < cumulative) {
28
- return variant.id;
29
- }
7
+ const resolved = resolveExperimentVariant(input.experimentId);
8
+ const match = resolved ? input.variants.find((variant) => variant.variantKey === resolved) : undefined;
9
+ if (match) {
10
+ return { variantKey: match.variantKey, routeToStepId: match.routeToStepId };
30
11
  }
31
- return ((_a = experiment.variants[experiment.variants.length - 1]) === null || _a === void 0 ? void 0 : _a.id) || null;
12
+ const control = (_a = input.variants.find((variant) => variant.variantKey === 'control')) !== null && _a !== void 0 ? _a : input.variants[0];
13
+ return { variantKey: control.variantKey, routeToStepId: control.routeToStepId };
32
14
  };
@@ -0,0 +1,18 @@
1
+ export type FunnelUserTouch = Record<string, string>;
2
+ export type FunnelUserAttribution = {
3
+ firstTouch: FunnelUserTouch;
4
+ lastTouch: FunnelUserTouch;
5
+ context: Record<string, unknown>;
6
+ };
7
+ export declare const collectCurrentFunnelAttribution: (input?: {
8
+ href?: string | null;
9
+ referrer?: string | null;
10
+ userAgent?: string | null;
11
+ language?: string | null;
12
+ platform?: string | null;
13
+ vendor?: string | null;
14
+ timeZone?: string | null;
15
+ posthogProperties?: Record<string, unknown> | null;
16
+ }) => FunnelUserAttribution;
17
+ export declare const mergeFunnelUserAttribution: (current: unknown, incoming: FunnelUserAttribution | null | undefined) => FunnelUserAttribution;
18
+ export declare const getFunnelUserAttribution: (document: unknown) => FunnelUserAttribution | null;
@@ -0,0 +1,226 @@
1
+ import { getPostHog } from './posthog-flags.js';
2
+ const INTERNAL_ATTRIBUTION_QUERY_KEYS = new Set([
3
+ 'editor',
4
+ 'previewframe',
5
+ 'source',
6
+ 'step',
7
+ 'user_id',
8
+ ]);
9
+ const POSTHOG_CONTEXT_MAPPINGS = {
10
+ $geoip_country_code: 'countryCode',
11
+ $geoip_country_name: 'countryName',
12
+ $browser: 'browser',
13
+ $browser_version: 'browserVersion',
14
+ $os: 'os',
15
+ $os_version: 'osVersion',
16
+ $device_type: 'deviceType',
17
+ $referrer: 'referrer',
18
+ $referring_domain: 'referringDomain',
19
+ };
20
+ const asTrimmedString = (value) => {
21
+ if (typeof value !== 'string') {
22
+ return null;
23
+ }
24
+ const trimmed = value.trim();
25
+ return trimmed || null;
26
+ };
27
+ const isRecord = (value) => {
28
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
29
+ };
30
+ const normalizeTouch = (value) => {
31
+ if (!isRecord(value)) {
32
+ return {};
33
+ }
34
+ return Object.fromEntries(Object.entries(value).flatMap(([key, candidate]) => {
35
+ const normalizedKey = key.trim();
36
+ const normalizedValue = asTrimmedString(candidate);
37
+ if (!normalizedKey || !normalizedValue) {
38
+ return [];
39
+ }
40
+ return [[normalizedKey, normalizedValue]];
41
+ }));
42
+ };
43
+ const normalizeContext = (value) => {
44
+ if (!isRecord(value)) {
45
+ return {};
46
+ }
47
+ return Object.fromEntries(Object.entries(value).flatMap(([key, candidate]) => {
48
+ const normalizedKey = key.trim();
49
+ if (!normalizedKey || candidate === undefined || candidate === null || candidate === '') {
50
+ return [];
51
+ }
52
+ return [[normalizedKey, candidate]];
53
+ }));
54
+ };
55
+ const deepMergeRecords = (base, patch) => {
56
+ const next = Object.assign({}, base);
57
+ for (const [key, patchValue] of Object.entries(patch)) {
58
+ const currentValue = next[key];
59
+ if (isRecord(currentValue) && isRecord(patchValue)) {
60
+ next[key] = deepMergeRecords(currentValue, patchValue);
61
+ continue;
62
+ }
63
+ next[key] = patchValue;
64
+ }
65
+ return next;
66
+ };
67
+ const resolveDeviceType = (userAgent) => {
68
+ if (!userAgent) {
69
+ return null;
70
+ }
71
+ const normalized = userAgent.toLowerCase();
72
+ if (/iphone|android.+mobile|ipod|mobile/.test(normalized)) {
73
+ return 'mobile';
74
+ }
75
+ if (/ipad|tablet/.test(normalized)) {
76
+ return 'tablet';
77
+ }
78
+ return 'desktop';
79
+ };
80
+ const resolveOsFamily = (userAgent) => {
81
+ if (!userAgent) {
82
+ return null;
83
+ }
84
+ const normalized = userAgent.toLowerCase();
85
+ if (/iphone|ipad|ipod|ios/.test(normalized)) {
86
+ return 'ios';
87
+ }
88
+ if (/android/.test(normalized)) {
89
+ return 'android';
90
+ }
91
+ if (/mac os x|macintosh/.test(normalized)) {
92
+ return 'macos';
93
+ }
94
+ if (/windows/.test(normalized)) {
95
+ return 'windows';
96
+ }
97
+ if (/linux/.test(normalized)) {
98
+ return 'linux';
99
+ }
100
+ return null;
101
+ };
102
+ const toUrl = (value) => {
103
+ const normalized = asTrimmedString(value);
104
+ if (!normalized) {
105
+ return null;
106
+ }
107
+ try {
108
+ return new URL(normalized);
109
+ }
110
+ catch (_a) {
111
+ return null;
112
+ }
113
+ };
114
+ const collectTouchFromUrl = (url) => {
115
+ if (!url) {
116
+ return {};
117
+ }
118
+ const nextTouch = {};
119
+ for (const [key, value] of url.searchParams.entries()) {
120
+ const normalizedKey = key.trim();
121
+ const normalizedValue = value.trim();
122
+ if (!normalizedKey || !normalizedValue) {
123
+ continue;
124
+ }
125
+ if (INTERNAL_ATTRIBUTION_QUERY_KEYS.has(normalizedKey.toLowerCase())) {
126
+ continue;
127
+ }
128
+ nextTouch[normalizedKey] = normalizedValue;
129
+ }
130
+ return nextTouch;
131
+ };
132
+ const collectPostHogContext = (posthogProperties) => {
133
+ if (!posthogProperties) {
134
+ return {};
135
+ }
136
+ return Object.fromEntries(Object.entries(POSTHOG_CONTEXT_MAPPINGS).flatMap(([sourceKey, targetKey]) => {
137
+ const normalizedValue = asTrimmedString(posthogProperties[sourceKey]);
138
+ return normalizedValue ? [[targetKey, normalizedValue]] : [];
139
+ }));
140
+ };
141
+ const resolvePostHogProperties = (explicitProperties) => {
142
+ if (explicitProperties) {
143
+ return explicitProperties;
144
+ }
145
+ const posthog = getPostHog();
146
+ if (!posthog) {
147
+ return null;
148
+ }
149
+ return Object.fromEntries(Object.keys(POSTHOG_CONTEXT_MAPPINGS).flatMap((key) => {
150
+ const value = posthog.get_property(key);
151
+ const normalized = asTrimmedString(value);
152
+ return normalized ? [[key, normalized]] : [];
153
+ }));
154
+ };
155
+ const getCurrentBrowserContext = () => {
156
+ if (typeof window === 'undefined') {
157
+ return {
158
+ href: null,
159
+ referrer: null,
160
+ userAgent: null,
161
+ language: null,
162
+ platform: null,
163
+ vendor: null,
164
+ timeZone: null,
165
+ };
166
+ }
167
+ return {
168
+ href: window.location.href,
169
+ referrer: typeof document !== 'undefined' ? document.referrer : null,
170
+ userAgent: navigator.userAgent,
171
+ language: navigator.language,
172
+ platform: navigator.platform,
173
+ vendor: navigator.vendor,
174
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || null,
175
+ };
176
+ };
177
+ export const collectCurrentFunnelAttribution = (input = {}) => {
178
+ const browserContext = getCurrentBrowserContext();
179
+ const href = asTrimmedString(input.href) || browserContext.href;
180
+ const referrer = asTrimmedString(input.referrer) || browserContext.referrer;
181
+ const userAgent = asTrimmedString(input.userAgent) || browserContext.userAgent;
182
+ const language = asTrimmedString(input.language) || browserContext.language;
183
+ const platform = asTrimmedString(input.platform) || browserContext.platform;
184
+ const vendor = asTrimmedString(input.vendor) || browserContext.vendor;
185
+ const timeZone = asTrimmedString(input.timeZone) || browserContext.timeZone;
186
+ const currentUrl = toUrl(href);
187
+ const referrerUrl = toUrl(referrer);
188
+ const firstTouch = collectTouchFromUrl(currentUrl);
189
+ const posthogContext = collectPostHogContext(resolvePostHogProperties(input.posthogProperties));
190
+ return {
191
+ firstTouch,
192
+ lastTouch: Object.assign({}, firstTouch),
193
+ context: normalizeContext(Object.assign({ host: (currentUrl === null || currentUrl === void 0 ? void 0 : currentUrl.host) || null, path: (currentUrl === null || currentUrl === void 0 ? void 0 : currentUrl.pathname) || null, referrer, referringDomain: (referrerUrl === null || referrerUrl === void 0 ? void 0 : referrerUrl.host) || null, userAgent,
194
+ language,
195
+ platform,
196
+ vendor,
197
+ timeZone, deviceType: resolveDeviceType(userAgent), osFamily: resolveOsFamily(userAgent) }, posthogContext)),
198
+ };
199
+ };
200
+ export const mergeFunnelUserAttribution = (current, incoming) => {
201
+ const currentRecord = isRecord(current) ? current : {};
202
+ const currentFirstTouch = normalizeTouch(currentRecord.firstTouch);
203
+ const currentLastTouch = normalizeTouch(currentRecord.lastTouch);
204
+ const currentContext = normalizeContext(currentRecord.context);
205
+ const nextIncoming = incoming !== null && incoming !== void 0 ? incoming : {
206
+ firstTouch: {},
207
+ lastTouch: {},
208
+ context: {},
209
+ };
210
+ const nextFirstTouch = normalizeTouch(nextIncoming.firstTouch);
211
+ const nextLastTouch = normalizeTouch(nextIncoming.lastTouch);
212
+ const nextContext = normalizeContext(nextIncoming.context);
213
+ return {
214
+ firstTouch: Object.keys(currentFirstTouch).length > 0 ? currentFirstTouch : nextFirstTouch,
215
+ lastTouch: Object.keys(nextLastTouch).length > 0
216
+ ? nextLastTouch
217
+ : currentLastTouch,
218
+ context: deepMergeRecords(currentContext, nextContext),
219
+ };
220
+ };
221
+ export const getFunnelUserAttribution = (document) => {
222
+ if (!isRecord(document) || !isRecord(document.attribution)) {
223
+ return null;
224
+ }
225
+ return mergeFunnelUserAttribution(document.attribution, null);
226
+ };
@@ -1,23 +1,22 @@
1
- import type { FunnelManifestExperiment } from '../config/funnel.manifest.types';
2
- import type { FunnelUserAnswers } from '../sdk/userAnswers';
1
+ import type { FunnelManifestExperiment } from '../config/funnel.manifest.types.js';
2
+ import type { FunnelUserAnswers } from '../sdk/userAnswers.js';
3
3
  export declare const isPreviewStepLockRequested: () => boolean;
4
4
  export declare const getRequestedEntryPointId: () => string | null;
5
5
  export declare const getRuntimeFunnelIdentity: () => string;
6
- export declare const getAssignedVariantId: (experiment: FunnelManifestExperiment, userId: string, storage: {
7
- read: (storageKey: string) => string | null;
8
- write: (storageKey: string, variantId: string) => void;
9
- }) => string | null;
6
+ export declare const shouldRunAutoAdvanceTimer: (input: {
7
+ autoAdvanceMs?: number;
8
+ isPreviewRuntime: boolean;
9
+ shouldLockToInitialStep: boolean;
10
+ }) => boolean;
10
11
  export declare const resolveNextStepFromContext: <StepId extends string>(input: {
11
12
  stepId: StepId;
12
13
  attributes: FunnelUserAnswers;
13
- userId: string;
14
14
  safeInitialStepId: StepId;
15
15
  resolveRenderableStepId: (stepId: string | null | undefined) => StepId | null;
16
16
  getConfiguredNextStepId: (stepId: StepId, context: {
17
17
  attributes: FunnelUserAnswers;
18
- userId: string;
19
- getAssignedVariantId: (experiment: FunnelManifestExperiment) => string | null;
18
+ resolveExperimentVariantKey: (experiment: FunnelManifestExperiment) => string | null;
20
19
  }) => StepId | null;
21
20
  getSequentialNextStepId: (stepId: StepId) => StepId | null;
22
- getAssignedVariantId: (experiment: FunnelManifestExperiment, userId: string) => string | null;
21
+ resolveExperimentVariantKey: (experiment: FunnelManifestExperiment) => string | null;
23
22
  }) => StepId;
@@ -1,5 +1,4 @@
1
- import { canUseDom } from './browser-helpers';
2
- import { chooseAssignedVariantId, getExperimentAssignmentStorageKey, } from './experiment-assignment';
1
+ import { canUseDom } from './browser-helpers.js';
3
2
  export const isPreviewStepLockRequested = () => {
4
3
  if (!canUseDom()) {
5
4
  return false;
@@ -37,27 +36,14 @@ export const getRuntimeFunnelIdentity = () => {
37
36
  return 'default';
38
37
  }
39
38
  };
40
- export const getAssignedVariantId = (experiment, userId, storage) => {
41
- if (!userId.trim()) {
42
- return null;
43
- }
44
- const storageKey = getExperimentAssignmentStorageKey(getRuntimeFunnelIdentity(), userId, experiment.experimentId);
45
- const storedVariantId = storage.read(storageKey);
46
- const selectedVariantId = chooseAssignedVariantId(experiment, userId, storedVariantId);
47
- if (!selectedVariantId) {
48
- return null;
49
- }
50
- if (storedVariantId !== selectedVariantId) {
51
- storage.write(storageKey, selectedVariantId);
52
- }
53
- return selectedVariantId;
39
+ export const shouldRunAutoAdvanceTimer = (input) => {
40
+ return Boolean(input.autoAdvanceMs) && !input.isPreviewRuntime && !input.shouldLockToInitialStep;
54
41
  };
55
42
  export const resolveNextStepFromContext = (input) => {
56
43
  var _a, _b;
57
44
  const configuredStepId = input.getConfiguredNextStepId(input.stepId, {
58
45
  attributes: input.attributes,
59
- userId: input.userId,
60
- getAssignedVariantId: (experiment) => input.getAssignedVariantId(experiment, input.userId),
46
+ resolveExperimentVariantKey: input.resolveExperimentVariantKey,
61
47
  });
62
48
  if (configuredStepId) {
63
49
  return (_a = input.resolveRenderableStepId(configuredStepId)) !== null && _a !== void 0 ? _a : input.safeInitialStepId;
@@ -1,2 +1,2 @@
1
- import type { FunnelManifest } from '../config/funnel.manifest.types';
1
+ import type { FunnelManifest } from '../config/funnel.manifest.types.js';
2
2
  export declare const validateFunnelManifest: <T extends FunnelManifest>(manifest: T) => T;
@@ -37,11 +37,7 @@ export const validateFunnelManifest = (manifest) => {
37
37
  pushDuplicateErrors('step paths', stepPaths, errors);
38
38
  pushDuplicateErrors('step file paths', filePaths, errors);
39
39
  pushDuplicateErrors('step component keys', componentKeys, errors);
40
- const defaultEntryPoints = manifest.entryPoints.filter((entryPoint) => entryPoint.isDefault === true);
41
- if (defaultEntryPoints.length !== 1) {
42
- errors.push(`manifest must declare exactly one default entry point, found ${defaultEntryPoints.length}`);
43
- }
44
- for (const entryPoint of manifest.entryPoints) {
40
+ for (const entryPoint of manifest.entryPoints || []) {
45
41
  assertValidReference(`entry point "${entryPoint.id}"`, entryPoint.stepId, validStepIds, errors);
46
42
  }
47
43
  for (const [stepId, edges] of Object.entries(manifest.edgesByStepId)) {
@@ -53,7 +49,7 @@ export const validateFunnelManifest = (manifest) => {
53
49
  for (const experiment of manifest.experiments) {
54
50
  assertValidReference(`experiment "${experiment.experimentId}"`, experiment.stepId, validStepIds, errors);
55
51
  for (const variant of experiment.variants) {
56
- assertValidReference(`experiment variant "${experiment.experimentId}:${variant.id}"`, variant.routeToStepId, validStepIds, errors);
52
+ assertValidReference(`experiment variant "${experiment.experimentId}:${variant.variantKey}"`, variant.routeToStepId, validStepIds, errors);
57
53
  }
58
54
  }
59
55
  if (errors.length > 0) {
@@ -1,4 +1,4 @@
1
- import type { FunnelManifest, FunnelManifestExperiment } from '../config/funnel.manifest.types';
1
+ import type { FunnelManifest, FunnelManifestExperiment } from '../config/funnel.manifest.types.js';
2
2
  export type FunnelStepId = string;
3
3
  export declare const getFunnelStepSequence: (manifest: FunnelManifest) => FunnelStepId[];
4
4
  export declare const getFunnelEntryPoints: (manifest: FunnelManifest) => {
@@ -26,9 +26,8 @@ export declare const getChoiceTargetsForStep: (manifest: FunnelManifest, stepId:
26
26
  export declare const resolveRuntimeInitialStepId: (input: {
27
27
  manifest: FunnelManifest;
28
28
  requestedStepId?: string | null;
29
- entryPointId?: string | null;
30
29
  }) => FunnelStepId;
31
30
  export declare const resolveConfiguredNextStep: (manifest: FunnelManifest, stepId: FunnelStepId, context: {
32
31
  attributes: Record<string, unknown>;
33
- getAssignedVariantId: (experiment: FunnelManifestExperiment) => string | null;
32
+ resolveExperimentVariantKey: (experiment: FunnelManifestExperiment) => string | null;
34
33
  }) => FunnelStepId | null;
@@ -1,14 +1,8 @@
1
- import { getChoiceTargetsFromEdges, isManifestStepId, resolveInitialStepId, resolveNextStepId, } from './route-resolver';
1
+ import { getChoiceTargetsFromEdges, isManifestStepId, resolveInitialStepId, resolveNextStepId, } from './route-resolver.js';
2
2
  export const getFunnelStepSequence = (manifest) => manifest.steps.map((step) => step.id);
3
- export const getFunnelEntryPoints = (manifest) => manifest.entryPoints.map((entryPoint) => (Object.assign(Object.assign({}, entryPoint), { stepId: entryPoint.stepId })));
3
+ export const getFunnelEntryPoints = (manifest) => (manifest.entryPoints || []).map((entryPoint) => (Object.assign(Object.assign({}, entryPoint), { stepId: entryPoint.stepId })));
4
4
  export const getFunnelExperiments = (manifest) => manifest.experiments.map((experiment) => (Object.assign(Object.assign({}, experiment), { stepId: experiment.stepId, variants: experiment.variants.map((variant) => (Object.assign(Object.assign({}, variant), { routeToStepId: variant.routeToStepId }))) })));
5
- export const getDefaultFunnelStepId = (manifest) => {
6
- var _a;
7
- return ((_a = manifest.entryPoints.find((entryPoint) => {
8
- return 'isDefault' in entryPoint && entryPoint.isDefault;
9
- })) === null || _a === void 0 ? void 0 : _a.stepId) ||
10
- getFunnelStepSequence(manifest)[0];
11
- };
5
+ export const getDefaultFunnelStepId = (manifest) => getFunnelStepSequence(manifest)[0];
12
6
  export const getIsFunnelStepId = (manifest, value) => {
13
7
  return new Set(getFunnelStepSequence(manifest)).has(value);
14
8
  };
@@ -28,11 +22,11 @@ export const getDefaultEntryPointStepId = (manifest) => {
28
22
  return getDefaultFunnelStepId(manifest);
29
23
  };
30
24
  export const getEntryPointStepId = (manifest, entryPointId) => {
31
- var _a;
25
+ var _a, _b;
32
26
  if (!entryPointId) {
33
27
  return null;
34
28
  }
35
- const stepId = (_a = manifest.entryPoints.find((entryPoint) => entryPoint.id === entryPointId)) === null || _a === void 0 ? void 0 : _a.stepId;
29
+ const stepId = (_b = (_a = manifest.entryPoints) === null || _a === void 0 ? void 0 : _a.find((entryPoint) => entryPoint.id === entryPointId)) === null || _b === void 0 ? void 0 : _b.stepId;
36
30
  return stepId && getIsFunnelStepId(manifest, stepId) ? stepId : null;
37
31
  };
38
32
  export const getSequentialNextStepId = (manifest, stepId) => {
@@ -58,7 +52,6 @@ export const resolveRuntimeInitialStepId = (input) => {
58
52
  const stepId = resolveInitialStepId({
59
53
  manifest: input.manifest,
60
54
  requestedStepId: input.requestedStepId,
61
- entryPointId: input.entryPointId,
62
55
  });
63
56
  return getIsFunnelStepId(input.manifest, stepId) ? stepId : getDefaultFunnelStepId(input.manifest);
64
57
  };
@@ -67,7 +60,7 @@ export const resolveConfiguredNextStep = (manifest, stepId, context) => {
67
60
  manifest,
68
61
  currentStepId: stepId,
69
62
  attributes: context.attributes,
70
- getAssignedVariantId: context.getAssignedVariantId,
63
+ resolveExperimentVariantKey: context.resolveExperimentVariantKey,
71
64
  });
72
65
  return nextStepId && isManifestStepId(manifest, nextStepId) && getIsFunnelStepId(manifest, nextStepId)
73
66
  ? nextStepId
@@ -0,0 +1,30 @@
1
+ import type { PostHog } from 'posthog-js';
2
+ type BootstrapConfig = {
3
+ apiKey: string;
4
+ apiHost: string;
5
+ distinctId: string;
6
+ };
7
+ export declare const bootstrapPostHog: (config: BootstrapConfig) => Promise<void>;
8
+ export declare const isPostHogReady: () => boolean;
9
+ /**
10
+ * Aliases an anonymous distinct id to a server-side user id after the user
11
+ * has been identified (e.g. once bootstrapSession resolves). Preserves the
12
+ * anonymous user's feature-flag bucket assignments via PostHog's
13
+ * ensure_experience_continuity behaviour, so they don't get re-bucketed to
14
+ * a different variant mid-session.
15
+ *
16
+ * No-op if PostHog has not finished initializing or if the ids already match.
17
+ */
18
+ export declare const identifyPostHog: (serverUserId: string, anonymousId: string) => void;
19
+ /**
20
+ * Returns the variant key for the flag, or undefined if:
21
+ * - PostHog SDK has not finished loading feature flags yet (see isPostHogReady)
22
+ * - OR no flag of that key exists
23
+ *
24
+ * Callers that render experiment variants should gate on isPostHogReady() first,
25
+ * otherwise users may see the fallback (control) variant on first paint and
26
+ * flip once the SDK loads. Task 8's controller handles this gate.
27
+ */
28
+ export declare const resolveExperimentVariant: (flagKey: string) => string | undefined;
29
+ export declare const getPostHog: () => PostHog | null;
30
+ export {};
@@ -0,0 +1,71 @@
1
+ import posthog from 'posthog-js';
2
+ let initialized = false;
3
+ let readyPromise = null;
4
+ export const bootstrapPostHog = (config) => {
5
+ if (readyPromise) {
6
+ return readyPromise;
7
+ }
8
+ readyPromise = new Promise((resolve) => {
9
+ if (!config.apiKey || !config.apiHost) {
10
+ resolve();
11
+ return;
12
+ }
13
+ const markReady = () => {
14
+ if (initialized) {
15
+ return;
16
+ }
17
+ initialized = true;
18
+ resolve();
19
+ };
20
+ posthog.init(config.apiKey, {
21
+ api_host: config.apiHost,
22
+ persistence: 'localStorage+cookie',
23
+ person_profiles: 'identified_only',
24
+ bootstrap: { distinctID: config.distinctId },
25
+ loaded: () => {
26
+ let unsubscribe = null;
27
+ unsubscribe = posthog.onFeatureFlags(() => {
28
+ unsubscribe === null || unsubscribe === void 0 ? void 0 : unsubscribe();
29
+ markReady();
30
+ });
31
+ },
32
+ });
33
+ });
34
+ return readyPromise;
35
+ };
36
+ export const isPostHogReady = () => initialized;
37
+ /**
38
+ * Aliases an anonymous distinct id to a server-side user id after the user
39
+ * has been identified (e.g. once bootstrapSession resolves). Preserves the
40
+ * anonymous user's feature-flag bucket assignments via PostHog's
41
+ * ensure_experience_continuity behaviour, so they don't get re-bucketed to
42
+ * a different variant mid-session.
43
+ *
44
+ * No-op if PostHog has not finished initializing or if the ids already match.
45
+ */
46
+ export const identifyPostHog = (serverUserId, anonymousId) => {
47
+ if (!initialized || serverUserId === anonymousId) {
48
+ return;
49
+ }
50
+ // posthog-js types the 3rd arg as userPropertiesToSetOnce, but passing the
51
+ // prior anonymous id here is the documented posthog pattern for explicitly
52
+ // linking an anonymous session to an identified user for experiment continuity.
53
+ posthog.identify(serverUserId, undefined, anonymousId);
54
+ };
55
+ /**
56
+ * Returns the variant key for the flag, or undefined if:
57
+ * - PostHog SDK has not finished loading feature flags yet (see isPostHogReady)
58
+ * - OR no flag of that key exists
59
+ *
60
+ * Callers that render experiment variants should gate on isPostHogReady() first,
61
+ * otherwise users may see the fallback (control) variant on first paint and
62
+ * flip once the SDK loads. Task 8's controller handles this gate.
63
+ */
64
+ export const resolveExperimentVariant = (flagKey) => {
65
+ if (!initialized) {
66
+ return undefined;
67
+ }
68
+ const value = posthog.getFeatureFlag(flagKey);
69
+ return typeof value === 'string' ? value : undefined;
70
+ };
71
+ export const getPostHog = () => (initialized ? posthog : null);
@@ -1,5 +1,6 @@
1
- import type { RuntimeMode } from '../services/runtime-mode.service';
2
- import type { FunnelStepId } from './funnel-runtime';
1
+ import { type BuilderPreviewDefinitionPatch, type BuilderPreviewPaywallPlan } from '../config/builder-preview.protocol.js';
2
+ import type { RuntimeMode } from '../services/runtime-mode.service.js';
3
+ import type { FunnelStepId } from './funnel-runtime.js';
3
4
  export type PreviewQuickEditPatch = {
4
5
  kind: 'tagText';
5
6
  stepId: string;
@@ -28,11 +29,18 @@ export type PreviewQuickEditPatch = {
28
29
  value: string;
29
30
  };
30
31
  type PreviewBridgeMessage = {
32
+ kind: 'definitionPatch';
33
+ patch: BuilderPreviewDefinitionPatch;
34
+ } | {
31
35
  kind: 'quickEditPatch';
32
36
  patch: PreviewQuickEditPatch;
33
37
  } | {
34
38
  kind: 'runtimeModeChanged';
35
39
  mode: RuntimeMode;
40
+ } | {
41
+ kind: 'paywallPlansChanged';
42
+ stepId: string;
43
+ plans: BuilderPreviewPaywallPlan[];
36
44
  } | {
37
45
  kind: 'goToStep';
38
46
  stepId: string;
@@ -40,6 +48,8 @@ type PreviewBridgeMessage = {
40
48
  export declare const parsePreviewQuickEditPatch: (value: unknown) => PreviewQuickEditPatch | null;
41
49
  export declare const parsePreviewBridgeMessage: (value: unknown) => PreviewBridgeMessage | null;
42
50
  export declare const applyPreviewQuickEditPatch: (patch: PreviewQuickEditPatch) => void;
51
+ export declare function emitPreviewVariableValues(stepId: FunnelStepId, values: Record<string, string>): void;
52
+ export declare function usePreviewVariableValues(stepId: FunnelStepId, values: Record<string, string>): void;
43
53
  export type PreviewBridgeOptions = {
44
54
  activeStepId: FunnelStepId;
45
55
  onGoToStep: (stepId: FunnelStepId) => void;
@@ -48,4 +58,4 @@ export type PreviewBridgeOptions = {
48
58
  shouldLockToInitialStep: boolean;
49
59
  };
50
60
  export declare function usePreviewBridge({ activeStepId, onGoToStep, resolveRenderableStepId, setRuntimeMode, shouldLockToInitialStep, }: PreviewBridgeOptions): void;
51
- export {};
61
+ export { applyPreviewDefinitionPatch, clearPreviewDefinitionPatches, resolvePreviewStepPaywallPlans, resolvePreviewStepCountryPricing, resolvePreviewStepLocalizedContent, usePreviewDefinitionOverrideRevision, usePreviewStepPaywallPlans, usePreviewStepCountryPricing, usePreviewStepLocalizedContent, } from './preview-definition-overrides.js';