@frak-labs/core-sdk
Version:
Core SDK of the Frak wallet, low level library to interact directly with the frak ecosystem.
213 lines (194 loc) • 7.62 kB
text/typescript
import type { InteractionTypeKey } from "../constants/interactionTypes";
import type {
Currency,
EstimatedReward,
MerchantReward,
TokenAmountType,
} from "../types";
import { formatAmount } from "../utils/format/formatAmount";
import { getCurrencyAmountKey } from "../utils/format/getCurrencyAmountKey";
import { getSupportedCurrency } from "../utils/format/getSupportedCurrency";
import { extractMinPurchaseAmount, extractStartDate } from "./conditions";
import { formatRewardOrHide } from "./format";
import { getRewardRank } from "./value";
/** Reward side a surface cares about: the sharer (`referrer`) or the referred
* user (`referee`). Drives both campaign ranking and which side is displayed. */
export type RewardAudience = "referrer" | "referee";
export type DisplayCampaign = {
campaign: MerchantReward;
status: "live" | "upcoming";
startsAt?: Date;
};
export type SelectDisplayCampaignOptions = {
/** Reference "now" for expiry / start gating; defaults to the current time. */
now?: Date;
/** Currency the ranking is expressed in; defaults to EUR. */
currency?: Currency;
/** When set, only campaigns triggered by this interaction are considered. */
targetInteraction?: InteractionTypeKey;
/** Reward side to rank campaigns by; defaults to `"referrer"`. */
audience?: RewardAudience;
};
function isExpired(campaign: MerchantReward, nowMs: number): boolean {
return (
campaign.expiresAt != null &&
new Date(campaign.expiresAt).getTime() <= nowMs
);
}
function hasStarted(campaign: MerchantReward, nowMs: number): boolean {
const startsAt = extractStartDate(campaign.conditions);
return startsAt == null || startsAt.getTime() <= nowMs;
}
function audienceReward(campaign: MerchantReward, audience: RewardAudience) {
return audience === "referee" ? campaign.referee : campaign.referrer;
}
function campaignRank(
campaign: MerchantReward,
key: keyof TokenAmountType,
audience: RewardAudience
): number {
const reward = audienceReward(campaign, audience);
return reward ? getRewardRank(reward, key) : 0;
}
/**
* Pick the single campaign a merchant surface should display.
*
* Filters out expired (and, when `targetInteraction` is set, non-matching)
* campaigns, then prefers the highest-ranked *live* campaign — ranked by the
* `audience` reward side in the requested currency. When none has started yet
* it falls back to the soonest-starting upcoming campaign (the endpoint does
* not gate on the start-date condition, so future-start campaigns come
* through).
*/
export function selectDisplayCampaign(
rewards: readonly MerchantReward[],
options: SelectDisplayCampaignOptions = {}
): DisplayCampaign | undefined {
const nowMs = (options.now ?? new Date()).getTime();
const audience = options.audience ?? "referrer";
const key = getCurrencyAmountKey(getSupportedCurrency(options.currency));
const matching = options.targetInteraction
? rewards.filter(
(campaign) =>
campaign.interactionTypeKey === options.targetInteraction
)
: rewards;
const active = matching.filter((campaign) => !isExpired(campaign, nowMs));
const live = active.filter((campaign) => hasStarted(campaign, nowMs));
if (live.length > 0) {
const best = live.reduce((a, b) =>
campaignRank(b, key, audience) > campaignRank(a, key, audience)
? b
: a
);
return { campaign: best, status: "live" };
}
const upcoming = active
.map((campaign) => ({
campaign,
startsAt: extractStartDate(campaign.conditions),
}))
.filter(
(entry): entry is { campaign: MerchantReward; startsAt: Date } =>
entry.startsAt != null
);
if (upcoming.length === 0) return undefined;
const soonest = upcoming.reduce((a, b) =>
b.startsAt.getTime() < a.startsAt.getTime() ? b : a
);
return {
campaign: soonest.campaign,
status: "upcoming",
startsAt: soonest.startsAt,
};
}
/**
* The single reward a merchant surface should display: its formatted string
* plus the `payoutType` of the underlying reward, so surfaces can adapt their
* presentation (e.g. hide percentage rewards, prefix tiered ones with "Up to").
*/
export type BestReward = {
/** Display-ready reward string (e.g. `"5 €"`, `"10 %"`). */
formatted: string;
/** Payout type of the selected reward. */
payoutType: EstimatedReward["payoutType"];
/**
* Minimum purchase amount gating the reward, formatted with the requested
* currency (e.g. `"10 €"`), or `undefined` when the campaign sets no
* minimum.
*/
minPurchaseAmount?: string;
/**
* Whole-day lockup applied before the reward settles, or `undefined` when
* the campaign has no lockup.
*/
lockupDurationDays?: number;
/**
* Raw referrer/referee rewards of the selected campaign, surfaced so
* consumers can render the full per-audience breakdown (tier rows,
* percentage examples) rather than only the headline number.
*/
referrerReward?: EstimatedReward;
refereeReward?: EstimatedReward;
/**
* Raw minimum purchase value (unformatted), used to build percentage
* worked-examples consistent with the campaign's gating.
*/
minPurchaseValue?: number;
};
/**
* Pick the best campaign for `options` and resolve its `audience`-side reward
* to a formatted string plus its `payoutType`, or `undefined` when there is
* nothing worth showing.
*
* Single entry point shared by every "headline reward" surface (share button,
* wallet modal, sharing/install screens) so they all show the same number for
* a given merchant and can branch on the payout type.
*/
export function selectBestReward(
rewards: readonly MerchantReward[],
options: SelectDisplayCampaignOptions = {}
): BestReward | undefined {
const selected = selectDisplayCampaign(rewards, options);
if (!selected) return undefined;
const reward = audienceReward(
selected.campaign,
options.audience ?? "referrer"
);
if (!reward) return undefined;
const formatted = formatRewardOrHide(reward, options.currency);
if (!formatted) return undefined;
const minPurchase = extractMinPurchaseAmount(selected.campaign.conditions);
const minPurchaseAmount =
minPurchase != null
? formatAmount(minPurchase, options.currency)
: undefined;
const lockupSeconds = selected.campaign.defaultLockupSeconds;
const lockupDurationDays =
lockupSeconds && lockupSeconds > 0
? Math.round(lockupSeconds / 86_400)
: undefined;
return {
formatted,
payoutType: reward.payoutType,
minPurchaseAmount,
lockupDurationDays,
referrerReward: selected.campaign.referrer,
refereeReward: selected.campaign.referee,
minPurchaseValue: minPurchase ?? undefined,
};
}
/**
* Headline reward string for a merchant: picks the best campaign for `options`
* and formats its `audience`-side reward, or returns `undefined` when there is
* nothing worth showing.
*
* Thin wrapper over {@link selectBestReward} for callers that only need the
* formatted string.
*/
export function formatBestReward(
rewards: readonly MerchantReward[],
options: SelectDisplayCampaignOptions = {}
): string | undefined {
return selectBestReward(rewards, options)?.formatted;
}