@frak-labs/core-sdk 1.1.3 → 1.1.5-beta.5b65e64f
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 +1 -1
- package/dist/actions-Cyn4asew.cjs +2 -0
- package/dist/actions-Cyn4asew.cjs.map +1 -0
- package/dist/actions-DtPVuk2e.js +2 -0
- package/dist/actions-DtPVuk2e.js.map +1 -0
- package/dist/actions.cjs +1 -1
- package/dist/actions.d.cts +3 -3
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/bundle.cjs +1 -1
- package/dist/bundle.d.cts +5 -4
- package/dist/bundle.d.ts +5 -4
- package/dist/bundle.js +1 -1
- package/dist/frakContext-Bgidv-Se.js +2 -0
- package/dist/frakContext-Bgidv-Se.js.map +1 -0
- package/dist/frakContext-CeVlWFcX.cjs +2 -0
- package/dist/frakContext-CeVlWFcX.cjs.map +1 -0
- package/dist/getCurrencyAmountKey-DQcwjJiB.js +2 -0
- package/dist/getCurrencyAmountKey-DQcwjJiB.js.map +1 -0
- package/dist/getCurrencyAmountKey-h2eOiYzv.cjs +2 -0
- package/dist/getCurrencyAmountKey-h2eOiYzv.cjs.map +1 -0
- package/dist/{index-lcImYQyL.d.ts → index-BCoPwxjq.d.cts} +4 -2
- package/dist/{index-C5pP_9We.d.cts → index-CiHjUnfc.d.ts} +43 -6
- package/dist/{index-DrYZwIjb.d.ts → index-D25z5B9K.d.cts} +43 -6
- package/dist/{index-DR4n-QOe.d.cts → index-trhAN7q9.d.ts} +4 -2
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/merchantInformation--tLnowAN.d.ts +344 -0
- package/dist/merchantInformation-BSuahpjl.d.cts +344 -0
- package/dist/{openSso-BErs7_Bg.d.cts → openSso-CENLMWJx.d.ts} +4 -279
- package/dist/{openSso-DRLH4D9a.d.ts → openSso-CKvkAGts.d.cts} +4 -279
- package/dist/rewards.cjs +2 -0
- package/dist/rewards.cjs.map +1 -0
- package/dist/rewards.d.cts +101 -0
- package/dist/rewards.d.ts +101 -0
- package/dist/rewards.js +2 -0
- package/dist/rewards.js.map +1 -0
- package/dist/src-BXtnEE3a.js +2 -0
- package/dist/src-BXtnEE3a.js.map +1 -0
- package/dist/src-TpX-7MwJ.cjs +2 -0
- package/dist/src-TpX-7MwJ.cjs.map +1 -0
- package/package.json +16 -3
- package/src/actions/getMerchantInformation.test.ts +2 -0
- package/src/actions/referral/processReferral.test.ts +4 -4
- package/src/actions/referral/processReferral.ts +4 -4
- package/src/clients/createIFrameFrakClient.ts +7 -7
- package/src/clients/transports/iframeLifecycleManager.ts +5 -21
- package/src/config/sdkConfigStore.ts +6 -3
- package/src/context/frakContext.test.ts +55 -0
- package/src/context/frakContext.ts +13 -2
- package/src/index.ts +8 -0
- package/src/rewards/conditions.test.ts +60 -0
- package/src/rewards/conditions.ts +72 -0
- package/src/rewards/example.test.ts +92 -0
- package/src/rewards/example.ts +57 -0
- package/src/rewards/format.test.ts +143 -0
- package/src/rewards/format.ts +81 -0
- package/src/rewards/index.ts +27 -0
- package/src/rewards/select.test.ts +212 -0
- package/src/rewards/select.ts +132 -0
- package/src/rewards/value.test.ts +89 -0
- package/src/rewards/value.ts +73 -0
- package/src/types/index.ts +6 -0
- package/src/types/rpc/merchantInformation.ts +120 -13
- package/src/utils/analytics/events/lifecycle.ts +1 -1
- package/src/utils/analytics/events/referral.ts +3 -3
- package/src/utils/browser/deepLinkWithFallback.ts +4 -1
- package/src/utils/browser/inAppBrowser.ts +2 -2
- package/src/utils/cache/withCache.ts +30 -11
- package/src/utils/iframe/iframeHelper.test.ts +39 -0
- package/src/utils/iframe/iframeHelper.ts +31 -2
- package/src/utils/index.ts +5 -0
- package/src/utils/url/queryParams.test.ts +85 -0
- package/src/utils/url/queryParams.ts +57 -0
- package/dist/actions-D5rNeJC0.js +0 -1
- package/dist/actions-gmdNZvNh.cjs +0 -1
- package/dist/frakContext-Cfp7sSid.cjs +0 -1
- package/dist/frakContext-UEOk9Bk4.js +0 -1
- package/dist/src-BEMVYU3J.js +0 -1
- package/dist/src-C9inMs4I.cjs +0 -1
|
@@ -584,6 +584,61 @@ describe("FrakContextManager", () => {
|
|
|
584
584
|
});
|
|
585
585
|
});
|
|
586
586
|
|
|
587
|
+
describe("case-insensitive fCtx key", () => {
|
|
588
|
+
const context: FrakContextV2 = {
|
|
589
|
+
v: 2,
|
|
590
|
+
c: "550e8400-e29b-41d4-a716-446655440001",
|
|
591
|
+
m: "550e8400-e29b-41d4-a716-446655440000",
|
|
592
|
+
t: 1709654400,
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
it("should parse a lowercased fctx key with an intact value", () => {
|
|
596
|
+
const compressed = FrakContextManager.compress(context);
|
|
597
|
+
const url = `https://example.com?fctx=${compressed}`;
|
|
598
|
+
|
|
599
|
+
const result = FrakContextManager.parse({ url });
|
|
600
|
+
|
|
601
|
+
expect(result).toEqual(context);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("should parse an uppercased FCTX key with an intact value", () => {
|
|
605
|
+
const compressed = FrakContextManager.compress(context);
|
|
606
|
+
const url = `https://example.com?FCTX=${compressed}`;
|
|
607
|
+
|
|
608
|
+
const result = FrakContextManager.parse({ url });
|
|
609
|
+
|
|
610
|
+
expect(result).toEqual(context);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("should prefer the exact-case fCtx key when both variants exist", () => {
|
|
614
|
+
const canonical = FrakContextManager.compress(context);
|
|
615
|
+
const url = `https://example.com?fctx=stale&fCtx=${canonical}`;
|
|
616
|
+
|
|
617
|
+
const result = FrakContextManager.parse({ url });
|
|
618
|
+
|
|
619
|
+
expect(result).toEqual(context);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("should remove a lowercased fctx key", () => {
|
|
623
|
+
const url = "https://example.com?fctx=abc&keep=me";
|
|
624
|
+
|
|
625
|
+
const result = FrakContextManager.remove(url);
|
|
626
|
+
|
|
627
|
+
expect(result).not.toContain("fctx");
|
|
628
|
+
expect(result).toContain("keep=me");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("should not leave a stale lowercased fctx variant on update", () => {
|
|
632
|
+
const url = "https://example.com?fctx=stale";
|
|
633
|
+
|
|
634
|
+
const result = FrakContextManager.update({ url, context });
|
|
635
|
+
|
|
636
|
+
expect(result).toContain("fCtx=");
|
|
637
|
+
expect(result).not.toContain("fctx=stale");
|
|
638
|
+
expect(FrakContextManager.parse({ url: result! })).toEqual(context);
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
587
642
|
describe("replaceUrl", () => {
|
|
588
643
|
const mockAddress =
|
|
589
644
|
"0x1234567890123456789012345678901234567890" as Address;
|
|
@@ -6,6 +6,10 @@ import type {
|
|
|
6
6
|
} from "../types";
|
|
7
7
|
import { isV2Context } from "../types";
|
|
8
8
|
import { base64urlDecode, base64urlEncode } from "../utils/compression/b64";
|
|
9
|
+
import {
|
|
10
|
+
deleteQueryParamCaseInsensitive,
|
|
11
|
+
getQueryParamCaseInsensitive,
|
|
12
|
+
} from "../utils/url/queryParams";
|
|
9
13
|
import { addressToBytes, bytesToAddress, isAddress } from "./address";
|
|
10
14
|
import { decodeFrakContextV2, encodeFrakContextV2 } from "./frakContextV2Codec";
|
|
11
15
|
|
|
@@ -77,6 +81,9 @@ function decompress(context?: string): FrakContext | undefined {
|
|
|
77
81
|
/**
|
|
78
82
|
* Parse a URL to extract the Frak referral context from the `fCtx` query parameter.
|
|
79
83
|
*
|
|
84
|
+
* The key is matched case-insensitively: some link channels (emails, messaging
|
|
85
|
+
* apps) lowercase query-param keys in transit, so `fCtx` can arrive as `fctx`.
|
|
86
|
+
*
|
|
80
87
|
* @param args
|
|
81
88
|
* @param args.url - The URL to parse
|
|
82
89
|
* @returns The parsed FrakContext, or null if absent
|
|
@@ -85,7 +92,10 @@ function parse({ url }: { url: string }): FrakContext | null | undefined {
|
|
|
85
92
|
if (!url) return null;
|
|
86
93
|
|
|
87
94
|
const urlObj = new URL(url);
|
|
88
|
-
const frakContext =
|
|
95
|
+
const frakContext = getQueryParamCaseInsensitive(
|
|
96
|
+
urlObj.searchParams,
|
|
97
|
+
contextKey
|
|
98
|
+
);
|
|
89
99
|
if (!frakContext) return null;
|
|
90
100
|
|
|
91
101
|
return decompress(frakContext);
|
|
@@ -165,6 +175,7 @@ function update({
|
|
|
165
175
|
if (!compressedContext) return null;
|
|
166
176
|
|
|
167
177
|
const urlObj = new URL(url);
|
|
178
|
+
deleteQueryParamCaseInsensitive(urlObj.searchParams, contextKey);
|
|
168
179
|
urlObj.searchParams.set(contextKey, compressedContext);
|
|
169
180
|
applyAttributionParams(urlObj, attribution);
|
|
170
181
|
return urlObj.toString();
|
|
@@ -178,7 +189,7 @@ function update({
|
|
|
178
189
|
*/
|
|
179
190
|
function remove(url: string): string {
|
|
180
191
|
const urlObj = new URL(url);
|
|
181
|
-
urlObj.searchParams
|
|
192
|
+
deleteQueryParamCaseInsensitive(urlObj.searchParams, contextKey);
|
|
182
193
|
return urlObj.toString();
|
|
183
194
|
}
|
|
184
195
|
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type {
|
|
|
23
23
|
AttributionParams,
|
|
24
24
|
ClientLifecycleEvent,
|
|
25
25
|
CompressedData,
|
|
26
|
+
ConditionGroup,
|
|
27
|
+
ConditionOperator,
|
|
26
28
|
Currency,
|
|
27
29
|
// RPC Embedded wallet
|
|
28
30
|
DisplayEmbeddedWalletParamsType,
|
|
@@ -61,6 +63,7 @@ export type {
|
|
|
61
63
|
LoggedOutEmbeddedView,
|
|
62
64
|
LoginModalStepType,
|
|
63
65
|
MerchantConfigResponse,
|
|
66
|
+
MerchantReward,
|
|
64
67
|
ModalRpcMetadata,
|
|
65
68
|
ModalRpcStepsInput,
|
|
66
69
|
ModalRpcStepsResultType,
|
|
@@ -75,6 +78,9 @@ export type {
|
|
|
75
78
|
ResolvedPlacement,
|
|
76
79
|
ResolvedSdkConfig,
|
|
77
80
|
RewardTier,
|
|
81
|
+
RuleCondition,
|
|
82
|
+
RuleConditions,
|
|
83
|
+
RuleField,
|
|
78
84
|
SdkResolvedConfig,
|
|
79
85
|
// RPC Interaction
|
|
80
86
|
SendInteractionParamsType,
|
|
@@ -107,11 +113,13 @@ export {
|
|
|
107
113
|
compressJsonToB64,
|
|
108
114
|
type DeepLinkFallbackOptions,
|
|
109
115
|
decompressJsonFromB64,
|
|
116
|
+
deleteQueryParamCaseInsensitive,
|
|
110
117
|
type FullSsoParams,
|
|
111
118
|
findIframeInOpener,
|
|
112
119
|
formatAmount,
|
|
113
120
|
generateSsoUrl,
|
|
114
121
|
getCurrencyAmountKey,
|
|
122
|
+
getQueryParamCaseInsensitive,
|
|
115
123
|
getSupportedCurrency,
|
|
116
124
|
isInAppBrowser,
|
|
117
125
|
isIOS,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { extractMinPurchaseAmount, extractStartDate } from "./conditions";
|
|
3
|
+
|
|
4
|
+
const unix = (iso: string) => Math.floor(new Date(iso).getTime() / 1000);
|
|
5
|
+
|
|
6
|
+
describe("extractMinPurchaseAmount", () => {
|
|
7
|
+
it("reads a flat purchase.amount gte condition", () => {
|
|
8
|
+
expect(
|
|
9
|
+
extractMinPurchaseAmount([
|
|
10
|
+
{ field: "purchase.amount", operator: "gte", value: 50 },
|
|
11
|
+
])
|
|
12
|
+
).toBe(50);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("walks nested groups and keeps the lowest threshold", () => {
|
|
16
|
+
expect(
|
|
17
|
+
extractMinPurchaseAmount({
|
|
18
|
+
logic: "all",
|
|
19
|
+
conditions: [
|
|
20
|
+
{ field: "purchase.amount", operator: "gte", value: 80 },
|
|
21
|
+
{
|
|
22
|
+
logic: "any",
|
|
23
|
+
conditions: [
|
|
24
|
+
{
|
|
25
|
+
field: "purchase.amount",
|
|
26
|
+
operator: "gt",
|
|
27
|
+
value: 30,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
})
|
|
33
|
+
).toBe(30);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns undefined without a purchase gate", () => {
|
|
37
|
+
expect(
|
|
38
|
+
extractMinPurchaseAmount([
|
|
39
|
+
{ field: "user.isNew", operator: "eq", value: true },
|
|
40
|
+
])
|
|
41
|
+
).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("extractStartDate", () => {
|
|
46
|
+
it("reads a time.timestamp gte condition (unix seconds)", () => {
|
|
47
|
+
const start = extractStartDate([
|
|
48
|
+
{
|
|
49
|
+
field: "time.timestamp",
|
|
50
|
+
operator: "gte",
|
|
51
|
+
value: unix("2025-02-01T00:00:00Z"),
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
expect(start?.toISOString()).toBe("2025-02-01T00:00:00.000Z");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns undefined without a start gate", () => {
|
|
58
|
+
expect(extractStartDate([])).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ConditionGroup, RuleCondition, RuleConditions } from "../types";
|
|
2
|
+
|
|
3
|
+
type ConditionNode = RuleCondition | ConditionGroup;
|
|
4
|
+
|
|
5
|
+
function conditionNodes(conditions: RuleConditions): ConditionNode[] {
|
|
6
|
+
return Array.isArray(conditions) ? conditions : conditions.conditions;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function conditionValueToNumber(
|
|
10
|
+
value: RuleCondition["value"]
|
|
11
|
+
): number | undefined {
|
|
12
|
+
if (typeof value === "number") return value;
|
|
13
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
14
|
+
const parsed = Number(value);
|
|
15
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Conditions arrive as a flat list or recursive groups; collect every numeric
|
|
21
|
+
// value whose field + operator match, descending into nested groups.
|
|
22
|
+
function collectConditionValues(
|
|
23
|
+
conditions: RuleConditions,
|
|
24
|
+
field: string,
|
|
25
|
+
operators: ReadonlySet<RuleCondition["operator"]>
|
|
26
|
+
): number[] {
|
|
27
|
+
const values: number[] = [];
|
|
28
|
+
for (const node of conditionNodes(conditions)) {
|
|
29
|
+
if ("logic" in node) {
|
|
30
|
+
values.push(...collectConditionValues(node, field, operators));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (node.field === field && operators.has(node.operator)) {
|
|
34
|
+
const value = conditionValueToNumber(node.value);
|
|
35
|
+
if (value != null) values.push(value);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return values;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MIN_PURCHASE_OPERATORS: ReadonlySet<RuleCondition["operator"]> = new Set([
|
|
42
|
+
"gt",
|
|
43
|
+
"gte",
|
|
44
|
+
"between",
|
|
45
|
+
]);
|
|
46
|
+
const START_DATE_OPERATORS: ReadonlySet<RuleCondition["operator"]> = new Set([
|
|
47
|
+
"gt",
|
|
48
|
+
"gte",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
export function extractMinPurchaseAmount(
|
|
52
|
+
conditions: RuleConditions
|
|
53
|
+
): number | undefined {
|
|
54
|
+
const values = collectConditionValues(
|
|
55
|
+
conditions,
|
|
56
|
+
"purchase.amount",
|
|
57
|
+
MIN_PURCHASE_OPERATORS
|
|
58
|
+
);
|
|
59
|
+
return values.length > 0 ? Math.min(...values) : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The business app encodes a campaign start as a `time.timestamp >= <unix s>`
|
|
63
|
+
// rule condition; the endpoint returns it verbatim inside `conditions`.
|
|
64
|
+
export function extractStartDate(conditions: RuleConditions): Date | undefined {
|
|
65
|
+
const values = collectConditionValues(
|
|
66
|
+
conditions,
|
|
67
|
+
"time.timestamp",
|
|
68
|
+
START_DATE_OPERATORS
|
|
69
|
+
);
|
|
70
|
+
if (values.length === 0) return undefined;
|
|
71
|
+
return new Date(Math.min(...values) * 1000);
|
|
72
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { EstimatedReward } from "../types";
|
|
3
|
+
import {
|
|
4
|
+
buildPercentageExample,
|
|
5
|
+
buildTierExample,
|
|
6
|
+
pickFlatBasket,
|
|
7
|
+
pickTierBasket,
|
|
8
|
+
} from "./example";
|
|
9
|
+
|
|
10
|
+
type PercentageReward = Extract<EstimatedReward, { payoutType: "percentage" }>;
|
|
11
|
+
|
|
12
|
+
function percentage(
|
|
13
|
+
percent: number,
|
|
14
|
+
bounds?: { min?: number; max?: number }
|
|
15
|
+
): PercentageReward {
|
|
16
|
+
const toAmount = (eur: number) => ({
|
|
17
|
+
amount: eur,
|
|
18
|
+
eurAmount: eur,
|
|
19
|
+
usdAmount: eur,
|
|
20
|
+
gbpAmount: eur,
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
payoutType: "percentage",
|
|
24
|
+
percent,
|
|
25
|
+
percentOf: "purchase_amount",
|
|
26
|
+
minAmount: bounds?.min != null ? toAmount(bounds.min) : undefined,
|
|
27
|
+
maxAmount: bounds?.max != null ? toAmount(bounds.max) : undefined,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("pickFlatBasket", () => {
|
|
32
|
+
it("defaults to the reference basket of 100", () => {
|
|
33
|
+
expect(pickFlatBasket(undefined)).toBe(100);
|
|
34
|
+
expect(pickFlatBasket(40)).toBe(100);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("uses the minimum purchase when it exceeds the reference", () => {
|
|
38
|
+
expect(pickFlatBasket(150)).toBe(150);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("pickTierBasket", () => {
|
|
43
|
+
it("uses the reference when it fits the tier range", () => {
|
|
44
|
+
expect(pickTierBasket(80, 200)).toBe(100);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("uses the lower bound for an open-ended tier", () => {
|
|
48
|
+
expect(pickTierBasket(80, undefined)).toBe(80);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("picks a round number inside a bounded tier excluding the reference", () => {
|
|
52
|
+
expect(pickTierBasket(0, 30)).toBe(20);
|
|
53
|
+
expect(pickTierBasket(30, 80)).toBe(60);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("buildPercentageExample", () => {
|
|
58
|
+
it("computes percent of the basket", () => {
|
|
59
|
+
const example = buildPercentageExample(percentage(8), undefined);
|
|
60
|
+
expect(example?.basket).toBe(100);
|
|
61
|
+
expect(example?.reward).toBeCloseTo(8);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("clamps the reward to the configured max amount", () => {
|
|
65
|
+
const example = buildPercentageExample(
|
|
66
|
+
percentage(8, { max: 4.8 }),
|
|
67
|
+
undefined
|
|
68
|
+
);
|
|
69
|
+
expect(example?.basket).toBe(100);
|
|
70
|
+
expect(example?.reward).toBeCloseTo(4.8);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("uses the minimum purchase as the basket when higher", () => {
|
|
74
|
+
const example = buildPercentageExample(percentage(5), 200);
|
|
75
|
+
expect(example?.basket).toBe(200);
|
|
76
|
+
expect(example?.reward).toBeCloseTo(10);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("buildTierExample", () => {
|
|
81
|
+
it("computes percent of the open tier lower bound", () => {
|
|
82
|
+
const example = buildTierExample(12, 80, undefined);
|
|
83
|
+
expect(example?.basket).toBe(80);
|
|
84
|
+
expect(example?.reward).toBeCloseTo(9.6);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("computes percent of a round basket inside a bounded tier", () => {
|
|
88
|
+
const example = buildTierExample(5, 30, 80);
|
|
89
|
+
expect(example?.basket).toBe(60);
|
|
90
|
+
expect(example?.reward).toBeCloseTo(3);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { EstimatedReward } from "../types";
|
|
2
|
+
|
|
3
|
+
type PercentageReward = Extract<EstimatedReward, { payoutType: "percentage" }>;
|
|
4
|
+
|
|
5
|
+
// Reference order total used to turn a "% of basket" reward into a concrete
|
|
6
|
+
// example amount. The campaign's minimum purchase overrides it when higher.
|
|
7
|
+
const REFERENCE_BASKET = 100;
|
|
8
|
+
|
|
9
|
+
export type RewardExample = {
|
|
10
|
+
basket: number;
|
|
11
|
+
reward: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function roundWithinRange(min: number, max: number): number {
|
|
15
|
+
const rounded = Math.round((min + max) / 2 / 10) * 10;
|
|
16
|
+
return Math.min(max, Math.max(min, rounded));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function pickFlatBasket(minPurchase: number | undefined): number {
|
|
20
|
+
return Math.max(REFERENCE_BASKET, minPurchase ?? 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Basket for a percentage tier: the reference when it sits inside the tier
|
|
24
|
+
// range, a round number within the range otherwise, or the lower bound for an
|
|
25
|
+
// open-ended (uncapped) top tier.
|
|
26
|
+
export function pickTierBasket(
|
|
27
|
+
minValue: number,
|
|
28
|
+
maxValue: number | undefined
|
|
29
|
+
): number {
|
|
30
|
+
if (maxValue == null) return minValue;
|
|
31
|
+
if (minValue <= REFERENCE_BASKET && REFERENCE_BASKET <= maxValue) {
|
|
32
|
+
return REFERENCE_BASKET;
|
|
33
|
+
}
|
|
34
|
+
return roundWithinRange(minValue, maxValue);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildPercentageExample(
|
|
38
|
+
reward: PercentageReward,
|
|
39
|
+
minPurchase: number | undefined
|
|
40
|
+
): RewardExample | undefined {
|
|
41
|
+
const basket = pickFlatBasket(minPurchase);
|
|
42
|
+
if (basket <= 0) return undefined;
|
|
43
|
+
let amount = (reward.percent / 100) * basket;
|
|
44
|
+
if (reward.minAmount) amount = Math.max(amount, reward.minAmount.eurAmount);
|
|
45
|
+
if (reward.maxAmount) amount = Math.min(amount, reward.maxAmount.eurAmount);
|
|
46
|
+
return { basket, reward: amount };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildTierExample(
|
|
50
|
+
percent: number,
|
|
51
|
+
minValue: number,
|
|
52
|
+
maxValue: number | undefined
|
|
53
|
+
): RewardExample | undefined {
|
|
54
|
+
const basket = pickTierBasket(minValue, maxValue);
|
|
55
|
+
if (basket <= 0) return undefined;
|
|
56
|
+
return { basket, reward: (percent / 100) * basket };
|
|
57
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { EstimatedReward } from "../types";
|
|
3
|
+
import {
|
|
4
|
+
applyRewardPlaceholder,
|
|
5
|
+
formatEstimatedReward,
|
|
6
|
+
formatRewardOrHide,
|
|
7
|
+
} from "./format";
|
|
8
|
+
|
|
9
|
+
const amount = (eur: number) => ({
|
|
10
|
+
amount: eur,
|
|
11
|
+
eurAmount: eur,
|
|
12
|
+
usdAmount: eur,
|
|
13
|
+
gbpAmount: eur,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("formatEstimatedReward", () => {
|
|
17
|
+
it("formats a fixed reward in the default currency, rounded", () => {
|
|
18
|
+
const reward: EstimatedReward = {
|
|
19
|
+
payoutType: "fixed",
|
|
20
|
+
amount: amount(5.4),
|
|
21
|
+
};
|
|
22
|
+
// Intl renders a narrow no-break space before "€" — assert content.
|
|
23
|
+
const formatted = formatEstimatedReward(reward);
|
|
24
|
+
expect(formatted).toContain("5");
|
|
25
|
+
expect(formatted).not.toContain("6");
|
|
26
|
+
expect(formatted).toContain("€");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders a percentage reward as a percent string (never a basket amount)", () => {
|
|
30
|
+
const reward: EstimatedReward = {
|
|
31
|
+
payoutType: "percentage",
|
|
32
|
+
percent: 10,
|
|
33
|
+
percentOf: "purchase_amount",
|
|
34
|
+
maxAmount: amount(50),
|
|
35
|
+
};
|
|
36
|
+
expect(formatEstimatedReward(reward)).toBe("10 %");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("uses the richest token tier for a tiered reward", () => {
|
|
40
|
+
const reward: EstimatedReward = {
|
|
41
|
+
payoutType: "tiered",
|
|
42
|
+
tierField: "purchase.amount",
|
|
43
|
+
tiers: [
|
|
44
|
+
{ minValue: 0, maxValue: 50, amount: amount(2) },
|
|
45
|
+
{ minValue: 50, amount: amount(8) },
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
const formatted = formatEstimatedReward(reward);
|
|
49
|
+
expect(formatted).toContain("8");
|
|
50
|
+
expect(formatted).toContain("€");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("falls back to the max percent when tiers carry no token amount", () => {
|
|
54
|
+
const reward: EstimatedReward = {
|
|
55
|
+
payoutType: "tiered",
|
|
56
|
+
tierField: "purchase.amount",
|
|
57
|
+
tiers: [
|
|
58
|
+
{ minValue: 0, maxValue: 50, percent: 5 },
|
|
59
|
+
{ minValue: 50, percent: 12 },
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
expect(formatEstimatedReward(reward)).toBe("12 %");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("respects the requested currency", () => {
|
|
66
|
+
const reward: EstimatedReward = {
|
|
67
|
+
payoutType: "fixed",
|
|
68
|
+
amount: amount(5),
|
|
69
|
+
};
|
|
70
|
+
const formatted = formatEstimatedReward(reward, "usd");
|
|
71
|
+
expect(formatted).toContain("5");
|
|
72
|
+
expect(formatted).toContain("$");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("formatRewardOrHide", () => {
|
|
77
|
+
it("returns undefined for a missing reward", () => {
|
|
78
|
+
expect(formatRewardOrHide(undefined)).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("formats a fixed reward that carries money value", () => {
|
|
82
|
+
const formatted = formatRewardOrHide({
|
|
83
|
+
payoutType: "fixed",
|
|
84
|
+
amount: amount(5),
|
|
85
|
+
});
|
|
86
|
+
expect(formatted).toContain("5");
|
|
87
|
+
expect(formatted).toContain("€");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("hides a fixed reward with no money value", () => {
|
|
91
|
+
expect(
|
|
92
|
+
formatRewardOrHide({ payoutType: "fixed", amount: amount(0) })
|
|
93
|
+
).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("always shows a capped percentage as a percent string", () => {
|
|
97
|
+
expect(
|
|
98
|
+
formatRewardOrHide({
|
|
99
|
+
payoutType: "percentage",
|
|
100
|
+
percent: 10,
|
|
101
|
+
percentOf: "purchase_amount",
|
|
102
|
+
maxAmount: amount(50),
|
|
103
|
+
})
|
|
104
|
+
).toBe("10 %");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("shows an uncapped percentage as a percent string (no money value)", () => {
|
|
108
|
+
expect(
|
|
109
|
+
formatRewardOrHide({
|
|
110
|
+
payoutType: "percentage",
|
|
111
|
+
percent: 8,
|
|
112
|
+
percentOf: "purchase_amount",
|
|
113
|
+
})
|
|
114
|
+
).toBe("8 %");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("shows a percent-only tiered reward instead of hiding it", () => {
|
|
118
|
+
expect(
|
|
119
|
+
formatRewardOrHide({
|
|
120
|
+
payoutType: "tiered",
|
|
121
|
+
tierField: "purchase.amount",
|
|
122
|
+
tiers: [
|
|
123
|
+
{ minValue: 0, maxValue: 50, percent: 5 },
|
|
124
|
+
{ minValue: 50, percent: 12 },
|
|
125
|
+
],
|
|
126
|
+
})
|
|
127
|
+
).toBe("12 %");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("applyRewardPlaceholder", () => {
|
|
132
|
+
it("substitutes the reward into the placeholder", () => {
|
|
133
|
+
expect(applyRewardPlaceholder("Earn {REWARD} now", "5 €")).toBe(
|
|
134
|
+
"Earn 5 € now"
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("strips the placeholder when no reward is provided", () => {
|
|
139
|
+
expect(applyRewardPlaceholder("Earn {REWARD} now", undefined)).toBe(
|
|
140
|
+
"Earn now"
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Currency, EstimatedReward } from "../types";
|
|
2
|
+
import { formatAmount } from "../utils/format/formatAmount";
|
|
3
|
+
import { getCurrencyAmountKey } from "../utils/format/getCurrencyAmountKey";
|
|
4
|
+
import { getSupportedCurrency } from "../utils/format/getSupportedCurrency";
|
|
5
|
+
import { getRewardRank, getRewardValue, maxRewardPercent } from "./value";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format an {@link EstimatedReward} into a human-readable string.
|
|
9
|
+
*
|
|
10
|
+
* - `fixed` → e.g. `"5 €"`
|
|
11
|
+
* - `percentage` → e.g. `"10 %"`
|
|
12
|
+
* - `tiered` → the richest tier: max token amount (e.g. `"50 €"`) or, when
|
|
13
|
+
* tiers only carry percentages, the max percent (e.g. `"10 %"`)
|
|
14
|
+
*
|
|
15
|
+
* Percentages are always rendered as a `"X %"` string: the backend never sends
|
|
16
|
+
* a reference basket, so the reward cannot be resolved to a concrete amount
|
|
17
|
+
* here. Callers that need a worked example use the `example` helpers instead.
|
|
18
|
+
*/
|
|
19
|
+
export function formatEstimatedReward(
|
|
20
|
+
reward: EstimatedReward,
|
|
21
|
+
currency?: Currency
|
|
22
|
+
): string {
|
|
23
|
+
const supportedCurrency = getSupportedCurrency(currency);
|
|
24
|
+
const key = getCurrencyAmountKey(supportedCurrency);
|
|
25
|
+
|
|
26
|
+
switch (reward.payoutType) {
|
|
27
|
+
case "fixed":
|
|
28
|
+
return formatAmount(
|
|
29
|
+
Math.round(reward.amount[key]),
|
|
30
|
+
supportedCurrency
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
case "percentage":
|
|
34
|
+
return `${reward.percent} %`;
|
|
35
|
+
|
|
36
|
+
case "tiered": {
|
|
37
|
+
const maxAmount = getRewardValue(reward, key);
|
|
38
|
+
if (maxAmount > 0) {
|
|
39
|
+
return formatAmount(Math.round(maxAmount), supportedCurrency);
|
|
40
|
+
}
|
|
41
|
+
const maxPercent = maxRewardPercent(reward);
|
|
42
|
+
if (maxPercent > 0) {
|
|
43
|
+
return `${maxPercent} %`;
|
|
44
|
+
}
|
|
45
|
+
return formatAmount(0, supportedCurrency);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format a reward for display, or return `undefined` when it is not worth
|
|
52
|
+
* advertising. Callers rely on `undefined` to hide a badge / fall back to
|
|
53
|
+
* other copy.
|
|
54
|
+
*
|
|
55
|
+
* A reward is hidden only when it carries no displayable value — a `fixed` or
|
|
56
|
+
* `tiered` reward whose money value is `0` (e.g. an unknown token price). An
|
|
57
|
+
* uncapped percentage always renders as `"X %"`, since the percent itself is
|
|
58
|
+
* meaningful even without a money value.
|
|
59
|
+
*/
|
|
60
|
+
export function formatRewardOrHide(
|
|
61
|
+
reward: EstimatedReward | undefined,
|
|
62
|
+
currency?: Currency
|
|
63
|
+
): string | undefined {
|
|
64
|
+
if (!reward) return undefined;
|
|
65
|
+
const key = getCurrencyAmountKey(getSupportedCurrency(currency));
|
|
66
|
+
if (getRewardRank(reward, key) <= 0) return undefined;
|
|
67
|
+
return formatEstimatedReward(reward, currency);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Replace the `{REWARD}` placeholder in a text string with a reward value.
|
|
72
|
+
* If no reward is provided, returns the text with `{REWARD}` stripped.
|
|
73
|
+
*/
|
|
74
|
+
export function applyRewardPlaceholder(
|
|
75
|
+
text: string,
|
|
76
|
+
reward: string | undefined
|
|
77
|
+
): string {
|
|
78
|
+
return reward
|
|
79
|
+
? text.replace("{REWARD}", reward)
|
|
80
|
+
: text.replace("{REWARD}", "");
|
|
81
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Framework-agnostic reward display logic shared across the SDK, listener,
|
|
2
|
+
// wallet and wallet-shared. Depends only on `@frak-labs/core-sdk`'s own reward
|
|
3
|
+
// types (`EstimatedReward`, `MerchantReward`, `RuleConditions`) — no backend or
|
|
4
|
+
// framework coupling — so it stays safe to ship inside the published SDK and
|
|
5
|
+
// lets core-sdk consume its own reward logic without a dependency cycle.
|
|
6
|
+
|
|
7
|
+
export { extractMinPurchaseAmount, extractStartDate } from "./conditions";
|
|
8
|
+
export {
|
|
9
|
+
buildPercentageExample,
|
|
10
|
+
buildTierExample,
|
|
11
|
+
pickFlatBasket,
|
|
12
|
+
pickTierBasket,
|
|
13
|
+
type RewardExample,
|
|
14
|
+
} from "./example";
|
|
15
|
+
export {
|
|
16
|
+
applyRewardPlaceholder,
|
|
17
|
+
formatEstimatedReward,
|
|
18
|
+
formatRewardOrHide,
|
|
19
|
+
} from "./format";
|
|
20
|
+
export {
|
|
21
|
+
type DisplayCampaign,
|
|
22
|
+
formatBestReward,
|
|
23
|
+
type RewardAudience,
|
|
24
|
+
type SelectDisplayCampaignOptions,
|
|
25
|
+
selectDisplayCampaign,
|
|
26
|
+
} from "./select";
|
|
27
|
+
export { getRewardValue } from "./value";
|