@funnelsgrove/runtime 0.1.18 → 0.1.20

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.
@@ -33,6 +33,7 @@ export type FunnelContextValue = {
33
33
  setAttribute: (key: string, value: unknown) => void;
34
34
  attributes: FunnelUserAnswers;
35
35
  user: FunnelUser;
36
+ userBootstrapped: boolean;
36
37
  setUser: (user: FunnelUser) => void;
37
38
  /** Manually record a step as completed (used by custom step components). */
38
39
  completeStep: (stepId: string, choices?: Record<string, unknown>) => void;
@@ -44,7 +44,7 @@ const renderStoreIcon = (variant) => {
44
44
  return _jsx(WebLinkIcon, {});
45
45
  };
46
46
  export function SubscriptionHandoffScreen({ stepId, content, confirmationUrl, nodeId, }) {
47
- const { user } = useFunnel();
47
+ const { isBuilder, user, userBootstrapped } = useFunnel();
48
48
  const [autoOpenAttempted, setAutoOpenAttempted] = useState(false);
49
49
  const [emailSent, setEmailSent] = useState(false);
50
50
  const browserSnapshotKey = useSyncExternalStore(subscribeToBrowserSnapshot, () => [
@@ -61,10 +61,11 @@ export function SubscriptionHandoffScreen({ stepId, content, confirmationUrl, no
61
61
  userAgent: userAgent || null,
62
62
  };
63
63
  }, [browserSnapshotKey]);
64
+ const handoffReady = isBuilder || (userBootstrapped && user.email.trim().length > 0);
64
65
  const handoff = useMemo(() => {
65
66
  return resolveSubscriptionHandoff({
66
- userId: user.id,
67
- email: user.email,
67
+ userId: handoffReady ? user.id : null,
68
+ email: handoffReady ? user.email : null,
68
69
  confirmationUrl: browserSnapshot.confirmationUrl,
69
70
  userAgent: browserSnapshot.userAgent,
70
71
  attribution: getFunnelUserAttribution(user.document),
@@ -76,50 +77,60 @@ export function SubscriptionHandoffScreen({ stepId, content, confirmationUrl, no
76
77
  androidDeepLink: runtimePublicConfig.androidDeepLink,
77
78
  },
78
79
  });
79
- }, [browserSnapshot, user.document, user.email, user.id]);
80
+ }, [browserSnapshot, handoffReady, user.document, user.email, user.id]);
80
81
  const qrCodeImageUrl = useMemo(() => buildQrCodeImageUrl(handoff.qrConfirmationUrl), [handoff.qrConfirmationUrl]);
81
82
  const installLinks = useMemo(() => {
82
83
  const links = [];
83
84
  const iosLinkContent = getStoreLinkContent(content.storeLinks, 'ios');
84
- if (handoff.iosStoreUrl && iosLinkContent) {
85
+ if ((handoff.iosStoreUrl || (!handoffReady && runtimePublicConfig.iosAppStoreUrl)) && iosLinkContent) {
85
86
  links.push({
86
87
  id: 'ios',
87
- href: handoff.iosStoreUrl,
88
+ href: handoffReady ? handoff.iosStoreUrl : null,
88
89
  eyebrow: iosLinkContent.eyebrow,
89
90
  title: iosLinkContent.title,
90
91
  variant: 'ios',
92
+ disabled: !handoffReady,
91
93
  });
92
94
  }
93
95
  const androidLinkContent = getStoreLinkContent(content.storeLinks, 'android');
94
- if (handoff.androidStoreUrl && androidLinkContent) {
96
+ if ((handoff.androidStoreUrl || (!handoffReady && runtimePublicConfig.androidPlayStoreUrl)) &&
97
+ androidLinkContent) {
95
98
  links.push({
96
99
  id: 'android',
97
- href: handoff.androidStoreUrl,
100
+ href: handoffReady ? handoff.androidStoreUrl : null,
98
101
  eyebrow: androidLinkContent.eyebrow,
99
102
  title: androidLinkContent.title,
100
103
  variant: 'android',
104
+ disabled: !handoffReady,
101
105
  });
102
106
  }
103
107
  const webLinkContent = getStoreLinkContent(content.storeLinks, 'web');
104
108
  const shouldShowWebLink = handoff.platform === 'desktop' || handoff.platform === 'android';
105
- if (shouldShowWebLink && handoff.webLinkUrl && webLinkContent) {
109
+ if (shouldShowWebLink &&
110
+ (handoff.webLinkUrl || (!handoffReady && runtimePublicConfig.universalLink)) &&
111
+ webLinkContent) {
106
112
  links.push({
107
113
  id: 'web',
108
- href: handoff.webLinkUrl,
114
+ href: handoffReady ? handoff.webLinkUrl : null,
109
115
  eyebrow: webLinkContent.eyebrow,
110
116
  title: webLinkContent.title,
111
117
  variant: 'web',
118
+ disabled: !handoffReady,
112
119
  });
113
120
  }
114
121
  return links;
115
122
  }, [
116
123
  content.storeLinks,
117
124
  handoff.androidStoreUrl,
125
+ handoffReady,
118
126
  handoff.iosStoreUrl,
119
127
  handoff.platform,
120
128
  handoff.webLinkUrl,
121
129
  ]);
122
130
  const emailHref = useMemo(() => {
131
+ if (!handoffReady) {
132
+ return null;
133
+ }
123
134
  const lines = [
124
135
  content.emailIntro,
125
136
  '',
@@ -142,10 +153,19 @@ export function SubscriptionHandoffScreen({ stepId, content, confirmationUrl, no
142
153
  subject: content.emailSubject,
143
154
  lines,
144
155
  });
145
- }, [content, handoff, user.email]);
156
+ }, [content, handoff, handoffReady, user.email]);
157
+ const hasConfiguredOpenAppTarget = handoff.platform === 'ios'
158
+ ? Boolean(runtimePublicConfig.iosDeepLink || runtimePublicConfig.universalLink || runtimePublicConfig.iosAppStoreUrl)
159
+ : handoff.platform === 'android'
160
+ ? Boolean(runtimePublicConfig.androidDeepLink ||
161
+ runtimePublicConfig.universalLink ||
162
+ runtimePublicConfig.androidPlayStoreUrl)
163
+ : false;
164
+ const shouldShowOpenAppButton = handoff.platform !== 'desktop' &&
165
+ (Boolean(handoff.openAppUrl) || (!handoffReady && hasConfiguredOpenAppTarget));
146
166
  useEffect(() => {
147
167
  const openAppUrl = handoff.openAppUrl;
148
- if (handoff.platform === 'desktop' || typeof window === 'undefined' || !openAppUrl) {
168
+ if (!handoffReady || handoff.platform === 'desktop' || typeof window === 'undefined' || !openAppUrl) {
149
169
  return;
150
170
  }
151
171
  const attemptKey = `app-deals:deep-link-attempt:${stepId}:${user.id}`;
@@ -153,13 +173,18 @@ export function SubscriptionHandoffScreen({ stepId, content, confirmationUrl, no
153
173
  return;
154
174
  }
155
175
  window.sessionStorage.setItem(attemptKey, '1');
156
- const timer = window.setTimeout(() => {
157
- window.location.assign(openAppUrl);
176
+ window.location.assign(openAppUrl);
177
+ const statusTimer = window.setTimeout(() => {
158
178
  setAutoOpenAttempted(true);
159
- }, 500);
160
- return () => window.clearTimeout(timer);
161
- }, [handoff.openAppUrl, handoff.platform, stepId, user.id]);
162
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: 'subscription-handoff-step', "data-node-id": nodeId || stepId, children: [_jsxs("header", { className: 'subscription-handoff-head', children: [_jsx("p", { className: 'subscription-handoff-kicker', children: content.kicker }), _jsx("h2", { className: 'subscription-handoff-title', children: content.title })] }), installLinks.length > 0 ? (_jsx("div", { className: 'subscription-handoff-store-links', children: installLinks.map((link) => (_jsxs("a", { href: link.href, target: '_blank', rel: 'noopener noreferrer', "aria-label": `${link.eyebrow} ${link.title}`, className: `subscription-handoff-store-badge is-${link.variant}`, children: [renderStoreIcon(link.variant), _jsxs("span", { className: 'subscription-handoff-store-copy', children: [_jsx("span", { className: 'subscription-handoff-store-eyebrow', children: link.eyebrow }), _jsx("span", { className: 'subscription-handoff-store-title', children: link.title })] })] }, link.id))) })) : null, _jsx("p", { className: 'subscription-handoff-copy', children: qrCodeImageUrl ? content.copyWithQr : content.copyWithoutQr }), qrCodeImageUrl && handoff.qrConfirmationUrl ? (_jsx("a", { href: handoff.qrConfirmationUrl, className: 'subscription-handoff-qr-card', target: '_blank', rel: 'noopener noreferrer', "aria-label": content.qrOpenLabel, children: _jsx("span", { "aria-hidden": 'true', className: 'subscription-handoff-qr-image', style: { backgroundImage: `url(${qrCodeImageUrl})` } }) })) : null, _jsx("p", { className: 'subscription-handoff-note', children: content.note }), handoff.platform !== 'desktop' && handoff.openAppUrl ? (_jsx("a", { href: handoff.openAppUrl, className: 'subscription-handoff-open-app', children: content.openAppLabel })) : null, _jsx("a", { href: emailHref, className: 'subscription-handoff-email-button', onClick: () => setEmailSent(true), children: content.emailButtonLabel }), emailSent ? (_jsx("p", { className: 'subscription-handoff-status', children: content.emailSentStatus })) : null, autoOpenAttempted ? (_jsx("p", { className: 'subscription-handoff-status', children: content.autoOpenStatus })) : null] }), _jsx("style", { children: subscriptionHandoffScreenStyles })] }));
179
+ }, 0);
180
+ return () => window.clearTimeout(statusTimer);
181
+ }, [handoff.openAppUrl, handoff.platform, handoffReady, stepId, user.id]);
182
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: 'subscription-handoff-step', "data-node-id": nodeId || stepId, children: [_jsxs("header", { className: 'subscription-handoff-head', children: [_jsx("p", { className: 'subscription-handoff-kicker', children: content.kicker }), _jsx("h2", { className: 'subscription-handoff-title', children: content.title })] }), installLinks.length > 0 ? (_jsx("div", { className: 'subscription-handoff-store-links', children: installLinks.map((link) => {
183
+ const label = `${link.eyebrow} ${link.title}`;
184
+ const className = `subscription-handoff-store-badge is-${link.variant}${link.disabled ? ' is-disabled' : ''}`;
185
+ const body = (_jsxs(_Fragment, { children: [renderStoreIcon(link.variant), _jsxs("span", { className: 'subscription-handoff-store-copy', children: [_jsx("span", { className: 'subscription-handoff-store-eyebrow', children: link.eyebrow }), _jsx("span", { className: 'subscription-handoff-store-title', children: link.title })] })] }));
186
+ return link.href ? (_jsx("a", { href: link.href, target: '_blank', rel: 'noopener noreferrer', "aria-label": label, className: className, children: body }, link.id)) : (_jsx("span", { role: 'link', "aria-label": label, "aria-disabled": 'true', "aria-busy": 'true', className: className, children: body }, link.id));
187
+ }) })) : null, _jsx("p", { className: 'subscription-handoff-copy', children: qrCodeImageUrl ? content.copyWithQr : content.copyWithoutQr }), qrCodeImageUrl && handoff.qrConfirmationUrl ? (_jsx("a", { href: handoff.qrConfirmationUrl, className: 'subscription-handoff-qr-card', target: '_blank', rel: 'noopener noreferrer', "aria-label": content.qrOpenLabel, children: _jsx("span", { "aria-hidden": 'true', className: 'subscription-handoff-qr-image', style: { backgroundImage: `url(${qrCodeImageUrl})` } }) })) : null, _jsx("p", { className: 'subscription-handoff-note', children: content.note }), shouldShowOpenAppButton && handoff.openAppUrl ? (_jsx("a", { href: handoff.openAppUrl, className: 'subscription-handoff-open-app', children: content.openAppLabel })) : null, shouldShowOpenAppButton && !handoff.openAppUrl ? (_jsx("span", { role: 'link', "aria-disabled": 'true', "aria-busy": 'true', className: 'subscription-handoff-open-app is-disabled', children: content.openAppLabel })) : null, emailHref ? (_jsx("a", { href: emailHref, className: 'subscription-handoff-email-button', onClick: () => setEmailSent(true), children: content.emailButtonLabel })) : (_jsx("span", { role: 'link', "aria-disabled": 'true', "aria-busy": 'true', className: 'subscription-handoff-email-button is-disabled', children: content.emailButtonLabel })), emailSent ? (_jsx("p", { className: 'subscription-handoff-status', children: content.emailSentStatus })) : null, autoOpenAttempted ? (_jsx("p", { className: 'subscription-handoff-status', children: content.autoOpenStatus })) : null] }), _jsx("style", { children: subscriptionHandoffScreenStyles })] }));
163
188
  }
