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
2 changes: 2 additions & 0 deletions app/(protected)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import CardHeroOverlay from '@/components/Card/NewCardDetails/CardHeroOverlay';
import { SidebarShell } from '@/components/Navbar/Sidebar';
import NotificationPermissionSheet from '@/components/Notifications/NotificationPermissionSheet';
import TierUpgradeModalProvider from '@/components/Rewards/NewRewards/UpgradeTier/TierUpgradeModalProvider';
import RewardsUpgradeFeedback from '@/components/Rewards/RewardsUpgradeFeedback';
import { DEPOSIT_MODAL } from '@/constants/modals';
import { path } from '@/constants/path';
Expand Down Expand Up @@ -299,6 +300,7 @@ export default function ProtectedLayout() {
screens only — stays dormant/null otherwise). Mounted above the whole
navigator so the card can fly across the home → card/details change. */}
<CardHeroOverlay />
<TierUpgradeModalProvider />
<RewardsUpgradeFeedback />
<NotificationPermissionSheet
visible={showNotificationPermissionSheet}
Expand Down
5 changes: 5 additions & 0 deletions assets/images/rewards-tiers/upgrade-card-cap.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/images/rewards-tiers/upgrade-card-star.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions assets/images/rewards-tiers/upgrade-card-subscription.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions assets/images/rewards-tiers/upgrade-card-yield.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 3 additions & 7 deletions components/Activity/CardActivityRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,8 @@ const CardActivityRow = ({

const formattedTimestamp = formatActivityTimestamp(getCardTransactionTimestamp(transaction));

const cashbackLabel = cashbackInfo?.isEscrowed
? 'Cashback (Escrowed)'
: cashbackInfo?.isPending
? 'Cashback (Pending)'
: 'Cashback';
const cashbackLabel =
cashbackInfo?.isPending && !cashbackInfo.isEscrowed ? 'Cashback (Pending)' : 'Cashback';

return (
<Pressable
Expand Down Expand Up @@ -134,8 +131,7 @@ const CardActivityRow = ({
</Text>
{usdEquivalent && <Text className="text-sm text-white/70">{usdEquivalent}</Text>}
{/* Green whether or not the payout has landed: it is money coming back
either way, and the label on the left already carries the escrow
status, so the figure does not repeat it. */}
either way. The receipt shows when escrowed cashback releases. */}
{cashbackInfo?.amount && (
<Text className="text-sm font-medium text-brand">{cashbackInfo.amount}</Text>
)}
Expand Down
36 changes: 31 additions & 5 deletions components/DepositOption/DepositTypeSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { Text } from '@/components/ui/text';
import { DEPOSIT_MODAL } from '@/constants/modals';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
import { useDimension } from '@/hooks/useDimension';
import { useSavingsFundFlow } from '@/hooks/useSavingsFundFlow';
import { track } from '@/lib/analytics';
import { getAsset } from '@/lib/assets';
import { getCryptoDepositEntry } from '@/lib/walletDepositFlow';
import { useSwapState } from '@/store/swapStore';
import { useDepositStore } from '@/store/useDepositStore';

import { DEPOSIT_CASH_CLUSTER_ICONS, DEPOSIT_CASH_CURRENCY_COUNT } from './DepositCashOptions';
Expand All @@ -27,18 +29,25 @@ type DepositTypeSelectionProps = {
};

/**
* "Deposit with" — the first step of the wallet deposit flow, and only a fork:
* crypto sent to the deposit address, or cash through a bank or local ramp.
* Each branch owns the methods underneath it (see `DepositCryptoOptions` and
* `DepositCashOptions`), which is what keeps this short enough to be a drawer on
* a phone rather than the full-height sheet the later steps use.
* "Deposit with" — the first step of the wallet deposit flow. The upgrade
* Top up entry also offers Buy FUSE, while ordinary deposits keep the two
* funding methods: crypto or cash.
*/
const DepositTypeSelection = ({ onClose }: DepositTypeSelectionProps) => {
const { isDesktop, isScreenMedium } = useDimension();
const setModal = useDepositStore(state => state.setModal);
const resetDepositFlow = useDepositStore(state => state.resetDepositFlow);
const upgradeTopUp = useDepositStore(state => state.upgradeTopUp);
const { selectToken: selectSavingsFundToken } = useSavingsFundFlow();
const openBuyFuse = useSwapState(state => state.actions.openBuyFuse);

const handleCryptoPress = () => {
track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { deposit_method: 'crypto' });
if (upgradeTopUp?.depositToSavings) {
useDepositStore.getState().setSavingsFundIntent('savings');
selectSavingsFundToken('WFUSE');
return;
}
setModal(getCryptoDepositEntry(isDesktop));
};

Expand All @@ -47,6 +56,15 @@ const DepositTypeSelection = ({ onClose }: DepositTypeSelectionProps) => {
setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH);
};

const handleBuyFusePress = () => {
if (!upgradeTopUp) return;
track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { deposit_method: 'buy_fuse' });
const { tier, depositToSavings } = upgradeTopUp;
resetDepositFlow();
// The deposit drawer must finish leaving before the swap dialog mounts.
setTimeout(() => openBuyFuse(tier, { depositToSavings }), 200);
};

return (
<View className="gap-y-6">
{/* The drawer carries its own header: a grab handle (small screens, where it
Expand Down Expand Up @@ -77,6 +95,14 @@ const DepositTypeSelection = ({ onClose }: DepositTypeSelectionProps) => {
subtitle="Transfer from your bank account or with local ramps"
onPress={handleCashPress}
/>
{upgradeTopUp ? (
<DepositMethodRow
icon={<DepositIconCluster icons={[getAsset('images/wfuse.png')]} />}
title="Buy FUSE"
subtitle="Buy with USDC on Fuse"
onPress={handleBuyFusePress}
/>
) : null}
</CardFundGroup>

<Button
Expand Down
116 changes: 116 additions & 0 deletions components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from 'react';
import { Platform } from 'react-native';

import DepositTypeSelection from '@/components/DepositOption/DepositTypeSelection';
import { DEPOSIT_MODAL } from '@/constants/modals';
import { RewardsTier } from '@/lib/types';

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

jest.mock('@/components/Card/CardFund/CardFundGroup', () => 'CardFundGroup');
jest.mock('@/components/ui/button', () => ({ Button: 'Button' }));
jest.mock('@/components/ui/text', () => ({ Text: 'Text' }));
jest.mock('@/components/DepositOption/DepositCashOptions', () => ({
DEPOSIT_CASH_CLUSTER_ICONS: [],
DEPOSIT_CASH_CURRENCY_COUNT: 0,
}));
jest.mock('@/components/DepositOption/DepositIconCluster', () => 'DepositIconCluster');
jest.mock('@/components/DepositOption/DepositMethodRow', () => 'DepositMethodRow');
jest.mock('@/hooks/useDimension', () => ({
useDimension: () => ({ isDesktop: false, isScreenMedium: false }),
}));
jest.mock('@/hooks/useSavingsFundFlow', () => ({
useSavingsFundFlow: () => ({ selectToken: mockSelectSavingsToken }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
jest.mock('@/lib/assets', () => ({ getAsset: (path: string) => path }));
jest.mock('@/store/swapStore', () => ({
useSwapState: (selector: (state: any) => unknown) =>
selector({ actions: { openBuyFuse: mockOpenBuyFuse } }),
}));
jest.mock('@/store/useDepositStore', () => ({
useDepositStore: Object.assign((selector: (state: any) => unknown) => selector(mockDeposit), {
getState: () => mockDeposit,
}),
}));

const mockSelectSavingsToken = jest.fn();
const mockOpenBuyFuse = jest.fn();
const mockDeposit = {
upgradeTopUp: undefined as undefined | { tier: RewardsTier; depositToSavings: boolean },
setModal: jest.fn(),
resetDepositFlow: jest.fn(),
setSavingsFundIntent: jest.fn(),
};
const originalPlatform = Platform.OS;

const render = () => {
let root: any;
act(() => {
root = create(<DepositTypeSelection onClose={jest.fn()} />);
});
return root;
};

beforeEach(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
jest.useFakeTimers();
jest.clearAllMocks();
mockDeposit.upgradeTopUp = undefined;
Platform.OS = 'android';
});

afterEach(() => {
jest.useRealTimers();
Platform.OS = originalPlatform;
});

it('keeps Buy FUSE out of the ordinary deposit chooser', () => {
const root = render();
const rows = root.root.findAllByType('DepositMethodRow');
expect(rows.map((row: any) => row.props.title)).toEqual(['Crypto', 'Cash']);
act(() => rows[0].props.onPress());
expect(mockDeposit.setModal).toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_DEPOSIT_TOKEN);
act(() => root.unmount());
});

it('offers Buy FUSE from upgrade Top up on iOS', () => {
Platform.OS = 'ios';
mockDeposit.upgradeTopUp = { tier: RewardsTier.ULTRA, depositToSavings: false };
const root = render();
const rows = root.root.findAllByType('DepositMethodRow');
expect(rows.map((row: any) => row.props.title)).toEqual(['Crypto', 'Cash', 'Buy FUSE']);
act(() => rows[2].props.onPress());
act(() => jest.advanceTimersByTime(200));
expect(mockOpenBuyFuse).toHaveBeenCalledWith(RewardsTier.ULTRA, {
depositToSavings: false,
});
act(() => root.unmount());
});

it('opens Buy FUSE for the selected upgrade tier after closing the deposit drawer', () => {
mockDeposit.upgradeTopUp = { tier: RewardsTier.ULTRA, depositToSavings: false };
const root = render();
const rows = root.root.findAllByType('DepositMethodRow');
expect(rows.map((row: any) => row.props.title)).toEqual(['Crypto', 'Cash', 'Buy FUSE']);

act(() => rows[2].props.onPress());
expect(mockDeposit.resetDepositFlow).toHaveBeenCalledTimes(1);
expect(mockOpenBuyFuse).not.toHaveBeenCalled();
act(() => jest.advanceTimersByTime(200));
expect(mockOpenBuyFuse).toHaveBeenCalledWith(RewardsTier.ULTRA, {
depositToSavings: false,
});
act(() => root.unmount());
});

it('preserves the existing Savings deposit route for Crypto from a soFUSE top up', () => {
mockDeposit.upgradeTopUp = { tier: RewardsTier.PRIME, depositToSavings: true };
const root = render();
act(() => root.root.findAllByType('DepositMethodRow')[0].props.onPress());
expect(mockDeposit.setSavingsFundIntent).toHaveBeenCalledWith('savings');
expect(mockSelectSavingsToken).toHaveBeenCalledWith('WFUSE');
expect(mockDeposit.setModal).not.toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_DEPOSIT_TOKEN);
act(() => root.unmount());
});
18 changes: 18 additions & 0 deletions components/Earn/EarnScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ import { View } from 'react-native';
import { Href, router } from 'expo-router';

import { BalanceHeadline, BalancePillRow } from '@/components/BalanceHeadline';
import LockedFuseTile from '@/components/Earn/LockedFuseTile';
import HeaderHelpButton from '@/components/Navbar/HeaderHelpButton';
import PageLayout from '@/components/PageLayout';
import SavingsHelpModal from '@/components/Savings/NewSavings/SavingsHelpModal';
import Skeleton from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { useMaxAPY } from '@/hooks/useAnalytics';
import { useTierMembership } from '@/hooks/useTierMembership';
import { useTotalSavingsUSD } from '@/hooks/useTotalSavingsUSD';
import { type AssetPath } from '@/lib/assets';
import { VaultType } from '@/lib/types';
import { useTierUpgradeStore } from '@/store/useTierUpgradeStore';

import {
calculateEstimatedDailyEarnings,
Expand Down Expand Up @@ -69,6 +72,11 @@ export default function EarnScreen() {
const [isHelpOpen, setIsHelpOpen] = useState(false);
useVaultDetailPrefetch();
const { data: portfolioTotal, valuesByVault, isLoading } = useTotalSavingsUSD();
// Deliberately outside the portfolio total: a locked position cannot be
// withdrawn, and adding it to a headline the withdraw flow then refuses is
// worse than showing it as its own line.
const { data: membership } = useTierMembership();
const openTierUpgrade = useTierUpgradeStore(state => state.open);
const usdcApy = useMaxAPY(VaultType.USDC);
const ethApy = useMaxAPY(VaultType.ETH);
const fuseApy = useMaxAPY(VaultType.FUSE);
Expand Down Expand Up @@ -155,6 +163,16 @@ export default function EarnScreen() {
{row.length === 1 && <View className="flex-1" />}
</View>
))}

{membership?.lock ? (
<LockedFuseTile
lock={membership.lock}
// No tier named: this screen knows the user wants to top up a
// lock and not which tier that buys. The flow resolves it to the
// cheapest one they do not already hold.
onPress={() => openTierUpgrade()}
/>
) : null}
</View>
</View>
</PageLayout>
Expand Down
64 changes: 64 additions & 0 deletions components/Earn/LockedFuseTile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Pressable, View } from 'react-native';

import { Text } from '@/components/ui/text';
import { getTierDisplayName } from '@/lib/tierNames';
import { formatFuse, formatMembershipDay } from '@/lib/tierUpgrade';
import { RewardsTier, type TierLockState } from '@/lib/types';

interface LockedFuseTileProps {
lock: TierLockState;
onPress: () => void;
}

/**
* The FUSE a user has committed to hold a tier, shown apart from their savings.
*
* Its own row rather than folded into the FUSE vault tile, because it behaves
* differently in the one way that matters: it cannot be withdrawn until its
* date. Adding it to the savings figure would make the Earn page promise a
* balance the withdraw flow then refuses, which is worse than a second line.
*
* It is still earning — what is locked is the vault share, not the asset — so
* the tile says so rather than reading as money set aside and idle.
*/
const LockedFuseTile = ({ lock, onPress }: LockedFuseTileProps) => {
if (!lock.enabled || lock.lockedFuse <= 0) return null;

const unlockLabel =
lock.maturedFuse > 0
? 'Unlocking now'
: lock.nextUnlockAt
? `Unlocks ${formatMembershipDay(lock.nextUnlockAt)}`
: null;

return (
<Pressable
accessibilityRole="button"
accessibilityLabel="Your locked FUSE"
onPress={onPress}
className="rounded-[20px] bg-[#1C1C1C] p-4 transition-all active:scale-[0.99] active:opacity-90"
>
<View className="flex-row items-center justify-between">
<Text className="text-[16px] font-semibold leading-5 text-white">Locked FUSE</Text>

{lock.unlockedTier !== RewardsTier.CORE ? (
<View className="rounded-full border border-[#94F27F]/15 bg-[#94F27F]/10 px-[10px] py-1">
<Text className="text-[13px] font-medium leading-4 text-[#94F27F]">
{getTierDisplayName(lock.unlockedTier)}
</Text>
</View>
) : null}
</View>

<Text className="mt-2 text-[24px] font-semibold leading-7 text-white">
{formatFuse(lock.lockedFuse)} FUSE
</Text>

<Text className="mt-1 text-[14px] leading-5 text-white/50">
{unlockLabel ? `${unlockLabel} · still earning` : 'Still earning while locked'}
</Text>
</Pressable>
);
};

export default LockedFuseTile;
Loading
Loading