@funnelsgrove/runtime 0.1.19 → 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;
@@ -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',
@@ -102,6 +102,7 @@ export function useFunnelFlowController({ analytics, initialStepId, lockToInitia
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({});
@@ -246,6 +247,7 @@ export function useFunnelFlowController({ analytics, initialStepId, lockToInitia
246
247
  const urlUserAttributes = resolveUrlUserAttributes();
247
248
  const bootstrapCandidateUserId = apiService.getBootstrapCandidateUserId(localUserId);
248
249
  const initialAttribution = collectCurrentFunnelAttribution();
250
+ setUserBootstrapped(isPreviewRuntime);
249
251
  setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId })));
250
252
  if (isPreviewRuntime) {
251
253
  return;
@@ -275,6 +277,7 @@ export function useFunnelFlowController({ analytics, initialStepId, lockToInitia
275
277
  setAttributes((prev) => (Object.assign(Object.assign({}, sessionAttributes), prev)));
276
278
  }
277
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);
278
281
  void apiService.pingUserContext(sessionUser.id)
279
282
  .then((pingedUser) => {
280
283
  if (!pingedUser) {
@@ -293,6 +296,7 @@ export function useFunnelFlowController({ analytics, initialStepId, lockToInitia
293
296
  })
294
297
  .catch((error) => {
295
298
  logger.error('Failed to bootstrap user session:', error);
299
+ setUserBootstrapped(false);
296
300
  return null;
297
301
  });
298
302
  Promise.all([postHogBootstrap, sessionBootstrap])
@@ -575,6 +579,7 @@ export function useFunnelFlowController({ analytics, initialStepId, lockToInitia
575
579
  setAttribute,
576
580
  attributes,
577
581
  user,
582
+ userBootstrapped,
578
583
  setUser,
579
584
  completeStep,
580
585
  }), [
@@ -592,6 +597,7 @@ export function useFunnelFlowController({ analytics, initialStepId, lockToInitia
592
597
  setAnswer,
593
598
  setAttribute,
594
599
  user,
600
+ userBootstrapped,
595
601
  ]);
596
602
  return {
597
603
  activeStepComponent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./dist/index.js",