From 7308fc11290ec46bd7b499d7bd1ea3d6d72d59a5 Mon Sep 17 00:00:00 2001 From: Eli Smargon Date: Wed, 23 Sep 2026 20:02:40 +0300 Subject: [PATCH 1/3] feat(rewards): refine tier upgrade and top-up flows --- .../images/rewards-tiers/upgrade-card-cap.svg | 5 + .../rewards-tiers/upgrade-card-star.svg | 3 + .../upgrade-card-subscription.svg | 6 + .../rewards-tiers/upgrade-card-yield.svg | 6 + components/Activity/CardActivityRow.tsx | 10 +- .../DepositOption/DepositTypeSelection.tsx | 36 ++++- .../__tests__/depositUpgradeTopUp.test.tsx | 116 +++++++++++++++ components/ResponsiveModal.tsx | 29 +++- .../Rewards/NewRewards/JoinTierClubCard.tsx | 110 +++++++------- .../NewRewards/RewardsBenefitsScreenNew.tsx | 36 +++-- .../Rewards/NewRewards/RewardsScreenNew.tsx | 23 +-- .../Rewards/NewRewards/RewardsSummaryCard.tsx | 88 +++++++++--- .../UpgradeTier/LockTokenSelector.tsx | 1 + .../NewRewards/UpgradeTier/TierDetailRow.tsx | 41 ++++-- .../UpgradeTier/TierUpgradeModalProvider.tsx | 1 + .../UpgradeTier/UpgradeTierCashbackIcon.tsx | 22 +++ .../UpgradeTier/UpgradeTierContent.tsx | 87 ++++++++---- .../UpgradeTier/UpgradeTierHeroCard.tsx | 120 +++++++++------- .../UpgradeTier/tierUpgradeBenefits.ts | 8 +- .../__tests__/tierUpgradeCta.test.ts | 24 +++- .../Rewards/NewRewards/tierUpgradeCta.ts | 35 ++++- components/Swap/BuyFuseScreen.tsx | 93 ++++++++---- components/Swap/BuyFuseUpgradeReview.tsx | 69 +++++++++ components/Swap/SwapModalProvider.tsx | 28 ++-- .../__tests__/BuyFuseScreen.upgrade.test.tsx | 134 ++++++++++++++++++ .../__tests__/BuyFuseUpgradeReview.test.tsx | 78 ++++++++++ .../__tests__/SwapModalProvider.ios.test.tsx | 92 ++++++++++++ components/Tooltip.tsx | 5 + .../WalletTokenSelector/WalletTokenButton.tsx | 17 ++- .../WalletTokenSelector/WalletTokenList.tsx | 22 +-- components/ui/dialog.tsx | 7 +- hooks/useRewards.ts | 7 +- store/swapStore.ts | 13 +- store/useDepositStore.ts | 7 + 34 files changed, 1091 insertions(+), 288 deletions(-) create mode 100644 assets/images/rewards-tiers/upgrade-card-cap.svg create mode 100644 assets/images/rewards-tiers/upgrade-card-star.svg create mode 100644 assets/images/rewards-tiers/upgrade-card-subscription.svg create mode 100644 assets/images/rewards-tiers/upgrade-card-yield.svg create mode 100644 components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx create mode 100644 components/Rewards/NewRewards/UpgradeTier/UpgradeTierCashbackIcon.tsx create mode 100644 components/Swap/BuyFuseUpgradeReview.tsx create mode 100644 components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx create mode 100644 components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx create mode 100644 components/Swap/__tests__/SwapModalProvider.ios.test.tsx diff --git a/assets/images/rewards-tiers/upgrade-card-cap.svg b/assets/images/rewards-tiers/upgrade-card-cap.svg new file mode 100644 index 000000000..937ba7161 --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-cap.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/rewards-tiers/upgrade-card-star.svg b/assets/images/rewards-tiers/upgrade-card-star.svg new file mode 100644 index 000000000..34530fb6c --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-star.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/rewards-tiers/upgrade-card-subscription.svg b/assets/images/rewards-tiers/upgrade-card-subscription.svg new file mode 100644 index 000000000..db7832187 --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-subscription.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/rewards-tiers/upgrade-card-yield.svg b/assets/images/rewards-tiers/upgrade-card-yield.svg new file mode 100644 index 000000000..f96e35f0d --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-yield.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/components/Activity/CardActivityRow.tsx b/components/Activity/CardActivityRow.tsx index 0880404fe..e297ee2e7 100644 --- a/components/Activity/CardActivityRow.tsx +++ b/components/Activity/CardActivityRow.tsx @@ -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 ( {usdEquivalent && {usdEquivalent}} {/* 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 && ( {cashbackInfo.amount} )} diff --git a/components/DepositOption/DepositTypeSelection.tsx b/components/DepositOption/DepositTypeSelection.tsx index ff3f819f9..c984c4a90 100644 --- a/components/DepositOption/DepositTypeSelection.tsx +++ b/components/DepositOption/DepositTypeSelection.tsx @@ -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'; @@ -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)); }; @@ -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 ( {/* The drawer carries its own header: a grab handle (small screens, where it @@ -77,6 +95,14 @@ const DepositTypeSelection = ({ onClose }: DepositTypeSelectionProps) => { subtitle="Transfer from your bank account or with local ramps" onPress={handleCashPress} /> + {upgradeTopUp ? ( + } + title="Buy FUSE" + subtitle="Buy with USDC on Fuse" + onPress={handleBuyFusePress} + /> + ) : null} + + + + ); +} diff --git a/components/Swap/SwapModalProvider.tsx b/components/Swap/SwapModalProvider.tsx index 4650028a5..c2e6b95bc 100644 --- a/components/Swap/SwapModalProvider.tsx +++ b/components/Swap/SwapModalProvider.tsx @@ -40,15 +40,17 @@ const SwapModalProvider = () => { })), ); - const { currentModal, previousModal, transaction, buyFuseTier, setModal } = useSwapState( - useShallow(state => ({ - currentModal: state.currentModal ?? SWAP_MODAL.CLOSE, - previousModal: state.previousModal ?? SWAP_MODAL.CLOSE, - transaction: state.transaction, - buyFuseTier: state.buyFuseTier, - setModal: state.actions.setModal, - })), - ); + const { currentModal, previousModal, transaction, buyFuseTier, buyFuseUpgrade, setModal } = + useSwapState( + useShallow(state => ({ + currentModal: state.currentModal ?? SWAP_MODAL.CLOSE, + previousModal: state.previousModal ?? SWAP_MODAL.CLOSE, + transaction: state.transaction, + buyFuseTier: state.buyFuseTier, + buyFuseUpgrade: state.buyFuseUpgrade, + setModal: state.actions.setModal, + })), + ); const isTransactionStatus = currentModal.name === SWAP_MODAL.OPEN_TRANSACTION_STATUS.name; const isBuyFuse = currentModal.name === SWAP_MODAL.OPEN_BUY_FUSE.name; @@ -102,7 +104,7 @@ const SwapModalProvider = () => { } if (isBuyFuse) { - return ; + return ; } if (isTransactionStatus) { @@ -141,13 +143,15 @@ const SwapModalProvider = () => { handleAcceptSwapDisclaimer, isBuyFuse, buyFuseTier, + buyFuseUpgrade, isTransactionStatus, transaction, handleTransactionStatusPress, ]); - // Swap is not available on iOS — never render the swap modal there. - if (Platform.OS === 'ios') { + // Regular Swap remains unavailable on iOS. Buy FUSE can open through its + // dedicated entry points and still passes the geo and disclaimer gates. + if (Platform.OS === 'ios' && !isBuyFuse) { return null; } diff --git a/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx b/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx new file mode 100644 index 000000000..0ea617125 --- /dev/null +++ b/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; + +import BuyFuseScreen from '@/components/Swap/BuyFuseScreen'; +import { RewardsTier } from '@/lib/types'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('@cryptoalgebra/fuse-sdk', () => ({ + ADDRESS_ZERO: '0x0000000000000000000000000000000000000000', +})); +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +jest.mock('@/assets/images/messages', () => 'MessageCircle'); +jest.mock('@/components/Max', () => 'Max'); +jest.mock('@/components/RenderTokenIcon', () => 'RenderTokenIcon'); +jest.mock('@/components/Swap/BuyFuseSavingsReview', () => 'BuyFuseSavingsReview'); +jest.mock('@/components/Swap/BuyFuseTierCard', () => 'BuyFuseTierCard'); +jest.mock('@/components/Swap/BuyFuseUpgradeReview', () => 'BuyFuseUpgradeReview'); +jest.mock('@/components/Swap/SwapButton', () => 'SwapButton'); +jest.mock('@/components/Swap/SwapParams', () => 'SwapParams'); +jest.mock('@/components/ui/text', () => ({ Text: 'Text' })); +jest.mock('@/constants/tokens', () => ({ STABLECOINS_TOKENS: { USDC: { address: '0x1234' } } })); +jest.mock('@/constants/vaults', () => ({ VAULTS: [{ name: 'FUSE', minimumAmount: '5000' }] })); +jest.mock('@/hooks/useRewards', () => ({ + useRewardsUserData: (options: unknown) => mockUseRewardsUserData(options), +})); +jest.mock('@/hooks/useUSDCValue', () => ({ useUSDCValue: () => ({ formatted: 0 }) })); +jest.mock('@/lib/buyFuseFunding', () => ({ formatBuyFuseFundingBalance: () => '0' })); +jest.mock('@/lib/buyFuseTiers', () => ({ + getBuyFuseProgress: jest.fn(), + getBuyFuseTierForAmount: jest.fn(), + getBuyFuseTierTargets: jest.fn(() => []), + getNextBuyFuseTier: jest.fn(), + hasReachedFuseTarget: jest.fn(), +})); +jest.mock('@/lib/getTokenIcon', () => ({ + __esModule: true, + default: () => ({ type: 'image', source: 'fuse' }), +})); +jest.mock('@/lib/utils', () => ({ formatUSD: () => '$0' })); +jest.mock('@/store/swapStore', () => ({ + useSwapState: (selector: (state: any) => unknown) => selector(mockSwapState), + useSwapActionHandlers: () => ({ onUserInput: jest.fn() }), + useDerivedSwapInfo: () => ({ + currencyBalances: { INPUT: undefined }, + parsedAmount: undefined, + toggledTrade: undefined, + voltageTrade: { trade: undefined }, + isVoltageTrade: false, + isVoltageTradeLoading: false, + tradeState: { state: 'NO_ROUTE_FOUND' }, + }), +})); +jest.mock('@/store/useRewardsUpgradeStore', () => ({ + useRewardsUpgradeStore: (selector: (state: any) => unknown) => + selector({ confirmed: undefined, pendingUntil: undefined, savingsConfirmed: false }), +})); +jest.mock('@/store/useSupportDrawerStore', () => ({ openSupportDrawer: jest.fn() })); +jest.mock('@/store/useUserStore', () => ({ + useUserStore: (selector: (state: any) => unknown) => + selector({ users: [{ selected: true, userId: 'test-user' }] }), +})); + +const mockUseRewardsUserData = jest.fn((_options?: unknown) => ({ + data: undefined, + isError: false, +})); +const mockSwapState = { + independentField: 'OUTPUT', + typedValue: '', + actions: { + selectCurrency: jest.fn(), + typeInput: jest.fn(), + resetForm: jest.fn(), + setModal: jest.fn(), + }, +}; + +it('shows the upgrade purchase without legacy tier loading or Savings instructions', () => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + let root: any; + act(() => { + root = create( + , + ); + }); + + expect(mockUseRewardsUserData).toHaveBeenCalledWith({ enabled: false }); + expect(root.root.findAllByType('BuyFuseTierCard')).toHaveLength(0); + expect(root.root.findByType('SwapButton').props.disabled).toBe(false); + expect(JSON.stringify(root.toJSON())).not.toContain('Checking available tier upgrades'); + expect(JSON.stringify(root.toJSON())).not.toContain('Savings minimum deposit'); + + act(() => root.root.findByType('SwapButton').props.onConfirmed()); + expect(root.root.findAllByType('BuyFuseUpgradeReview')).toHaveLength(1); + act(() => root.unmount()); +}); + +it('keeps the legacy tier guidance for non-upgrade Buy FUSE entry points', () => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + mockUseRewardsUserData.mockClear(); + let root: any; + act(() => { + root = create(); + }); + + expect(mockUseRewardsUserData).toHaveBeenCalledWith({ enabled: true }); + expect(JSON.stringify(root.toJSON())).toContain('Checking available tier upgrades'); + expect(JSON.stringify(root.toJSON())).toContain('Savings minimum deposit'); + expect(root.root.findByType('SwapButton').props.disabled).toBe(true); + act(() => root.unmount()); +}); + +it('shows the Savings minimum only when the upgrade needs soFUSE', () => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + let root: any; + act(() => { + root = create( + , + ); + }); + + expect(JSON.stringify(root.toJSON())).toContain('Savings minimum deposit'); + expect(JSON.stringify(root.toJSON())).not.toContain('Checking available tier upgrades'); + act(() => root.unmount()); +}); diff --git a/components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx b/components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx new file mode 100644 index 000000000..3e90cfca2 --- /dev/null +++ b/components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +import BuyFuseUpgradeReview from '@/components/Swap/BuyFuseUpgradeReview'; +import { DEPOSIT_MODAL, SWAP_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/ui/button', () => ({ Button: 'Button' })); +jest.mock('@/components/ui/text', () => ({ Text: 'Text' })); +jest.mock('@/constants/vaults', () => ({ VAULTS: [{ name: 'USDC' }, { name: 'FUSE' }] })); +jest.mock('@/store/swapStore', () => ({ + useSwapState: { getState: () => ({ actions: { setModal: mockCloseSwap } }) }, +})); +jest.mock('@/store/useDepositStore', () => ({ useDepositStore: { getState: () => mockDeposit } })); +jest.mock('@/store/useSavingStore', () => ({ + useSavingStore: { getState: () => ({ selectVaultForDeposit: mockSelectVault }) }, +})); +jest.mock('@/store/useTierUpgradeStore', () => ({ + useTierUpgradeStore: { getState: () => mockUpgrade }, +})); + +const mockCloseSwap = jest.fn(); +const mockSelectVault = jest.fn(); +const mockDeposit = { + resetDepositFlow: jest.fn(), + setSrcChainId: jest.fn(), + setPrincipalToken: jest.fn(), + setDepositFromSolid: jest.fn(), + setModal: jest.fn(), +}; +const mockUpgrade = { + open: jest.fn(), + setRoute: jest.fn(), + setLockAsset: jest.fn(), +}; + +const render = (depositToSavings: boolean) => { + let root: any; + act(() => { + root = create(); + }); + return root; +}; + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + jest.useFakeTimers(); + jest.clearAllMocks(); +}); + +afterEach(() => jest.useRealTimers()); + +it('returns a native FUSE purchase to the lock upgrade', () => { + const root = render(false); + act(() => root.root.findAllByType('Button')[0].props.onPress()); + expect(mockCloseSwap).toHaveBeenCalledWith(SWAP_MODAL.CLOSE); + expect(mockUpgrade.open).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(200)); + expect(mockUpgrade.open).toHaveBeenCalledWith(RewardsTier.ULTRA); + expect(mockUpgrade.setRoute).toHaveBeenCalledWith('lock'); + expect(mockUpgrade.setLockAsset).toHaveBeenCalledWith('FUSE'); + expect(mockDeposit.setModal).not.toHaveBeenCalled(); + act(() => root.unmount()); +}); + +it('opens Savings funding only when the chosen upgrade asset was soFUSE', () => { + const root = render(true); + act(() => root.root.findAllByType('Button')[0].props.onPress()); + expect(mockCloseSwap).toHaveBeenCalledWith(SWAP_MODAL.CLOSE); + act(() => jest.advanceTimersByTime(200)); + expect(mockSelectVault).toHaveBeenCalledWith(1); + expect(mockDeposit.setPrincipalToken).toHaveBeenCalledWith('FUSE'); + expect(mockDeposit.setModal).toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_FORM); + expect(mockUpgrade.open).not.toHaveBeenCalled(); + act(() => root.unmount()); +}); diff --git a/components/Swap/__tests__/SwapModalProvider.ios.test.tsx b/components/Swap/__tests__/SwapModalProvider.ios.test.tsx new file mode 100644 index 000000000..f6ce11ad3 --- /dev/null +++ b/components/Swap/__tests__/SwapModalProvider.ios.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Platform } from 'react-native'; + +import SwapModalProvider from '@/components/Swap/SwapModalProvider'; +import { SWAP_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('expo-router', () => ({ useRouter: () => ({ push: jest.fn() }) })); +jest.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector })); +jest.mock('@/components/Compliance/ExchangeDisclaimer', () => 'ExchangeDisclaimer'); +jest.mock('@/components/Compliance/GeoRestrictionNotice', () => 'GeoRestrictionNotice'); +jest.mock('@/components/NeedHelp', () => 'NeedHelp'); +jest.mock('@/components/ResponsiveModal', () => 'ResponsiveModal'); +jest.mock('@/components/Swap/BuyFuseScreen', () => 'BuyFuseScreen'); +jest.mock('@/components/Swap/SwapButton', () => 'SwapButton'); +jest.mock('@/components/Swap/SwapPair', () => 'SwapPair'); +jest.mock('@/components/Swap/SwapParams', () => 'SwapParams'); +jest.mock('@/components/TransactionStatus', () => 'TransactionStatus'); +jest.mock('@/hooks/useGeoCompliance', () => ({ + __esModule: true, + default: () => ({ isSwapAvailable: mockSwapAvailable }), +})); +jest.mock('@/lib/analytics', () => ({ track: jest.fn() })); +jest.mock('@/lib/getTokenIcon', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('@/store/useComplianceStore', () => ({ + useComplianceStore: (selector: (state: any) => unknown) => + selector({ hasAcceptedDisclaimer: () => true, acceptDisclaimer: jest.fn() }), +})); +jest.mock('@/store/swapStore', () => ({ + useSwapState: (selector: (state: any) => unknown) => + selector({ + currentModal: mockCurrentModal, + previousModal: { name: 'close', number: 0 }, + transaction: {}, + buyFuseTier: undefined, + buyFuseUpgrade: mockBuyFuseUpgrade, + actions: { setModal: jest.fn() }, + }), +})); + +let mockCurrentModal = SWAP_MODAL.CLOSE; +let mockSwapAvailable = true; +let mockBuyFuseUpgrade: { tier: RewardsTier; depositToSavings: boolean } | undefined; +const originalPlatform = Platform.OS; + +const render = () => { + let root: any; + act(() => { + root = create(); + }); + return root; +}; + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + Platform.OS = 'ios'; + mockSwapAvailable = true; + mockCurrentModal = SWAP_MODAL.CLOSE; + mockBuyFuseUpgrade = undefined; +}); + +afterEach(() => { + Platform.OS = originalPlatform; +}); + +it('renders Buy FUSE on iOS', () => { + mockCurrentModal = SWAP_MODAL.OPEN_BUY_FUSE; + mockBuyFuseUpgrade = { tier: RewardsTier.ULTRA, depositToSavings: false }; + const root = render(); + expect(root.root.findAllByType('ResponsiveModal')).toHaveLength(1); + expect(root.root.findByType('BuyFuseScreen').props.upgradeContext).toEqual(mockBuyFuseUpgrade); + act(() => root.unmount()); +}); + +it('keeps the regular Swap modal hidden on iOS', () => { + mockCurrentModal = SWAP_MODAL.OPEN_FORM; + const root = render(); + expect(root.root.findAllByType('ResponsiveModal')).toHaveLength(0); + act(() => root.unmount()); +}); + +it('shows the restriction notice when Buy FUSE is unavailable in the current country', () => { + mockCurrentModal = SWAP_MODAL.OPEN_BUY_FUSE; + mockSwapAvailable = false; + const root = render(); + expect(root.root.findAllByType('GeoRestrictionNotice')).toHaveLength(1); + expect(root.root.findAllByType('BuyFuseScreen')).toHaveLength(0); + act(() => root.unmount()); +}); diff --git a/components/Tooltip.tsx b/components/Tooltip.tsx index 4c906a393..ca8c9aad1 100644 --- a/components/Tooltip.tsx +++ b/components/Tooltip.tsx @@ -21,6 +21,7 @@ interface TooltipProps { side?: 'top' | 'right' | 'bottom' | 'left'; sideOffset?: number; analyticsContext?: string; + accessibilityLabel?: string; classNames?: TooltipClassNames; } @@ -31,6 +32,7 @@ const TooltipPopover = ({ side = 'bottom', sideOffset = 4, analyticsContext, + accessibilityLabel, classNames, }: TooltipProps) => { const insets = useSafeAreaInsets(); @@ -44,6 +46,9 @@ const TooltipPopover = ({ const getTrigger = () => { return ( { track(TRACKING_EVENTS.TOOLTIP_OPENED, { context: analyticsContext || 'unknown', diff --git a/components/WalletTokenSelector/WalletTokenButton.tsx b/components/WalletTokenSelector/WalletTokenButton.tsx index 7ba048364..47bb40429 100644 --- a/components/WalletTokenSelector/WalletTokenButton.tsx +++ b/components/WalletTokenSelector/WalletTokenButton.tsx @@ -13,6 +13,9 @@ interface WalletTokenButtonProps { onPress: () => void; /** When true the button is static (no chevron, not pressable). */ disabled?: boolean; + /** Hide the chain name under the token ticker. */ + showChainName?: boolean; + tickerFontSize?: 16 | 18; } /** @@ -25,6 +28,8 @@ const WalletTokenButton = ({ selectedToken, onPress, disabled = false, + showChainName = true, + tickerFontSize = 18, }: WalletTokenButtonProps) => { return ( - {selectedToken.contractTickerSymbol} - - {getBridgeChain(selectedToken.chainId)?.name} + + {selectedToken.contractTickerSymbol} + {showChainName && ( + + {getBridgeChain(selectedToken.chainId)?.name} + + )} {!disabled && } diff --git a/components/WalletTokenSelector/WalletTokenList.tsx b/components/WalletTokenSelector/WalletTokenList.tsx index a4374e6f2..e5f99e472 100644 --- a/components/WalletTokenSelector/WalletTokenList.tsx +++ b/components/WalletTokenSelector/WalletTokenList.tsx @@ -15,6 +15,7 @@ interface WalletTokenListProps { onSelect: (token: TokenBalance) => void; emptyMessage?: string; emptyDescription?: string; + showChainLabel?: boolean; } /** @@ -27,6 +28,7 @@ const WalletTokenList: React.FC = ({ onSelect, emptyMessage = 'No tokens found', emptyDescription, + showChainLabel = true, }) => { // Sort by USD value descending const sortedTokens = useMemo(() => { @@ -54,14 +56,14 @@ const WalletTokenList: React.FC = ({ {sortedTokens.map(token => { - const balance = Number( - formatUnits(BigInt(token.balance || '0'), token.contractDecimals), - ); + const balance = Number(formatUnits(BigInt(token.balance || '0'), token.contractDecimals)); const balanceUSD = balance * (token.quoteRate || 0); const isSelected = selectedToken?.contractAddress === token.contractAddress && selectedToken?.chainId === token.chainId; - const chainName = getBridgeChain(token.chainId)?.name || `Chain ${token.chainId}`; + const chainName = showChainLabel + ? getBridgeChain(token.chainId)?.name || `Chain ${token.chainId}` + : null; return ( = ({ /> {token.contractTickerSymbol} - - {token.contractTickerSymbol} on {chainName} - + {showChainLabel && ( + + {token.contractTickerSymbol} on {chainName} + + )} ${formatNumber(balanceUSD, 2)} - - {formatNumber(balance, 2)} - + {formatNumber(balance, 2)} ); diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 4e36ac2e0..b30339e6d 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -290,7 +290,12 @@ const DialogContent = React.forwardRef< closeOnPress={isNativeBottomSheet} /> { +export const useRewardsUserData = (options?: { + refetchInterval?: number | false; + enabled?: boolean; +}) => { const userId = useSelectedUserId(); const hasConfirmedTier = useRewardsUpgradeStore( state => state.userId === userId && !!state.confirmed, @@ -73,7 +76,7 @@ export const useRewardsUserData = (options?: { refetchInterval?: number | false useRewardsUpgradeStore.getState().observe(userId!, session, data); return data; }, - enabled: !!userId, + enabled: !!userId && options?.enabled !== false, refetchInterval: options?.refetchInterval ?? false, // A cached response from an earlier account session cannot establish this // session's baseline. Switching back must fetch even within staleTime. diff --git a/store/swapStore.ts b/store/swapStore.ts index f65dbf1a8..e042d4319 100644 --- a/store/swapStore.ts +++ b/store/swapStore.ts @@ -46,6 +46,7 @@ interface SwapState { readonly currentModal: SwapModal; readonly previousModal: SwapModal; readonly buyFuseTier: RewardsTier | undefined; + readonly buyFuseUpgrade: BuyFuseUpgradeContext | undefined; readonly transaction: TransactionStatusModal & { inputCurrencySymbol?: string; outputCurrencySymbol?: string; @@ -58,11 +59,16 @@ interface SwapState { typeInput: (field: SwapFieldType, typedValue: string) => void; resetForm: () => void; setModal: (modal: SwapModal) => void; - openBuyFuse: (tier?: RewardsTier) => void; + openBuyFuse: (tier?: RewardsTier, upgrade?: { depositToSavings: boolean }) => void; setTransaction: (transaction: SwapState['transaction']) => void; }; } +export interface BuyFuseUpgradeContext { + tier: RewardsTier; + depositToSavings: boolean; +} + export const useSwapState = create((set, get) => ({ independentField: SwapField.INPUT, typedValue: '', @@ -77,6 +83,7 @@ export const useSwapState = create((set, get) => ({ currentModal: SWAP_MODAL.CLOSE, previousModal: SWAP_MODAL.CLOSE, buyFuseTier: undefined, + buyFuseUpgrade: undefined, transaction: {}, actions: { selectCurrency: (field, currencyId) => { @@ -126,12 +133,14 @@ export const useSwapState = create((set, get) => ({ set({ previousModal: get().currentModal, currentModal: modal, + ...(modal.name === SWAP_MODAL.CLOSE.name ? { buyFuseUpgrade: undefined } : {}), }), - openBuyFuse: tier => + openBuyFuse: (tier, upgrade) => set({ previousModal: get().currentModal, currentModal: SWAP_MODAL.OPEN_BUY_FUSE, buyFuseTier: tier, + buyFuseUpgrade: tier && upgrade ? { tier, ...upgrade } : undefined, }), setTransaction: transaction => set({ transaction }), }, diff --git a/store/useDepositStore.ts b/store/useDepositStore.ts index c411c336c..dea86e94b 100644 --- a/store/useDepositStore.ts +++ b/store/useDepositStore.ts @@ -11,6 +11,7 @@ import { USER } from '@/lib/config'; import mmkvStorage from '@/lib/mmvkStorage'; import { DepositModal, + RewardsTier, SavingsFundIntent, SourceDepositInstructions, TransactionStatusModal, @@ -119,6 +120,8 @@ interface DepositState { * one flow and is cleared by `resetDepositFlow`. */ savingsFundIntent: SavingsFundIntent; + /** Only set while the deposit chooser was opened by an upgrade Top up action. */ + upgradeTopUp?: { tier: RewardsTier; depositToSavings: boolean }; /** * Chain and currency chosen for the wallet deposit address ("Show deposit * address" -> "Select chain" -> the address). @@ -134,6 +137,7 @@ interface DepositState { setExternalWallet: (data: ExternalWalletState) => void; setDepositFromSolid: (v: boolean) => void; setSavingsFundIntent: (intent: SavingsFundIntent) => void; + setUpgradeTopUp: (context: DepositState['upgradeTopUp']) => void; setModal: (modal: DepositModal) => void; setTransaction: (transaction: TransactionStatusModal) => void; setBankTransferData: (data: Partial) => void; @@ -164,6 +168,7 @@ export const useDepositStore = create()( sessionStartTime: undefined, depositFromSolid: false, savingsFundIntent: 'savings', + upgradeTopUp: undefined, walletDeposit: {}, externalWallet: { address: undefined, @@ -175,6 +180,7 @@ export const useDepositStore = create()( setExternalWallet: data => set({ externalWallet: data }), setDepositFromSolid: (v: boolean) => set({ depositFromSolid: v }), setSavingsFundIntent: (intent: SavingsFundIntent) => set({ savingsFundIntent: intent }), + setUpgradeTopUp: context => set({ upgradeTopUp: context }), setModal: modal => { const isClose = modal.name === DEPOSIT_MODAL.CLOSE.name; @@ -209,6 +215,7 @@ export const useDepositStore = create()( sessionStartTime: undefined, depositFromSolid: false, savingsFundIntent: 'savings', + upgradeTopUp: undefined, walletDeposit: {}, }), }), From 24383b23e7bd2caeae85665a364237f8bed20b41 Mon Sep 17 00:00:00 2001 From: Eli Smargon Date: Wed, 23 Sep 2026 20:14:38 +0300 Subject: [PATCH 2/3] feat(rewards): show USD value for tier lock amount --- .../NewRewards/UpgradeTier/UpgradeTierContent.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx index d46af94d4..813f30577 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo } from 'react'; import { Linking, Pressable, View } from 'react-native'; import { formatUnits } from 'viem'; +import { fuse } from 'viem/chains'; import Loading from '@/components/Loading'; import { Button } from '@/components/ui/button'; @@ -9,6 +10,7 @@ import { WalletTokenButton } from '@/components/WalletTokenSelector'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useMaxAPY } from '@/hooks/useAnalytics'; +import { useNativePriceUsd } from '@/hooks/useNativePriceUsd'; import { useTierBenefits } from '@/hooks/useRewards'; import { useTierMembership, useTierUpgradeChainState } from '@/hooks/useTierMembership'; import { track } from '@/lib/analytics'; @@ -70,6 +72,7 @@ const UpgradeTierContent = () => { const { maxAPY: fuseSavingsApy } = useMaxAPY(VaultType.FUSE); const requestedTier = useTierUpgradeStore(state => state.tier); const route = useTierUpgradeStore(state => state.route); + const fusePriceUsd = useNativePriceUsd(fuse.id, 'fusePriceUsd', route === 'lock'); const setRoute = useTierUpgradeStore(state => state.setRoute); const review = useTierUpgradeStore(state => state.review); const selectToken = useTierUpgradeStore(state => state.selectToken); @@ -270,7 +273,14 @@ const UpgradeTierContent = () => { tickerFontSize={16} /> - + 0 ? formatUsd(remainingFuse * fusePriceUsd) : undefined + } + withDivider + /> 0 ? `${fuseSavingsApy.toFixed(1)}%` : '—'} From ea32c606f2671649d56adcc4c5687d2f9a2eb9d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:48:14 +0000 Subject: [PATCH 3/3] test(deposit): expect the token step from Crypto, as on qa qa now opens "Select token" first from Crypto and has deleted the "Select chain" step (a1e3912, 6ee35e0), so DEPOSIT_MODAL.OPEN_DEPOSIT_CHAIN no longer exists. The chooser test asserted that an ordinary Crypto press opens it, which failed once qa was merged. The soFUSE top-up test's negative check against it passed vacuously, since it compared against undefined. Both now name the step Crypto opens on a phone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E6iamcwoqjwq77x9kDCPzR --- .../DepositOption/__tests__/depositUpgradeTopUp.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx b/components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx index 3752f1b82..0ac59c363 100644 --- a/components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx +++ b/components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx @@ -71,7 +71,7 @@ it('keeps Buy FUSE out of the ordinary deposit chooser', () => { 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_CHAIN); + expect(mockDeposit.setModal).toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_DEPOSIT_TOKEN); act(() => root.unmount()); }); @@ -111,6 +111,6 @@ it('preserves the existing Savings deposit route for Crypto from a soFUSE top up 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_CHAIN); + expect(mockDeposit.setModal).not.toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_DEPOSIT_TOKEN); act(() => root.unmount()); });