@frak-labs/core-sdk 1.1.4 → 1.1.5-beta.1700e6ec

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 (78) 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-Cm1PNC9z.d.ts → index-CD-GW6rL.d.ts} +8 -6
  23. package/dist/{index-Cumn_FNI.d.cts → index-D5_EwZ3f.d.cts} +8 -6
  24. package/dist/{index-lcImYQyL.d.ts → index-DPez7SXh.d.cts} +4 -2
  25. package/dist/{index-DR4n-QOe.d.cts → index-DQYipoH_.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-CjZ8lAEA.d.ts} +8 -281
  33. package/dist/{openSso-DRLH4D9a.d.ts → openSso-DSXbUP-j.d.cts} +8 -281
  34. package/dist/rewards.cjs +2 -0
  35. package/dist/rewards.cjs.map +1 -0
  36. package/dist/rewards.d.cts +130 -0
  37. package/dist/rewards.d.ts +130 -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/index.ts +6 -0
  52. package/src/rewards/conditions.test.ts +60 -0
  53. package/src/rewards/conditions.ts +72 -0
  54. package/src/rewards/example.test.ts +92 -0
  55. package/src/rewards/example.ts +57 -0
  56. package/src/rewards/format.test.ts +143 -0
  57. package/src/rewards/format.ts +81 -0
  58. package/src/rewards/index.ts +29 -0
  59. package/src/rewards/select.test.ts +281 -0
  60. package/src/rewards/select.ts +197 -0
  61. package/src/rewards/value.test.ts +89 -0
  62. package/src/rewards/value.ts +73 -0
  63. package/src/types/index.ts +6 -0
  64. package/src/types/resolvedConfig.ts +4 -0
  65. package/src/types/rpc/merchantInformation.ts +120 -13
  66. package/src/utils/analytics/events/lifecycle.ts +1 -1
  67. package/src/utils/analytics/events/referral.ts +3 -3
  68. package/src/utils/browser/deepLinkWithFallback.ts +4 -1
  69. package/src/utils/browser/inAppBrowser.ts +2 -2
  70. package/src/utils/cache/withCache.ts +30 -11
  71. package/src/utils/iframe/iframeHelper.test.ts +39 -0
  72. package/src/utils/iframe/iframeHelper.ts +31 -2
  73. package/dist/actions-D5GbNcvt.cjs +0 -1
  74. package/dist/actions-DE2dHqtp.js +0 -1
  75. package/dist/frakContext-Bl17GGa3.cjs +0 -1
  76. package/dist/frakContext-DmI5CaSw.js +0 -1
  77. package/dist/src-5vpUuhfY.js +0 -1
  78. package/dist/src-BQsjMpvA.cjs +0 -1
