Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion components/Activity/CashbackDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ const CashbackDetailsCard = memo(function CashbackDetailsCard({
</View>
{isIneligible ? (
<Text className={NOTE}>
Cash withdrawals, money transfers and government payments don&apos;t earn cashback
Cash withdrawals, money transfers, wallet top-ups, gambling and government payments
don&apos;t earn cashback
</Text>
) : isPendingCharge ? (
<Text className={NOTE}>
Expand Down
37 changes: 37 additions & 0 deletions components/Referral/ReferralFriendRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ interface ReferralFriendRowProps {
/** Program-level targets, for rows the API sends without their own bar. */
spendTargetUsd: number;
merchantTarget: number;
/** Spend a merchant needs before it counts toward the target, when the program has one. */
minMerchantSpendUsd?: number;
/** The purchase a qualified friend still needs to make before payout, when required. */
activityMinPurchaseUsd?: number | null;
/** Called when a countdown reaches zero, so the screen can re-fetch. */
onPayoutDue?: () => void;
}
Expand All @@ -160,6 +164,8 @@ export default function ReferralFriendRow({
index,
spendTargetUsd: programSpendTargetUsd,
merchantTarget: programMerchantTarget,
minMerchantSpendUsd,
activityMinPurchaseUsd,
onPayoutDue,
}: ReferralFriendRowProps) {
// The row's own bar when the API sends one: a friend who qualified before the
Expand Down Expand Up @@ -270,6 +276,31 @@ export default function ReferralFriendRow({
// was sent.
const paidLine = item.stage === ReferralFriendStage.PAID ? formatPayoutToken(item) : null;

// Why the progress reads lower than what the friend has spent, so the
// referrer can tell them what still counts rather than guess.
const ruleNotes: string[] = [];
if (item.stage === ReferralFriendStage.SPENDING) {
const belowMinimum = item.merchantsBelowMinimum ?? 0;
if (belowMinimum > 0 && minMerchantSpendUsd) {
ruleNotes.push(
`${belowMinimum} ${belowMinimum === 1 ? 'merchant needs' : 'merchants need'} ${formatUsdWhole(
minMerchantSpendUsd,
)}+ to count`,
);
}
if ((item.excludedSpendUsd ?? 0) >= 1) {
ruleNotes.push(`${formatUsdCompact(item.excludedSpendUsd ?? 0)} doesn't count`);
}
}
// Qualified, but the payout will be cancelled unless the friend makes one
// more purchase — the one thing the referrer can still do something about.
const activityNote =
item.stage === ReferralFriendStage.REWARD_UNLOCKING && item.awaitingActivity
? activityMinPurchaseUsd
? `Needs one more ${formatUsdWhole(activityMinPurchaseUsd)}+ purchase to unlock`
: 'Needs one more purchase to unlock'
: null;

const detailChip = presentation.detail ? (
<Chip tone={presentation.detail.tone}>{presentation.detail.label}</Chip>
) : null;
Expand Down Expand Up @@ -320,6 +351,12 @@ export default function ReferralFriendRow({
{formatJoined(item.signupAt)}
</Text>
<Text className="text-xs leading-[14px] text-white/50">{spentLine}</Text>
{ruleNotes.length > 0 ? (
<Text className="text-xs leading-[14px] text-white/40">{ruleNotes.join(' · ')}</Text>
) : null}
{activityNote ? (
<Text className="text-xs leading-[14px] text-rewards">{activityNote}</Text>
) : null}
{paidLine ? (
<Text className="text-xs leading-[14px] text-white/50">Paid {paidLine}</Text>
) : null}
Expand Down
22 changes: 20 additions & 2 deletions components/Referral/ReferralProgramContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ export default function ReferralProgramContent({ onClose }: ReferralProgramConte
const spendTarget = summary?.qualification.spendTargetUsd ?? 75;
const merchantTarget = summary?.qualification.merchantTarget ?? 3;
const windowDays = summary?.qualification.windowDays ?? 30;
// The anti-abuse rules: shown only when the backend reports them, since one
// that does not is not enforcing them.
const minMerchantSpendUsd = summary?.qualification.minMerchantSpendUsd;
const activityMinPurchaseUsd = summary?.qualification.activityMinPurchaseUsd ?? null;
const hasActiveCard = summary?.hasActiveCard ?? false;
const referrals = summary?.referrals ?? [];
// The reward is quoted in dollars but settled in a token; name it rather than
Expand Down Expand Up @@ -209,7 +213,11 @@ export default function ReferralProgramContent({ onClose }: ReferralProgramConte
payments at different merchants within {windowDays} days.
</InfoRow>
<Text className="text-xs leading-4 text-white/50">
Qualified spend excludes reversed or charged-back transactions.
Qualified spend excludes reversed or charged-back transactions
{summary?.qualification.minMerchantSpendUsd !== undefined
? ', cash withdrawals, transfers, top-ups, game credit and gambling'
: ''}
.
</Text>
</View>

Expand Down Expand Up @@ -273,8 +281,18 @@ export default function ReferralProgramContent({ onClose }: ReferralProgramConte
</Text>
<Text className="text-sm text-white/70">• Order and activate a Solid card.</Text>
<Text className="text-sm text-white/70">
• Spend {formatUsd(spendTarget)} across {merchantTarget}+ different merchants.
• Spend {formatUsd(spendTarget)} across {merchantTarget}+ different merchants
{minMerchantSpendUsd
? `, with at least ${formatUsdWhole(minMerchantSpendUsd)} at each`
: ''}
.
</Text>
{activityMinPurchaseUsd ? (
<Text className="text-sm text-white/70">
• Make one more purchase of {formatUsdWhole(activityMinPurchaseUsd)} or more before
the reward unlocks.
</Text>
) : null}
<Text className="text-sm text-white/70">
You get {formatUsd(referrerUsd)} and they get {formatUsd(newUserUsd)}
{payoutToken ? ` in ${payoutToken}` : ''}, credited about 40 days after they qualify.
Expand Down
40 changes: 36 additions & 4 deletions components/Referral/ReferralProgramContentNew.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ export default function ReferralProgramContentNew({
const merchantTarget = summary?.qualification.merchantTarget ?? 3;
const windowDays = summary?.qualification.windowDays ?? 30;
const payoutDelayDays = summary?.qualification.payoutDelayDays ?? 30;
// The anti-abuse rules. No fallbacks: a backend that does not report one is
// not enforcing it, and the copy must not promise a rule nobody applies.
const minPurchaseUsd = summary?.qualification.minPurchaseUsd;
const minMerchantSpendUsd = summary?.qualification.minMerchantSpendUsd;
const activityMinPurchaseUsd = summary?.qualification.activityMinPurchaseUsd ?? null;
const referrals = summary?.referrals ?? [];
const totalRewardedUsd = summary?.totalRewardedUsd ?? 0;
// The reward is quoted in dollars but settled in a token, so the "how it
Expand Down Expand Up @@ -289,9 +294,13 @@ export default function ReferralProgramContentNew({
>
Via bank deposit or crypto
</ChecklistRow>
<ChecklistRow icon="images/referral-new-icon-wallet.png" title="Make 3+ purchases">
spend {formatUsdWhole(spendTarget)} across {merchantTarget}+ payments at different
merchants within {windowDays} days.
<ChecklistRow
icon="images/referral-new-icon-wallet.png"
title={`Shop at ${merchantTarget}+ merchants`}
>
{minMerchantSpendUsd
? `spend ${formatUsdWhole(spendTarget)} within ${windowDays} days, with at least ${formatUsdWhole(minMerchantSpendUsd)} at each of ${merchantTarget}+ different merchants.`
: `spend ${formatUsdWhole(spendTarget)} across ${merchantTarget}+ payments at different merchants within ${windowDays} days.`}
</ChecklistRow>
</View>
</View>
Expand Down Expand Up @@ -334,6 +343,8 @@ export default function ReferralProgramContentNew({
index={index}
spendTargetUsd={spendTarget}
merchantTarget={merchantTarget}
minMerchantSpendUsd={minMerchantSpendUsd}
activityMinPurchaseUsd={activityMinPurchaseUsd}
onPayoutDue={handlePayoutDue}
/>
))}
Expand Down Expand Up @@ -371,14 +382,35 @@ export default function ReferralProgramContentNew({
• Deposit to their account via bank deposit or crypto.
</Text>
<Text className="text-sm text-white/70">
• Spend {formatUsdWhole(spendTarget)} across {merchantTarget}+ different merchants.
• Spend {formatUsdWhole(spendTarget)} across {merchantTarget}+ different merchants
{minMerchantSpendUsd
? `, with at least ${formatUsdWhole(minMerchantSpendUsd)} at each`
: ''}
.
</Text>
{minPurchaseUsd !== undefined ? (
<Text className="text-sm text-white/70">
Only everyday purchases count, in the US dollars charged
{minPurchaseUsd > 0
? `. Purchases under ${formatUsdWhole(minPurchaseUsd)} don't count, and neither do`
: ' — not'}{' '}
cash withdrawals, money transfers, wallet or card top-ups, QR-code transfers, game
credit, gambling, or tax and government payments.
</Text>
) : null}
<Text className="text-sm text-white/70">
You get {formatUsdWhole(referrerUsd)} and they get {formatUsdWhole(newUserUsd)}
{payoutToken ? ` in ${payoutToken}` : ''}, credited {payoutDelayDays} days after they
qualify — that window covers refunds and disputes. You&apos;ll see the exact unlock
date on each friend above. One reward per friend, no cap.
</Text>
{activityMinPurchaseUsd ? (
<Text className="text-sm text-white/70">
To unlock the reward, your friend needs to keep using their card: one more purchase
of {formatUsdWhole(activityMinPurchaseUsd)} or more before the unlock date, or the
reward is cancelled.
</Text>
) : null}
{paysVaultShare && (
<Text className="text-sm text-white/70">
Rewards land in your FUSE savings, so they keep earning yield and count towards your
Expand Down
108 changes: 108 additions & 0 deletions components/Referral/__tests__/ReferralFriendRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React from 'react';

import ReferralFriendRow from '@/components/Referral/ReferralFriendRow';
import { ReferralFriendStage, type ReferralRewardListItem } from '@/lib/types';

// eslint-disable-next-line @typescript-eslint/no-require-imports
const { act, create } = require('react-test-renderer');

jest.mock('@/components/ui/text', () => ({ Text: 'Text' }));
jest.mock('@/lib/utils', () => ({
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
}));
jest.mock('lucide-react-native', () => ({ ExternalLink: () => null }));
jest.mock('@/hooks/useReferralCountdown', () => ({
useReferralCountdown: () => ({ label: '3d 4h', isElapsed: false }),
}));
jest.mock('react-native-reanimated', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { View } = require('react-native');
const chain = { delay: () => chain, duration: () => chain };
return {
__esModule: true,
default: { View },
Easing: { bezier: () => () => 0, out: () => () => 0, cubic: () => 0 },
FadeIn: chain,
useAnimatedStyle: () => ({}),
useSharedValue: (value: unknown) => ({ value }),
withDelay: (_delay: number, value: unknown) => value,
withTiming: (value: unknown) => value,
};
});

const row = (overrides: Partial<ReferralRewardListItem> = {}): ReferralRewardListItem => ({
referredUserId: 'friend-1',
username: 'friend',
stage: ReferralFriendStage.SPENDING,
status: null,
signupAt: '2026-10-01T10:00:00.000Z',
spendUsd: 68.15,
merchantCount: 2,
hasActiveCard: true,
rewardUsd: 25,
...overrides,
});

function render(item: ReferralRewardListItem, props: Record<string, unknown> = {}) {
let renderer: any;
act(() => {
renderer = create(
<ReferralFriendRow
item={item}
index={0}
spendTargetUsd={150}
merchantTarget={3}
minMerchantSpendUsd={10}
activityMinPurchaseUsd={10}
{...props}
/>,
);
});
return renderer;
}

const textOf = (renderer: any) => JSON.stringify(renderer.toJSON());

describe('ReferralFriendRow — the referral spend rules', () => {
it('says why spend and merchants are not counting yet', () => {
const text = textOf(render(row({ merchantsBelowMinimum: 1, excludedSpendUsd: 212.67 })));

expect(text).toContain('1 merchant needs $10+ to count');
expect(text).toContain("$212.67 doesn't count");
});

it('stays quiet when nothing was left out', () => {
const text = textOf(render(row({ merchantsBelowMinimum: 0, excludedSpendUsd: 0 })));

expect(text).not.toContain('to count');
expect(text).not.toContain("doesn't count");
});

it('tells the referrer a qualified friend still needs one more purchase', () => {
const text = textOf(
render(
row({
stage: ReferralFriendStage.REWARD_UNLOCKING,
payoutEtaAt: '2026-11-01T02:00:00.000Z',
awaitingActivity: true,
}),
),
);

expect(text).toContain('Needs one more $10+ purchase to unlock');
});

it('does not nag once the friend has made it', () => {
const text = textOf(
render(
row({
stage: ReferralFriendStage.REWARD_UNLOCKING,
payoutEtaAt: '2026-11-01T02:00:00.000Z',
awaitingActivity: false,
}),
),
);

expect(text).not.toContain('Needs one more');
});
});
23 changes: 23 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3562,7 +3562,21 @@ export interface ReferralRewardListItem {
payoutEtaAt?: string;
paidAt?: string;
spendUsd: number;
/** Merchants that count toward the target: those with the per-merchant minimum spent there. */
merchantCount: number;
/**
* Spend the referral rules left out — excluded merchants (cash, transfers,
* top-ups, game credit…) and purchases under the minimum. Absent on backends
* that predate the rules.
*/
excludedSpendUsd?: number;
/** Merchants the friend spent at that have not reached the per-merchant minimum yet. */
merchantsBelowMinimum?: number;
/**
* Qualified, but the friend has not yet made the one more purchase the payout
* needs — the referrer's cue to nudge them before the reward unlocks.
*/
awaitingActivity?: boolean;
/**
* The bar this row is measured against. For a friend who has qualified it is
* the bar they cleared, which can be lower than today's — the target moved
Expand Down Expand Up @@ -3615,6 +3629,15 @@ export interface ReferralSummary {
windowDays: number;
/** Days between qualifying and the payout — the dispute/chargeback cover. */
payoutDelayDays: number;
/** Purchases under this (USD) count for nothing. Absent on older backends. */
minPurchaseUsd?: number;
/** A merchant counts toward the target once this much (USD) is spent there. */
minMerchantSpendUsd?: number;
/**
* The purchase (USD) the friend must make between qualifying and the
* payout; null when none is required, absent on older backends.
*/
activityMinPurchaseUsd?: number | null;
};
totalRewardedUsd: number;
friendsInvited: number;
Expand Down
Loading