@frak-labs/core-sdk 1.0.0-beta.7f085fb7 → 1.0.0-beta.a7645eaa
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-3g2MTs6f.cjs +1 -0
- package/dist/actions-C-vPLo1H.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-BphwTmKA.d.cts → index-BWic1g0J.d.cts} +1 -1
- package/dist/{index-Dwmo109y.d.cts → index-CNukNJzV.d.ts} +165 -42
- package/dist/{index-_f8EuN_1.d.ts → index-Du4nB3qO.d.ts} +1 -1
- package/dist/{index-BV5D9DsW.d.ts → index-tYNUZNRd.d.cts} +165 -42
- 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-3YqtmSkM.d.ts} +116 -10
- package/dist/{openSso-C1Wzl5-i.d.ts → openSso-SP6T9cHA.d.cts} +115 -9
- package/dist/sdkConfigStore-thuozJuB.cjs +1 -0
- package/dist/sdkConfigStore-ylgC-Tp0.js +1 -0
- package/dist/src-BhrneHv1.js +13 -0
- package/dist/src-DkkrSfA4.cjs +13 -0
- package/package.json +2 -2
- package/src/actions/referral/processReferral.test.ts +129 -8
- package/src/actions/referral/processReferral.ts +27 -17
- package/src/clients/createIFrameFrakClient.ts +84 -3
- package/src/index.ts +8 -1
- package/src/types/config.ts +9 -0
- package/src/types/context.ts +16 -4
- 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/rpc/interaction.ts +1 -1
- package/src/types/tracking.ts +37 -1
- package/src/utils/FrakContext.test.ts +235 -1
- package/src/utils/FrakContext.ts +87 -8
- 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 +11 -0
- package/src/utils/analytics/index.ts +8 -0
- package/src/utils/{trackEvent.test.ts → analytics/trackEvent.test.ts} +22 -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
|
@@ -42,7 +42,7 @@ describe("FrakContextManager", () => {
|
|
|
42
42
|
expect(result).not.toMatch(/[+/=]/);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
it("should return undefined when v2 context
|
|
45
|
+
it("should return undefined when v2 context has neither clientId nor wallet", () => {
|
|
46
46
|
const partial = { v: 2 as const, m: "m", t: 123 };
|
|
47
47
|
const result = FrakContextManager.compress(
|
|
48
48
|
partial as FrakContextV2
|
|
@@ -50,6 +50,33 @@ describe("FrakContextManager", () => {
|
|
|
50
50
|
expect(result).toBeUndefined();
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
it("should compress v2 context with wallet only (no clientId)", () => {
|
|
54
|
+
const v2WithWalletOnly: FrakContextV2 = {
|
|
55
|
+
v: 2,
|
|
56
|
+
m: "merchant-uuid-1234",
|
|
57
|
+
t: 1709654400,
|
|
58
|
+
w: "0x1234567890123456789012345678901234567890" as Address,
|
|
59
|
+
};
|
|
60
|
+
const result = FrakContextManager.compress(v2WithWalletOnly);
|
|
61
|
+
expect(result).toBeDefined();
|
|
62
|
+
const decompressed = FrakContextManager.decompress(result);
|
|
63
|
+
expect(decompressed).toEqual(v2WithWalletOnly);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should compress v2 context with both clientId and wallet", () => {
|
|
67
|
+
const v2Hybrid: FrakContextV2 = {
|
|
68
|
+
v: 2,
|
|
69
|
+
c: "test-client-id-uuid",
|
|
70
|
+
m: "merchant-uuid-1234",
|
|
71
|
+
t: 1709654400,
|
|
72
|
+
w: "0x1234567890123456789012345678901234567890" as Address,
|
|
73
|
+
};
|
|
74
|
+
const result = FrakContextManager.compress(v2Hybrid);
|
|
75
|
+
expect(result).toBeDefined();
|
|
76
|
+
const decompressed = FrakContextManager.decompress(result);
|
|
77
|
+
expect(decompressed).toEqual(v2Hybrid);
|
|
78
|
+
});
|
|
79
|
+
|
|
53
80
|
it("should return undefined when v2 context is missing merchantId", () => {
|
|
54
81
|
const partial = { v: 2 as const, c: "c", t: 123 };
|
|
55
82
|
const result = FrakContextManager.compress(
|
|
@@ -65,6 +92,40 @@ describe("FrakContextManager", () => {
|
|
|
65
92
|
);
|
|
66
93
|
expect(result).toBeUndefined();
|
|
67
94
|
});
|
|
95
|
+
|
|
96
|
+
it("should reject v2 context with a malformed wallet address", () => {
|
|
97
|
+
const partial = {
|
|
98
|
+
v: 2 as const,
|
|
99
|
+
m: "merchant-uuid",
|
|
100
|
+
t: 1709654400,
|
|
101
|
+
w: "0xnot-a-valid-address" as Address,
|
|
102
|
+
};
|
|
103
|
+
const result = FrakContextManager.compress(
|
|
104
|
+
partial as FrakContextV2
|
|
105
|
+
);
|
|
106
|
+
// Invalid wallet → falls back to clientId requirement; absent here → undefined
|
|
107
|
+
expect(result).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should drop a malformed wallet but keep a valid clientId", () => {
|
|
111
|
+
const hybrid = {
|
|
112
|
+
v: 2 as const,
|
|
113
|
+
c: "valid-client-id",
|
|
114
|
+
m: "merchant-uuid",
|
|
115
|
+
t: 1709654400,
|
|
116
|
+
w: "0xnot-a-valid-address" as Address,
|
|
117
|
+
};
|
|
118
|
+
const compressed = FrakContextManager.compress(
|
|
119
|
+
hybrid as FrakContextV2
|
|
120
|
+
);
|
|
121
|
+
const decompressed = FrakContextManager.decompress(compressed);
|
|
122
|
+
expect(decompressed).toEqual({
|
|
123
|
+
v: 2,
|
|
124
|
+
c: "valid-client-id",
|
|
125
|
+
m: "merchant-uuid",
|
|
126
|
+
t: 1709654400,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
68
129
|
});
|
|
69
130
|
|
|
70
131
|
describe("decompress", () => {
|
|
@@ -74,6 +135,28 @@ describe("FrakContextManager", () => {
|
|
|
74
135
|
|
|
75
136
|
expect(decompressed).toEqual(v2Context);
|
|
76
137
|
});
|
|
138
|
+
|
|
139
|
+
it("should drop malformed `w` field on decompress (defense against crafted URLs)", async () => {
|
|
140
|
+
// Manually compress a payload bypassing client-side validation
|
|
141
|
+
// by reaching into the underlying compressJsonToB64.
|
|
142
|
+
const { compressJsonToB64 } = await import(
|
|
143
|
+
"./compression/compress"
|
|
144
|
+
);
|
|
145
|
+
const malformed = compressJsonToB64({
|
|
146
|
+
v: 2,
|
|
147
|
+
c: "valid-client-id",
|
|
148
|
+
m: "merchant-uuid",
|
|
149
|
+
t: 1709654400,
|
|
150
|
+
w: "0xnot-a-real-address",
|
|
151
|
+
});
|
|
152
|
+
const result = FrakContextManager.decompress(malformed);
|
|
153
|
+
expect(result).toEqual({
|
|
154
|
+
v: 2,
|
|
155
|
+
c: "valid-client-id",
|
|
156
|
+
m: "merchant-uuid",
|
|
157
|
+
t: 1709654400,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
77
160
|
});
|
|
78
161
|
|
|
79
162
|
describe("parse", () => {
|
|
@@ -121,6 +204,157 @@ describe("FrakContextManager", () => {
|
|
|
121
204
|
expect(result).toContain("baz=qux");
|
|
122
205
|
expect(result).toContain("fCtx=");
|
|
123
206
|
});
|
|
207
|
+
|
|
208
|
+
describe("update with attribution", () => {
|
|
209
|
+
const url = "https://example.com/product";
|
|
210
|
+
|
|
211
|
+
it("should apply default attribution params when attribution is omitted", () => {
|
|
212
|
+
const result = FrakContextManager.update({
|
|
213
|
+
url,
|
|
214
|
+
context: v2Context,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(result).toBeDefined();
|
|
218
|
+
expect(result).toContain("fCtx=");
|
|
219
|
+
const parsedUrl = new URL(result!);
|
|
220
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
221
|
+
"frak"
|
|
222
|
+
);
|
|
223
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
224
|
+
"referral"
|
|
225
|
+
);
|
|
226
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
227
|
+
v2Context.m
|
|
228
|
+
);
|
|
229
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
230
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("should apply default attribution params when attribution is an empty object", () => {
|
|
234
|
+
const result = FrakContextManager.update({
|
|
235
|
+
url,
|
|
236
|
+
context: v2Context,
|
|
237
|
+
attribution: {},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(result).toBeDefined();
|
|
241
|
+
const parsedUrl = new URL(result!);
|
|
242
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
243
|
+
"frak"
|
|
244
|
+
);
|
|
245
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
246
|
+
"referral"
|
|
247
|
+
);
|
|
248
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
249
|
+
v2Context.m
|
|
250
|
+
);
|
|
251
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
252
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
253
|
+
expect(
|
|
254
|
+
parsedUrl.searchParams.get("utm_content")
|
|
255
|
+
).toBeNull();
|
|
256
|
+
expect(parsedUrl.searchParams.get("utm_term")).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("should honor overrides over defaults", () => {
|
|
260
|
+
const result = FrakContextManager.update({
|
|
261
|
+
url,
|
|
262
|
+
context: v2Context,
|
|
263
|
+
attribution: {
|
|
264
|
+
utmSource: "newsletter",
|
|
265
|
+
utmMedium: "email",
|
|
266
|
+
utmCampaign: "spring-sale",
|
|
267
|
+
utmContent: "hero-banner",
|
|
268
|
+
utmTerm: "wallet",
|
|
269
|
+
via: "partner",
|
|
270
|
+
ref: "alice",
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const parsedUrl = new URL(result!);
|
|
275
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
276
|
+
"newsletter"
|
|
277
|
+
);
|
|
278
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
279
|
+
"email"
|
|
280
|
+
);
|
|
281
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
282
|
+
"spring-sale"
|
|
283
|
+
);
|
|
284
|
+
expect(parsedUrl.searchParams.get("utm_content")).toBe(
|
|
285
|
+
"hero-banner"
|
|
286
|
+
);
|
|
287
|
+
expect(parsedUrl.searchParams.get("utm_term")).toBe(
|
|
288
|
+
"wallet"
|
|
289
|
+
);
|
|
290
|
+
expect(parsedUrl.searchParams.get("via")).toBe("partner");
|
|
291
|
+
expect(parsedUrl.searchParams.get("ref")).toBe("alice");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("should preserve merchant-provided UTMs on the base URL (gap-fill)", () => {
|
|
295
|
+
const baseUrl =
|
|
296
|
+
"https://example.com/product?utm_source=google&utm_campaign=merchant-spring";
|
|
297
|
+
const result = FrakContextManager.update({
|
|
298
|
+
url: baseUrl,
|
|
299
|
+
context: v2Context,
|
|
300
|
+
attribution: {},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const parsedUrl = new URL(result!);
|
|
304
|
+
// Merchant-provided values preserved
|
|
305
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
306
|
+
"google"
|
|
307
|
+
);
|
|
308
|
+
expect(parsedUrl.searchParams.get("utm_campaign")).toBe(
|
|
309
|
+
"merchant-spring"
|
|
310
|
+
);
|
|
311
|
+
// Missing ones filled by Frak defaults
|
|
312
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
313
|
+
"referral"
|
|
314
|
+
);
|
|
315
|
+
expect(parsedUrl.searchParams.get("ref")).toBe(v2Context.c);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("should skip fields with empty-string overrides", () => {
|
|
319
|
+
const result = FrakContextManager.update({
|
|
320
|
+
url,
|
|
321
|
+
context: v2Context,
|
|
322
|
+
attribution: { utmContent: "", utmTerm: "" },
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const parsedUrl = new URL(result!);
|
|
326
|
+
expect(parsedUrl.searchParams.has("utm_content")).toBe(
|
|
327
|
+
false
|
|
328
|
+
);
|
|
329
|
+
expect(parsedUrl.searchParams.has("utm_term")).toBe(false);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("should skip context-derived defaults for V1 (no merchantId/clientId)", () => {
|
|
333
|
+
const v1Context: FrakContextV1 = {
|
|
334
|
+
r: "0x1234567890123456789012345678901234567890" as Address,
|
|
335
|
+
};
|
|
336
|
+
const result = FrakContextManager.update({
|
|
337
|
+
url,
|
|
338
|
+
context: v1Context,
|
|
339
|
+
attribution: {},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const parsedUrl = new URL(result!);
|
|
343
|
+
// Static defaults still applied
|
|
344
|
+
expect(parsedUrl.searchParams.get("utm_source")).toBe(
|
|
345
|
+
"frak"
|
|
346
|
+
);
|
|
347
|
+
expect(parsedUrl.searchParams.get("utm_medium")).toBe(
|
|
348
|
+
"referral"
|
|
349
|
+
);
|
|
350
|
+
expect(parsedUrl.searchParams.get("via")).toBe("frak");
|
|
351
|
+
// No derivable values from V1
|
|
352
|
+
expect(parsedUrl.searchParams.has("utm_campaign")).toBe(
|
|
353
|
+
false
|
|
354
|
+
);
|
|
355
|
+
expect(parsedUrl.searchParams.has("ref")).toBe(false);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
124
358
|
});
|
|
125
359
|
});
|
|
126
360
|
|
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";
|
|
@@ -23,13 +28,17 @@ function compress(context?: FrakContextV1 | FrakContextV2): string | undefined {
|
|
|
23
28
|
if (!context) return;
|
|
24
29
|
try {
|
|
25
30
|
if (isV2Context(context)) {
|
|
26
|
-
// Runtime validation:
|
|
27
|
-
|
|
31
|
+
// Runtime validation: m + t are always required, and at least one of
|
|
32
|
+
// c (anonymous fingerprint) or w (valid wallet) must be present.
|
|
33
|
+
if (!context.m || !context.t) return undefined;
|
|
34
|
+
const hasValidWallet = context.w && isAddress(context.w);
|
|
35
|
+
if (!context.c && !hasValidWallet) return undefined;
|
|
28
36
|
return compressJsonToB64({
|
|
29
37
|
v: 2,
|
|
30
|
-
c: context.c,
|
|
31
38
|
m: context.m,
|
|
32
39
|
t: context.t,
|
|
40
|
+
...(context.c ? { c: context.c } : {}),
|
|
41
|
+
...(hasValidWallet ? { w: context.w } : {}),
|
|
33
42
|
});
|
|
34
43
|
}
|
|
35
44
|
|
|
@@ -56,10 +65,18 @@ function decompress(context?: string): FrakContext | undefined {
|
|
|
56
65
|
// Try V2 JSON first — V2 payloads are longer than V1's 20-byte address
|
|
57
66
|
const json = decompressJsonFromB64<FrakContextV2>(context);
|
|
58
67
|
if (json && typeof json === "object" && json.v === 2) {
|
|
59
|
-
if (json.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
if (!json.m || !json.t) return undefined;
|
|
69
|
+
// Validate `w` with isAddress() — protects self-referral checks and
|
|
70
|
+
// URL replacement from crafted payloads carrying a malformed wallet.
|
|
71
|
+
const hasValidWallet = json.w && isAddress(json.w);
|
|
72
|
+
if (!json.c && !hasValidWallet) return undefined;
|
|
73
|
+
return {
|
|
74
|
+
v: 2,
|
|
75
|
+
m: json.m,
|
|
76
|
+
t: json.t,
|
|
77
|
+
...(json.c ? { c: json.c } : {}),
|
|
78
|
+
...(hasValidWallet ? { w: json.w } : {}),
|
|
79
|
+
};
|
|
63
80
|
}
|
|
64
81
|
|
|
65
82
|
// Fall back to V1: raw 20-byte address
|
|
@@ -91,20 +108,81 @@ function parse({ url }: { url: string }): FrakContext | null | undefined {
|
|
|
91
108
|
return decompress(frakContext);
|
|
92
109
|
}
|
|
93
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Default UTM medium value when attribution is requested.
|
|
113
|
+
*/
|
|
114
|
+
const DEFAULT_UTM_MEDIUM = "referral";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Default utm_source / via value when attribution is requested.
|
|
118
|
+
*/
|
|
119
|
+
const DEFAULT_ATTRIBUTION_SOURCE = "frak";
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve attribution defaults from the provided context.
|
|
123
|
+
*
|
|
124
|
+
* V2 contexts expose the merchantId (`m`) and, when anonymous, the clientId
|
|
125
|
+
* (`c`), which feed `utm_campaign` and `ref` respectively. When V2 only carries
|
|
126
|
+
* a wallet (`w`), `ref` is intentionally left unset — we don't want wallet
|
|
127
|
+
* addresses leaking into UTM params. V1 contexts have no equivalent.
|
|
128
|
+
*/
|
|
129
|
+
function resolveAttributionValues(
|
|
130
|
+
context: FrakContextV1 | FrakContextV2,
|
|
131
|
+
overrides: AttributionParams
|
|
132
|
+
): Record<string, string | undefined> {
|
|
133
|
+
const isV2 = isV2Context(context);
|
|
134
|
+
return {
|
|
135
|
+
utm_source: overrides.utmSource ?? DEFAULT_ATTRIBUTION_SOURCE,
|
|
136
|
+
utm_medium: overrides.utmMedium ?? DEFAULT_UTM_MEDIUM,
|
|
137
|
+
utm_campaign: overrides.utmCampaign ?? (isV2 ? context.m : undefined),
|
|
138
|
+
utm_content: overrides.utmContent,
|
|
139
|
+
utm_term: overrides.utmTerm,
|
|
140
|
+
via: overrides.via ?? DEFAULT_ATTRIBUTION_SOURCE,
|
|
141
|
+
ref: overrides.ref ?? (isV2 ? context.c : undefined),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Append attribution query params to a URL using gap-fill semantics.
|
|
147
|
+
*
|
|
148
|
+
* Existing params on the URL are preserved untouched (so merchant-provided
|
|
149
|
+
* UTMs take precedence). Only missing keys are populated.
|
|
150
|
+
*/
|
|
151
|
+
function applyAttributionParams(
|
|
152
|
+
urlObj: URL,
|
|
153
|
+
context: FrakContextV1 | FrakContextV2,
|
|
154
|
+
attribution?: AttributionParams
|
|
155
|
+
): void {
|
|
156
|
+
const values = resolveAttributionValues(context, attribution ?? {});
|
|
157
|
+
for (const [key, value] of Object.entries(values)) {
|
|
158
|
+
if (value === undefined || value === "") continue;
|
|
159
|
+
if (urlObj.searchParams.has(key)) continue;
|
|
160
|
+
urlObj.searchParams.set(key, value);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
94
164
|
/**
|
|
95
165
|
* Add or replace the `fCtx` query parameter in a URL with the given context.
|
|
96
166
|
*
|
|
167
|
+
* Standard affiliation params (`utm_source`, `utm_medium`, `utm_campaign`,
|
|
168
|
+
* `ref`, `via`, ...) are always appended using gap-fill semantics: pre-existing
|
|
169
|
+
* params on the URL are preserved, defaults are derived from the context when
|
|
170
|
+
* applicable, and `attribution` overrides take precedence when provided.
|
|
171
|
+
*
|
|
97
172
|
* @param args
|
|
98
173
|
* @param args.url - The URL to update
|
|
99
174
|
* @param args.context - The context to embed (V1 or V2)
|
|
175
|
+
* @param args.attribution - Optional attribution overrides. Defaults are applied even when omitted.
|
|
100
176
|
* @returns The updated URL string, or null on failure
|
|
101
177
|
*/
|
|
102
178
|
function update({
|
|
103
179
|
url,
|
|
104
180
|
context,
|
|
181
|
+
attribution,
|
|
105
182
|
}: {
|
|
106
183
|
url?: string;
|
|
107
184
|
context: FrakContextV1 | FrakContextV2;
|
|
185
|
+
attribution?: AttributionParams;
|
|
108
186
|
}): string | null {
|
|
109
187
|
if (!url) return null;
|
|
110
188
|
|
|
@@ -113,6 +191,7 @@ function update({
|
|
|
113
191
|
|
|
114
192
|
const urlObj = new URL(url);
|
|
115
193
|
urlObj.searchParams.set(contextKey, compressedContext);
|
|
194
|
+
applyAttributionParams(urlObj, context, attribution);
|
|
116
195
|
return urlObj.toString();
|
|
117
196
|
}
|
|
118
197
|
|
|
@@ -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;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type SdkHandshakeFailureReason =
|
|
2
|
+
| "timeout"
|
|
3
|
+
| "origin"
|
|
4
|
+
| "asset_push"
|
|
5
|
+
| "unknown";
|
|
6
|
+
|
|
7
|
+
export type SdkLifecycleEventMap = {
|
|
8
|
+
sdk_initialized: {
|
|
9
|
+
sdkVersion?: string;
|
|
10
|
+
};
|
|
11
|
+
sdk_iframe_connected: {
|
|
12
|
+
handshake_duration_ms: number;
|
|
13
|
+
};
|
|
14
|
+
sdk_iframe_handshake_failed: {
|
|
15
|
+
reason: SdkHandshakeFailureReason;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Emitted by the CDN bootstrap when `initFrakSdk()` throws before a
|
|
19
|
+
* client is available. Uses a transient OpenPanel instance so broken
|
|
20
|
+
* partner integrations are still captured.
|
|
21
|
+
*/
|
|
22
|
+
sdk_init_failed: {
|
|
23
|
+
reason: string;
|
|
24
|
+
config_missing?: boolean;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
expect,
|
|
11
11
|
it,
|
|
12
12
|
vi,
|
|
13
|
-
} from "
|
|
14
|
-
import type { FrakClient } from "
|
|
13
|
+
} from "../../../tests/vitest-fixtures";
|
|
14
|
+
import type { FrakClient } from "../../types";
|
|
15
15
|
import { trackEvent } from "./trackEvent";
|
|
16
16
|
|
|
17
17
|
describe("trackEvent", () => {
|
|
@@ -42,16 +42,19 @@ describe("trackEvent", () => {
|
|
|
42
42
|
|
|
43
43
|
expect(mockClient.openPanel?.track).toHaveBeenCalledWith(
|
|
44
44
|
"share_button_clicked",
|
|
45
|
-
|
|
45
|
+
undefined
|
|
46
46
|
);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
it("should track event with props", () => {
|
|
50
|
-
const props = {
|
|
51
|
-
|
|
50
|
+
const props = {
|
|
51
|
+
placement: "footer",
|
|
52
|
+
click_action: "share-modal",
|
|
53
|
+
} as const;
|
|
54
|
+
trackEvent(mockClient, "share_button_clicked", props);
|
|
52
55
|
|
|
53
56
|
expect(mockClient.openPanel?.track).toHaveBeenCalledWith(
|
|
54
|
-
"
|
|
57
|
+
"share_button_clicked",
|
|
55
58
|
props
|
|
56
59
|
);
|
|
57
60
|
});
|
|
@@ -71,25 +74,18 @@ describe("trackEvent", () => {
|
|
|
71
74
|
|
|
72
75
|
expect(mockClient.openPanel?.track).toHaveBeenCalledWith(
|
|
73
76
|
"user_referred_started",
|
|
74
|
-
|
|
77
|
+
undefined
|
|
75
78
|
);
|
|
76
79
|
});
|
|
77
80
|
|
|
78
81
|
it("should track user_referred_completed event", () => {
|
|
79
|
-
trackEvent(mockClient, "user_referred_completed"
|
|
82
|
+
trackEvent(mockClient, "user_referred_completed", {
|
|
83
|
+
status: "success",
|
|
84
|
+
});
|
|
80
85
|
|
|
81
86
|
expect(mockClient.openPanel?.track).toHaveBeenCalledWith(
|
|
82
87
|
"user_referred_completed",
|
|
83
|
-
{}
|
|
84
|
-
);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("should track user_referred_error event", () => {
|
|
88
|
-
trackEvent(mockClient, "user_referred_error");
|
|
89
|
-
|
|
90
|
-
expect(mockClient.openPanel?.track).toHaveBeenCalledWith(
|
|
91
|
-
"user_referred_error",
|
|
92
|
-
{}
|
|
88
|
+
{ status: "success" }
|
|
93
89
|
);
|
|
94
90
|
});
|
|
95
91
|
});
|
|
@@ -102,7 +98,7 @@ describe("trackEvent", () => {
|
|
|
102
98
|
});
|
|
103
99
|
|
|
104
100
|
it("should log debug message when client is undefined", () => {
|
|
105
|
-
trackEvent(undefined, "
|
|
101
|
+
trackEvent(undefined, "share_button_clicked");
|
|
106
102
|
|
|
107
103
|
expect(consoleDebugSpy).toHaveBeenCalledWith(
|
|
108
104
|
"[Frak] No client provided, skipping event tracking"
|
|
@@ -143,30 +139,26 @@ describe("trackEvent", () => {
|
|
|
143
139
|
const clientWithoutPanel = {} as FrakClient;
|
|
144
140
|
|
|
145
141
|
expect(() => {
|
|
146
|
-
trackEvent(clientWithoutPanel, "
|
|
142
|
+
trackEvent(clientWithoutPanel, "share_button_clicked");
|
|
147
143
|
}).not.toThrow();
|
|
148
144
|
});
|
|
149
145
|
});
|
|
150
146
|
|
|
151
147
|
describe("edge cases", () => {
|
|
152
|
-
it("should handle
|
|
153
|
-
trackEvent(mockClient, "share_button_clicked", {
|
|
148
|
+
it("should handle typed props object", () => {
|
|
149
|
+
trackEvent(mockClient, "share_button_clicked", {
|
|
150
|
+
click_action: "share-modal",
|
|
151
|
+
});
|
|
154
152
|
|
|
155
153
|
expect(mockClient.openPanel?.track).toHaveBeenCalledWith(
|
|
156
154
|
"share_button_clicked",
|
|
157
|
-
{}
|
|
155
|
+
{ click_action: "share-modal" }
|
|
158
156
|
);
|
|
159
157
|
});
|
|
160
158
|
|
|
161
159
|
it("should handle complex props object", () => {
|
|
162
160
|
const complexProps = {
|
|
163
|
-
|
|
164
|
-
metadata: {
|
|
165
|
-
page: "home",
|
|
166
|
-
section: "header",
|
|
167
|
-
},
|
|
168
|
-
tags: ["tag1", "tag2"],
|
|
169
|
-
timestamp: Date.now(),
|
|
161
|
+
status: "success" as const,
|
|
170
162
|
};
|
|
171
163
|
|
|
172
164
|
trackEvent(mockClient, "user_referred_completed", complexProps);
|