@@ -0,0 +1,281 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { InteractionTypeKey } from "../constants/interactionTypes";
3
+ import type { EstimatedReward, MerchantReward, RuleConditions } from "../types";
4
+ import {
5
+ formatBestReward,
6
+ selectBestReward,
7
+ selectDisplayCampaign,
8
+ } from "./select";
9
+
10
+ const NOW = new Date("2025-01-15T00:00:00Z");
11
+ const unix = (iso: string) => Math.floor(new Date(iso).getTime() / 1000);
12
+
13
+ function fixedReward(eur: number): EstimatedReward {
14
+ return {
15
+ payoutType: "fixed",
16
+ amount: { amount: eur, eurAmount: eur, usdAmount: eur, gbpAmount: eur },
17
+ };
18
+ }
19
+
20
+ function uncappedPercentage(percent: number): EstimatedReward {
21
+ return { payoutType: "percentage", percent, percentOf: "purchase_amount" };
22
+ }
23
+
24
+ function startsAtCondition(iso: string): RuleConditions {
25
+ return [{ field: "time.timestamp", operator: "gte", value: unix(iso) }];
26
+ }
27
+
28
+ function campaign(opts: {
29
+ id: string;
30
+ interactionTypeKey?: InteractionTypeKey;
31
+ referrer?: EstimatedReward;
32
+ referee?: EstimatedReward;
33
+ conditions?: RuleConditions;
34
+ expiresAt?: string | null;
35
+ defaultLockupSeconds?: number;
36
+ }): MerchantReward {
37
+ return {
38
+ campaignId: opts.id,
39
+ name: opts.id,
40
+ interactionTypeKey: opts.interactionTypeKey ?? "purchase",
41
+ conditions: opts.conditions ?? [],
42
+ referrer: opts.referrer,
43
+ referee: opts.referee,
44
+ expiresAt: opts.expiresAt,
45
+ defaultLockupSeconds: opts.defaultLockupSeconds,
46
+ };
47
+ }
48
+
49
+ describe("selectDisplayCampaign", () => {
50
+ it("returns undefined for an empty set", () => {
51
+ expect(selectDisplayCampaign([], { now: NOW })).toBeUndefined();
52
+ });
53
+
54
+ it("picks the highest-reward live campaign", () => {
55
+ const result = selectDisplayCampaign(
56
+ [
57
+ campaign({ id: "low", referrer: fixedReward(2) }),
58
+ campaign({ id: "high", referrer: fixedReward(9) }),
59
+ ],
60
+ { now: NOW }
61
+ );
62
+ expect(result?.status).toBe("live");
63
+ expect(result?.campaign.campaignId).toBe("high");
64
+ });
65
+
66
+ it("prefers a live campaign over a richer upcoming one", () => {
67
+ const result = selectDisplayCampaign(
68
+ [
69
+ campaign({
70
+ id: "upcoming-rich",
71
+ referrer: fixedReward(50),
72
+ conditions: startsAtCondition("2025-03-01T00:00:00Z"),
73
+ }),
74
+ campaign({ id: "live-poor", referrer: fixedReward(3) }),
75
+ ],
76
+ { now: NOW }
77
+ );
78
+ expect(result?.status).toBe("live");
79
+ expect(result?.campaign.campaignId).toBe("live-poor");
80
+ });
81
+
82
+ it("falls back to the soonest-starting upcoming campaign", () => {
83
+ const result = selectDisplayCampaign(
84
+ [
85
+ campaign({
86
+ id: "later",
87
+ referrer: fixedReward(50),
88
+ conditions: startsAtCondition("2025-04-01T00:00:00Z"),
89
+ }),
90
+ campaign({
91
+ id: "sooner",
92
+ referrer: fixedReward(5),
93
+ conditions: startsAtCondition("2025-02-01T00:00:00Z"),
94
+ }),
95
+ ],
96
+ { now: NOW }
97
+ );
98
+ expect(result?.status).toBe("upcoming");
99
+ expect(result?.campaign.campaignId).toBe("sooner");
100
+ expect(result?.startsAt?.toISOString()).toBe(
101
+ "2025-02-01T00:00:00.000Z"
102
+ );
103
+ });
104
+
105
+ it("skips expired campaigns", () => {
106
+ const result = selectDisplayCampaign(
107
+ [
108
+ campaign({
109
+ id: "expired",
110
+ referrer: fixedReward(99),
111
+ expiresAt: "2025-01-01T00:00:00Z",
112
+ }),
113
+ campaign({ id: "active", referrer: fixedReward(4) }),
114
+ ],
115
+ { now: NOW }
116
+ );
117
+ expect(result?.campaign.campaignId).toBe("active");
118
+ });
119
+
120
+ it("only considers campaigns matching targetInteraction", () => {
121
+ const result = selectDisplayCampaign(
122
+ [
123
+ campaign({
124
+ id: "referral-rich",
125
+ interactionTypeKey: "referral",
126
+ referrer: fixedReward(50),
127
+ }),
128
+ campaign({
129
+ id: "purchase-poor",
130
+ interactionTypeKey: "purchase",
131
+ referrer: fixedReward(4),
132
+ }),
133
+ ],
134
+ { now: NOW, targetInteraction: "purchase" }
135
+ );
136
+ expect(result?.campaign.campaignId).toBe("purchase-poor");
137
+ });
138
+
139
+ it("ranks by the referee side when audience is 'referee'", () => {
140
+ const rewards = [
141
+ campaign({
142
+ id: "rich-referrer",
143
+ referrer: fixedReward(50),
144
+ referee: fixedReward(2),
145
+ }),
146
+ campaign({
147
+ id: "rich-referee",
148
+ referrer: fixedReward(1),
149
+ referee: fixedReward(9),
150
+ }),
151
+ ];
152
+ expect(
153
+ selectDisplayCampaign(rewards, { now: NOW })?.campaign.campaignId
154
+ ).toBe("rich-referrer");
155
+ expect(
156
+ selectDisplayCampaign(rewards, { now: NOW, audience: "referee" })
157
+ ?.campaign.campaignId
158
+ ).toBe("rich-referee");
159
+ });
160
+ });
161
+
162
+ describe("formatBestReward", () => {
163
+ it("returns undefined for an empty set", () => {
164
+ expect(formatBestReward([], { now: NOW })).toBeUndefined();
165
+ });
166
+
167
+ it("formats the selected campaign's referrer reward by default", () => {
168
+ const formatted = formatBestReward(
169
+ [
170
+ campaign({ id: "low", referrer: fixedReward(2) }),
171
+ campaign({ id: "high", referrer: fixedReward(50) }),
172
+ ],
173
+ { now: NOW }
174
+ );
175
+ expect(formatted).toContain("50");
176
+ });
177
+
178
+ it("formats the referee reward when audience is 'referee'", () => {
179
+ const formatted = formatBestReward(
180
+ [
181
+ campaign({
182
+ id: "c",
183
+ referrer: fixedReward(50),
184
+ referee: fixedReward(3),
185
+ }),
186
+ ],
187
+ { now: NOW, audience: "referee" }
188
+ );
189
+ expect(formatted).toContain("3");
190
+ expect(formatted).not.toContain("50");
191
+ });
192
+
193
+ it("ignores expired campaigns (does not advertise a stale reward)", () => {
194
+ expect(
195
+ formatBestReward(
196
+ [
197
+ campaign({
198
+ id: "expired",
199
+ referrer: fixedReward(99),
200
+ expiresAt: "2025-01-01T00:00:00Z",
201
+ }),
202
+ ],
203
+ { now: NOW }
204
+ )
205
+ ).toBeUndefined();
206
+ });
207
+
208
+ it("renders an uncapped percentage reward as a percent string", () => {
209
+ expect(
210
+ formatBestReward(
211
+ [campaign({ id: "c", referrer: uncappedPercentage(10) })],
212
+ { now: NOW }
213
+ )
214
+ ).toBe("10 %");
215
+ });
216
+ });
217
+
218
+ describe("selectBestReward", () => {
219
+ it("returns undefined for an empty set", () => {
220
+ expect(selectBestReward([], { now: NOW })).toBeUndefined();
221
+ });
222
+
223
+ it("exposes the formatted reward and its payout type", () => {
224
+ const best = selectBestReward(
225
+ [campaign({ id: "c", referrer: fixedReward(50) })],
226
+ { now: NOW }
227
+ );
228
+ expect(best?.formatted).toContain("50");
229
+ expect(best?.payoutType).toBe("fixed");
230
+ expect(best?.minPurchaseAmount).toBeUndefined();
231
+ expect(best?.lockupDurationDays).toBeUndefined();
232
+ });
233
+
234
+ it("surfaces the minimum purchase amount when the campaign gates on one", () => {
235
+ const best = selectBestReward(
236
+ [
237
+ campaign({
238
+ id: "c",
239
+ referrer: fixedReward(50),
240
+ conditions: [
241
+ {
242
+ field: "purchase.amount",
243
+ operator: "gte",
244
+ value: 25,
245
+ },
246
+ ],
247
+ }),
248
+ ],
249
+ { now: NOW }
250
+ );
251
+ expect(best?.minPurchaseAmount).toContain("25");
252
+ });
253
+
254
+ it("surfaces the lockup duration in whole days", () => {
255
+ const best = selectBestReward(
256
+ [
257
+ campaign({
258
+ id: "c",
259
+ referrer: fixedReward(50),
260
+ defaultLockupSeconds: 7 * 86_400,
261
+ }),
262
+ ],
263
+ { now: NOW }
264
+ );
265
+ expect(best?.lockupDurationDays).toBe(7);
266
+ });
267
+
268
+ it("omits a zero lockup", () => {
269
+ const best = selectBestReward(
270
+ [
271
+ campaign({
272
+ id: "c",
273
+ referrer: fixedReward(50),
274
+ defaultLockupSeconds: 0,
275
+ }),
276
+ ],
277
+ { now: NOW }
278
+ );
279
+ expect(best?.lockupDurationDays).toBeUndefined();
280
+ });
281
+ });
@@ -0,0 +1,197 @@
1
+ import type { InteractionTypeKey } from "../constants/interactionTypes";
2
+ import type {
3
+ Currency,
4
+ EstimatedReward,
5
+ MerchantReward,
6
+ TokenAmountType,
7
+ } from "../types";
8
+ import { formatAmount } from "../utils/format/formatAmount";
9
+ import { getCurrencyAmountKey } from "../utils/format/getCurrencyAmountKey";
10
+ import { getSupportedCurrency } from "../utils/format/getSupportedCurrency";
11
+ import { extractMinPurchaseAmount, extractStartDate } from "./conditions";
12
+ import { formatRewardOrHide } from "./format";
13
+ import { getRewardRank } from "./value";
14
+
15
+ /** Reward side a surface cares about: the sharer (`referrer`) or the referred
16
+ * user (`referee`). Drives both campaign ranking and which side is displayed. */
17
+ export type RewardAudience = "referrer" | "referee";
18
+
19
+ export type DisplayCampaign = {
20
+ campaign: MerchantReward;
21
+ status: "live" | "upcoming";
22
+ startsAt?: Date;
23
+ };
24
+
25
+ export type SelectDisplayCampaignOptions = {
26
+ /** Reference "now" for expiry / start gating; defaults to the current time. */
27
+ now?: Date;
28
+ /** Currency the ranking is expressed in; defaults to EUR. */
29
+ currency?: Currency;
30
+ /** When set, only campaigns triggered by this interaction are considered. */
31
+ targetInteraction?: InteractionTypeKey;
32
+ /** Reward side to rank campaigns by; defaults to `"referrer"`. */
33
+ audience?: RewardAudience;
34
+ };
35
+
36
+ function isExpired(campaign: MerchantReward, nowMs: number): boolean {
37
+ return (
38
+ campaign.expiresAt != null &&
39
+ new Date(campaign.expiresAt).getTime() <= nowMs
40
+ );
41
+ }
42
+
43
+ function hasStarted(campaign: MerchantReward, nowMs: number): boolean {
44
+ const startsAt = extractStartDate(campaign.conditions);
45
+ return startsAt == null || startsAt.getTime() <= nowMs;
46
+ }
47
+
48
+ function audienceReward(campaign: MerchantReward, audience: RewardAudience) {
49
+ return audience === "referee" ? campaign.referee : campaign.referrer;
50
+ }
51
+
52
+ function campaignRank(
53
+ campaign: MerchantReward,
54
+ key: keyof TokenAmountType,
55
+ audience: RewardAudience
56
+ ): number {
57
+ const reward = audienceReward(campaign, audience);
58
+ return reward ? getRewardRank(reward, key) : 0;
59
+ }
60
+
61
+ /**
62
+ * Pick the single campaign a merchant surface should display.
63
+ *
64
+ * Filters out expired (and, when `targetInteraction` is set, non-matching)
65
+ * campaigns, then prefers the highest-ranked *live* campaign — ranked by the
66
+ * `audience` reward side in the requested currency. When none has started yet
67
+ * it falls back to the soonest-starting upcoming campaign (the endpoint does
68
+ * not gate on the start-date condition, so future-start campaigns come
69
+ * through).
70
+ */
71
+ export function selectDisplayCampaign(
72
+ rewards: readonly MerchantReward[],
73
+ options: SelectDisplayCampaignOptions = {}
74
+ ): DisplayCampaign | undefined {
75
+ const nowMs = (options.now ?? new Date()).getTime();
76
+ const audience = options.audience ?? "referrer";
77
+ const key = getCurrencyAmountKey(getSupportedCurrency(options.currency));
78
+
79
+ const matching = options.targetInteraction
80
+ ? rewards.filter(
81
+ (campaign) =>
82
+ campaign.interactionTypeKey === options.targetInteraction
83
+ )
84
+ : rewards;
85
+ const active = matching.filter((campaign) => !isExpired(campaign, nowMs));
86
+
87
+ const live = active.filter((campaign) => hasStarted(campaign, nowMs));
88
+ if (live.length > 0) {
89
+ const best = live.reduce((a, b) =>
90
+ campaignRank(b, key, audience) > campaignRank(a, key, audience)
91
+ ? b
92
+ : a
93
+ );
94
+ return { campaign: best, status: "live" };
95
+ }
96
+
97
+ const upcoming = active
98
+ .map((campaign) => ({
99
+ campaign,
100
+ startsAt: extractStartDate(campaign.conditions),
101
+ }))
102
+ .filter(
103
+ (entry): entry is { campaign: MerchantReward; startsAt: Date } =>
104
+ entry.startsAt != null
105
+ );
106
+ if (upcoming.length === 0) return undefined;
107
+
108
+ const soonest = upcoming.reduce((a, b) =>
109
+ b.startsAt.getTime() < a.startsAt.getTime() ? b : a
110
+ );
111
+ return {
112
+ campaign: soonest.campaign,
113
+ status: "upcoming",
114
+ startsAt: soonest.startsAt,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * The single reward a merchant surface should display: its formatted string
120
+ * plus the `payoutType` of the underlying reward, so surfaces can adapt their
121
+ * presentation (e.g. hide percentage rewards, prefix tiered ones with "Up to").
122
+ */
123
+ export type BestReward = {
124
+ /** Display-ready reward string (e.g. `"5 €"`, `"10 %"`). */
125
+ formatted: string;
126
+ /** Payout type of the selected reward. */
127
+ payoutType: EstimatedReward["payoutType"];
128
+ /**
129
+ * Minimum purchase amount gating the reward, formatted with the requested
130
+ * currency (e.g. `"10 €"`), or `undefined` when the campaign sets no
131
+ * minimum.
132
+ */
133
+ minPurchaseAmount?: string;
134
+ /**
135
+ * Whole-day lockup applied before the reward settles, or `undefined` when
136
+ * the campaign has no lockup.
137
+ */
138
+ lockupDurationDays?: number;
139
+ };
140
+
141
+ /**
142
+ * Pick the best campaign for `options` and resolve its `audience`-side reward
143
+ * to a formatted string plus its `payoutType`, or `undefined` when there is
144
+ * nothing worth showing.
145
+ *
146
+ * Single entry point shared by every "headline reward" surface (share button,
147
+ * wallet modal, sharing/install screens) so they all show the same number for
148
+ * a given merchant and can branch on the payout type.
149
+ */
150
+ export function selectBestReward(
151
+ rewards: readonly MerchantReward[],
152
+ options: SelectDisplayCampaignOptions = {}
153
+ ): BestReward | undefined {
154
+ const selected = selectDisplayCampaign(rewards, options);
155
+ if (!selected) return undefined;
156
+ const reward = audienceReward(
157
+ selected.campaign,
158
+ options.audience ?? "referrer"
159
+ );
160
+ if (!reward) return undefined;
161
+ const formatted = formatRewardOrHide(reward, options.currency);
162
+ if (!formatted) return undefined;
163
+
164
+ const minPurchase = extractMinPurchaseAmount(selected.campaign.conditions);
165
+ const minPurchaseAmount =
166
+ minPurchase != null
167
+ ? formatAmount(minPurchase, options.currency)
168
+ : undefined;
169
+
170
+ const lockupSeconds = selected.campaign.defaultLockupSeconds;
171
+ const lockupDurationDays =
172
+ lockupSeconds && lockupSeconds > 0
173
+ ? Math.round(lockupSeconds / 86_400)
174
+ : undefined;
175
+
176
+ return {
177
+ formatted,
178
+ payoutType: reward.payoutType,
179
+ minPurchaseAmount,
180
+ lockupDurationDays,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Headline reward string for a merchant: picks the best campaign for `options`
186
+ * and formats its `audience`-side reward, or returns `undefined` when there is
187
+ * nothing worth showing.
188
+ *
189
+ * Thin wrapper over {@link selectBestReward} for callers that only need the
190
+ * formatted string.
191
+ */
192
+ export function formatBestReward(
193
+ rewards: readonly MerchantReward[],
194
+ options: SelectDisplayCampaignOptions = {}
195
+ ): string | undefined {
196
+ return selectBestReward(rewards, options)?.formatted;
197
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { EstimatedReward } from "../types";
3
+ import { getRewardRank, getRewardValue } from "./value";
4
+
5
+ const amount = (eur: number) => ({
6
+ amount: eur,
7
+ eurAmount: eur,
8
+ usdAmount: eur,
9
+ gbpAmount: eur,
10
+ });
11
+
12
+ const fixed = (eur: number): EstimatedReward => ({
13
+ payoutType: "fixed",
14
+ amount: amount(eur),
15
+ });
16
+
17
+ const cappedPercentage = (percent: number, cap: number): EstimatedReward => ({
18
+ payoutType: "percentage",
19
+ percent,
20
+ percentOf: "purchase_amount",
21
+ maxAmount: amount(cap),
22
+ });
23
+
24
+ const uncappedPercentage = (percent: number): EstimatedReward => ({
25
+ payoutType: "percentage",
26
+ percent,
27
+ percentOf: "purchase_amount",
28
+ });
29
+
30
+ describe("getRewardValue", () => {
31
+ it("returns the fixed amount in the requested currency", () => {
32
+ expect(getRewardValue(fixed(5), "eurAmount")).toBe(5);
33
+ });
34
+
35
+ it("uses the capped maxAmount for a percentage reward", () => {
36
+ expect(getRewardValue(cappedPercentage(8, 4.8), "eurAmount")).toBe(4.8);
37
+ });
38
+
39
+ it("returns 0 for an uncapped percentage reward", () => {
40
+ expect(getRewardValue(uncappedPercentage(8), "eurAmount")).toBe(0);
41
+ });
42
+
43
+ it("returns the richest token tier for a tiered reward", () => {
44
+ expect(
45
+ getRewardValue(
46
+ {
47
+ payoutType: "tiered",
48
+ tierField: "purchase.amount",
49
+ tiers: [
50
+ { minValue: 0, maxValue: 50, amount: amount(2) },
51
+ { minValue: 50, amount: amount(9) },
52
+ ],
53
+ },
54
+ "eurAmount"
55
+ )
56
+ ).toBe(9);
57
+ });
58
+ });
59
+
60
+ describe("getRewardRank", () => {
61
+ it("ranks a reward with real money by its money value", () => {
62
+ expect(getRewardRank(fixed(5), "eurAmount")).toBe(5);
63
+ expect(getRewardRank(cappedPercentage(8, 4.8), "eurAmount")).toBe(4.8);
64
+ });
65
+
66
+ it("gives an uncapped percentage a positive weight (never buried at 0)", () => {
67
+ expect(
68
+ getRewardRank(uncappedPercentage(8), "eurAmount")
69
+ ).toBeGreaterThan(0);
70
+ });
71
+
72
+ it("keeps real money ranked above any uncapped percentage", () => {
73
+ expect(getRewardRank(fixed(1), "eurAmount")).toBeGreaterThan(
74
+ getRewardRank(uncappedPercentage(50), "eurAmount")
75
+ );
76
+ });
77
+
78
+ it("keeps an uncapped percentage ranked above a zero-value reward", () => {
79
+ expect(
80
+ getRewardRank(uncappedPercentage(8), "eurAmount")
81
+ ).toBeGreaterThan(getRewardRank(fixed(0), "eurAmount"));
82
+ });
83
+
84
+ it("ranks a higher uncapped percentage above a lower one", () => {
85
+ expect(
86
+ getRewardRank(uncappedPercentage(20), "eurAmount")
87
+ ).toBeGreaterThan(getRewardRank(uncappedPercentage(5), "eurAmount"));
88
+ });
89
+ });
@@ -0,0 +1,73 @@
1
+ import type { EstimatedReward, TokenAmountType } from "../types";
2
+
3
+ /**
4
+ * Comparable fiat value of a single reward, used to rank rewards against each
5
+ * other.
6
+ *
7
+ * - `fixed` → the token amount in the requested currency
8
+ * - `tiered` → the highest token amount across tiers
9
+ * - `percentage` → the capped (`maxAmount`) value when present, otherwise `0`
10
+ * (an uncapped percentage has no comparable fiat value)
11
+ */
12
+ export function getRewardValue(
13
+ reward: EstimatedReward,
14
+ key: keyof TokenAmountType
15
+ ): number {
16
+ switch (reward.payoutType) {
17
+ case "fixed":
18
+ return reward.amount[key];
19
+ case "percentage":
20
+ return reward.maxAmount?.[key] ?? 0;
21
+ case "tiered":
22
+ return reward.tiers.reduce(
23
+ (max, tier) =>
24
+ "amount" in tier ? Math.max(max, tier.amount[key]) : max,
25
+ 0
26
+ );
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Highest percent a reward exposes: the flat `percentage` percent, or the
32
+ * richest percent across `tiered` tiers, or `0` when it carries none.
33
+ *
34
+ * Shared by {@link getRewardRank} (to weight a percentage-only reward) and
35
+ * `formatEstimatedReward` (to render it as `"X %"`), so the value the UI ranks
36
+ * by and the value it prints stay derived from one tier traversal.
37
+ */
38
+ export function maxRewardPercent(reward: EstimatedReward): number {
39
+ if (reward.payoutType === "percentage") return reward.percent;
40
+ if (reward.payoutType === "tiered") {
41
+ return reward.tiers.reduce(
42
+ (max, tier) =>
43
+ "percent" in tier ? Math.max(max, tier.percent) : max,
44
+ 0
45
+ );
46
+ }
47
+ return 0;
48
+ }
49
+
50
+ // A reward with no money value (an uncapped percentage, or a percent-only
51
+ // tier set) still renders as "X %", so it is worth surfacing. We give it a
52
+ // positive ranking weight derived from its percent but scaled far below any
53
+ // real-money reward, so the two invariants the UI relies on hold: (1) a reward
54
+ // with real money always outranks a percentage-only reward, and (2) a
55
+ // percentage-only reward still outranks a zero-value reward. Together they
56
+ // guarantee the reward the ranking picks is always one we can display.
57
+ const PERCENT_ONLY_RANK_WEIGHT = 1e-6;
58
+
59
+ /**
60
+ * Ranking weight used to pick the single most attractive reward to surface.
61
+ *
62
+ * Mirrors {@link getRewardValue} (money value) but lifts a percentage-only
63
+ * reward to a tiny positive weight instead of `0`, so it is never buried
64
+ * behind a zero-value reward when choosing what to display.
65
+ */
66
+ export function getRewardRank(
67
+ reward: EstimatedReward,
68
+ key: keyof TokenAmountType
69
+ ): number {
70
+ const value = getRewardValue(reward, key);
71
+ if (value > 0) return value;
72
+ return maxRewardPercent(reward) * PERCENT_ONLY_RANK_WEIGHT;
73
+ }
@@ -53,9 +53,15 @@ export type {
53
53
  } from "./rpc/embedded";
54
54
  export type { SendInteractionParamsType } from "./rpc/interaction";
55
55
  export type {
56
+ ConditionGroup,
57
+ ConditionOperator,
56
58
  EstimatedReward,
57
59
  GetMerchantInformationReturnType,
60
+ MerchantReward,
58
61
  RewardTier,
62
+ RuleCondition,
63
+ RuleConditions,
64
+ RuleField,
59
65
  TokenAmountType,
60
66
  } from "./rpc/merchantInformation";
61
67
  export type {
@@ -43,6 +43,8 @@ export type ResolvedPlacement = {
43
43
  referrerNoRewardText?: string;
44
44
  ctaText?: string;
45
45
  ctaNoRewardText?: string;
46
+ /** Custom illustration URL replacing the built-in gift icon. */
47
+ imageUrl?: string;
46
48
  css?: string;
47
49
  };
48
50
  banner?: {
@@ -52,6 +54,8 @@ export type ResolvedPlacement = {
52
54
  inappTitle?: string;
53
55
  inappDescription?: string;
54
56
  inappCta?: string;
57
+ /** Custom illustration URL replacing the built-in gift icon. */
58
+ imageUrl?: string;
55
59
  css?: string;
56
60
  };
57
61
  };