@funnelsgrove/runtime 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/FunnelContext.d.ts +1 -0
- package/dist/components/SubscriptionHandoffScreen.js +51 -18
- package/dist/config/funnel.experiments.types.d.ts +6 -3
- package/dist/config/funnel.experiments.types.js +3 -1
- package/dist/runtime/posthog-flags.d.ts +1 -0
- package/dist/runtime/posthog-flags.js +6 -0
- package/dist/runtime/use-funnel-flow-controller.js +6 -0
- package/package.json +1 -1
|
@@ -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 &&
|
|
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 &&
|
|
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
|
-
|
|
157
|
-
|
|
176
|
+
window.location.assign(openAppUrl);
|
|
177
|
+
const statusTimer = window.setTimeout(() => {
|
|
158
178
|
setAutoOpenAttempted(true);
|
|
159
|
-
},
|
|
160
|
-
return () => window.clearTimeout(
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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,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,
|