@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,25 +1,69 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
3
- import { writeStepChoice } from '../sdk/userAnswers';
4
- import { apiService } from '../services/api.service';
5
- import { logger } from '../services/logger';
6
- import { buildHostedStepLocation, dispatchWindowCustomEvent, readWindowStorageValue, writeWindowStorageValue, } from './browser-helpers';
7
- import { getAssignedVariantId, getRequestedEntryPointId, getRuntimeFunnelIdentity, isPreviewStepLockRequested, resolveNextStepFromContext, } from './funnel-flow';
8
- import { usePreviewBridge } from './preview-bridge';
9
- import { getExperimentAssignmentAttributeKey, getExperimentAssignmentStorageKey } from './experiment-assignment';
10
- import { isEditorEnabled, useRuntimeMode } from '../services/runtime-mode.service';
11
- import { isPreviewFrameRuntime } from '../services/preview-frame.service';
3
+ import { writeStepChoice } from '../sdk/userAnswers.js';
4
+ import { apiService } from '../services/api.service.js';
5
+ import { logger } from '../services/logger.js';
6
+ import { FUNNEL_ID, FUNNEL_VERSION_ID, POSTHOG_API_HOST, POSTHOG_PROJECT_API_KEY, PROJECT_ID, } from '../services/runtime-api.config.js';
7
+ import { buildHostedStepLocation, dispatchWindowCustomEvent, } from './browser-helpers.js';
8
+ import { isPreviewStepLockRequested, resolveNextStepFromContext, shouldRunAutoAdvanceTimer, } from './funnel-flow.js';
9
+ import { usePreviewBridge } from './preview-bridge.js';
10
+ import { resolveExperimentAssignment } from './experiment-assignment.js';
11
+ import { collectCurrentFunnelAttribution } from './funnel-attribution.js';
12
+ import { bootstrapPostHog, getPostHog, identifyPostHog, isPostHogReady, resolveExperimentVariant, } from './posthog-flags.js';
13
+ import { isEditorEnabled, useRuntimeMode } from '../services/runtime-mode.service.js';
14
+ import { isPreviewFrameRuntime } from '../services/preview-frame.service.js';
15
+ const buildFeatureFlagProperties = (featureFlags) => {
16
+ return Object.fromEntries(Object.entries(featureFlags).map(([key, value]) => [`$feature/${key}`, value]));
17
+ };
18
+ const capturePostHogStepEvent = (event, properties) => {
19
+ if (!getPostHog() || !POSTHOG_API_HOST || !POSTHOG_PROJECT_API_KEY || !PROJECT_ID) {
20
+ return;
21
+ }
22
+ const payload = JSON.stringify({
23
+ api_key: POSTHOG_PROJECT_API_KEY,
24
+ batch: [
25
+ {
26
+ event,
27
+ properties: Object.assign({ $groups: {
28
+ project: PROJECT_ID,
29
+ }, $insert_id: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
30
+ ? crypto.randomUUID()
31
+ : `${event}_${Date.now()}_${Math.random().toString(16).slice(2)}`, event_source: 'client' }, properties),
32
+ timestamp: new Date().toISOString(),
33
+ },
34
+ ],
35
+ });
36
+ const endpoint = `${POSTHOG_API_HOST}/batch/`;
37
+ if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
38
+ const accepted = navigator.sendBeacon(endpoint, new Blob([payload], { type: 'application/json' }));
39
+ if (accepted) {
40
+ return;
41
+ }
42
+ }
43
+ void fetch(endpoint, {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body: payload,
47
+ keepalive: true,
48
+ }).catch(() => {
49
+ // Analytics delivery must never block funnel interaction.
50
+ });
51
+ };
52
+ export function computeRenderSuspended(input) {
53
+ if (input.isPreviewRuntime || input.postHogReady) {
54
+ return false;
55
+ }
56
+ return input.experiments.some((experiment) => experiment.stepId === input.activeStepId);
57
+ }
12
58
  export function useFunnelFlowController({ initialStepId, lockToInitialStep = false, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }) {
13
59
  var _a, _b;
14
60
  const [runtimeMode, setRuntimeMode] = useRuntimeMode();
15
61
  const [editorModeEnabled, setEditorModeEnabled] = useState(false);
16
62
  const [editorPanelExpanded, setEditorPanelExpanded] = useState(false);
63
+ const [postHogReady, setPostHogReady] = useState(() => isPostHogReady());
64
+ const [editorVariantOverrides, setEditorVariantOverrides] = useState({});
17
65
  const shouldLockToInitialStep = lockToInitialStep || isPreviewStepLockRequested();
18
66
  const isPreviewRuntime = useMemo(() => isPreviewFrameRuntime(), []);
19
- const experimentAssignmentStorage = useMemo(() => ({
20
- read: readWindowStorageValue,
21
- write: writeWindowStorageValue,
22
- }), []);
23
67
  const resolveRenderableStepId = useCallback((stepId) => {
24
68
  if (!stepId || !isFunnelStepId(stepId)) {
25
69
  return null;
@@ -46,7 +90,6 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
46
90
  const safeInitialStepId = useMemo(() => {
47
91
  const requestedStepId = resolveRuntimeInitialStepId({
48
92
  requestedStepId: initialStepId,
49
- entryPointId: getRequestedEntryPointId(),
50
93
  });
51
94
  return resolveFallbackStepId(requestedStepId);
52
95
  }, [initialStepId, resolveFallbackStepId, resolveRuntimeInitialStepId]);
@@ -57,30 +100,69 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
57
100
  name: '',
58
101
  email: '',
59
102
  attributes: {},
103
+ document: {},
60
104
  completedSteps: [],
61
105
  }));
62
106
  const attributesAtStepStart = useRef({});
63
107
  const attributesRef = useRef(attributes);
108
+ const postHogFeatureFlagsRef = useRef({});
64
109
  const prevStepIdRef = useRef(null);
65
110
  const stepStartedAtByIdRef = useRef({});
66
111
  const currentUserIdRef = useRef(user.id);
67
112
  const safeActiveStepId = useMemo(() => { var _a; return (_a = resolveRenderableStepId(activeStepId)) !== null && _a !== void 0 ? _a : safeInitialStepId; }, [activeStepId, resolveRenderableStepId, safeInitialStepId]);
68
113
  const activeStepMeta = stepById[safeActiveStepId];
114
+ const renderSuspended = useMemo(() => computeRenderSuspended({
115
+ activeStepId: safeActiveStepId,
116
+ postHogReady,
117
+ experiments: funnelExperiments,
118
+ isPreviewRuntime,
119
+ }), [funnelExperiments, isPreviewRuntime, postHogReady, safeActiveStepId]);
120
+ const resolveExperimentVariantKey = useCallback((experiment) => {
121
+ var _a;
122
+ const override = editorVariantOverrides[experiment.experimentId];
123
+ if (override) {
124
+ return override;
125
+ }
126
+ if (!postHogReady) {
127
+ return null;
128
+ }
129
+ return (_a = resolveExperimentVariant(experiment.experimentId)) !== null && _a !== void 0 ? _a : null;
130
+ }, [editorVariantOverrides, postHogReady]);
69
131
  const renderedStepId = useMemo(() => {
70
- const activeExperiment = funnelExperiments.find((experiment) => experiment.status === 'active' && experiment.stepId === safeActiveStepId);
71
- if (!activeExperiment) {
132
+ const experiment = funnelExperiments.find((candidate) => candidate.stepId === safeActiveStepId);
133
+ if (!experiment) {
72
134
  return safeActiveStepId;
73
135
  }
74
- const assignedVariantId = getAssignedVariantId(activeExperiment, user.id, experimentAssignmentStorage);
75
- if (!assignedVariantId) {
136
+ const override = editorVariantOverrides[experiment.experimentId];
137
+ if (override) {
138
+ const matchedVariant = experiment.variants.find((variant) => variant.variantKey === override);
139
+ const variantStepId = resolveRenderableStepId((matchedVariant === null || matchedVariant === void 0 ? void 0 : matchedVariant.routeToStepId) || null);
140
+ return variantStepId !== null && variantStepId !== void 0 ? variantStepId : safeActiveStepId;
141
+ }
142
+ if (!postHogReady) {
76
143
  return safeActiveStepId;
77
144
  }
78
- const assignedVariant = activeExperiment.variants.find((variant) => variant.id === assignedVariantId);
79
- const variantStepId = resolveRenderableStepId((assignedVariant === null || assignedVariant === void 0 ? void 0 : assignedVariant.routeToStepId) || null);
145
+ const assignment = resolveExperimentAssignment({
146
+ experimentId: experiment.experimentId,
147
+ variants: experiment.variants,
148
+ });
149
+ if (!assignment) {
150
+ return safeActiveStepId;
151
+ }
152
+ const variantStepId = resolveRenderableStepId(assignment.routeToStepId);
80
153
  return variantStepId !== null && variantStepId !== void 0 ? variantStepId : safeActiveStepId;
81
- }, [experimentAssignmentStorage, funnelExperiments, resolveRenderableStepId, safeActiveStepId, user.id]);
154
+ }, [editorVariantOverrides, funnelExperiments, postHogReady, resolveRenderableStepId, safeActiveStepId]);
82
155
  const renderedStepMeta = stepById[renderedStepId];
83
156
  const activeStepComponent = stepComponentById[renderedStepId];
157
+ const postHogFeatureFlags = useMemo(() => {
158
+ if (!postHogReady || isPreviewRuntime) {
159
+ return {};
160
+ }
161
+ return Object.fromEntries(funnelExperiments.flatMap((experiment) => {
162
+ const variantKey = resolveExperimentVariant(experiment.experimentId);
163
+ return variantKey ? [[experiment.experimentId, variantKey]] : [];
164
+ }));
165
+ }, [funnelExperiments, isPreviewRuntime, postHogReady]);
84
166
  const goToStep = useCallback((stepId) => {
85
167
  var _a;
86
168
  if (shouldLockToInitialStep) {
@@ -119,38 +201,67 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
119
201
  useEffect(() => {
120
202
  attributesRef.current = attributes;
121
203
  }, [attributes]);
204
+ useEffect(() => {
205
+ postHogFeatureFlagsRef.current = postHogFeatureFlags;
206
+ }, [postHogFeatureFlags]);
207
+ useEffect(() => {
208
+ if (postHogReady) {
209
+ return;
210
+ }
211
+ if (isPostHogReady()) {
212
+ setPostHogReady(true);
213
+ return;
214
+ }
215
+ const intervalId = window.setInterval(() => {
216
+ if (isPostHogReady()) {
217
+ setPostHogReady(true);
218
+ window.clearInterval(intervalId);
219
+ }
220
+ }, 100);
221
+ return () => window.clearInterval(intervalId);
222
+ }, [postHogReady]);
122
223
  const resolveNextStepId = useCallback((stepId) => {
123
- const activeUserId = currentUserIdRef.current || user.id;
124
224
  return resolveNextStepFromContext({
125
225
  stepId,
126
226
  attributes: attributesRef.current,
127
- userId: activeUserId,
128
227
  safeInitialStepId,
129
228
  resolveRenderableStepId,
130
229
  getConfiguredNextStepId: (currentStepId, context) => resolveConfiguredNextStep(currentStepId, context),
131
230
  getSequentialNextStepId,
132
- getAssignedVariantId: (experiment, currentUserId) => getAssignedVariantId(experiment, currentUserId, experimentAssignmentStorage),
231
+ resolveExperimentVariantKey,
133
232
  });
134
233
  }, [
135
- experimentAssignmentStorage,
136
234
  getSequentialNextStepId,
137
235
  resolveConfiguredNextStep,
236
+ resolveExperimentVariantKey,
138
237
  resolveRenderableStepId,
139
238
  safeInitialStepId,
140
- user.id,
141
239
  ]);
142
240
  const goNext = useCallback(() => {
143
241
  goToStep(resolveNextStepId(safeActiveStepId));
144
242
  }, [goToStep, resolveNextStepId, safeActiveStepId]);
145
243
  useEffect(() => {
146
244
  const localUserId = apiService.getOrCreateClientUserId();
245
+ const bootstrapCandidateUserId = apiService.getBootstrapCandidateUserId(localUserId);
246
+ const initialAttribution = collectCurrentFunnelAttribution();
147
247
  setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId })));
148
248
  if (isPreviewRuntime) {
149
249
  return;
150
250
  }
151
- apiService
251
+ const postHogBootstrap = bootstrapPostHog({
252
+ apiKey: POSTHOG_PROJECT_API_KEY,
253
+ apiHost: POSTHOG_API_HOST,
254
+ distinctId: localUserId,
255
+ })
256
+ .then(() => setPostHogReady(true))
257
+ .catch((error) => {
258
+ logger.error('Failed to bootstrap PostHog feature flags:', error);
259
+ setPostHogReady(true);
260
+ });
261
+ const sessionBootstrap = apiService
152
262
  .bootstrapSession({
153
- userId: localUserId,
263
+ userId: bootstrapCandidateUserId || localUserId,
264
+ attribution: initialAttribution,
154
265
  })
155
266
  .then((sessionUser) => {
156
267
  const sessionAttributes = Object.keys(sessionUser.attributes || {}).length > 0
@@ -159,70 +270,23 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
159
270
  if (sessionAttributes) {
160
271
  setAttributes((prev) => (Object.assign(Object.assign({}, sessionAttributes), prev)));
161
272
  }
162
- setUser((prev) => (Object.assign(Object.assign({}, prev), { id: sessionUser.id || prev.id, name: sessionUser.name || prev.name, email: sessionUser.email || prev.email, attributes: sessionAttributes ? Object.assign(Object.assign({}, sessionAttributes), prev.attributes) : prev.attributes })));
273
+ setUser((prev) => (Object.assign(Object.assign({}, prev), { id: sessionUser.id || prev.id, name: sessionUser.name || prev.name, email: sessionUser.email || prev.email, document: sessionUser.document, attributes: sessionAttributes ? Object.assign(Object.assign({}, sessionAttributes), prev.attributes) : prev.attributes })));
274
+ return sessionUser;
163
275
  })
164
276
  .catch((error) => {
165
277
  logger.error('Failed to bootstrap user session:', error);
278
+ return null;
166
279
  });
167
- }, [isPreviewRuntime]);
168
- useEffect(() => {
169
- if (!currentUserIdRef.current) {
170
- return;
171
- }
172
- const assignmentUpdates = {};
173
- const assignmentEvents = [];
174
- for (const experiment of funnelExperiments) {
175
- if (experiment.status !== 'active') {
176
- continue;
177
- }
178
- const assignedVariantId = getAssignedVariantId(experiment, currentUserIdRef.current, experimentAssignmentStorage);
179
- if (!assignedVariantId) {
180
- continue;
280
+ Promise.all([postHogBootstrap, sessionBootstrap])
281
+ .then(([, sessionUser]) => {
282
+ if (sessionUser === null || sessionUser === void 0 ? void 0 : sessionUser.id) {
283
+ identifyPostHog(sessionUser.id, localUserId);
181
284
  }
182
- const assignmentKey = getExperimentAssignmentAttributeKey(experiment.experimentId);
183
- if (attributesRef.current[assignmentKey] === assignedVariantId) {
184
- continue;
185
- }
186
- assignmentUpdates[assignmentKey] = assignedVariantId;
187
- const assignedVariant = experiment.variants.find((variant) => variant.id === assignedVariantId);
188
- assignmentEvents.push({
189
- experiment,
190
- variantId: assignedVariantId,
191
- variantLabel: (assignedVariant === null || assignedVariant === void 0 ? void 0 : assignedVariant.label) || assignedVariantId,
192
- });
193
- }
194
- const entries = Object.entries(assignmentUpdates);
195
- if (entries.length === 0) {
196
- return;
197
- }
198
- setAttributes((prev) => {
199
- let changed = false;
200
- const next = Object.assign({}, prev);
201
- for (const [key, value] of entries) {
202
- if (next[key] !== value) {
203
- next[key] = value;
204
- changed = true;
205
- }
206
- }
207
- return changed ? next : prev;
285
+ })
286
+ .catch((error) => {
287
+ logger.error('Failed to identify PostHog user:', error);
208
288
  });
209
- if (!isPreviewRuntime) {
210
- assignmentEvents.forEach(({ experiment, variantId, variantLabel }) => {
211
- const stepMeta = stepById[experiment.stepId];
212
- apiService.trackFunnelEvent({
213
- userId: currentUserIdRef.current,
214
- eventType: 'experiment_variant_assigned',
215
- stepId: experiment.stepId,
216
- stepName: (stepMeta === null || stepMeta === void 0 ? void 0 : stepMeta.name) || (stepMeta === null || stepMeta === void 0 ? void 0 : stepMeta.title) || experiment.stepId,
217
- selected: {
218
- experimentId: experiment.experimentId,
219
- variantId,
220
- variantLabel,
221
- },
222
- });
223
- });
224
- }
225
- }, [experimentAssignmentStorage, funnelExperiments, isPreviewRuntime, stepById]);
289
+ }, [isPreviewRuntime]);
226
290
  const recordStepCompletion = useCallback((stepId, explicitChoices) => {
227
291
  const meta = stepById[stepId];
228
292
  if (!meta) {
@@ -258,11 +322,12 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
258
322
  endedAt: record.completedAt,
259
323
  selected: record.choices,
260
324
  });
325
+ capturePostHogStepEvent('step_completed', Object.assign({ distinct_id: currentUserIdRef.current, funnel_id: FUNNEL_ID || undefined, funnel_version_id: FUNNEL_VERSION_ID || undefined, project_id: PROJECT_ID || undefined, step_id: record.stepId, step_name: record.stepName, started_at: startedAt, ended_at: record.completedAt, selected: record.choices }, buildFeatureFlagProperties(postHogFeatureFlagsRef.current)));
261
326
  }
262
327
  setUser((prev) => {
263
- var _a;
328
+ var _a, _b;
264
329
  const updatedSteps = [...((_a = prev.completedSteps) !== null && _a !== void 0 ? _a : []), record];
265
- const updatedUser = Object.assign(Object.assign({}, prev), { attributes: Object.assign(Object.assign({}, prev.attributes), attributesRef.current), completedSteps: updatedSteps });
330
+ const updatedUser = Object.assign(Object.assign({}, prev), { attributes: Object.assign(Object.assign({}, prev.attributes), attributesRef.current), document: prev.document, completedSteps: updatedSteps });
266
331
  if (!isPreviewRuntime) {
267
332
  apiService
268
333
  .updateUser({
@@ -270,6 +335,9 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
270
335
  name: updatedUser.name,
271
336
  email: updatedUser.email,
272
337
  attributes: Object.assign(Object.assign({}, updatedUser.attributes), { completedSteps: updatedSteps }),
338
+ document: (_b = updatedUser.document) !== null && _b !== void 0 ? _b : {},
339
+ }, {
340
+ attribution: collectCurrentFunnelAttribution(),
273
341
  })
274
342
  .catch((error) => {
275
343
  logger.error('Failed to persist step completion:', error);
@@ -287,6 +355,9 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
287
355
  setActiveStepId(safeActiveStepId);
288
356
  return;
289
357
  }
358
+ if (renderSuspended) {
359
+ return;
360
+ }
290
361
  const prevId = prevStepIdRef.current;
291
362
  if (prevId && prevId !== safeActiveStepId) {
292
363
  recordStepCompletion(prevId);
@@ -300,28 +371,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
300
371
  stepName: (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name) || (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title) || safeActiveStepId,
301
372
  startedAt,
302
373
  });
303
- }
304
- const stepExperiments = funnelExperiments.filter((experiment) => experiment.status === 'active' && experiment.stepId === safeActiveStepId);
305
- for (const experiment of stepExperiments) {
306
- const assignedVariantId = getAssignedVariantId(experiment, currentUserIdRef.current, experimentAssignmentStorage);
307
- if (!assignedVariantId) {
308
- continue;
309
- }
310
- const assignedVariant = experiment.variants.find((variant) => variant.id === assignedVariantId);
311
- if (!isPreviewRuntime) {
312
- apiService.trackFunnelEvent({
313
- userId: currentUserIdRef.current,
314
- eventType: 'experiment_step_exposed',
315
- stepId: safeActiveStepId,
316
- stepName: (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name) || (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title) || safeActiveStepId,
317
- startedAt,
318
- selected: {
319
- experimentId: experiment.experimentId,
320
- variantId: assignedVariantId,
321
- variantLabel: (assignedVariant === null || assignedVariant === void 0 ? void 0 : assignedVariant.label) || assignedVariantId,
322
- },
323
- });
324
- }
374
+ capturePostHogStepEvent('step_started', Object.assign({ distinct_id: currentUserIdRef.current, funnel_id: FUNNEL_ID || undefined, funnel_version_id: FUNNEL_VERSION_ID || undefined, project_id: PROJECT_ID || undefined, step_id: safeActiveStepId, step_name: (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name) || (activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title) || safeActiveStepId, started_at: startedAt }, buildFeatureFlagProperties(postHogFeatureFlagsRef.current)));
325
375
  }
326
376
  attributesAtStepStart.current = Object.assign({}, attributesRef.current);
327
377
  prevStepIdRef.current = safeActiveStepId;
@@ -329,10 +379,9 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
329
379
  activeStepId,
330
380
  activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.name,
331
381
  activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.title,
332
- experimentAssignmentStorage,
333
- funnelExperiments,
334
382
  isPreviewRuntime,
335
383
  recordStepCompletion,
384
+ renderSuspended,
336
385
  safeActiveStepId,
337
386
  ]);
338
387
  useEffect(() => {
@@ -363,21 +412,19 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
363
412
  const nextStepId = useMemo(() => resolveNextStepFromContext({
364
413
  stepId: safeActiveStepId,
365
414
  attributes,
366
- userId: user.id,
367
415
  safeInitialStepId,
368
416
  resolveRenderableStepId,
369
417
  getConfiguredNextStepId: (currentStepId, context) => resolveConfiguredNextStep(currentStepId, context),
370
418
  getSequentialNextStepId,
371
- getAssignedVariantId: (experiment, currentUserId) => getAssignedVariantId(experiment, currentUserId, experimentAssignmentStorage),
419
+ resolveExperimentVariantKey,
372
420
  }), [
373
421
  attributes,
374
- experimentAssignmentStorage,
375
422
  getSequentialNextStepId,
376
423
  resolveConfiguredNextStep,
424
+ resolveExperimentVariantKey,
377
425
  resolveRenderableStepId,
378
426
  safeActiveStepId,
379
427
  safeInitialStepId,
380
- user.id,
381
428
  ]);
382
429
  const choiceTargets = getChoiceTargetsForStep(safeActiveStepId);
383
430
  const actionBar = (_a = renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.actionBar) !== null && _a !== void 0 ? _a : activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.actionBar;
@@ -387,10 +434,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
387
434
  const buttonText = (actionBar === null || actionBar === void 0 ? void 0 : actionBar.buttonText) || 'Continue';
388
435
  const hideActionBar = (actionBar === null || actionBar === void 0 ? void 0 : actionBar.hidden) === true;
389
436
  useEffect(() => {
390
- if (shouldLockToInitialStep) {
391
- return;
392
- }
393
- if (!autoAdvanceMs) {
437
+ if (!shouldRunAutoAdvanceTimer({ autoAdvanceMs, isPreviewRuntime, shouldLockToInitialStep })) {
394
438
  return;
395
439
  }
396
440
  const autoAdvanceTarget = resolveNextStepId(safeActiveStepId);
@@ -398,7 +442,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
398
442
  goToStep(autoAdvanceTarget);
399
443
  }, autoAdvanceMs);
400
444
  return () => window.clearTimeout(timer);
401
- }, [autoAdvanceMs, goToStep, resolveNextStepId, safeActiveStepId, shouldLockToInitialStep]);
445
+ }, [autoAdvanceMs, goToStep, isPreviewRuntime, resolveNextStepId, safeActiveStepId, shouldLockToInitialStep]);
402
446
  const handleContinue = useCallback(() => {
403
447
  goToStep(nextStepId);
404
448
  }, [goToStep, nextStepId]);