164
189
  const subscriptionHandoffScreenStyles = `
165
190
  .subscription-handoff-step {
@@ -229,6 +254,14 @@ const subscriptionHandoffScreenStyles = `
229
254
  color: var(--color-text, #262729);
230
255
  }
231
256
 
257
+ .subscription-handoff-store-badge.is-disabled,
258
+ .subscription-handoff-open-app.is-disabled,
259
+ .subscription-handoff-email-button.is-disabled {
260
+ opacity: 0.55;
261
+ cursor: progress;
262
+ pointer-events: none;
263
+ }
264
+
232
265
  .subscription-handoff-store-icon {
233
266
  width: 28px;
234
267
  height: 28px;
@@ -5,6 +5,7 @@ export declare const BUILDER_PREVIEW_RUNTIME_MODE_CHANGED = "builder.preview.run
5
5
  export declare const BUILDER_PREVIEW_DEFINITION_PATCH = "builder.preview.definitionPatch";
6
6
  export declare const BUILDER_PREVIEW_VARIABLE_VALUES_CHANGED = "builder.preview.variableValuesChanged";
7
7
  export declare const BUILDER_PREVIEW_PAYWALL_PLANS_CHANGED = "builder.preview.paywallPlansChanged";
8
+ export declare const BUILDER_PREVIEW_THEME_CHANGED = "builder.preview.themeChanged";
8
9
  export type BuilderPreviewReadyMessage = {
9
10
  type: typeof BUILDER_PREVIEW_READY;
10
11
  stepId: string;
@@ -75,3 +76,7 @@ export type BuilderPreviewPaywallPlansChangedMessage = {
75
76
  stepId: string;
76
77
  plans: BuilderPreviewPaywallPlan[];
77
78
  };
79
+ export type BuilderPreviewThemeChangedMessage = {
80
+ type: typeof BUILDER_PREVIEW_THEME_CHANGED;
81
+ cssVariables: Record<string, string>;
82
+ };
@@ -5,3 +5,4 @@ export const BUILDER_PREVIEW_RUNTIME_MODE_CHANGED = 'builder.preview.runtimeMode
5
5
  export const BUILDER_PREVIEW_DEFINITION_PATCH = 'builder.preview.definitionPatch';
6
6
  export const BUILDER_PREVIEW_VARIABLE_VALUES_CHANGED = 'builder.preview.variableValuesChanged';
7
7
  export const BUILDER_PREVIEW_PAYWALL_PLANS_CHANGED = 'builder.preview.paywallPlansChanged';
8
+ export const BUILDER_PREVIEW_THEME_CHANGED = 'builder.preview.themeChanged';
@@ -1,8 +1,9 @@
1
1
  import type { FunnelManifestExperiment } from './funnel.manifest.types.js';
2
- export type PaywallExperimentDefinition = {
2
+ export type FunnelExperimentDefinition = {
3
3
  id: string;
4
4
  name: string;
5
- type: 'paywall';
5
+ type: 'paywall' | 'step';
6
+ status?: 'running' | 'paused' | 'stopped';
6
7
  launchDate: string;
7
8
  control: {
8
9
  stepId: string;
@@ -13,6 +14,8 @@ export type PaywallExperimentDefinition = {
13
14
  trafficPercent: number;
14
15
  };
15
16
  };
16
- export type FunnelExperimentDefinition = PaywallExperimentDefinition;
17
+ export type PaywallExperimentDefinition = FunnelExperimentDefinition & {
18
+ type: 'paywall';
19
+ };
17
20
  export declare const defineFunnelExperiments: <const T extends readonly FunnelExperimentDefinition[]>(experiments: T) => T;
18
21
  export declare const toManifestExperiments: (experiments: readonly FunnelExperimentDefinition[]) => FunnelManifestExperiment[];
@@ -7,7 +7,9 @@ export const defineFunnelExperiments = (experiments) => {
7
7
  }
8
8
  return experiments;
9
9
  };
10
- export const toManifestExperiments = (experiments) => experiments.map((experiment) => ({
10
+ export const toManifestExperiments = (experiments) => experiments
11
+ .filter((experiment) => { var _a; return ((_a = experiment.status) !== null && _a !== void 0 ? _a : 'running') === 'running'; })
12
+ .map((experiment) => ({
11
13
  experimentId: experiment.id,
12
14
  stepId: experiment.control.stepId,
13
15
  variants: [
@@ -1,4 +1,5 @@
1
1
  import type { PostHog } from 'posthog-js';
2
+ export declare const POSTHOG_FEATURE_FLAG_READY_TIMEOUT_MS = 2500;
2
3
  type BootstrapConfig = {
3
4
  apiKey: string;
4
5
  apiHost: string;
@@ -1,6 +1,7 @@
1
1
  import posthog from 'posthog-js';
2
2
  let initialized = false;
3
3
  let readyPromise = null;
4
+ export const POSTHOG_FEATURE_FLAG_READY_TIMEOUT_MS = 2500;
4
5
  export const bootstrapPostHog = (config) => {
5
6
  if (readyPromise) {
6
7
  return readyPromise;
@@ -10,13 +11,18 @@ export const bootstrapPostHog = (config) => {
10
11
  resolve();
11
12
  return;
12
13
  }
14
+ let readyTimeoutId = null;
13
15
  const markReady = () => {
14
16
  if (initialized) {
15
17
  return;
16
18
  }
17
19
  initialized = true;
20
+ if (readyTimeoutId) {
21
+ clearTimeout(readyTimeoutId);
22
+ }
18
23
  resolve();
19
24
  };
25
+ readyTimeoutId = setTimeout(markReady, POSTHOG_FEATURE_FLAG_READY_TIMEOUT_MS);
20
26
  posthog.init(config.apiKey, {
21
27
  api_host: config.apiHost,
22
28
  persistence: 'localStorage+cookie',
@@ -41,12 +41,16 @@ type PreviewBridgeMessage = {
41
41
  kind: 'paywallPlansChanged';
42
42
  stepId: string;
43
43
  plans: BuilderPreviewPaywallPlan[];
44
+ } | {
45
+ kind: 'themeChanged';
46
+ cssVariables: Record<string, string>;
44
47
  } | {
45
48
  kind: 'goToStep';
46
49
  stepId: string;
47
50
  };
48
51
  export declare const parsePreviewQuickEditPatch: (value: unknown) => PreviewQuickEditPatch | null;
49
52
  export declare const parsePreviewBridgeMessage: (value: unknown) => PreviewBridgeMessage | null;
53
+ export declare const applyPreviewThemeVariables: (cssVariables: Record<string, string>) => void;
50
54
  export declare const applyPreviewQuickEditPatch: (patch: PreviewQuickEditPatch) => void;
51
55
  export declare function emitPreviewVariableValues(stepId: FunnelStepId, values: Record<string, string>): void;
52
56
  export declare function usePreviewVariableValues(stepId: FunnelStepId, values: Record<string, string>): void;
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { useEffect, useRef } from 'react';
3
- import { BUILDER_PREVIEW_ACTIVE_STEP_CHANGED, BUILDER_PREVIEW_DEFINITION_PATCH, BUILDER_PREVIEW_GO_TO_STEP, BUILDER_PREVIEW_PAYWALL_PLANS_CHANGED, BUILDER_PREVIEW_READY, BUILDER_PREVIEW_RUNTIME_MODE_CHANGED, BUILDER_PREVIEW_VARIABLE_VALUES_CHANGED, } from '../config/builder-preview.protocol.js';
3
+ import { BUILDER_PREVIEW_ACTIVE_STEP_CHANGED, BUILDER_PREVIEW_DEFINITION_PATCH, BUILDER_PREVIEW_GO_TO_STEP, BUILDER_PREVIEW_PAYWALL_PLANS_CHANGED, BUILDER_PREVIEW_READY, BUILDER_PREVIEW_RUNTIME_MODE_CHANGED, BUILDER_PREVIEW_THEME_CHANGED, BUILDER_PREVIEW_VARIABLE_VALUES_CHANGED, } from '../config/builder-preview.protocol.js';
4
4
  import { isPreviewFrameRuntime } from '../services/preview-frame.service.js';
5
5
  import { logger } from '../services/logger.js';
6
6
  import { applyPreviewDefinitionPatch, applyPreviewPaywallPlansPatch } from './preview-definition-overrides.js';
@@ -107,6 +107,13 @@ export const parsePreviewQuickEditPatch = (value) => {
107
107
  }
108
108
  return null;
109
109
  };
110
+ const parseThemeCssVariables = (value) => {
111
+ if (!isRecord(value)) {
112
+ return null;
113
+ }
114
+ const cssVariables = Object.fromEntries(Object.entries(value).filter((entry) => entry[0].startsWith('--') && typeof entry[1] === 'string'));
115
+ return Object.keys(cssVariables).length > 0 ? cssVariables : null;
116
+ };
110
117
  export const parsePreviewBridgeMessage = (value) => {
111
118
  if (!isRecord(value)) {
112
119
  return null;
@@ -149,12 +156,33 @@ export const parsePreviewBridgeMessage = (value) => {
149
156
  }
150
157
  : null;
151
158
  }
159
+ if (messageType === BUILDER_PREVIEW_THEME_CHANGED) {
160
+ const cssVariables = parseThemeCssVariables(value.cssVariables);
161
+ return cssVariables ? { kind: 'themeChanged', cssVariables } : null;
162
+ }
152
163
  if (messageType === BUILDER_PREVIEW_GO_TO_STEP) {
153
164
  const stepId = typeof value.stepId === 'string' ? value.stepId.trim() : '';
154
165
  return stepId ? { kind: 'goToStep', stepId } : null;
155
166
  }
156
167
  return null;
157
168
  };
169
+ export const applyPreviewThemeVariables = (cssVariables) => {
170
+ if (typeof document === 'undefined') {
171
+ return;
172
+ }
173
+ const targets = [
174
+ document.documentElement,
175
+ document.body,
176
+ ...Array.from(document.querySelectorAll('.page-root')),
177
+ ].filter((target) => Boolean(target));
178
+ for (const target of targets) {
179
+ for (const [key, value] of Object.entries(cssVariables)) {
180
+ if (key.startsWith('--')) {
181
+ target.style.setProperty(key, value);
182
+ }
183
+ }
184
+ }
185
+ };
158
186
  export const applyPreviewQuickEditPatch = (patch) => {
159
187
  if (typeof document === 'undefined') {
160
188
  return;
@@ -291,6 +319,10 @@ export function usePreviewBridge({ activeStepId, onGoToStep, resolveRenderableSt
291
319
  applyPreviewPaywallPlansPatch(action.stepId, action.plans);
292
320
  return;
293
321
  }
322
+ if (action.kind === 'themeChanged') {
323
+ applyPreviewThemeVariables(action.cssVariables);
324
+ return;
325
+ }
294
326
  if (action.kind === 'runtimeModeChanged') {
295
327
  setRuntimeMode(action.mode);
296
328
  return;
@@ -9,7 +9,16 @@ export type FunnelFlowControllerExperiment<StepId extends string> = FunnelManife
9
9
  })[];
10
10
  };
11
11
  type StepComponentRegistry = Record<string, unknown>;
12
+ type FunnelFlowAnalyticsAdapter = {
13
+ trackFirstStepViewed?: (input: {
14
+ stepId: string;
15
+ stepName: string;
16
+ occurredAt: string;
17
+ featureFlags?: Record<string, string>;
18
+ }) => string | null;
19
+ };
12
20
  type UseFunnelFlowControllerInput<StepId extends string> = {
21
+ analytics?: FunnelFlowAnalyticsAdapter;
13
22
  initialStepId?: StepId;
14
23
  lockToInitialStep?: boolean;
15
24
  defaultStepId: StepId;
@@ -63,5 +72,5 @@ export declare function computeRenderSuspended(input: {
63
72
  }>;
64
73
  isPreviewRuntime?: boolean;
65
74
  }): boolean;
66
- export declare function useFunnelFlowController<StepId extends string>({ initialStepId, lockToInitialStep, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }: UseFunnelFlowControllerInput<StepId>): UseFunnelFlowControllerResult<StepId>;
75
+ export declare function useFunnelFlowController<StepId extends string>({ analytics, initialStepId, lockToInitialStep, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }: UseFunnelFlowControllerInput<StepId>): UseFunnelFlowControllerResult<StepId>;
67
76
  export {};
@@ -54,7 +54,7 @@ export function computeRenderSuspended(input) {
54
54
  }
55
55
  return input.experiments.some((experiment) => experiment.stepId === input.activeStepId);
56
56
  }
57
- export function useFunnelFlowController({ initialStepId, lockToInitialStep = false, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }) {
57
+ export function useFunnelFlowController({ analytics, initialStepId, lockToInitialStep = false, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }) {
58
58
  var _a, _b;
59
59
  const [runtimeMode, setRuntimeMode] = useRuntimeMode();
60
60
  const [editorModeEnabled, setEditorModeEnabled] = useState(false);
@@ -102,10 +102,12 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
102
102
  document: {},
103
103
  completedSteps: [],
104
104
  }));
105
+ const [userBootstrapped, setUserBootstrapped] = useState(isPreviewRuntime);
105
106
  const attributesAtStepStart = useRef({});
106
107
  const attributesRef = useRef(attributes);
107
108
  const postHogFeatureFlagsRef = useRef({});
108
109
  const prevStepIdRef = useRef(null);
110
+ const firstStepViewedTrackedRef = useRef(false);
109
111
  const stepStartedAtByIdRef = useRef({});
110
112
  const engagedStepIdsRef = useRef(new Set());
111
113
  const currentUserIdRef = useRef(user.id);
@@ -245,6 +247,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
245
247
  const urlUserAttributes = resolveUrlUserAttributes();
246
248
  const bootstrapCandidateUserId = apiService.getBootstrapCandidateUserId(localUserId);
247
249
  const initialAttribution = collectCurrentFunnelAttribution();
250
+ setUserBootstrapped(isPreviewRuntime);
248
251
  setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId })));
249
252
  if (isPreviewRuntime) {
250
253
  return;
@@ -274,6 +277,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
274
277
  setAttributes((prev) => (Object.assign(Object.assign({}, sessionAttributes), prev)));
275
278
  }
276
279
  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 })));
280
+ setUserBootstrapped(true);
277
281
  void apiService.pingUserContext(sessionUser.id)
278
282
  .then((pingedUser) => {
279
283
  if (!pingedUser) {
@@ -292,6 +296,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
292
296
  })
293
297
  .catch((error) => {
294
298
  logger.error('Failed to bootstrap user session:', error);
299
+ setUserBootstrapped(false);
295
300
  return null;
296
301
  });
297
302
  Promise.all([postHogBootstrap, sessionBootstrap])
@@ -368,6 +373,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
368
373
  recordStepCompletion(stepId, choices);
369
374
  }, [recordStepCompletion]);
370
375
  useEffect(() => {
376
+ var _a;
371
377
  if (safeActiveStepId !== activeStepId) {
372
378
  logger.warn(`[FunnelFlow] Unknown or non-renderable step "${activeStepId}", falling back to "${safeActiveStepId}".`);
373
379
  setActiveStepId(safeActiveStepId);
@@ -391,6 +397,15 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
391
397
  startedAt,
392
398
  });
393
399
  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, environment: runtimeMode, step_id: renderedStepId, step_name: stepName, started_at: startedAt }, buildFeatureFlagProperties(postHogFeatureFlagsRef.current)));
400
+ if (!firstStepViewedTrackedRef.current && safeActiveStepId === defaultStepId) {
401
+ firstStepViewedTrackedRef.current = true;
402
+ (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _a === void 0 ? void 0 : _a.call(analytics, {
403
+ stepId: renderedStepId,
404
+ stepName,
405
+ occurredAt: startedAt,
406
+ featureFlags: postHogFeatureFlagsRef.current,
407
+ });
408
+ }
394
409
  }
395
410
  attributesAtStepStart.current = Object.assign({}, attributesRef.current);
396
411
  prevStepIdRef.current = renderedStepId;
@@ -425,6 +440,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
425
440
  return () => window.clearTimeout(timer);
426
441
  }, [
427
442
  activeStepId,
443
+ analytics,
428
444
  defaultStepId,
429
445
  isPreviewRuntime,
430
446
  recordStepCompletion,
@@ -563,6 +579,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
563
579
  setAttribute,
564
580
  attributes,
565
581
  user,
582
+ userBootstrapped,
566
583
  setUser,
567
584
  completeStep,
568
585
  }), [
@@ -580,6 +597,7 @@ export function useFunnelFlowController({ initialStepId, lockToInitialStep = fal
580
597
  setAnswer,
581
598
  setAttribute,
582
599
  user,
600
+ userBootstrapped,
583
601
  ]);
584
602
  return {
585
603
  activeStepComponent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./dist/index.js",