@frak-labs/core-sdk 0.2.1 → 1.0.0-beta.0cd79998
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 +1 -2
- package/cdn/bundle.js +3 -3
- package/dist/actions-BMTVobuH.js +1 -0
- package/dist/actions-ukNCM0d7.cjs +1 -0
- package/dist/actions.cjs +1 -1
- package/dist/actions.d.cts +3 -3
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/bundle.cjs +1 -1
- package/dist/bundle.d.cts +4 -4
- package/dist/bundle.d.ts +4 -4
- package/dist/bundle.js +1 -1
- package/dist/{computeLegacyProductId-b5cUWdAm.d.ts → index-BCwGNRmk.d.cts} +144 -58
- package/dist/{computeLegacyProductId-CCAZvLa5.d.cts → index-BfmJnxzo.d.ts} +144 -58
- package/dist/{siweAuthenticate-CnCZ7mok.d.ts → index-CVnwk1E_.d.cts} +122 -8
- package/dist/{siweAuthenticate-CVigMOxz.d.cts → index-DZuYiI2M.d.ts} +122 -8
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/{openSso-B0g7-807.d.cts → openSso-BQ-q-_Y9.d.ts} +373 -46
- package/dist/{openSso-CMzwvaCa.d.ts → openSso-CMBCbhvP.d.cts} +372 -45
- package/dist/src-Cx0RZEA3.js +13 -0
- package/dist/src-DmYZ4ZLk.cjs +13 -0
- package/dist/trackEvent-B5xo_5K3.cjs +1 -0
- package/dist/trackEvent-DdykyX0U.js +1 -0
- package/package.json +12 -13
- package/src/actions/displayEmbeddedWallet.ts +6 -2
- package/src/actions/displayModal.ts +6 -2
- package/src/actions/displaySharingPage.ts +49 -0
- package/src/actions/ensureIdentity.ts +2 -2
- package/src/actions/getMerchantInformation.test.ts +13 -1
- package/src/actions/getMerchantInformation.ts +20 -5
- package/src/actions/getMergeToken.ts +33 -0
- package/src/actions/getUserReferralStatus.ts +42 -0
- package/src/actions/index.ts +8 -1
- package/src/actions/referral/setupReferral.test.ts +79 -0
- package/src/actions/referral/setupReferral.ts +32 -0
- package/src/actions/trackPurchaseStatus.test.ts +32 -20
- package/src/actions/trackPurchaseStatus.ts +3 -5
- package/src/actions/wrapper/modalBuilder.test.ts +4 -2
- package/src/actions/wrapper/modalBuilder.ts +6 -8
- package/src/clients/createIFrameFrakClient.ts +162 -27
- package/src/clients/transports/iframeLifecycleManager.test.ts +14 -94
- package/src/clients/transports/iframeLifecycleManager.ts +35 -53
- package/src/index.ts +21 -4
- package/src/stubs/rrweb.ts +9 -0
- package/src/types/config.ts +19 -3
- package/src/types/index.ts +15 -1
- package/src/types/lifecycle/client.ts +22 -27
- package/src/types/lifecycle/iframe.ts +7 -8
- package/src/types/resolvedConfig.ts +138 -0
- package/src/types/rpc/displaySharingPage.ts +100 -0
- package/src/types/rpc/embedded/index.ts +1 -1
- package/src/types/rpc/interaction.ts +4 -0
- package/src/types/rpc/userReferralStatus.ts +20 -0
- package/src/types/rpc.ts +54 -5
- package/src/types/tracking.ts +36 -0
- package/src/utils/FrakContext.test.ts +144 -0
- package/src/utils/FrakContext.ts +67 -1
- package/src/utils/backendUrl.test.ts +2 -2
- package/src/utils/backendUrl.ts +1 -1
- package/src/utils/cache/index.ts +7 -0
- package/src/utils/cache/lruMap.test.ts +55 -0
- package/src/utils/cache/lruMap.ts +38 -0
- package/src/utils/cache/withCache.test.ts +168 -0
- package/src/utils/cache/withCache.ts +124 -0
- package/src/utils/inAppBrowser.ts +60 -0
- package/src/utils/index.ts +10 -4
- package/src/utils/mergeAttribution.test.ts +153 -0
- package/src/utils/mergeAttribution.ts +75 -0
- package/src/utils/sdkConfigStore.test.ts +405 -0
- package/src/utils/sdkConfigStore.ts +263 -0
- package/src/utils/sso.ts +3 -7
- package/dist/setupClient-BduY6Sym.cjs +0 -13
- package/dist/setupClient-ftmdQ-I8.js +0 -13
- package/dist/siweAuthenticate-BWmI2_TN.cjs +0 -1
- package/dist/siweAuthenticate-zczqxm0a.js +0 -1
- package/dist/trackEvent-CeLFVzZn.js +0 -1
- package/dist/trackEvent-Ew5r5zfI.cjs +0 -1
- package/src/utils/merchantId.test.ts +0 -653
- package/src/utils/merchantId.ts +0 -143
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
} from "../../../tests/vitest-fixtures";
|
|
9
|
+
import { clearAllCache, withCache } from "./withCache";
|
|
10
|
+
|
|
11
|
+
describe("withCache", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
clearAllCache();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("caching behavior", () => {
|
|
21
|
+
it("should call fn on first invocation", async () => {
|
|
22
|
+
const fn = vi.fn().mockResolvedValue("result");
|
|
23
|
+
|
|
24
|
+
const result = await withCache(fn, {
|
|
25
|
+
cacheKey: "test-key",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
29
|
+
expect(result).toBe("result");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should return cached result on subsequent calls within TTL", async () => {
|
|
33
|
+
const fn = vi.fn().mockResolvedValue("result");
|
|
34
|
+
|
|
35
|
+
await withCache(fn, {
|
|
36
|
+
cacheKey: "test-key",
|
|
37
|
+
cacheTime: 10_000,
|
|
38
|
+
});
|
|
39
|
+
const result = await withCache(fn, {
|
|
40
|
+
cacheKey: "test-key",
|
|
41
|
+
cacheTime: 10_000,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
45
|
+
expect(result).toBe("result");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should re-fetch after cache expires", async () => {
|
|
49
|
+
vi.useFakeTimers();
|
|
50
|
+
|
|
51
|
+
const fn = vi
|
|
52
|
+
.fn()
|
|
53
|
+
.mockResolvedValueOnce("first")
|
|
54
|
+
.mockResolvedValueOnce("second");
|
|
55
|
+
|
|
56
|
+
const first = await withCache(fn, {
|
|
57
|
+
cacheKey: "test-key",
|
|
58
|
+
cacheTime: 100,
|
|
59
|
+
});
|
|
60
|
+
expect(first).toBe("first");
|
|
61
|
+
|
|
62
|
+
// Advance past TTL
|
|
63
|
+
vi.advanceTimersByTime(200);
|
|
64
|
+
|
|
65
|
+
const second = await withCache(fn, {
|
|
66
|
+
cacheKey: "test-key",
|
|
67
|
+
cacheTime: 100,
|
|
68
|
+
});
|
|
69
|
+
expect(second).toBe("second");
|
|
70
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
71
|
+
|
|
72
|
+
vi.useRealTimers();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should not cache when cacheTime is 0", async () => {
|
|
76
|
+
const fn = vi.fn().mockResolvedValue("result");
|
|
77
|
+
|
|
78
|
+
await withCache(fn, { cacheKey: "test-key", cacheTime: 0 });
|
|
79
|
+
await withCache(fn, { cacheKey: "test-key", cacheTime: 0 });
|
|
80
|
+
|
|
81
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should use different caches for different keys", async () => {
|
|
85
|
+
const fnA = vi.fn().mockResolvedValue("a");
|
|
86
|
+
const fnB = vi.fn().mockResolvedValue("b");
|
|
87
|
+
|
|
88
|
+
const a = await withCache(fnA, { cacheKey: "key-a" });
|
|
89
|
+
const b = await withCache(fnB, { cacheKey: "key-b" });
|
|
90
|
+
|
|
91
|
+
expect(a).toBe("a");
|
|
92
|
+
expect(b).toBe("b");
|
|
93
|
+
expect(fnA).toHaveBeenCalledOnce();
|
|
94
|
+
expect(fnB).toHaveBeenCalledOnce();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("deduplication", () => {
|
|
99
|
+
it("should deduplicate concurrent calls with the same key", async () => {
|
|
100
|
+
let resolvePromise: (value: string) => void;
|
|
101
|
+
const fn = vi.fn().mockImplementation(
|
|
102
|
+
() =>
|
|
103
|
+
new Promise<string>((resolve) => {
|
|
104
|
+
resolvePromise = resolve;
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const promise1 = withCache(fn, { cacheKey: "dedup-key" });
|
|
109
|
+
const promise2 = withCache(fn, { cacheKey: "dedup-key" });
|
|
110
|
+
|
|
111
|
+
// fn should only be called once
|
|
112
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
113
|
+
|
|
114
|
+
// Both should resolve to the same value
|
|
115
|
+
resolvePromise!("shared");
|
|
116
|
+
const [result1, result2] = await Promise.all([promise1, promise2]);
|
|
117
|
+
expect(result1).toBe("shared");
|
|
118
|
+
expect(result2).toBe("shared");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("error handling", () => {
|
|
123
|
+
it("should propagate errors from fn", async () => {
|
|
124
|
+
const fn = vi.fn().mockRejectedValue(new Error("fetch failed"));
|
|
125
|
+
|
|
126
|
+
await expect(
|
|
127
|
+
withCache(fn, { cacheKey: "error-key" })
|
|
128
|
+
).rejects.toThrow("fetch failed");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should not cache errors — subsequent call retries", async () => {
|
|
132
|
+
vi.useFakeTimers();
|
|
133
|
+
const fn = vi
|
|
134
|
+
.fn()
|
|
135
|
+
.mockRejectedValueOnce(new Error("fail"))
|
|
136
|
+
.mockResolvedValueOnce("recovered");
|
|
137
|
+
|
|
138
|
+
await expect(
|
|
139
|
+
withCache(fn, { cacheKey: "retry-key" })
|
|
140
|
+
).rejects.toThrow("fail");
|
|
141
|
+
|
|
142
|
+
// Advance past the negative cache backoff (1s)
|
|
143
|
+
vi.advanceTimersByTime(1_001);
|
|
144
|
+
|
|
145
|
+
const result = await withCache(fn, { cacheKey: "retry-key" });
|
|
146
|
+
expect(result).toBe("recovered");
|
|
147
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
148
|
+
|
|
149
|
+
vi.useRealTimers();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("clearAllCache", () => {
|
|
154
|
+
it("should clear all cached data", async () => {
|
|
155
|
+
const fn = vi
|
|
156
|
+
.fn()
|
|
157
|
+
.mockResolvedValueOnce("first")
|
|
158
|
+
.mockResolvedValueOnce("second");
|
|
159
|
+
|
|
160
|
+
await withCache(fn, { cacheKey: "clear-key" });
|
|
161
|
+
clearAllCache();
|
|
162
|
+
const result = await withCache(fn, { cacheKey: "clear-key" });
|
|
163
|
+
|
|
164
|
+
expect(result).toBe("second");
|
|
165
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { LruMap } from "./lruMap";
|
|
2
|
+
|
|
3
|
+
type CacheEntry<TData> = {
|
|
4
|
+
data: TData;
|
|
5
|
+
created: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Global cache for in-flight promises (dedup concurrent calls) */
|
|
9
|
+
const promiseCache = new LruMap<Promise<unknown>>(1024);
|
|
10
|
+
|
|
11
|
+
/** Global cache for resolved responses (TTL-based) */
|
|
12
|
+
const responseCache = new LruMap<CacheEntry<unknown>>(1024);
|
|
13
|
+
|
|
14
|
+
/** Default cache time: 30 seconds */
|
|
15
|
+
export const DEFAULT_CACHE_TIME = 30_000;
|
|
16
|
+
|
|
17
|
+
/** Short negative cache to avoid flooding on transient failures */
|
|
18
|
+
const NEGATIVE_CACHE_TIME = 1_000;
|
|
19
|
+
|
|
20
|
+
/** Tracks recently failed keys to avoid request floods */
|
|
21
|
+
const failureCache = new LruMap<number>(1024);
|
|
22
|
+
|
|
23
|
+
type WithCacheOptions = {
|
|
24
|
+
/** The key to cache the data against */
|
|
25
|
+
cacheKey: string;
|
|
26
|
+
/** Time in ms that cached data will remain valid. Default: 30_000 (30s). Set to 0 to disable caching. */
|
|
27
|
+
cacheTime?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns the result of a given promise, and caches the result for
|
|
32
|
+
* subsequent invocations against a provided cache key.
|
|
33
|
+
*
|
|
34
|
+
* Also deduplicates concurrent calls — if multiple callers request the same
|
|
35
|
+
* cache key while the promise is pending, they share the same promise.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* // First call fetches, subsequent calls return cached data for 30s
|
|
40
|
+
* const data = await withCache(
|
|
41
|
+
* () => client.request({ method: "frak_getMerchantInformation" }),
|
|
42
|
+
* { cacheKey: "merchantInfo", cacheTime: 30_000 }
|
|
43
|
+
* );
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export async function withCache<TData>(
|
|
47
|
+
fn: () => Promise<TData>,
|
|
48
|
+
{ cacheKey, cacheTime = DEFAULT_CACHE_TIME }: WithCacheOptions
|
|
49
|
+
): Promise<TData> {
|
|
50
|
+
// Check response cache — return immediately if fresh
|
|
51
|
+
if (cacheTime > 0) {
|
|
52
|
+
const cached = responseCache.get(cacheKey) as
|
|
53
|
+
| CacheEntry<TData>
|
|
54
|
+
| undefined;
|
|
55
|
+
if (cached) {
|
|
56
|
+
const age = Date.now() - cached.created;
|
|
57
|
+
if (age < cacheTime) return cached.data;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check if this key recently failed — back off briefly
|
|
62
|
+
const lastFailure = failureCache.get(cacheKey);
|
|
63
|
+
if (lastFailure && Date.now() - lastFailure < NEGATIVE_CACHE_TIME) {
|
|
64
|
+
throw new Error(`Cache: ${cacheKey} recently failed, backing off`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check if there's already a pending promise (dedup concurrent calls)
|
|
68
|
+
let promise = promiseCache.get(cacheKey) as Promise<TData> | undefined;
|
|
69
|
+
if (!promise) {
|
|
70
|
+
promise = fn();
|
|
71
|
+
promiseCache.set(cacheKey, promise);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const data = await promise;
|
|
76
|
+
// Store the response with a timestamp
|
|
77
|
+
responseCache.set(cacheKey, { data, created: Date.now() });
|
|
78
|
+
// Clear any previous failure
|
|
79
|
+
failureCache.delete(cacheKey);
|
|
80
|
+
return data;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// Record the failure timestamp
|
|
83
|
+
failureCache.set(cacheKey, Date.now());
|
|
84
|
+
throw error;
|
|
85
|
+
} finally {
|
|
86
|
+
// Clear the promise cache so subsequent calls can re-fetch after TTL
|
|
87
|
+
promiseCache.delete(cacheKey);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get a cache handle for a specific key, useful for manual invalidation.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* // Invalidate merchant info cache after a mutation
|
|
97
|
+
* getCache("frak_getMerchantInformation").clear();
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function getCache(cacheKey: string) {
|
|
101
|
+
return {
|
|
102
|
+
/** Clear both the pending promise and the cached response */
|
|
103
|
+
clear: () => {
|
|
104
|
+
promiseCache.delete(cacheKey);
|
|
105
|
+
responseCache.delete(cacheKey);
|
|
106
|
+
},
|
|
107
|
+
/** Check if a non-expired response exists */
|
|
108
|
+
has: (cacheTime: number = DEFAULT_CACHE_TIME) => {
|
|
109
|
+
const cached = responseCache.get(cacheKey);
|
|
110
|
+
if (!cached) return false;
|
|
111
|
+
return Date.now() - cached.created < cacheTime;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Clear all cached data (both pending promises and resolved responses).
|
|
118
|
+
* Called automatically when the client is destroyed.
|
|
119
|
+
*/
|
|
120
|
+
export function clearAllCache() {
|
|
121
|
+
promiseCache.clear();
|
|
122
|
+
responseCache.clear();
|
|
123
|
+
failureCache.clear();
|
|
124
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if the current device runs iOS (including iPadOS 13+).
|
|
3
|
+
*/
|
|
4
|
+
function checkIsIOS(): boolean {
|
|
5
|
+
if (typeof navigator === "undefined") return false;
|
|
6
|
+
const ua = navigator.userAgent;
|
|
7
|
+
// Standard iOS devices
|
|
8
|
+
if (/iPhone|iPad|iPod/i.test(ua)) return true;
|
|
9
|
+
// iPadOS 13+ reports as Macintosh — detect via touch support
|
|
10
|
+
if (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1) return true;
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether the current device runs iOS (including iPadOS 13+).
|
|
16
|
+
*/
|
|
17
|
+
export const isIOS: boolean = checkIsIOS();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if the current browser is a social media in-app browser
|
|
21
|
+
* (Instagram, Facebook WebView).
|
|
22
|
+
*/
|
|
23
|
+
function checkInAppBrowser(): boolean {
|
|
24
|
+
if (typeof navigator === "undefined") return false;
|
|
25
|
+
const ua = navigator.userAgent.toLowerCase();
|
|
26
|
+
return (
|
|
27
|
+
ua.includes("instagram") ||
|
|
28
|
+
ua.includes("fban") ||
|
|
29
|
+
ua.includes("fbav") ||
|
|
30
|
+
ua.includes("facebook")
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the current browser is a social media in-app browser
|
|
36
|
+
* (Instagram, Facebook).
|
|
37
|
+
*/
|
|
38
|
+
export const isInAppBrowser: boolean = checkInAppBrowser();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Redirect to external browser from in-app WebView.
|
|
42
|
+
*
|
|
43
|
+
* - **iOS**: Uses `x-safari-https://` scheme — server-side 302 redirects
|
|
44
|
+
* to custom URL schemes are silently swallowed by WKWebView.
|
|
45
|
+
* Direct `window.location.href` assignment works (confirmed iOS 17+).
|
|
46
|
+
*
|
|
47
|
+
* - **Android**: Uses backend `/common/social` endpoint which returns a PDF
|
|
48
|
+
* Content-Type response, forcing the WebView to hand off to the default browser.
|
|
49
|
+
*
|
|
50
|
+
* @param targetUrl - The URL to open in the external browser
|
|
51
|
+
*/
|
|
52
|
+
export function redirectToExternalBrowser(targetUrl: string): void {
|
|
53
|
+
if (isIOS && targetUrl.startsWith("https://")) {
|
|
54
|
+
window.location.href = `x-safari-https://${targetUrl.slice(8)}`;
|
|
55
|
+
} else if (isIOS && targetUrl.startsWith("http://")) {
|
|
56
|
+
window.location.href = `x-safari-http://${targetUrl.slice(7)}`;
|
|
57
|
+
} else {
|
|
58
|
+
window.location.href = `${process.env.BACKEND_URL}/common/social?u=${encodeURIComponent(targetUrl)}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { Deferred } from "@frak-labs/frame-connector";
|
|
2
2
|
export { getBackendUrl } from "./backendUrl";
|
|
3
|
+
export { clearAllCache, getCache, withCache } from "./cache";
|
|
3
4
|
export { getClientId } from "./clientId";
|
|
4
5
|
export { base64urlDecode, base64urlEncode } from "./compression/b64";
|
|
5
6
|
export { compressJsonToB64 } from "./compression/compress";
|
|
@@ -23,10 +24,15 @@ export {
|
|
|
23
24
|
findIframeInOpener,
|
|
24
25
|
} from "./iframeHelper";
|
|
25
26
|
export {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
} from "./
|
|
27
|
+
isInAppBrowser,
|
|
28
|
+
isIOS,
|
|
29
|
+
redirectToExternalBrowser,
|
|
30
|
+
} from "./inAppBrowser";
|
|
31
|
+
export {
|
|
32
|
+
type MergeAttributionInput,
|
|
33
|
+
mergeAttribution,
|
|
34
|
+
} from "./mergeAttribution";
|
|
35
|
+
export { sdkConfigStore } from "./sdkConfigStore";
|
|
30
36
|
export {
|
|
31
37
|
type AppSpecificSsoMetadata,
|
|
32
38
|
type CompressedSsoData,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from "../../tests/vitest-fixtures";
|
|
2
|
+
import { mergeAttribution } from "./mergeAttribution";
|
|
3
|
+
|
|
4
|
+
describe("mergeAttribution", () => {
|
|
5
|
+
describe("explicit disable", () => {
|
|
6
|
+
it("returns undefined when perCall is null", () => {
|
|
7
|
+
expect(
|
|
8
|
+
mergeAttribution({
|
|
9
|
+
perCall: null,
|
|
10
|
+
defaults: { utmSource: "brand" },
|
|
11
|
+
productUtmContent: "product-123",
|
|
12
|
+
})
|
|
13
|
+
).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("no inputs", () => {
|
|
18
|
+
it("returns undefined when all layers are empty", () => {
|
|
19
|
+
expect(mergeAttribution({ perCall: undefined })).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns undefined when perCall is undefined and defaults is empty object", () => {
|
|
23
|
+
expect(
|
|
24
|
+
mergeAttribution({ perCall: undefined, defaults: {} })
|
|
25
|
+
).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("defaults only", () => {
|
|
30
|
+
it("returns defaults when perCall is undefined", () => {
|
|
31
|
+
expect(
|
|
32
|
+
mergeAttribution({
|
|
33
|
+
perCall: undefined,
|
|
34
|
+
defaults: {
|
|
35
|
+
utmSource: "brand",
|
|
36
|
+
utmMedium: "newsletter",
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
).toEqual({
|
|
40
|
+
utmSource: "brand",
|
|
41
|
+
utmMedium: "newsletter",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("perCall only", () => {
|
|
47
|
+
it("returns perCall when defaults is undefined", () => {
|
|
48
|
+
expect(
|
|
49
|
+
mergeAttribution({
|
|
50
|
+
perCall: { utmSource: "custom", utmContent: "hero" },
|
|
51
|
+
})
|
|
52
|
+
).toEqual({
|
|
53
|
+
utmSource: "custom",
|
|
54
|
+
utmContent: "hero",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns empty object when perCall is empty and no defaults", () => {
|
|
59
|
+
// perCall: {} signals \"apply attribution with hardcoded defaults downstream\"
|
|
60
|
+
expect(mergeAttribution({ perCall: {} })).toEqual({});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("per-field merge (perCall wins over defaults)", () => {
|
|
65
|
+
it("merges perCall over defaults field-by-field", () => {
|
|
66
|
+
expect(
|
|
67
|
+
mergeAttribution({
|
|
68
|
+
perCall: { utmMedium: "email" },
|
|
69
|
+
defaults: {
|
|
70
|
+
utmSource: "brand",
|
|
71
|
+
utmMedium: "newsletter",
|
|
72
|
+
utmCampaign: "spring",
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
).toEqual({
|
|
76
|
+
utmSource: "brand",
|
|
77
|
+
utmMedium: "email",
|
|
78
|
+
utmCampaign: "spring",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("lets perCall override every default field", () => {
|
|
83
|
+
expect(
|
|
84
|
+
mergeAttribution({
|
|
85
|
+
perCall: {
|
|
86
|
+
utmSource: "pc-src",
|
|
87
|
+
utmMedium: "pc-med",
|
|
88
|
+
utmCampaign: "pc-camp",
|
|
89
|
+
utmTerm: "pc-term",
|
|
90
|
+
via: "pc-via",
|
|
91
|
+
ref: "pc-ref",
|
|
92
|
+
},
|
|
93
|
+
defaults: {
|
|
94
|
+
utmSource: "def-src",
|
|
95
|
+
utmMedium: "def-med",
|
|
96
|
+
utmCampaign: "def-camp",
|
|
97
|
+
utmTerm: "def-term",
|
|
98
|
+
via: "def-via",
|
|
99
|
+
ref: "def-ref",
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
).toEqual({
|
|
103
|
+
utmSource: "pc-src",
|
|
104
|
+
utmMedium: "pc-med",
|
|
105
|
+
utmCampaign: "pc-camp",
|
|
106
|
+
utmTerm: "pc-term",
|
|
107
|
+
via: "pc-via",
|
|
108
|
+
ref: "pc-ref",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("utm_content handling", () => {
|
|
114
|
+
it("uses productUtmContent when provided", () => {
|
|
115
|
+
expect(
|
|
116
|
+
mergeAttribution({
|
|
117
|
+
perCall: { utmContent: "fallback" },
|
|
118
|
+
productUtmContent: "product-42",
|
|
119
|
+
})
|
|
120
|
+
).toEqual({ utmContent: "product-42" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("falls back to perCall.utmContent when productUtmContent is absent", () => {
|
|
124
|
+
expect(
|
|
125
|
+
mergeAttribution({
|
|
126
|
+
perCall: { utmContent: "fallback" },
|
|
127
|
+
})
|
|
128
|
+
).toEqual({ utmContent: "fallback" });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("never inherits utm_content from defaults (shape excludes it)", () => {
|
|
132
|
+
// Even if a backend/SDK config erroneously contained utm_content
|
|
133
|
+
// at runtime, the merged result must not carry it.
|
|
134
|
+
expect(
|
|
135
|
+
mergeAttribution({
|
|
136
|
+
perCall: {},
|
|
137
|
+
// @ts-expect-error — defaults typing disallows utmContent,
|
|
138
|
+
// but we simulate runtime data coming from a loose source.
|
|
139
|
+
defaults: { utmContent: "should-not-leak" },
|
|
140
|
+
})
|
|
141
|
+
).toEqual({});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("adds attribution solely to carry a productUtmContent", () => {
|
|
145
|
+
expect(
|
|
146
|
+
mergeAttribution({
|
|
147
|
+
perCall: undefined,
|
|
148
|
+
productUtmContent: "product-7",
|
|
149
|
+
})
|
|
150
|
+
).toEqual({ utmContent: "product-7" });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { AttributionDefaults, AttributionParams } from "../types/tracking";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inputs for {@link mergeAttribution}.
|
|
5
|
+
*/
|
|
6
|
+
export type MergeAttributionInput = {
|
|
7
|
+
/**
|
|
8
|
+
* Per-call attribution override passed to actions like `displaySharingPage`.
|
|
9
|
+
*
|
|
10
|
+
* - `null` explicitly disables attribution (no UTM/ref/via params are added).
|
|
11
|
+
* - `undefined` means "no per-call override" — defaults apply if present.
|
|
12
|
+
* - An object (including `{}`) merges field-by-field with defaults.
|
|
13
|
+
*/
|
|
14
|
+
perCall: AttributionParams | null | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Pre-merged merchant-level defaults (backend config > SDK static config).
|
|
17
|
+
* `utm_content` is intentionally absent from this shape.
|
|
18
|
+
*/
|
|
19
|
+
defaults?: AttributionDefaults;
|
|
20
|
+
/**
|
|
21
|
+
* Per-product `utm_content` override (from the currently selected
|
|
22
|
+
* `SharingPageProduct`). Takes precedence over `perCall.utmContent`.
|
|
23
|
+
*/
|
|
24
|
+
productUtmContent?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Merge the three attribution layers into a single {@link AttributionParams}
|
|
29
|
+
* value suitable for `FrakContextManager.update`.
|
|
30
|
+
*
|
|
31
|
+
* Priority per field:
|
|
32
|
+
* 1. `perCall` (wins)
|
|
33
|
+
* 2. `defaults` (merchant-level, backend > SDK static, already pre-merged)
|
|
34
|
+
* 3. Hardcoded fallbacks resolved later by `FrakContextManager`
|
|
35
|
+
*
|
|
36
|
+
* Special rules:
|
|
37
|
+
* - `perCall === null` returns `undefined` (explicit disable: no UTM/ref/via).
|
|
38
|
+
* - `perCall === undefined` (no opinion) yields at least `{}` so `FrakContextManager`
|
|
39
|
+
* applies its hardcoded defaults (utm_source=frak, utm_medium=referral,
|
|
40
|
+
* utm_campaign=<merchantId>, ref=<clientId>, via=frak).
|
|
41
|
+
* - `utm_content` never comes from `defaults`; only `productUtmContent` or
|
|
42
|
+
* `perCall.utmContent` can populate it.
|
|
43
|
+
*/
|
|
44
|
+
export function mergeAttribution({
|
|
45
|
+
perCall,
|
|
46
|
+
defaults,
|
|
47
|
+
productUtmContent,
|
|
48
|
+
}: MergeAttributionInput): AttributionParams | undefined {
|
|
49
|
+
// Explicit disable
|
|
50
|
+
if (perCall === null) return undefined;
|
|
51
|
+
|
|
52
|
+
const hasPerCall = perCall !== undefined;
|
|
53
|
+
const hasDefaults =
|
|
54
|
+
defaults !== undefined && Object.keys(defaults).length > 0;
|
|
55
|
+
const hasProductUtm =
|
|
56
|
+
productUtmContent !== undefined && productUtmContent !== "";
|
|
57
|
+
|
|
58
|
+
if (!hasPerCall && !hasDefaults && !hasProductUtm) return undefined;
|
|
59
|
+
|
|
60
|
+
// Per-field merge: per-call wins over defaults.
|
|
61
|
+
const merged: AttributionParams = {
|
|
62
|
+
...defaults,
|
|
63
|
+
...(perCall ?? {}),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// utm_content priority: productUtmContent > perCall.utmContent; never from defaults.
|
|
67
|
+
const utmContent = productUtmContent ?? perCall?.utmContent;
|
|
68
|
+
if (utmContent !== undefined && utmContent !== "") {
|
|
69
|
+
merged.utmContent = utmContent;
|
|
70
|
+
} else {
|
|
71
|
+
delete merged.utmContent;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return merged;
|
|
75
|
+
}
|