@funnelsgrove/runtime 0.7.25 → 0.7.27
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.
|
|
@@ -13,6 +13,7 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
13
13
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
14
14
|
import { createContext, useContext, useEffect, useMemo, useState, } from 'react';
|
|
15
15
|
import { loadFunnelRuntimeConfig, resolvePublishedFunnelRuntimeConfig, } from '../services/funnel-runtime-config.js';
|
|
16
|
+
import { isPreviewFrameRuntime } from '../services/preview-frame.service.js';
|
|
16
17
|
const FunnelRuntimeConfigContext = createContext({
|
|
17
18
|
status: 'fallback',
|
|
18
19
|
config: null,
|
|
@@ -32,6 +33,12 @@ function ActiveFunnelRuntimeConfigBoundary({ funnelId, funnelVersionId, children
|
|
|
32
33
|
error: null,
|
|
33
34
|
});
|
|
34
35
|
useEffect(() => {
|
|
36
|
+
// The server snapshot cannot see the browser query string, so a preview
|
|
37
|
+
// frame can briefly mount this active boundary during hydration. Avoid a
|
|
38
|
+
// same-origin config request before the parent switches to disabled mode.
|
|
39
|
+
if (isPreviewFrameRuntime()) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
35
42
|
let active = true;
|
|
36
43
|
let refreshTimer = null;
|
|
37
44
|
const refresh = () => loadFunnelRuntimeConfig(funnelId, { versionId: funnelVersionId })
|
|
@@ -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'
|