@frak-labs/core-sdk 0.2.1-beta.eb3cff34 → 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/cdn/bundle.js +3 -3
- package/dist/{actions-Dq_uN-wn.js → actions-BMTVobuH.js} +1 -1
- package/dist/{actions-D4aBXbdp.cjs → actions-ukNCM0d7.cjs} +1 -1
- 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-BV5D9DsW.d.ts → index-BCwGNRmk.d.cts} +59 -27
- package/dist/{index-Dwmo109y.d.cts → index-BfmJnxzo.d.ts} +59 -27
- package/dist/{index-BphwTmKA.d.cts → index-CVnwk1E_.d.cts} +1 -1
- package/dist/{index-_f8EuN_1.d.ts → index-DZuYiI2M.d.ts} +1 -1
- 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-BQ-q-_Y9.d.ts} +92 -4
- package/dist/{openSso-C1Wzl5-i.d.ts → openSso-CMBCbhvP.d.cts} +91 -3
- 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 +2 -2
- package/src/clients/createIFrameFrakClient.ts +12 -1
- package/src/index.ts +4 -0
- package/src/types/config.ts +9 -0
- package/src/types/index.ts +2 -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 +144 -0
- package/src/utils/FrakContext.ts +67 -1
- package/src/utils/index.ts +4 -0
- package/src/utils/mergeAttribution.test.ts +153 -0
- package/src/utils/mergeAttribution.ts +75 -0
- package/dist/src-B3Dusips.cjs +0 -13
- package/dist/src-CnnhYPyK.js +0 -13
- package/dist/trackEvent-BqJqRZ-u.cjs +0 -1
- package/dist/trackEvent-Bqq4jd6R.js +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{bytesToHex as e,hexToBytes as t,isAddress as n,keccak256 as r,toHex as i}from"viem";import{jsonDecode as a,jsonEncode as o}from"@frak-labs/frame-connector";const s=`frak-client-id`;function c(){return typeof crypto<`u`&&crypto.randomUUID?crypto.randomUUID():`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`.replace(/[xy]/g,e=>{let t=Math.random()*16|0;return(e===`x`?t:t&3|8).toString(16)})}function l(){if(typeof window>`u`||!window.localStorage)return console.warn(`[Frak SDK] No Window / localStorage available to save the clientId`),c();let e=localStorage.getItem(s);return e||(e=c(),localStorage.setItem(s,e)),e}function u({domain:e}={}){return r(i((e??window.location.host).replace(`www.`,``)))}function d(e){return btoa(Array.from(e,e=>String.fromCharCode(e)).join(``)).replace(/\+/g,`-`).replace(/\//g,`_`).replace(/=+$/,``)}function f(e){let t=e.length%4;return Uint8Array.from(atob(e.replace(/-/g,`+`).replace(/_/g,`/`).padEnd(e.length+(t===0?0:4-t),`=`)),e=>e.charCodeAt(0))}function p(e){return d(o(e))}function m(e,t,n,r,i,a){let o=p(h({redirectUrl:t.redirectUrl,directExit:t.directExit,lang:t.lang,merchantId:n,metadata:{name:r,css:a,logoUrl:t.metadata?.logoUrl,homepageLink:t.metadata?.homepageLink},clientId:i})),s=new URL(e);return s.pathname=`/sso`,s.searchParams.set(`p`,o),s.toString()}function h(e){return{r:e.redirectUrl,cId:e.clientId,d:e.directExit,l:e.lang,m:e.merchantId,md:{n:e.metadata?.name,css:e.metadata?.css,l:e.metadata?.logoUrl,h:e.metadata?.homepageLink}}}const g=`menubar=no,status=no,scrollbars=no,fullscreen=no,width=500, height=800`,_=`frak-sso`;async function ee(e,t){let{metadata:n,customizations:r,walletUrl:i}=e.config;if(t.openInSameWindow??!!t.redirectUrl)return await e.request({method:`frak_openSso`,params:[t,n.name,r?.css]});let a=t.ssoPopupUrl??m(i??`https://wallet.frak.id`,t,u(),n.name,l(),r?.css),o=window.open(a,_,g);if(!o)throw Error(`Popup was blocked. Please allow popups for this site.`);return o.focus(),await e.request({method:`frak_openSso`,params:[t,n.name,r?.css]})??{}}const v=`https://backend.frak.id`;function te(e){return e.includes(`localhost:3000`)||e.includes(`localhost:3010`)}function y(e){return te(e)?`https://localhost:3030`:e.includes(`wallet-dev.frak.id`)||e.includes(`wallet.gcp-dev.frak.id`)?`https://backend.gcp-dev.frak.id`:v}function b(e){if(e)return y(e);if(typeof window<`u`){let e=window.FrakSetup?.client?.config?.walletUrl;if(e)return y(e)}return v}var x=class extends Map{maxSize;constructor(e){super(),this.maxSize=e}get(e){let t=super.get(e);return super.has(e)&&(super.delete(e),super.set(e,t)),t}set(e,t){if(super.has(e)&&super.delete(e),super.set(e,t),this.maxSize&&this.size>this.maxSize){let e=super.keys().next().value;e!==void 0&&super.delete(e)}return this}};const S=new x(1024),C=new x(1024),w=3e4,T=new x(1024);async function E(e,{cacheKey:t,cacheTime:n=w}){if(n>0){let e=C.get(t);if(e&&Date.now()-e.created<n)return e.data}let r=T.get(t);if(r&&Date.now()-r<1e3)throw Error(`Cache: ${t} recently failed, backing off`);let i=S.get(t);i||(i=e(),S.set(t,i));try{let e=await i;return C.set(t,{data:e,created:Date.now()}),T.delete(t),e}catch(e){throw T.set(t,Date.now()),e}finally{S.delete(t)}}function D(e){return{clear:()=>{S.delete(e),C.delete(e)},has:(t=w)=>{let n=C.get(e);return n?Date.now()-n.created<t:!1}}}function O(){S.clear(),C.clear(),T.clear()}function k(e){return a(f(e))}function A(e){return`r`in e&&!(`v`in e)}function j(e){return`v`in e&&e.v===2}const M=`fCtx`;function N(e){if(e)try{return j(e)?!e.c||!e.m||!e.t?void 0:p({v:2,c:e.c,m:e.m,t:e.t}):d(t(e.r))}catch(t){console.error(`Error compressing Frak context`,{e:t,context:e})}}function P(t){if(!(!t||t.length===0))try{let r=k(t);if(r&&typeof r==`object`&&r.v===2)return r.c&&r.m&&r.t?{v:2,c:r.c,m:r.m,t:r.t}:void 0;let i=e(f(t),{size:20});if(n(i))return{r:i}}catch(e){console.error(`Error decompressing Frak context`,{e,context:t})}}function ne({url:e}){if(!e)return null;let t=new URL(e).searchParams.get(M);return t?P(t):null}const F=`frak`;function I(e,t){let n=j(e);return{utm_source:t.utmSource??F,utm_medium:t.utmMedium??`referral`,utm_campaign:t.utmCampaign??(n?e.m:void 0),utm_content:t.utmContent,utm_term:t.utmTerm,via:t.via??F,ref:t.ref??(n?e.c:void 0)}}function L(e,t,n){let r=I(t,n??{});for(let[t,n]of Object.entries(r))n===void 0||n===``||e.searchParams.has(t)||e.searchParams.set(t,n)}function R({url:e,context:t,attribution:n}){if(!e)return null;let r=N(t);if(!r)return null;let i=new URL(e);return i.searchParams.set(M,r),L(i,t,n),i.toString()}function z(e){let t=new URL(e);return t.searchParams.delete(M),t.toString()}function B({url:e,context:t}){if(!window.location?.href||typeof window>`u`){console.error(`No window found, can't update context`);return}let n=e??window.location.href,r;r=t===null?z(n):R({url:n,context:t}),r&&window.history.replaceState(null,``,r.toString())}const re={compress:N,decompress:P,parse:ne,update:R,remove:z,replaceUrl:B},V=`__frakSdkConfig`,H=`frak-config-cache`,U=`frak-merchant-id`,W={key:H},G=typeof window<`u`;function K(){return{isResolved:!1,merchantId:``}}let q=null;function J(){if(!G)return null;try{let e=localStorage.getItem(W.key);if(!e)return null;let t=JSON.parse(e);return t.config?.isResolved?(q=t,t):null}catch{return null}}function Y(){return(q??J())?.config}function ie(){let e=q??J();return e?Date.now()-e.timestamp<3e4:!1}function ae(e){if(!(!G||!e.isResolved))try{let t={config:e,timestamp:Date.now()};localStorage.setItem(W.key,JSON.stringify(t)),q=t}catch{}}function oe(){if(G){q=null;try{localStorage.removeItem(W.key)}catch{}}}function se(){G&&(window[V]||(window[V]=Y()??K()))}se();function X(){return G?window[V]??K():K()}function Z(e){G&&window.dispatchEvent(new CustomEvent(`frak:config`,{detail:e}))}function Q(e){return e??(G?window.location.hostname:``)}async function ce(e,t,n){try{let r=b(t),i=n?`&lang=${encodeURIComponent(n)}`:``,a=await fetch(`${r}/user/merchant/resolve?domain=${encodeURIComponent(e)}${i}`);if(!a.ok){console.warn(`[Frak SDK] Merchant lookup failed for domain ${e}: ${a.status}`);return}let o=await a.json();if(G)try{sessionStorage.setItem(U,o.merchantId)}catch{}return o}catch(e){console.warn(`[Frak SDK] Failed to fetch merchant config:`,e);return}}const $={getConfig:X,get isResolved(){return X().isResolved},get isCacheFresh(){return ie()},setCacheScope(e,t){W.key=`${H}:${`${e}:${t??``}`}`,q=null},setConfig(e){if(G&&(window[V]=e),ae(e),Z(e),G&&e.merchantId)try{sessionStorage.setItem(U,e.merchantId)}catch{}},reset(){let e=Y()??K();G&&(window[V]=e),Z(e)},clearCache(){if(oe(),O(),G)try{sessionStorage.removeItem(U)}catch{}},resolve(e,t,n){let r=Q(e);return r?E(async()=>{let e=await ce(r,t,n);if(!e)throw Error(`Config resolution returned empty`);return e},{cacheKey:`sdkConfig:${r}:${n??``}`,cacheTime:1/0}).catch(()=>void 0):Promise.resolve(void 0)},getMerchantId(){let e=X();if(e.isResolved&&e.merchantId)return e.merchantId;if(G)try{return sessionStorage.getItem(U)??void 0}catch{}},async resolveMerchantId(e,t){return $.getMerchantId()||(await $.resolve(e,t))?.merchantId}};function le(e,t,n={}){if(!e){console.debug(`[Frak] No client provided, skipping event tracking`);return}try{e.openPanel?.track(t,n)}catch(e){console.debug(`[Frak] Failed to track event:`,t,e)}}export{d as _,j as a,D as c,ee as d,g as f,f as g,p as h,A as i,E as l,m,$ as n,k as o,_ as p,re as r,O as s,le as t,b as u,u as v,l as y};
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"url": "https://twitter.com/QNivelais"
|
|
12
12
|
}
|
|
13
13
|
],
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "1.0.0-beta.0cd79998",
|
|
15
15
|
"description": "Core SDK of the Frak wallet, low level library to interact directly with the frak ecosystem.",
|
|
16
16
|
"repository": {
|
|
17
17
|
"url": "https://github.com/frak-id/wallet",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"viem": "^2.x"
|
|
92
92
|
},
|
|
93
93
|
"dependencies": {
|
|
94
|
-
"@frak-labs/frame-connector": "0.2.0-beta.
|
|
94
|
+
"@frak-labs/frame-connector": "0.2.0-beta.0cd79998",
|
|
95
95
|
"@openpanel/web": "^1.2.0"
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
@@ -300,6 +300,12 @@ async function postConnectionSetup({
|
|
|
300
300
|
const allowedDomains = merchantConfig?.allowedDomains ?? [];
|
|
301
301
|
const raw = merchantConfig?.sdkConfig;
|
|
302
302
|
|
|
303
|
+
// Per-field merge: backend wins over SDK static config.
|
|
304
|
+
const mergedAttribution =
|
|
305
|
+
raw?.attribution || config.attribution
|
|
306
|
+
? { ...config.attribution, ...raw?.attribution }
|
|
307
|
+
: undefined;
|
|
308
|
+
|
|
303
309
|
sdkConfigStore.setConfig(
|
|
304
310
|
raw
|
|
305
311
|
? {
|
|
@@ -319,6 +325,7 @@ async function postConnectionSetup({
|
|
|
319
325
|
translations: raw.translations,
|
|
320
326
|
placements: raw.placements,
|
|
321
327
|
components: raw.components,
|
|
328
|
+
attribution: mergedAttribution,
|
|
322
329
|
}
|
|
323
330
|
: {
|
|
324
331
|
isResolved: true,
|
|
@@ -330,6 +337,7 @@ async function postConnectionSetup({
|
|
|
330
337
|
homepageLink: config.metadata.homepageLink,
|
|
331
338
|
lang: config.metadata.lang,
|
|
332
339
|
currency: config.metadata.currency,
|
|
340
|
+
attribution: mergedAttribution,
|
|
333
341
|
}
|
|
334
342
|
);
|
|
335
343
|
};
|
|
@@ -351,8 +359,11 @@ async function postConnectionSetup({
|
|
|
351
359
|
css: resolved.css,
|
|
352
360
|
translations: resolved.translations,
|
|
353
361
|
placements: resolved.placements,
|
|
362
|
+
attribution: resolved.attribution,
|
|
354
363
|
}
|
|
355
|
-
:
|
|
364
|
+
: resolved.attribution
|
|
365
|
+
? { attribution: resolved.attribution }
|
|
366
|
+
: undefined;
|
|
356
367
|
|
|
357
368
|
rpcClient.sendLifecycle({
|
|
358
369
|
clientLifecycle: "resolved-config",
|
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,
|
|
@@ -115,6 +117,8 @@ export {
|
|
|
115
117
|
isFrakDeepLink,
|
|
116
118
|
isInAppBrowser,
|
|
117
119
|
isIOS,
|
|
120
|
+
type MergeAttributionInput,
|
|
121
|
+
mergeAttribution,
|
|
118
122
|
redirectToExternalBrowser,
|
|
119
123
|
sdkConfigStore,
|
|
120
124
|
toAndroidIntentUrl,
|
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,
|
|
@@ -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,150 @@ 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 not add 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
|
+
expect(result).not.toContain("utm_source");
|
|
137
|
+
expect(result).not.toContain("utm_medium");
|
|
138
|
+
expect(result).not.toContain("utm_campaign");
|
|
139
|
+
expect(result).not.toContain("ref=");
|
|
140
|
+
expect(result).not.toContain("via=");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should apply default attribution params when attribution is an empty object", () => {
|
|
144
|
+
const result = FrakContextManager.update({
|
|
145
|
+
url,
|
|
146
|
+
context: v2Context,
|
|
147
|
+
attribution: {},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(result).toBeDefined();
|
|
151
|
+
const parsedUrl = new URL(result!);
|
|
152
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
153
|
+
"frak"
|
|
154
|
+
);
|
|
155
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
156
|
+
"referral"
|
|
157
|
+
);
|
|
158
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
159
|
+
v2Context.m
|
|
160
|
+
);
|
|
161
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
162
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
163
|
+
expect(
|
|
164
|
+
parsedUrl.searchParams.get("utm_content")
|
|
165
|
+
).toBeNull();
|
|
166
|
+
expect(parsedUrl.searchParams.get("utm_term")).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should honor overrides over defaults", () => {
|
|
170
|
+
const result = FrakContextManager.update({
|
|
171
|
+
url,
|
|
172
|
+
context: v2Context,
|
|
173
|
+
attribution: {
|
|
174
|
+
utmSource: "newsletter",
|
|
175
|
+
utmMedium: "email",
|
|
176
|
+
utmCampaign: "spring-sale",
|
|
177
|
+
utmContent: "hero-banner",
|
|
178
|
+
utmTerm: "wallet",
|
|
179
|
+
via: "partner",
|
|
180
|
+
ref: "alice",
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const parsedUrl = new URL(result!);
|
|
185
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
186
|
+
"newsletter"
|
|
187
|
+
);
|
|
188
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
189
|
+
"email"
|
|
190
|
+
);
|
|
191
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
192
|
+
"spring-sale"
|
|
193
|
+
);
|
|
194
|
+
expect(parsedUrl.searchParams.get("utm_content")).toBe(
|
|
195
|
+
"hero-banner"
|
|
196
|
+
);
|
|
197
|
+
expect(parsedUrl.searchParams.get("utm_term")).toBe(
|
|
198
|
+
"wallet"
|
|
199
|
+
);
|
|
200
|
+
expect(parsedUrl.searchParams.get("via")).toBe("partner");
|
|
201
|
+
expect(parsedUrl.searchParams.get("ref")).toBe("alice");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should preserve merchant-provided UTMs on the base URL (gap-fill)", () => {
|
|
205
|
+
const baseUrl =
|
|
206
|
+
"https://example.com/product?utm_source=google&utm_campaign=merchant-spring";
|
|
207
|
+
const result = FrakContextManager.update({
|
|
208
|
+
url: baseUrl,
|
|
209
|
+
context: v2Context,
|
|
210
|
+
attribution: {},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const parsedUrl = new URL(result!);
|
|
214
|
+
// Merchant-provided values preserved
|
|
215
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
216
|
+
"google"
|
|
217
|
+
);
|
|
218
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
219
|
+
"merchant-spring"
|
|
220
|
+
);
|
|
221
|
+
// Missing ones filled by Frak defaults
|
|
222
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
223
|
+
"referral"
|
|
224
|
+
);
|
|
225
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should skip fields with empty-string overrides", () => {
|
|
229
|
+
const result = FrakContextManager.update({
|
|
230
|
+
url,
|
|
231
|
+
context: v2Context,
|
|
232
|
+
attribution: { utmContent: "", utmTerm: "" },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const parsedUrl = new URL(result!);
|
|
236
|
+
expect(parsedUrl.searchParams.has("utm_content")).toBe(
|
|
237
|
+
false
|
|
238
|
+
);
|
|
239
|
+
expect(parsedUrl.searchParams.has("utm_term")).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("should skip context-derived defaults for V1 (no merchantId/clientId)", () => {
|
|
243
|
+
const v1Context: FrakContextV1 = {
|
|
244
|
+
r: "0x1234567890123456789012345678901234567890" as Address,
|
|
245
|
+
};
|
|
246
|
+
const result = FrakContextManager.update({
|
|
247
|
+
url,
|
|
248
|
+
context: v1Context,
|
|
249
|
+
attribution: {},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const parsedUrl = new URL(result!);
|
|
253
|
+
// Static defaults still applied
|
|
254
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
255
|
+
"frak"
|
|
256
|
+
);
|
|
257
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
258
|
+
"referral"
|
|
259
|
+
);
|
|
260
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
261
|
+
// No derivable values from V1
|
|
262
|
+
expect(parsedUrl.searchParams.has("utm_campaign")).toBe(
|
|
263
|
+
false
|
|
264
|
+
);
|
|
265
|
+
expect(parsedUrl.searchParams.has("ref")).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
124
268
|
});
|
|
125
269
|
});
|
|
126
270
|
|
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
|
+
* When `attribution` is provided (even as an empty object), standard affiliation
|
|
155
|
+
* params (`utm_source`, `utm_medium`, `utm_campaign`, `ref`, `via`, ...) are
|
|
156
|
+
* also appended using gap-fill semantics: pre-existing params on the URL are
|
|
157
|
+
* preserved, and defaults are derived from the context when applicable.
|
|
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. Omit to skip UTM/ref params.
|
|
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
|
|
package/src/utils/index.ts
CHANGED
|
@@ -28,6 +28,10 @@ export {
|
|
|
28
28
|
isIOS,
|
|
29
29
|
redirectToExternalBrowser,
|
|
30
30
|
} from "./inAppBrowser";
|
|
31
|
+
export {
|
|
32
|
+
type MergeAttributionInput,
|
|
33
|
+
mergeAttribution,
|
|
34
|
+
} from "./mergeAttribution";
|
|
31
35
|
export { sdkConfigStore } from "./sdkConfigStore";
|
|
32
36
|
export {
|
|
33
37
|
type AppSpecificSsoMetadata,
|
|
@@ -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
|
+
});
|