@@ -423,53 +467,37 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
423
467
  setRuntimeMode,
424
468
  shouldLockToInitialStep,
425
469
  });
426
- const setEditorVariantSelection = useCallback((experiment, variantId) => {
427
- const nextVariantId = variantId.trim();
428
- if (!nextVariantId) {
470
+ const setEditorVariantSelection = useCallback((experiment, variantKey) => {
471
+ const nextVariantKey = variantKey.trim();
472
+ if (!nextVariantKey) {
429
473
  return;
430
474
  }
431
- const selectedVariant = experiment.variants.find((variant) => variant.id === nextVariantId);
475
+ const selectedVariant = experiment.variants.find((variant) => variant.variantKey === nextVariantKey);
432
476
  if (!selectedVariant) {
433
477
  return;
434
478
  }
435
- const userId = currentUserIdRef.current || user.id;
436
- if (!userId) {
437
- return;
438
- }
439
- try {
440
- const storageKey = getExperimentAssignmentStorageKey(getRuntimeFunnelIdentity(), userId, experiment.experimentId);
441
- writeWindowStorageValue(storageKey, selectedVariant.id);
442
- }
443
- catch (_a) {
444
- // ignore storage access failures
445
- }
446
- const assignmentKey = getExperimentAssignmentAttributeKey(experiment.experimentId);
447
- setAttributes((prev) => prev[assignmentKey] === selectedVariant.id
448
- ? prev
449
- : Object.assign(Object.assign({}, prev), { [assignmentKey]: selectedVariant.id }));
450
- if (!isPreviewRuntime) {
451
- const stepMeta = stepById[experiment.stepId];
452
- apiService.trackFunnelEvent({
453
- userId,
454
- eventType: 'experiment_variant_selected_editor',
455
- stepId: experiment.stepId,
456
- stepName: (stepMeta === null || stepMeta === void 0 ? void 0 : stepMeta.name) || (stepMeta === null || stepMeta === void 0 ? void 0 : stepMeta.title) || experiment.stepId,
457
- selected: {
458
- experimentId: experiment.experimentId,
459
- variantId: selectedVariant.id,
460
- variantLabel: selectedVariant.label,
461
- },
462
- });
463
- }
464
- }, [isPreviewRuntime, stepById, user.id]);
479
+ setEditorVariantOverrides((prev) => (Object.assign(Object.assign({}, prev), { [experiment.experimentId]: selectedVariant.variantKey })));
480
+ }, []);
465
481
  const experimentAssignmentForEditor = useCallback((experiment) => {
466
482
  var _a;
467
- return (getAssignedVariantId(experiment, user.id, experimentAssignmentStorage) ||
468
- ((_a = experiment.variants[0]) === null || _a === void 0 ? void 0 : _a.id) ||
469
- '');
470
- }, [experimentAssignmentStorage, user.id]);
483
+ const override = editorVariantOverrides[experiment.experimentId];
484
+ if (override) {
485
+ return override;
486
+ }
487
+ if (postHogReady) {
488
+ const assignment = resolveExperimentAssignment({
489
+ experimentId: experiment.experimentId,
490
+ variants: experiment.variants,
491
+ });
492
+ if (assignment) {
493
+ return assignment.variantKey;
494
+ }
495
+ }
496
+ return ((_a = experiment.variants[0]) === null || _a === void 0 ? void 0 : _a.variantKey) || '';
497
+ }, [editorVariantOverrides, postHogReady]);
471
498
  const contextValue = useMemo(() => ({
472
499
  activeStepId: safeActiveStepId,
500
+ isBuilder: isPreviewRuntime,
473
501
  goToStep: goToStepFromContext,
474
502
  goNext,
475
503
  goChoice,
@@ -494,6 +522,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
494
522
  goChoice,
495
523
  goNext,
496
524
  goToStepFromContext,
525
+ isPreviewRuntime,
497
526
  resolveRenderableStepId,
498
527
  safeActiveStepId,
499
528
  setAnswer,
@@ -512,6 +541,8 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
512
541
  experimentAssignmentForEditor,
513
542
  funnelExperiments,
514
543
  handleContinue,
544
+ postHogReady,
545
+ renderSuspended,
515
546
  renderedStepId,
516
547
  renderedStepMeta,
517
548
  runtimeMode,
@@ -1,4 +1,4 @@
1
- import type { FunnelStepId } from '../runtime/funnel-runtime';
1
+ import type { FunnelStepId } from '../runtime/funnel-runtime.js';
2
2
  export type FunnelBinaryChoice = 'yes' | 'no';
3
3
  export type FunnelBirthDate = {
4
4
  year: number;
@@ -14,7 +14,7 @@ export type FunnelTempUpload = {
14
14
  };
15
15
  /**
16
16
  * Typed SDK-facing answers object shared across all steps.
17
- * Keep this shape in sync with SDK_USER_OBJECT.md so future funnels can extend it safely.
17
+ * Keep this shape in sync with docs/references/funnel-sdk-user-answers.md so future funnels can extend it safely.
18
18
  */
19
19
  export type FunnelUserAnswers = Record<string, unknown> & {
20
20
  /** Generic branching answers by step id for yes/no splits. */
@@ -1,9 +1,11 @@
1
- import type { FunnelUserAnswers } from '../sdk/userAnswers';
1
+ import type { FunnelUserAnswers } from '../sdk/userAnswers.js';
2
+ import { type FunnelUserAttribution } from '../runtime/funnel-attribution.js';
2
3
  export type AppUser = {
3
4
  id: string;
4
5
  name: string;
5
6
  email: string;
6
7
  attributes: FunnelUserAnswers;
8
+ document: Record<string, unknown>;
7
9
  };
8
10
  export type FunnelEvent = {
9
11
  userId: string;
@@ -26,7 +28,7 @@ export type StepCompletionEvent = {
26
28
  export type ManageSubscriptionsResponse = {
27
29
  user: {
28
30
  id: string;
29
- externalUserId: string;
31
+ user_id: string;
30
32
  email: string | null;
31
33
  fullName: string | null;
32
34
  subscriptionStatus: string | null;
@@ -35,8 +37,15 @@ export type ManageSubscriptionsResponse = {
35
37
  id: string;
36
38
  status: string;
37
39
  providerSubscriptionId: string;
40
+ providerPlanId: string | null;
38
41
  cancelAtPeriodEnd: boolean;
42
+ currentPeriodStart: string | null;
39
43
  currentPeriodEnd: string | null;
44
+ createdAt: string | null;
45
+ amountCents?: number | null;
46
+ currency?: string | null;
47
+ billingInterval?: string | null;
48
+ billingIntervalCount?: number | null;
40
49
  environment: 'test' | 'live';
41
50
  }>;
42
51
  eventsCount: number;
@@ -51,15 +60,23 @@ export type TempPhotoUploadResult = {
51
60
  declare class ApiService {
52
61
  private readFileAsDataUrl;
53
62
  getOrCreateClientUserId(): string;
63
+ getSubscriptionManagementUserId(): string | null;
64
+ getSubscriptionManagementStripeCustomerId(): string | null;
65
+ getBootstrapCandidateUserId(fallbackUserId?: string | null): string | null;
66
+ private fetchCurrentUserState;
67
+ private resolveBootstrapUserId;
54
68
  bootstrapSession(input?: {
55
69
  userId?: string;
56
70
  email?: string;
57
71
  name?: string;
72
+ attribution?: FunnelUserAttribution;
73
+ }): Promise<AppUser>;
74
+ updateUser(user: AppUser, options?: {
75
+ attribution?: FunnelUserAttribution;
58
76
  }): Promise<AppUser>;
59
- updateUser(user: AppUser): Promise<AppUser>;
60
77
  uploadTempPhoto(input: {
61
78
  file: File;
62
- externalUserId?: string;
79
+ userId?: string;
63
80
  source?: 'camera' | 'upload';
64
81
  }): Promise<TempPhotoUploadResult>;
65
82
  private sendEventAsync;