@funnelsgrove/runtime 0.7.24 → 0.7.26
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/README.md
CHANGED
|
@@ -55,6 +55,9 @@ documented in [`docs/product-specs/funnel-template-runtime.md`](../../docs/produ
|
|
|
55
55
|
- Do not import `@funnelsgrove/payments` or `@funnelsgrove/analytics` here for checkout or transport orchestration.
|
|
56
56
|
- Runtime UI must be generic and copy-injectable.
|
|
57
57
|
- Browser-only values must resolve after mount when SSR hydration can be affected.
|
|
58
|
+
- Subscription handoff app and universal links receive the known funnel-user email even when the
|
|
59
|
+
configured template omits `{email}`. Store links never receive email, including case-insensitive
|
|
60
|
+
`email` keys present in configured URLs or first-touch attribution.
|
|
58
61
|
- Funnel code must not copy `useSyncExternalStore`/`popstate` query-string hooks. Use
|
|
59
62
|
`useBrowserLocationSearch`; the shared controller also notifies it after
|
|
60
63
|
`history.pushState` and `history.replaceState` navigation.
|
|
@@ -5,6 +5,7 @@ export declare const writeWindowStorageValue: (storageKey: string, value: string
|
|
|
5
5
|
export declare const removeWindowStorageValue: (storageKey: string) => void;
|
|
6
6
|
export declare const dispatchWindowCustomEvent: <Detail>(eventType: string, detail: Detail) => void;
|
|
7
7
|
export declare const dispatchFunnelLocationChange: () => void;
|
|
8
|
+
export declare const updateFunnelHistory: (method: "push" | "replace", state: Record<string, unknown>, location: string) => void;
|
|
8
9
|
export declare const buildHostedStepLocation: (input: {
|
|
9
10
|
currentHref: string;
|
|
10
11
|
stepPath: string;
|
|
@@ -51,6 +51,15 @@ export const dispatchFunnelLocationChange = () => {
|
|
|
51
51
|
href: window.location.href,
|
|
52
52
|
});
|
|
53
53
|
};
|
|
54
|
+
export const updateFunnelHistory = (method, state, location) => {
|
|
55
|
+
const historyPrototype = Object.getPrototypeOf(window.history);
|
|
56
|
+
const update = method === 'replace'
|
|
57
|
+
? historyPrototype.replaceState
|
|
58
|
+
: historyPrototype.pushState;
|
|
59
|
+
const currentState = window.history.state;
|
|
60
|
+
update.call(window.history, Object.assign(Object.assign({}, (currentState && typeof currentState === 'object' ? currentState : {})), state), '', location);
|
|
61
|
+
dispatchFunnelLocationChange();
|
|
62
|
+
};
|
|
54
63
|
const getHostedFunnelBaseSegments = (pathname) => {
|
|
55
64
|
const pathSegments = pathname.split('/').filter(Boolean);
|
|
56
65
|
if (pathSegments[0] === 'published' && pathSegments[1] === 'f' && pathSegments[2]) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const LINK_RESOLUTION_BASE = 'https://subscription-handoff.invalid/';
|
|
2
|
+
const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+.-]*:/i;
|
|
1
3
|
const asTrimmedString = (value) => {
|
|
2
4
|
if (typeof value !== 'string') {
|
|
3
5
|
return null;
|
|
@@ -16,33 +18,86 @@ const resolvePlatform = (userAgent) => {
|
|
|
16
18
|
}
|
|
17
19
|
return 'desktop';
|
|
18
20
|
};
|
|
19
|
-
const
|
|
21
|
+
const normalizedQueryKey = (value) => value.trim().toLowerCase();
|
|
22
|
+
const hasQueryKey = (url, key) => {
|
|
23
|
+
return [...url.searchParams.keys()].some((candidate) => normalizedQueryKey(candidate) === key);
|
|
24
|
+
};
|
|
25
|
+
const removeQueryKey = (url, key) => {
|
|
26
|
+
for (const candidate of [...url.searchParams.keys()]) {
|
|
27
|
+
if (normalizedQueryKey(candidate) === key) {
|
|
28
|
+
url.searchParams.delete(candidate);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const applyLinkTemplates = (value, variables, includeEmail) => {
|
|
20
33
|
return value
|
|
21
34
|
.replace(/\{user_id\}/g, encodeURIComponent(variables.userId))
|
|
22
|
-
.replace(/\{email\}/g, variables.email ? encodeURIComponent(variables.email) : '');
|
|
35
|
+
.replace(/\{email\}/g, includeEmail && variables.email ? encodeURIComponent(variables.email) : '');
|
|
36
|
+
};
|
|
37
|
+
const hasRelativeDotSegment = (source) => {
|
|
38
|
+
var _a, _b;
|
|
39
|
+
if (ABSOLUTE_URL_PATTERN.test(source) || source.startsWith('/')) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const path = (_b = (_a = source.match(/^[^?#]*/)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : '';
|
|
43
|
+
return path.split(/[\\/]/).some((segment) => {
|
|
44
|
+
try {
|
|
45
|
+
return /^\.{1,2}$/.test(decodeURIComponent(segment));
|
|
46
|
+
}
|
|
47
|
+
catch (_a) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
const hasAmbiguousPathBackslash = (source) => {
|
|
53
|
+
var _a, _b;
|
|
54
|
+
return ((_b = (_a = source.match(/^[^?#]*/)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : '').includes('\\');
|
|
55
|
+
};
|
|
56
|
+
const serializeResolvedUrl = (url, source) => {
|
|
57
|
+
if (ABSOLUTE_URL_PATTERN.test(source)) {
|
|
58
|
+
return url.toString();
|
|
59
|
+
}
|
|
60
|
+
if (source.startsWith('//')) {
|
|
61
|
+
return url.toString().slice(url.protocol.length);
|
|
62
|
+
}
|
|
63
|
+
const relativeUrl = `${url.pathname}${url.search}${url.hash}`;
|
|
64
|
+
return source.startsWith('/') ? relativeUrl : relativeUrl.slice(1);
|
|
23
65
|
};
|
|
24
|
-
const resolveUrl = (value, variables, firstTouch) => {
|
|
66
|
+
const resolveUrl = (value, variables, firstTouch, includeEmail = false) => {
|
|
25
67
|
const normalized = asTrimmedString(value);
|
|
26
68
|
if (!normalized) {
|
|
27
69
|
return null;
|
|
28
70
|
}
|
|
71
|
+
const templated = applyLinkTemplates(normalized, variables, includeEmail);
|
|
29
72
|
try {
|
|
30
|
-
|
|
73
|
+
if (hasAmbiguousPathBackslash(templated) || hasRelativeDotSegment(templated)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const url = new URL(templated, LINK_RESOLUTION_BASE);
|
|
31
77
|
if (!url.searchParams.has('user_id')) {
|
|
32
78
|
url.searchParams.set('user_id', variables.userId);
|
|
33
79
|
}
|
|
80
|
+
if (!includeEmail) {
|
|
81
|
+
removeQueryKey(url, 'email');
|
|
82
|
+
}
|
|
83
|
+
else if (variables.email && !hasQueryKey(url, 'email')) {
|
|
84
|
+
url.searchParams.set('email', variables.email);
|
|
85
|
+
}
|
|
34
86
|
for (const [key, rawValue] of Object.entries(firstTouch)) {
|
|
35
87
|
const nextKey = key.trim();
|
|
36
88
|
const nextValue = rawValue.trim();
|
|
37
|
-
if (!nextKey
|
|
89
|
+
if (!nextKey
|
|
90
|
+
|| !nextValue
|
|
91
|
+
|| normalizedQueryKey(nextKey) === 'email'
|
|
92
|
+
|| url.searchParams.has(nextKey)) {
|
|
38
93
|
continue;
|
|
39
94
|
}
|
|
40
95
|
url.searchParams.set(nextKey, nextValue);
|
|
41
96
|
}
|
|
42
|
-
return url
|
|
97
|
+
return serializeResolvedUrl(url, templated);
|
|
43
98
|
}
|
|
44
99
|
catch (_a) {
|
|
45
|
-
return
|
|
100
|
+
return null;
|
|
46
101
|
}
|
|
47
102
|
};
|
|
48
103
|
const buildQrConfirmationUrl = (confirmationUrl, userId) => {
|
|
@@ -87,9 +142,9 @@ export const resolveSubscriptionHandoff = (input) => {
|
|
|
87
142
|
const variables = { userId, email };
|
|
88
143
|
const iosStoreUrl = resolveUrl(input.config.iosAppStoreUrl, variables, firstTouch);
|
|
89
144
|
const androidStoreUrl = resolveUrl(input.config.androidPlayStoreUrl, variables, firstTouch);
|
|
90
|
-
const iosDeepLink = resolveUrl(input.config.iosDeepLink, variables, firstTouch);
|
|
91
|
-
const androidDeepLink = resolveUrl(input.config.androidDeepLink, variables, firstTouch);
|
|
92
|
-
const universalLink = resolveUrl(input.config.universalLink, variables, firstTouch);
|
|
145
|
+
const iosDeepLink = resolveUrl(input.config.iosDeepLink, variables, firstTouch, true);
|
|
146
|
+
const androidDeepLink = resolveUrl(input.config.androidDeepLink, variables, firstTouch, true);
|
|
147
|
+
const universalLink = resolveUrl(input.config.universalLink, variables, firstTouch, true);
|
|
93
148
|
const openAppUrl = platform === 'ios'
|
|
94
149
|
? iosDeepLink || universalLink || iosStoreUrl
|
|
95
150
|
: platform === 'android'
|
|
@@ -5,7 +5,7 @@ import { writeStepChoice } from '../sdk/userAnswers.js';
|
|
|
5
5
|
import { apiService } from '../services/api.service.js';
|
|
6
6
|
import { logger } from '../services/logger.js';
|
|
7
7
|
import { FUNNEL_ID, POSTHOG_API_HOST, POSTHOG_PROJECT_API_KEY, PROJECT_ID, } from '../services/runtime-api.config.js';
|
|
8
|
-
import { buildHostedStepLocation,
|
|
8
|
+
import { buildHostedStepLocation, dispatchWindowCustomEvent, updateFunnelHistory, } from './browser-helpers.js';
|
|
9
9
|
import { isPreviewStepLockRequested, resolveNextStepFromContext, shouldRunAutoAdvanceTimer, } from './funnel-flow.js';
|
|
10
10
|
import { usePreviewBridge } from './preview-bridge.js';
|
|
11
11
|
import { resolveExperimentAssignment, resolveForcedExperimentAssignment, } from './experiment-assignment.js';
|
|
@@ -433,13 +433,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
|
|
|
433
433
|
});
|
|
434
434
|
const currentLocation = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
435
435
|
if (currentLocation !== nextLocation) {
|
|
436
|
-
|
|
437
|
-
window.history.replaceState({ stepId: safeStepId }, '', nextLocation);
|
|
438
|
-
}
|
|
439
|
-
else {
|
|
440
|
-
window.history.pushState({ stepId: safeStepId }, '', nextLocation);
|
|
441
|
-
}
|
|
442
|
-
dispatchFunnelLocationChange();
|
|
436
|
+
updateFunnelHistory(isPreviewFrameRuntime() ? 'replace' : 'push', { stepId: safeStepId }, nextLocation);
|
|
443
437
|
}
|
|
444
438
|
setActiveStepId(safeStepId);
|
|
445
439
|
}, [getPathForStep]);
|