@frak-labs/core-sdk 1.0.0-beta.bd7d4823 → 1.0.0-beta.cb2a3a9a
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/cdn/bundle.js +3 -3
- package/dist/actions-B-TIi011.cjs +1 -0
- package/dist/actions-IId7MAgk.js +1 -0
- package/dist/actions.cjs +1 -1
- package/dist/actions.d.cts +2 -2
- package/dist/actions.d.ts +2 -2
- 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/{index-Dwmo109y.d.cts → index-B1NOuCFq.d.ts} +158 -36
- package/dist/{index-BphwTmKA.d.cts → index-CGyEOo9J.d.cts} +1 -1
- package/dist/{index-_f8EuN_1.d.ts → index-Cdf5j2_W.d.ts} +1 -1
- package/dist/{index-BV5D9DsW.d.ts → index-CjEdFSJU.d.cts} +158 -36
- 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-BwEK2M98.d.cts → openSso-B6pD2oA6.d.ts} +99 -4
- package/dist/{openSso-C1Wzl5-i.d.ts → openSso-qjaccFd0.d.cts} +98 -3
- package/dist/src-CePGcxy2.js +13 -0
- package/dist/src-D6ad7fS-.cjs +13 -0
- package/dist/trackEvent-DmZ8n4tR.js +1 -0
- package/dist/trackEvent-DsMwWkUv.cjs +1 -0
- package/package.json +2 -2
- package/src/actions/referral/processReferral.test.ts +4 -8
- package/src/actions/referral/processReferral.ts +5 -11
- package/src/clients/createIFrameFrakClient.ts +84 -3
- package/src/index.ts +8 -1
- package/src/types/config.ts +9 -0
- package/src/types/index.ts +2 -0
- package/src/types/lifecycle/client.ts +7 -0
- package/src/types/resolvedConfig.ts +10 -0
- package/src/types/rpc/displaySharingPage.ts +18 -0
- package/src/types/tracking.ts +36 -0
- package/src/utils/FrakContext.test.ts +151 -0
- package/src/utils/FrakContext.ts +67 -1
- package/src/utils/analytics/events/component.ts +58 -0
- package/src/utils/analytics/events/index.ts +20 -0
- package/src/utils/analytics/events/lifecycle.ts +26 -0
- package/src/utils/analytics/events/referral.ts +10 -0
- package/src/utils/analytics/index.ts +8 -0
- package/src/utils/{trackEvent.test.ts → analytics/trackEvent.test.ts} +19 -30
- package/src/utils/analytics/trackEvent.ts +34 -0
- package/src/utils/index.ts +5 -1
- package/src/utils/mergeAttribution.test.ts +153 -0
- package/src/utils/mergeAttribution.ts +75 -0
- package/dist/actions-D4aBXbdp.cjs +0 -1
- package/dist/actions-Dq_uN-wn.js +0 -1
- package/dist/src-BorHuT1g.cjs +0 -13
- package/dist/src-aPlQD0ZW.js +0 -13
- package/dist/trackEvent-BqJqRZ-u.cjs +0 -1
- package/dist/trackEvent-Bqq4jd6R.js +0 -1
- package/src/utils/trackEvent.ts +0 -41
|
@@ -80,6 +80,10 @@ export function createIFrameFrakClient({
|
|
|
80
80
|
// Resolved after first resolved-config is sent to iframe (prevents RPC before context exists)
|
|
81
81
|
const contextSent = new Deferred<void>();
|
|
82
82
|
|
|
83
|
+
// Handshake timing: measured from client creation until the iframe
|
|
84
|
+
// lifecycle manager resolves the `isConnected` promise.
|
|
85
|
+
const handshakeStartedAt = Date.now();
|
|
86
|
+
|
|
83
87
|
// Create our debug info gatherer
|
|
84
88
|
const debugInfo = new DebugInfoGatherer(config, iframe);
|
|
85
89
|
|
|
@@ -180,6 +184,38 @@ export function createIFrameFrakClient({
|
|
|
180
184
|
userAnonymousClientId: getClientId(),
|
|
181
185
|
});
|
|
182
186
|
openPanel.init();
|
|
187
|
+
openPanel.track("sdk_initialized", {
|
|
188
|
+
sdkVersion: process.env.SDK_VERSION,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Race the connection against the heartbeat timeout so we can
|
|
192
|
+
// distinguish "connected" from "timeout" cleanly without touching
|
|
193
|
+
// the heartbeat plumbing. 30s matches `HEARTBEAT_TIMEOUT`.
|
|
194
|
+
let settled = false;
|
|
195
|
+
const timeoutHandle = setTimeout(() => {
|
|
196
|
+
if (settled) return;
|
|
197
|
+
settled = true;
|
|
198
|
+
openPanel?.track("sdk_iframe_handshake_failed", {
|
|
199
|
+
reason: "timeout",
|
|
200
|
+
});
|
|
201
|
+
}, 30_000);
|
|
202
|
+
lifecycleManager.isConnected
|
|
203
|
+
.then(() => {
|
|
204
|
+
if (settled) return;
|
|
205
|
+
settled = true;
|
|
206
|
+
clearTimeout(timeoutHandle);
|
|
207
|
+
openPanel?.track("sdk_iframe_connected", {
|
|
208
|
+
handshake_duration_ms: Date.now() - handshakeStartedAt,
|
|
209
|
+
});
|
|
210
|
+
})
|
|
211
|
+
.catch(() => {
|
|
212
|
+
if (settled) return;
|
|
213
|
+
settled = true;
|
|
214
|
+
clearTimeout(timeoutHandle);
|
|
215
|
+
openPanel?.track("sdk_iframe_handshake_failed", {
|
|
216
|
+
reason: "unknown",
|
|
217
|
+
});
|
|
218
|
+
});
|
|
183
219
|
}
|
|
184
220
|
|
|
185
221
|
// Perform the post connection setup
|
|
@@ -189,6 +225,7 @@ export function createIFrameFrakClient({
|
|
|
189
225
|
lifecycleManager,
|
|
190
226
|
configPromise,
|
|
191
227
|
contextSent,
|
|
228
|
+
openPanel,
|
|
192
229
|
})
|
|
193
230
|
.then(() => debugInfo.updateSetupStatus(true))
|
|
194
231
|
.catch((err) => {
|
|
@@ -273,12 +310,14 @@ async function postConnectionSetup({
|
|
|
273
310
|
lifecycleManager,
|
|
274
311
|
configPromise,
|
|
275
312
|
contextSent,
|
|
313
|
+
openPanel,
|
|
276
314
|
}: {
|
|
277
315
|
config: FrakWalletSdkConfig;
|
|
278
316
|
rpcClient: SdkRpcClient;
|
|
279
317
|
lifecycleManager: IframeLifecycleManager;
|
|
280
318
|
configPromise: Promise<MerchantConfigResult> | undefined;
|
|
281
319
|
contextSent: Deferred<void>;
|
|
320
|
+
openPanel: OpenPanel | undefined;
|
|
282
321
|
}): Promise<void> {
|
|
283
322
|
await lifecycleManager.isConnected;
|
|
284
323
|
|
|
@@ -300,6 +339,12 @@ async function postConnectionSetup({
|
|
|
300
339
|
const allowedDomains = merchantConfig?.allowedDomains ?? [];
|
|
301
340
|
const raw = merchantConfig?.sdkConfig;
|
|
302
341
|
|
|
342
|
+
// Per-field merge: backend wins over SDK static config.
|
|
343
|
+
const mergedAttribution =
|
|
344
|
+
raw?.attribution || config.attribution
|
|
345
|
+
? { ...config.attribution, ...raw?.attribution }
|
|
346
|
+
: undefined;
|
|
347
|
+
|
|
303
348
|
sdkConfigStore.setConfig(
|
|
304
349
|
raw
|
|
305
350
|
? {
|
|
@@ -319,6 +364,7 @@ async function postConnectionSetup({
|
|
|
319
364
|
translations: raw.translations,
|
|
320
365
|
placements: raw.placements,
|
|
321
366
|
components: raw.components,
|
|
367
|
+
attribution: mergedAttribution,
|
|
322
368
|
}
|
|
323
369
|
: {
|
|
324
370
|
isResolved: true,
|
|
@@ -330,11 +376,16 @@ async function postConnectionSetup({
|
|
|
330
376
|
homepageLink: config.metadata.homepageLink,
|
|
331
377
|
lang: config.metadata.lang,
|
|
332
378
|
currency: config.metadata.currency,
|
|
379
|
+
attribution: mergedAttribution,
|
|
333
380
|
}
|
|
334
381
|
);
|
|
335
382
|
};
|
|
336
383
|
|
|
337
|
-
// Send the resolved-config lifecycle event to the iframe
|
|
384
|
+
// Send the resolved-config lifecycle event to the iframe.
|
|
385
|
+
// This is where we also update SDK-side OpenPanel global props with
|
|
386
|
+
// `merchantId` + `domain` (first time they are known) so every
|
|
387
|
+
// subsequent SDK event is merchant-attributed. We pass
|
|
388
|
+
// `sdkAnonymousId` through so the listener can join SDK funnels.
|
|
338
389
|
let mergeTokenConsumed = false;
|
|
339
390
|
const sendLifecycleConfig = (resolved: SdkResolvedConfig) => {
|
|
340
391
|
const token = mergeTokenConsumed ? undefined : pendingMergeToken;
|
|
@@ -351,8 +402,22 @@ async function postConnectionSetup({
|
|
|
351
402
|
css: resolved.css,
|
|
352
403
|
translations: resolved.translations,
|
|
353
404
|
placements: resolved.placements,
|
|
405
|
+
attribution: resolved.attribution,
|
|
354
406
|
}
|
|
355
|
-
:
|
|
407
|
+
: resolved.attribution
|
|
408
|
+
? { attribution: resolved.attribution }
|
|
409
|
+
: undefined;
|
|
410
|
+
|
|
411
|
+
const sdkAnonymousId = getClientId();
|
|
412
|
+
|
|
413
|
+
if (openPanel) {
|
|
414
|
+
const current = openPanel.global ?? {};
|
|
415
|
+
openPanel.setGlobalProperties({
|
|
416
|
+
...current,
|
|
417
|
+
merchantId: resolved.merchantId,
|
|
418
|
+
domain: resolved.domain ?? "",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
356
421
|
|
|
357
422
|
rpcClient.sendLifecycle({
|
|
358
423
|
clientLifecycle: "resolved-config",
|
|
@@ -361,6 +426,7 @@ async function postConnectionSetup({
|
|
|
361
426
|
domain: resolved.domain ?? "",
|
|
362
427
|
allowedDomains: resolved.allowedDomains ?? [],
|
|
363
428
|
sourceUrl: window.location.href,
|
|
429
|
+
...(sdkAnonymousId && { sdkAnonymousId }),
|
|
364
430
|
...(token && { pendingMergeToken: token }),
|
|
365
431
|
...(sdkConfig && { sdkConfig }),
|
|
366
432
|
},
|
|
@@ -412,5 +478,20 @@ async function postConnectionSetup({
|
|
|
412
478
|
});
|
|
413
479
|
}
|
|
414
480
|
|
|
415
|
-
|
|
481
|
+
// Inspect each setup result — a failed CSS/i18n/backup push leaves the
|
|
482
|
+
// partner UI in a broken-but-connected state (iframe reports
|
|
483
|
+
// `sdk_iframe_connected`, user sees no modal styles / wrong locale).
|
|
484
|
+
// Surface it as a distinct handshake reason so dashboards can
|
|
485
|
+
// distinguish timeout vs. asset-push failures.
|
|
486
|
+
const results = await Promise.allSettled([
|
|
487
|
+
pushCss(),
|
|
488
|
+
pushI18n(),
|
|
489
|
+
pushBackup(),
|
|
490
|
+
]);
|
|
491
|
+
const hasFailedAssetPush = results.some((r) => r.status === "rejected");
|
|
492
|
+
if (hasFailedAssetPush) {
|
|
493
|
+
openPanel?.track("sdk_iframe_handshake_failed", {
|
|
494
|
+
reason: "asset_push",
|
|
495
|
+
});
|
|
496
|
+
}
|
|
416
497
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ export { type LocalesKey, locales } from "./constants/locales";
|
|
|
12
12
|
|
|
13
13
|
// Types
|
|
14
14
|
export type {
|
|
15
|
+
AttributionDefaults,
|
|
16
|
+
AttributionParams,
|
|
15
17
|
ClientLifecycleEvent,
|
|
16
18
|
CompressedData,
|
|
17
19
|
Currency,
|
|
@@ -100,7 +102,6 @@ export {
|
|
|
100
102
|
type DeepLinkFallbackOptions,
|
|
101
103
|
decompressJsonFromB64,
|
|
102
104
|
FrakContextManager,
|
|
103
|
-
type FrakEvent,
|
|
104
105
|
type FullSsoParams,
|
|
105
106
|
findIframeInOpener,
|
|
106
107
|
formatAmount,
|
|
@@ -115,6 +116,8 @@ export {
|
|
|
115
116
|
isFrakDeepLink,
|
|
116
117
|
isInAppBrowser,
|
|
117
118
|
isIOS,
|
|
119
|
+
type MergeAttributionInput,
|
|
120
|
+
mergeAttribution,
|
|
118
121
|
redirectToExternalBrowser,
|
|
119
122
|
sdkConfigStore,
|
|
120
123
|
toAndroidIntentUrl,
|
|
@@ -122,4 +125,8 @@ export {
|
|
|
122
125
|
triggerDeepLinkWithFallback,
|
|
123
126
|
withCache,
|
|
124
127
|
} from "./utils";
|
|
128
|
+
export type {
|
|
129
|
+
SdkEventMap,
|
|
130
|
+
SdkHandshakeFailureReason,
|
|
131
|
+
} from "./utils/analytics";
|
|
125
132
|
export { computeLegacyProductId } from "./utils/computeLegacyProductId";
|
package/src/types/config.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AttributionDefaults } from "./tracking";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* All the currencies available
|
|
3
5
|
* @category Config
|
|
@@ -78,6 +80,13 @@ export type FrakWalletSdkConfig = {
|
|
|
78
80
|
* @defaultValue true
|
|
79
81
|
*/
|
|
80
82
|
waitForBackendConfig?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Default attribution params (UTM / via / ref) appended to outbound
|
|
85
|
+
* sharing URLs. Per-call `displaySharingPage` overrides win, then backend
|
|
86
|
+
* config, then this SDK-level default. `utm_content` is intentionally
|
|
87
|
+
* excluded — it is per-content/per-product, never a merchant-wide default.
|
|
88
|
+
*/
|
|
89
|
+
attribution?: AttributionDefaults;
|
|
81
90
|
};
|
|
82
91
|
|
|
83
92
|
/**
|
package/src/types/index.ts
CHANGED
|
@@ -80,6 +80,8 @@ export type { UserReferralStatusType } from "./rpc/userReferralStatus";
|
|
|
80
80
|
export type { WalletStatusReturnType } from "./rpc/walletStatus";
|
|
81
81
|
// Tracking
|
|
82
82
|
export type {
|
|
83
|
+
AttributionDefaults,
|
|
84
|
+
AttributionParams,
|
|
83
85
|
TrackArrivalParams,
|
|
84
86
|
TrackArrivalResult,
|
|
85
87
|
UtmParams,
|
|
@@ -59,6 +59,13 @@ type ResolvedConfigEvent = {
|
|
|
59
59
|
* When present, listener should execute identity merge in background.
|
|
60
60
|
*/
|
|
61
61
|
pendingMergeToken?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Persistent per-origin anonymous id generated on the partner site
|
|
64
|
+
* (SDK-side localStorage). Propagated here so the listener can
|
|
65
|
+
* set it as an OpenPanel global property and stitch SDK events
|
|
66
|
+
* with listener events in the same funnel.
|
|
67
|
+
*/
|
|
68
|
+
sdkAnonymousId?: string;
|
|
62
69
|
sdkConfig?: ResolvedSdkConfig;
|
|
63
70
|
};
|
|
64
71
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Currency, Language } from "./config";
|
|
2
|
+
import type { AttributionDefaults } from "./tracking";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Response from the merchant resolve endpoint
|
|
@@ -80,6 +81,12 @@ export type ResolvedSdkConfig = {
|
|
|
80
81
|
placements?: Record<string, ResolvedPlacement>;
|
|
81
82
|
/** Global component defaults (used when no placement override exists) */
|
|
82
83
|
components?: ResolvedPlacement["components"];
|
|
84
|
+
/**
|
|
85
|
+
* Default attribution params applied when building outbound sharing URLs.
|
|
86
|
+
* Per-call overrides win over these backend defaults; `utm_content` is
|
|
87
|
+
* intentionally excluded (per-content/per-product, never a merchant default).
|
|
88
|
+
*/
|
|
89
|
+
attribution?: AttributionDefaults;
|
|
83
90
|
};
|
|
84
91
|
|
|
85
92
|
/**
|
|
@@ -125,4 +132,7 @@ export type SdkResolvedConfig = {
|
|
|
125
132
|
|
|
126
133
|
/** Global component defaults (fallback for placement-level overrides) */
|
|
127
134
|
components?: ResolvedPlacement["components"];
|
|
135
|
+
|
|
136
|
+
/** Merged attribution defaults: backend > SDK static config */
|
|
137
|
+
attribution?: AttributionDefaults;
|
|
128
138
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { InteractionTypeKey } from "../../constants/interactionTypes";
|
|
2
2
|
import type { I18nConfig } from "../config";
|
|
3
|
+
import type { AttributionParams } from "../tracking";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Product information to display on the sharing page
|
|
@@ -19,6 +20,11 @@ export type SharingPageProduct = {
|
|
|
19
20
|
* When provided and the product is selected, this link is used instead of the default sharing link
|
|
20
21
|
*/
|
|
21
22
|
link?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Optional `utm_content` value to apply when this product is selected.
|
|
25
|
+
* Falls back to the page-level `attribution.utmContent` when omitted.
|
|
26
|
+
*/
|
|
27
|
+
utmContent?: string;
|
|
22
28
|
};
|
|
23
29
|
|
|
24
30
|
/**
|
|
@@ -37,6 +43,18 @@ export type DisplaySharingPageParamsType = {
|
|
|
37
43
|
* If not provided, the sharing link will be generated from the current page URL + merchant context
|
|
38
44
|
*/
|
|
39
45
|
link?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Optional attribution overrides for the outbound sharing URL.
|
|
48
|
+
*
|
|
49
|
+
* When provided (even as an empty object), Frak adds standard affiliation
|
|
50
|
+
* params (`utm_source=frak`, `utm_medium=referral`, `utm_campaign=<merchantId>`,
|
|
51
|
+
* `ref=<clientId>`, `via=frak`) alongside `fCtx`. Existing UTMs on the base
|
|
52
|
+
* URL are preserved (gap-fill). Set this to `null` to disable attribution
|
|
53
|
+
* params entirely (only `fCtx` is added).
|
|
54
|
+
*
|
|
55
|
+
* @default {} — defaults applied
|
|
56
|
+
*/
|
|
57
|
+
attribution?: AttributionParams | null;
|
|
40
58
|
/**
|
|
41
59
|
* Optional metadata overrides for the sharing page
|
|
42
60
|
*/
|
package/src/types/tracking.ts
CHANGED
|
@@ -8,6 +8,42 @@ export type UtmParams = {
|
|
|
8
8
|
content?: string;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Attribution parameters appended to outbound sharing URLs.
|
|
13
|
+
*
|
|
14
|
+
* Defaults are derived from the V2 Frak context when available:
|
|
15
|
+
* - `utmSource`: `"frak"`
|
|
16
|
+
* - `utmMedium`: `"referral"`
|
|
17
|
+
* - `utmCampaign`: merchantId (`context.m`)
|
|
18
|
+
* - `via`: `"frak"`
|
|
19
|
+
* - `ref`: clientId (`context.c`)
|
|
20
|
+
*
|
|
21
|
+
* Fields explicitly set here override the defaults. Existing params on the
|
|
22
|
+
* base URL are preserved (gap-fill policy) to respect merchant-provided UTMs.
|
|
23
|
+
*/
|
|
24
|
+
export type AttributionParams = {
|
|
25
|
+
utmSource?: string;
|
|
26
|
+
utmMedium?: string;
|
|
27
|
+
utmCampaign?: string;
|
|
28
|
+
utmContent?: string;
|
|
29
|
+
utmTerm?: string;
|
|
30
|
+
via?: string;
|
|
31
|
+
ref?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Merchant-level attribution defaults.
|
|
36
|
+
*
|
|
37
|
+
* Same shape as {@link AttributionParams} minus `utmContent`, because
|
|
38
|
+
* `utm_content` describes the specific content/creative being shared and is
|
|
39
|
+
* inherently per-call or per-product (never a merchant-wide default).
|
|
40
|
+
*
|
|
41
|
+
* Used as the shape for both:
|
|
42
|
+
* - `FrakWalletSdkConfig.attribution` (SDK-side compile-time defaults)
|
|
43
|
+
* - Backend merchant-config attribution (dashboard-driven defaults)
|
|
44
|
+
*/
|
|
45
|
+
export type AttributionDefaults = Omit<AttributionParams, "utmContent">;
|
|
46
|
+
|
|
11
47
|
export type TrackArrivalParams = {
|
|
12
48
|
/** @deprecated V1 legacy — use referrerClientId for v2 contexts */
|
|
13
49
|
referrerWallet?: Address;
|
|
@@ -121,6 +121,157 @@ describe("FrakContextManager", () => {
|
|
|
121
121
|
expect(result).toContain("baz=qux");
|
|
122
122
|
expect(result).toContain("fCtx=");
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
describe("update with attribution", () => {
|
|
126
|
+
const url = "https://example.com/product";
|
|
127
|
+
|
|
128
|
+
it("should apply default attribution params when attribution is omitted", () => {
|
|
129
|
+
const result = FrakContextManager.update({
|
|
130
|
+
url,
|
|
131
|
+
context: v2Context,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result).toBeDefined();
|
|
135
|
+
expect(result).toContain("fCtx=");
|
|
136
|
+
const parsedUrl = new URL(result!);
|
|
137
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
138
|
+
"frak"
|
|
139
|
+
);
|
|
140
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
141
|
+
"referral"
|
|
142
|
+
);
|
|
143
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
144
|
+
v2Context.m
|
|
145
|
+
);
|
|
146
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
147
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should apply default attribution params when attribution is an empty object", () => {
|
|
151
|
+
const result = FrakContextManager.update({
|
|
152
|
+
url,
|
|
153
|
+
context: v2Context,
|
|
154
|
+
attribution: {},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result).toBeDefined();
|
|
158
|
+
const parsedUrl = new URL(result!);
|
|
159
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
160
|
+
"frak"
|
|
161
|
+
);
|
|
162
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
163
|
+
"referral"
|
|
164
|
+
);
|
|
165
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
166
|
+
v2Context.m
|
|
167
|
+
);
|
|
168
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
169
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
170
|
+
expect(
|
|
171
|
+
parsedUrl.searchParams.get("utm_content")
|
|
172
|
+
).toBeNull();
|
|
173
|
+
expect(parsedUrl.searchParams.get("utm_term")).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should honor overrides over defaults", () => {
|
|
177
|
+
const result = FrakContextManager.update({
|
|
178
|
+
url,
|
|
179
|
+
context: v2Context,
|
|
180
|
+
attribution: {
|
|
181
|
+
utmSource: "newsletter",
|
|
182
|
+
utmMedium: "email",
|
|
183
|
+
utmCampaign: "spring-sale",
|
|
184
|
+
utmContent: "hero-banner",
|
|
185
|
+
utmTerm: "wallet",
|
|
186
|
+
via: "partner",
|
|
187
|
+
ref: "alice",
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const parsedUrl = new URL(result!);
|
|
192
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
193
|
+
"newsletter"
|
|
194
|
+
);
|
|
195
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
196
|
+
"email"
|
|
197
|
+
);
|
|
198
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
199
|
+
"spring-sale"
|
|
200
|
+
);
|
|
201
|
+
expect(parsedUrl.searchParams.get("utm_content")).toBe(
|
|
202
|
+
"hero-banner"
|
|
203
|
+
);
|
|
204
|
+
expect(parsedUrl.searchParams.get("utm_term")).toBe(
|
|
205
|
+
"wallet"
|
|
206
|
+
);
|
|
207
|
+
expect(parsedUrl.searchParams.get("via")).toBe("partner");
|
|
208
|
+
expect(parsedUrl.searchParams.get("ref")).toBe("alice");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("should preserve merchant-provided UTMs on the base URL (gap-fill)", () => {
|
|
212
|
+
const baseUrl =
|
|
213
|
+
"https://example.com/product?utm_source=google&utm_campaign=merchant-spring";
|
|
214
|
+
const result = FrakContextManager.update({
|
|
215
|
+
url: baseUrl,
|
|
216
|
+
context: v2Context,
|
|
217
|
+
attribution: {},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const parsedUrl = new URL(result!);
|
|
221
|
+
// Merchant-provided values preserved
|
|
222
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
223
|
+
"google"
|
|
224
|
+
);
|
|
225
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
226
|
+
"merchant-spring"
|
|
227
|
+
);
|
|
228
|
+
// Missing ones filled by Frak defaults
|
|
229
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
230
|
+
"referral"
|
|
231
|
+
);
|
|
232
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should skip fields with empty-string overrides", () => {
|
|
236
|
+
const result = FrakContextManager.update({
|
|
237
|
+
url,
|
|
238
|
+
context: v2Context,
|
|
239
|
+
attribution: { utmContent: "", utmTerm: "" },
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const parsedUrl = new URL(result!);
|
|
243
|
+
expect(parsedUrl.searchParams.has("utm_content")).toBe(
|
|
244
|
+
false
|
|
245
|
+
);
|
|
246
|
+
expect(parsedUrl.searchParams.has("utm_term")).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("should skip context-derived defaults for V1 (no merchantId/clientId)", () => {
|
|
250
|
+
const v1Context: FrakContextV1 = {
|
|
251
|
+
r: "0x1234567890123456789012345678901234567890" as Address,
|
|
252
|
+
};
|
|
253
|
+
const result = FrakContextManager.update({
|
|
254
|
+
url,
|
|
255
|
+
context: v1Context,
|
|
256
|
+
attribution: {},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const parsedUrl = new URL(result!);
|
|
260
|
+
// Static defaults still applied
|
|
261
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
262
|
+
"frak"
|
|
263
|
+
);
|
|
264
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
265
|
+
"referral"
|
|
266
|
+
);
|
|
267
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
268
|
+
// No derivable values from V1
|
|
269
|
+
expect(parsedUrl.searchParams.has("utm_campaign")).toBe(
|
|
270
|
+
false
|
|
271
|
+
);
|
|
272
|
+
expect(parsedUrl.searchParams.has("ref")).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
124
275
|
});
|
|
125
276
|
});
|
|
126
277
|
|
package/src/utils/FrakContext.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { type Address, bytesToHex, hexToBytes, isAddress } from "viem";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AttributionParams,
|
|
4
|
+
FrakContext,
|
|
5
|
+
FrakContextV1,
|
|
6
|
+
FrakContextV2,
|
|
7
|
+
} from "../types";
|
|
3
8
|
import { isV2Context } from "../types";
|
|
4
9
|
import { base64urlDecode, base64urlEncode } from "./compression/b64";
|
|
5
10
|
import { compressJsonToB64 } from "./compression/compress";
|
|
@@ -91,20 +96,80 @@ function parse({ url }: { url: string }): FrakContext | null | undefined {
|
|
|
91
96
|
return decompress(frakContext);
|
|
92
97
|
}
|
|
93
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Default UTM medium value when attribution is requested.
|
|
101
|
+
*/
|
|
102
|
+
const DEFAULT_UTM_MEDIUM = "referral";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Default utm_source / via value when attribution is requested.
|
|
106
|
+
*/
|
|
107
|
+
const DEFAULT_ATTRIBUTION_SOURCE = "frak";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve attribution defaults from the provided context.
|
|
111
|
+
*
|
|
112
|
+
* V2 contexts expose the merchantId (`m`) and clientId (`c`), which feed
|
|
113
|
+
* `utm_campaign` and `ref` respectively. V1 contexts have no equivalent, so
|
|
114
|
+
* only the static defaults (`utm_source`, `utm_medium`, `via`) apply.
|
|
115
|
+
*/
|
|
116
|
+
function resolveAttributionValues(
|
|
117
|
+
context: FrakContextV1 | FrakContextV2,
|
|
118
|
+
overrides: AttributionParams
|
|
119
|
+
): Record<string, string | undefined> {
|
|
120
|
+
const isV2 = isV2Context(context);
|
|
121
|
+
return {
|
|
122
|
+
utm_source: overrides.utmSource ?? DEFAULT_ATTRIBUTION_SOURCE,
|
|
123
|
+
utm_medium: overrides.utmMedium ?? DEFAULT_UTM_MEDIUM,
|
|
124
|
+
utm_campaign: overrides.utmCampaign ?? (isV2 ? context.m : undefined),
|
|
125
|
+
utm_content: overrides.utmContent,
|
|
126
|
+
utm_term: overrides.utmTerm,
|
|
127
|
+
via: overrides.via ?? DEFAULT_ATTRIBUTION_SOURCE,
|
|
128
|
+
ref: overrides.ref ?? (isV2 ? context.c : undefined),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Append attribution query params to a URL using gap-fill semantics.
|
|
134
|
+
*
|
|
135
|
+
* Existing params on the URL are preserved untouched (so merchant-provided
|
|
136
|
+
* UTMs take precedence). Only missing keys are populated.
|
|
137
|
+
*/
|
|
138
|
+
function applyAttributionParams(
|
|
139
|
+
urlObj: URL,
|
|
140
|
+
context: FrakContextV1 | FrakContextV2,
|
|
141
|
+
attribution?: AttributionParams
|
|
142
|
+
): void {
|
|
143
|
+
const values = resolveAttributionValues(context, attribution ?? {});
|
|
144
|
+
for (const [key, value] of Object.entries(values)) {
|
|
145
|
+
if (value === undefined || value === "") continue;
|
|
146
|
+
if (urlObj.searchParams.has(key)) continue;
|
|
147
|
+
urlObj.searchParams.set(key, value);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
94
151
|
/**
|
|
95
152
|
* Add or replace the `fCtx` query parameter in a URL with the given context.
|
|
96
153
|
*
|
|
154
|
+
* Standard affiliation params (`utm_source`, `utm_medium`, `utm_campaign`,
|
|
155
|
+
* `ref`, `via`, ...) are always appended using gap-fill semantics: pre-existing
|
|
156
|
+
* params on the URL are preserved, defaults are derived from the context when
|
|
157
|
+
* applicable, and `attribution` overrides take precedence when provided.
|
|
158
|
+
*
|
|
97
159
|
* @param args
|
|
98
160
|
* @param args.url - The URL to update
|
|
99
161
|
* @param args.context - The context to embed (V1 or V2)
|
|
162
|
+
* @param args.attribution - Optional attribution overrides. Defaults are applied even when omitted.
|
|
100
163
|
* @returns The updated URL string, or null on failure
|
|
101
164
|
*/
|
|
102
165
|
function update({
|
|
103
166
|
url,
|
|
104
167
|
context,
|
|
168
|
+
attribution,
|
|
105
169
|
}: {
|
|
106
170
|
url?: string;
|
|
107
171
|
context: FrakContextV1 | FrakContextV2;
|
|
172
|
+
attribution?: AttributionParams;
|
|
108
173
|
}): string | null {
|
|
109
174
|
if (!url) return null;
|
|
110
175
|
|
|
@@ -113,6 +178,7 @@ function update({
|
|
|
113
178
|
|
|
114
179
|
const urlObj = new URL(url);
|
|
115
180
|
urlObj.searchParams.set(contextKey, compressedContext);
|
|
181
|
+
applyAttributionParams(urlObj, context, attribution);
|
|
116
182
|
return urlObj.toString();
|
|
117
183
|
}
|
|
118
184
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
type ButtonBaseProps = {
|
|
2
|
+
placement?: string;
|
|
3
|
+
target_interaction?: string;
|
|
4
|
+
has_reward?: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type BannerVariant = "referral" | "inapp";
|
|
8
|
+
type BannerOutcome = "clicked" | "dismissed";
|
|
9
|
+
type PostPurchaseVariant = "referrer" | "referee";
|
|
10
|
+
type ShareClickAction = "share-modal" | "embedded-wallet" | "sharing-page";
|
|
11
|
+
|
|
12
|
+
export type SdkComponentEventMap = {
|
|
13
|
+
// Share button — click carries the resolved action + reward presence so
|
|
14
|
+
// we can compare per-merchant configuration impact on conversion.
|
|
15
|
+
share_button_clicked: ButtonBaseProps & {
|
|
16
|
+
click_action: ShareClickAction;
|
|
17
|
+
};
|
|
18
|
+
share_modal_error: ButtonBaseProps & {
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Wallet button (floating) — NOT actively used in production. No tracking.
|
|
23
|
+
|
|
24
|
+
// Open in app — path lets us compare deep-link destinations once we add more.
|
|
25
|
+
open_in_app_clicked: {
|
|
26
|
+
placement?: string;
|
|
27
|
+
path: string;
|
|
28
|
+
};
|
|
29
|
+
app_not_installed: {
|
|
30
|
+
placement?: string;
|
|
31
|
+
path: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Banner — referral vs in-app variants share the funnel shape.
|
|
35
|
+
banner_impression: {
|
|
36
|
+
placement?: string;
|
|
37
|
+
variant: BannerVariant;
|
|
38
|
+
has_reward?: boolean;
|
|
39
|
+
};
|
|
40
|
+
banner_resolved: {
|
|
41
|
+
placement?: string;
|
|
42
|
+
variant: BannerVariant;
|
|
43
|
+
outcome: BannerOutcome;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Post-purchase — the card drives the highest-intent entry into the
|
|
47
|
+
// referral loop; variant tells us whether we upsold a new share or
|
|
48
|
+
// celebrated an existing referee.
|
|
49
|
+
post_purchase_impression: {
|
|
50
|
+
placement?: string;
|
|
51
|
+
variant: PostPurchaseVariant;
|
|
52
|
+
has_reward?: boolean;
|
|
53
|
+
};
|
|
54
|
+
post_purchase_clicked: {
|
|
55
|
+
placement?: string;
|
|
56
|
+
variant: PostPurchaseVariant;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SdkComponentEventMap } from "./component";
|
|
2
|
+
import type { SdkLifecycleEventMap } from "./lifecycle";
|
|
3
|
+
import type { SdkReferralEventMap } from "./referral";
|
|
4
|
+
|
|
5
|
+
export type { SdkComponentEventMap } from "./component";
|
|
6
|
+
export type {
|
|
7
|
+
SdkHandshakeFailureReason,
|
|
8
|
+
SdkLifecycleEventMap,
|
|
9
|
+
} from "./lifecycle";
|
|
10
|
+
export type { SdkReferralEventMap } from "./referral";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Merged SDK event map. Consumed by the SDK's typed `trackEvent`.
|
|
14
|
+
* Stays isolated from wallet-shared because the SDK ships in partner
|
|
15
|
+
* bundles (different OpenPanel client id, no wallet-shared dependency
|
|
16
|
+
* allowed — see `packages/wallet-shared/AGENTS.md`).
|
|
17
|
+
*/
|
|
18
|
+
export type SdkEventMap = SdkLifecycleEventMap &
|
|
19
|
+
SdkComponentEventMap &
|
|
20
|
+
SdkReferralEventMap;
|