@frak-labs/core-sdk 1.1.5 → 1.1.6
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-DrZ_PIs1.cjs → actions-Cyn4asew.cjs} +1 -1
- package/dist/{actions-DrZ_PIs1.cjs.map → actions-Cyn4asew.cjs.map} +1 -1
- package/dist/{actions-HG5M8wX7.js → actions-DtPVuk2e.js} +1 -1
- package/dist/{actions-HG5M8wX7.js.map → actions-DtPVuk2e.js.map} +1 -1
- package/dist/actions.cjs +1 -1
- package/dist/actions.d.cts +2 -2
- package/dist/actions.d.ts +2 -2
- package/dist/actions.js +1 -1
- package/dist/bundle.cjs +1 -1
- package/dist/bundle.d.cts +5 -4
- package/dist/bundle.d.ts +5 -4
- package/dist/bundle.js +1 -1
- 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-BysidNLB.d.ts → index-CD-GW6rL.d.ts} +3 -2
- package/dist/{index-k1lfbVkl.d.cts → index-D5_EwZ3f.d.cts} +3 -2
- package/dist/{index-C1A3I4x1.d.cts → index-DPez7SXh.d.cts} +3 -2
- package/dist/{index-BJMazhpm.d.ts → index-DQYipoH_.d.ts} +3 -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-BWeD0Fb2.d.ts → openSso-CjZ8lAEA.d.ts} +7 -286
- package/dist/{openSso-B0igO6Iu.d.cts → openSso-DSXbUP-j.d.cts} +7 -286
- package/dist/rewards.cjs +2 -0
- package/dist/rewards.cjs.map +1 -0
- package/dist/rewards.d.cts +130 -0
- package/dist/rewards.d.ts +130 -0
- package/dist/rewards.js +2 -0
- package/dist/rewards.js.map +1 -0
- package/dist/src-CTtM8g9p.cjs +2 -0
- package/dist/src-CTtM8g9p.cjs.map +1 -0
- package/dist/src-Dq2hi427.js +2 -0
- package/dist/src-Dq2hi427.js.map +1 -0
- package/package.json +13 -1
- package/src/actions/getMerchantInformation.test.ts +2 -0
- package/src/index.ts +6 -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 +29 -0
- package/src/rewards/select.test.ts +281 -0
- package/src/rewards/select.ts +197 -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/resolvedConfig.ts +4 -0
- package/src/types/rpc/merchantInformation.ts +107 -7
- package/dist/src-DXE40XuK.js +0 -2
- package/dist/src-DXE40XuK.js.map +0 -1
- package/dist/src-hwLbVkQI.cjs +0 -2
- package/dist/src-hwLbVkQI.cjs.map +0 -1
|
@@ -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.replaceAll("{REWARD}", reward)
|
|
80
|
+
: text.replaceAll("{REWARD}", "");
|
|
81
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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 BestReward,
|
|
22
|
+
type DisplayCampaign,
|
|
23
|
+
formatBestReward,
|
|
24
|
+
type RewardAudience,
|
|
25
|
+
type SelectDisplayCampaignOptions,
|
|
26
|
+
selectBestReward,
|
|
27
|
+
selectDisplayCampaign,
|
|
28
|
+
} from "./select";
|
|
29
|
+
export { getRewardValue } from "./value";
|
|
@@ -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
|
+
});
|