@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.
Files changed (82) hide show
  1. package/cdn/bundle.js +1 -1
  2. package/dist/actions-Cyn4asew.cjs +2 -0
  3. package/dist/actions-Cyn4asew.cjs.map +1 -0
  4. package/dist/actions-DtPVuk2e.js +2 -0
  5. package/dist/actions-DtPVuk2e.js.map +1 -0
  6. package/dist/actions.cjs +1 -1
  7. package/dist/actions.d.cts +3 -3
  8. package/dist/actions.d.ts +3 -3
  9. package/dist/actions.js +1 -1
  10. package/dist/bundle.cjs +1 -1
  11. package/dist/bundle.d.cts +5 -4
  12. package/dist/bundle.d.ts +5 -4
  13. package/dist/bundle.js +1 -1
  14. package/dist/frakContext-Bgidv-Se.js +2 -0
  15. package/dist/frakContext-Bgidv-Se.js.map +1 -0
  16. package/dist/frakContext-CeVlWFcX.cjs +2 -0
  17. package/dist/frakContext-CeVlWFcX.cjs.map +1 -0
  18. package/dist/getCurrencyAmountKey-DQcwjJiB.js +2 -0
  19. package/dist/getCurrencyAmountKey-DQcwjJiB.js.map +1 -0
  20. package/dist/getCurrencyAmountKey-h2eOiYzv.cjs +2 -0
  21. package/dist/getCurrencyAmountKey-h2eOiYzv.cjs.map +1 -0
  22. package/dist/{index-lcImYQyL.d.ts → index-BCoPwxjq.d.cts} +4 -2
  23. package/dist/{index-C5pP_9We.d.cts → index-CiHjUnfc.d.ts} +43 -6
  24. package/dist/{index-DrYZwIjb.d.ts → index-D25z5B9K.d.cts} +43 -6
  25. package/dist/{index-DR4n-QOe.d.cts → index-trhAN7q9.d.ts} +4 -2
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.d.cts +4 -3
  28. package/dist/index.d.ts +4 -3
  29. package/dist/index.js +1 -1
  30. package/dist/merchantInformation--tLnowAN.d.ts +344 -0
  31. package/dist/merchantInformation-BSuahpjl.d.cts +344 -0
  32. package/dist/{openSso-BErs7_Bg.d.cts → openSso-CENLMWJx.d.ts} +4 -279
  33. package/dist/{openSso-DRLH4D9a.d.ts → openSso-CKvkAGts.d.cts} +4 -279
  34. package/dist/rewards.cjs +2 -0
  35. package/dist/rewards.cjs.map +1 -0
  36. package/dist/rewards.d.cts +101 -0
  37. package/dist/rewards.d.ts +101 -0
  38. package/dist/rewards.js +2 -0
  39. package/dist/rewards.js.map +1 -0
  40. package/dist/src-BXtnEE3a.js +2 -0
  41. package/dist/src-BXtnEE3a.js.map +1 -0
  42. package/dist/src-TpX-7MwJ.cjs +2 -0
  43. package/dist/src-TpX-7MwJ.cjs.map +1 -0
  44. package/package.json +16 -3
  45. package/src/actions/getMerchantInformation.test.ts +2 -0
  46. package/src/actions/referral/processReferral.test.ts +4 -4
  47. package/src/actions/referral/processReferral.ts +4 -4
  48. package/src/clients/createIFrameFrakClient.ts +7 -7
  49. package/src/clients/transports/iframeLifecycleManager.ts +5 -21
  50. package/src/config/sdkConfigStore.ts +6 -3
  51. package/src/context/frakContext.test.ts +55 -0
  52. package/src/context/frakContext.ts +13 -2
  53. package/src/index.ts +8 -0
  54. package/src/rewards/conditions.test.ts +60 -0
  55. package/src/rewards/conditions.ts +72 -0
  56. package/src/rewards/example.test.ts +92 -0
  57. package/src/rewards/example.ts +57 -0
  58. package/src/rewards/format.test.ts +143 -0
  59. package/src/rewards/format.ts +81 -0
  60. package/src/rewards/index.ts +27 -0
  61. package/src/rewards/select.test.ts +212 -0
  62. package/src/rewards/select.ts +132 -0
  63. package/src/rewards/value.test.ts +89 -0
  64. package/src/rewards/value.ts +73 -0
  65. package/src/types/index.ts +6 -0
  66. package/src/types/rpc/merchantInformation.ts +120 -13
  67. package/src/utils/analytics/events/lifecycle.ts +1 -1
  68. package/src/utils/analytics/events/referral.ts +3 -3
  69. package/src/utils/browser/deepLinkWithFallback.ts +4 -1
  70. package/src/utils/browser/inAppBrowser.ts +2 -2
  71. package/src/utils/cache/withCache.ts +30 -11
  72. package/src/utils/iframe/iframeHelper.test.ts +39 -0
  73. package/src/utils/iframe/iframeHelper.ts +31 -2
  74. package/src/utils/index.ts +5 -0
  75. package/src/utils/url/queryParams.test.ts +85 -0
  76. package/src/utils/url/queryParams.ts +57 -0
  77. package/dist/actions-D5rNeJC0.js +0 -1
  78. package/dist/actions-gmdNZvNh.cjs +0 -1
  79. package/dist/frakContext-Cfp7sSid.cjs +0 -1
  80. package/dist/frakContext-UEOk9Bk4.js +0 -1
  81. package/dist/src-BEMVYU3J.js +0 -1
  82. 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 = urlObj.searchParams.get(contextKey);
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.delete(contextKey);
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";