diff --git a/packages/common/src/api/tan-query/wallets/useSendCoins.ts b/packages/common/src/api/tan-query/wallets/useSendCoins.ts index 9e2c2a15bd1..28ef68289fd 100644 --- a/packages/common/src/api/tan-query/wallets/useSendCoins.ts +++ b/packages/common/src/api/tan-query/wallets/useSendCoins.ts @@ -18,6 +18,7 @@ import { useCoinBalance } from './useCoinBalance' export type SendCoinsParams = { recipientWallet: SolanaWalletAddress amount: bigint + recipientEthAddress?: string // Optional: when sending to a user, provide their ETH address to derive user-bank ATA } export type SendCoinsResult = { @@ -49,7 +50,8 @@ export const useSendCoins = ({ mint }: { mint: string }) => { return useMutation({ mutationFn: async ({ recipientWallet, - amount + amount, + recipientEthAddress }: SendCoinsParams): Promise => { try { const currentUser = walletAddresses?.currentUser @@ -68,7 +70,8 @@ export const useSendCoins = ({ mint }: { mint: string }) => { amount: amount as any, // TODO: Fix type mismatch between bigint and AudioWei ethAddress: currentUser, sdk, - mint: new PublicKey(mint) as any // TODO: Fix type mismatch between string and MintName | PublicKey + mint: new PublicKey(mint) as any, // TODO: Fix type mismatch between string and MintName | PublicKey + recipientEthAddress // Optional: when provided, derives user-bank ATA instead of regular ATA }) return { diff --git a/packages/common/src/models/AudioRewards.ts b/packages/common/src/models/AudioRewards.ts index 10ea3225458..64b6e5e4ec1 100644 --- a/packages/common/src/models/AudioRewards.ts +++ b/packages/common/src/models/AudioRewards.ts @@ -50,7 +50,6 @@ export enum ChallengeName { ConnectVerified = 'v', ProfileCompletion = 'p', Referred = 'rd', - FirstTip = 'ft', FirstPlaylist = 'fp', ListenStreak = 'l', ListenStreakEndless = 'e', @@ -71,7 +70,6 @@ export type ChallengeRewardID = | 'connect-verified' | 'listen-streak' | 'profile-completion' - | 'send-first-tip' | 'first-playlist' | ChallengeName.AudioMatchingSell // $AUDIO matching seller | ChallengeName.AudioMatchingBuy // $AUDIO matching buyer @@ -90,7 +88,6 @@ export type ChallengeRewardID = | ChallengeName.ConnectVerified | ChallengeName.ProfileCompletion | ChallengeName.Referred - | ChallengeName.FirstTip | ChallengeName.FirstPlaylist | ChallengeName.ListenStreak | ChallengeName.ListenStreakEndless diff --git a/packages/common/src/services/audius-backend/AudiusBackend.ts b/packages/common/src/services/audius-backend/AudiusBackend.ts index 8b4ddcfcec3..8205353cd12 100644 --- a/packages/common/src/services/audius-backend/AudiusBackend.ts +++ b/packages/common/src/services/audius-backend/AudiusBackend.ts @@ -901,19 +901,34 @@ export const audiusBackend = ({ amount, ethAddress, sdk, - mint + mint, + recipientEthAddress }: { address: string amount: AudioWei ethAddress: string sdk: AudiusSdk mint: PublicKey + recipientEthAddress?: string // When provided, derives user-bank ATA for the recipient }) { - const tokenAccountAddress = await getOrCreateAssociatedTokenAccount({ - address, - sdk, - mint - }) + let tokenAccountAddress: PublicKey + + if (recipientEthAddress) { + // When sending to a user, derive their user-bank ATA for this Solana mint + // The user-bank is a PDA derived from their Ethereum address and the mint + tokenAccountAddress = + await sdk.services.claimableTokensClient.deriveUserBank({ + ethWallet: recipientEthAddress, + mint + }) + } else { + // When sending to a Solana wallet address directly, use regular ATA logic + tokenAccountAddress = await getOrCreateAssociatedTokenAccount({ + address, + sdk, + mint + }) + } const res = await transferTokens({ destination: tokenAccountAddress, diff --git a/packages/common/src/utils/challenges.ts b/packages/common/src/utils/challenges.ts index 4cfc24fb785..503bc912e51 100644 --- a/packages/common/src/utils/challenges.ts +++ b/packages/common/src/utils/challenges.ts @@ -23,9 +23,8 @@ export type ChallengeRewardsInfo = { isVerifiedChallenge?: boolean } -export const challengeRewardsConfig: Record< - ChallengeRewardID, - ChallengeRewardsInfo +export const challengeRewardsConfig: Partial< + Record > = { [ChallengeName.Referrals]: { id: ChallengeName.Referrals, @@ -149,27 +148,6 @@ export const challengeRewardsConfig: Record< progressLabel: '%0/%1 Uploaded', panelButtonText: 'Upload Tracks' }, - 'send-first-tip': { - id: 'send-first-tip', - title: 'Send Your First Tip', - description: (_) => - 'Show some love to your favorite artist and send them a tip.', - fullDescription: () => - 'Show some love to your favorite artist and send them a tip.', - progressLabel: 'Not Earned', - completedLabel: 'Tip Another Artist', - panelButtonText: 'Send a Tip' - }, - [ChallengeName.FirstTip]: { - id: ChallengeName.FirstTip, - title: 'Send Your First Tip', - description: (_) => - 'Show some love to your favorite artist and send them a tip.', - fullDescription: () => - 'Show some love to your favorite artist and send them a tip.', - progressLabel: 'Not Earned', - panelButtonText: 'Send a Tip' - }, 'first-playlist': { id: 'first-playlist', title: 'Create a Playlist', diff --git a/packages/mobile/src/components/challenge-rewards-drawer/PlayCountMilestoneContent.tsx b/packages/mobile/src/components/challenge-rewards-drawer/PlayCountMilestoneContent.tsx index 903667a6e63..05bb521e1a0 100644 --- a/packages/mobile/src/components/challenge-rewards-drawer/PlayCountMilestoneContent.tsx +++ b/packages/mobile/src/components/challenge-rewards-drawer/PlayCountMilestoneContent.tsx @@ -22,11 +22,11 @@ const { getOptimisticUserChallenges } = challengesSelectors const messages = { description250: - challengeRewardsConfig[ChallengeName.PlayCount250].description(), + challengeRewardsConfig[ChallengeName.PlayCount250]?.description() ?? '', description1000: - challengeRewardsConfig[ChallengeName.PlayCount1000].description(), + challengeRewardsConfig[ChallengeName.PlayCount1000]?.description() ?? '', description10000: - challengeRewardsConfig[ChallengeName.PlayCount10000].description(), + challengeRewardsConfig[ChallengeName.PlayCount10000]?.description() ?? '', progressLabel: 'PLAYS', audio: '$AUDIO', close: 'Close', diff --git a/packages/mobile/src/screens/rewards-screen/ChallengeRewardsTile.tsx b/packages/mobile/src/screens/rewards-screen/ChallengeRewardsTile.tsx index f5314bb64ae..36c4013957a 100644 --- a/packages/mobile/src/screens/rewards-screen/ChallengeRewardsTile.tsx +++ b/packages/mobile/src/screens/rewards-screen/ChallengeRewardsTile.tsx @@ -44,12 +44,10 @@ const validRewardIds: Set = new Set([ 'mobile-install', 'listen-streak', 'profile-completion', - 'send-first-tip', 'first-playlist', ChallengeName.AudioMatchingBuy, // $AUDIO matching buyer ChallengeName.AudioMatchingSell, // $AUDIO matching seller ChallengeName.FirstPlaylist, - ChallengeName.FirstTip, ChallengeName.ListenStreak, ChallengeName.ListenStreakEndless, ChallengeName.MobileInstall, diff --git a/packages/mobile/src/utils/challenges.tsx b/packages/mobile/src/utils/challenges.tsx index 6fc2bcb82db..a5ca8fa221e 100644 --- a/packages/mobile/src/utils/challenges.tsx +++ b/packages/mobile/src/utils/challenges.tsx @@ -6,7 +6,6 @@ import { ChallengeName } from '@audius/common/models' import type { Dayjs } from '@audius/common/utils' import { challengeRewardsConfig } from '@audius/common/utils' import type { ImageSourcePropType } from 'react-native' -import { Platform } from 'react-native' import type { IconComponent } from '@audius/harmony-native' import { @@ -26,7 +25,6 @@ import Headphone from 'app/assets/images/emojis/headphone.png' import IncomingEnvelope from 'app/assets/images/emojis/incoming-envelope.png' import LoveLetter from 'app/assets/images/emojis/love-letter.png' import MobilePhoneWithArrow from 'app/assets/images/emojis/mobile-phone-with-arrow.png' -import MoneyWings from 'app/assets/images/emojis/money-with-wings.png' import MultipleMusicalNotes from 'app/assets/images/emojis/multiple-musical-notes.png' import Parachute from 'app/assets/images/emojis/parachute.png' import Recycle from 'app/assets/images/emojis/recycle.png' @@ -80,12 +78,14 @@ export type MobileChallengeConfig = { } } -const mobileChallengeConfig: Record< - Exclude< - ChallengeRewardID, - 'connect-verified' | ChallengeName.ConnectVerified - >, - MobileChallengeConfig +const mobileChallengeConfig: Partial< + Record< + Exclude< + ChallengeRewardID, + 'connect-verified' | ChallengeName.ConnectVerified + >, + MobileChallengeConfig + > > = { 'listen-streak': { icon: Headphone, @@ -138,59 +138,6 @@ const mobileChallengeConfig: Record< iconRight: IconCloudUpload } }, - 'send-first-tip': { - icon: MoneyWings, - title: - Platform.OS === 'ios' - ? messages.sendFirstTipTitleAlt - : messages.sendFirstTipTitle, - description: () => - Platform.OS === 'ios' - ? messages.sendFirstTipDescriptionAlt - : messages.sendFirstTipDescription, - shortDescription: - Platform.OS === 'ios' - ? messages.sendFirstTipShortDescriptionAlt - : messages.sendFirstTipShortDescription, - panelButtonText: - Platform.OS === 'ios' - ? messages.sendFirstTipButtonAlt - : messages.sendFirstTipButton, - completedLabel: - Platform.OS === 'ios' - ? messages.sendFirstTipCompletedLabelAlt - : undefined, - buttonInfo: { - navigation: { - screen: 'library' - }, - iconRight: IconArrowRight - } - }, - [ChallengeName.FirstTip]: { - icon: MoneyWings, - title: - Platform.OS === 'ios' - ? messages.sendFirstTipTitleAlt - : messages.sendFirstTipTitle, - description: () => - Platform.OS === 'ios' - ? messages.sendFirstTipDescriptionAlt - : messages.sendFirstTipDescription, - shortDescription: - Platform.OS === 'ios' - ? messages.sendFirstTipShortDescriptionAlt - : messages.sendFirstTipShortDescription, - panelButtonText: - Platform.OS === 'ios' - ? messages.sendFirstTipButtonAlt - : messages.sendFirstTipButton, - buttonInfo: { - navigation: { - screen: 'library' - } - } - }, 'first-playlist': { icon: TrebleClef, buttonInfo: { diff --git a/packages/web/src/components/send-tokens-modal/SendTokensConfirmation.tsx b/packages/web/src/components/send-tokens-modal/SendTokensConfirmation.tsx index 555ee12333a..5562f2db0c3 100644 --- a/packages/web/src/components/send-tokens-modal/SendTokensConfirmation.tsx +++ b/packages/web/src/components/send-tokens-modal/SendTokensConfirmation.tsx @@ -1,43 +1,47 @@ -import React, { ChangeEvent, useState } from 'react' +import React from 'react' import { useArtistCoin, - useCoinBalance, transformArtistCoinToTokenInfo } from '@audius/common/api' -import { walletMessages } from '@audius/common/messages' +import { User, SquareSizes } from '@audius/common/models' import { FixedDecimal } from '@audius/fixed-decimal' import { Button, Text, Flex, Divider, - Hint, - Checkbox, - useMedia, - useTheme + Avatar, + SegmentedControl } from '@audius/harmony' -import { CryptoBalanceSection } from 'components/buy-sell-modal/CryptoBalanceSection' +import { TokenIcon } from 'components/buy-sell-modal/TokenIcon' +import UserBadges from 'components/user-badges/UserBadges' +import { useProfilePicture } from 'hooks/useProfilePicture' + +type RecipientType = 'user' | 'wallet' interface SendTokensConfirmationProps { mint: string amount: bigint destinationAddress: string + selectedUser: User | null + recipientType: RecipientType onConfirm: () => void onBack: () => void onClose: () => void } const messages = { - sendTitle: 'SEND', - amountToSend: 'Amount to Send', + sending: 'Sending', + toRecipient: 'To Recipient', + toDestinationAddress: 'To Destination Address', + recipient: 'Recipient', destinationAddress: 'Destination Address', - reviewDetails: 'Review Details Carefully', - reviewDescription: - 'By proceeding, you accept full responsibility for any errors, including the risk of irreversible loss of funds. Transfers are final and cannot be reversed.', - confirmationText: - 'I have reviewed the information and understand that transfers are final.', + user: 'User', + wallet: 'Wallet', + pleaseReview: + 'Please review your transaction details. This action cannot be undone.', back: 'Back', confirm: 'Confirm', loadingTokenInformation: 'Loading token information...' @@ -47,22 +51,20 @@ const SendTokensConfirmation = ({ mint, amount, destinationAddress, + selectedUser, + recipientType, onConfirm, onBack }: SendTokensConfirmationProps) => { - const { color } = useTheme() - const [isConfirmed, setIsConfirmed] = useState(false) - const { isMobile } = useMedia() - - // Get token data and balance using the same hooks as ReceiveTokensModal + // Get token data const { data: coin } = useArtistCoin(mint) - const { data: tokenBalance } = useCoinBalance({ - mint, - includeExternalWallets: false, - includeStaked: false - }) const tokenInfo = coin ? transformArtistCoinToTokenInfo(coin) : undefined + const profilePicture = useProfilePicture({ + userId: selectedUser?.user_id, + size: SquareSizes.SIZE_150_BY_150 + }) + const formatAmount = (amount: bigint) => { return new FixedDecimal(amount, tokenInfo?.decimals).toLocaleString( 'en-US', @@ -73,16 +75,6 @@ const SendTokensConfirmation = ({ ) } - const formattedBalance = - tokenBalance?.balance.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }) ?? '' - - const handleCheckboxChange = (event: ChangeEvent) => { - setIsConfirmed(event.target.checked) - } - // Show loading state if we don't have tokenInfo yet if (!tokenInfo) { return ( @@ -96,76 +88,109 @@ const SendTokensConfirmation = ({ return ( - {/* Token Balance Section */} - { + // Intentionally empty - not clickable on confirmation screen + }} + disabled /> + {/* Please Review Text */} + + {messages.pleaseReview} + + - {/* Amount Info */} - + {/* Sending Section - using CryptoBalanceSection format */} + - {messages.amountToSend} - - - {walletMessages.minus} - {formatAmount(amount)} ${tokenInfo.symbol} + {messages.sending} + + + + + {tokenInfo.name} + + + + {formatAmount(amount)} + + + ${tokenInfo.symbol} + + + + - {/* Transfer Info */} - + {/* To Recipient/Destination Address Section */} + - {messages.destinationAddress} - - - {destinationAddress} + {recipientType === 'user' + ? messages.toRecipient + : messages.toDestinationAddress} - - - {/* Review Details Hint */} - - - - {messages.reviewDetails} - - - {messages.reviewDescription} - - - - - {messages.confirmationText} - + {selectedUser ? ( + + + + + + {selectedUser.name} + + + + + @{selectedUser.handle} + + - - + ) : ( + + {destinationAddress} + + )} + {/* Action Buttons */} - diff --git a/packages/web/src/components/send-tokens-modal/SendTokensFailure.tsx b/packages/web/src/components/send-tokens-modal/SendTokensFailure.tsx index af90f8ccc93..30b02823557 100644 --- a/packages/web/src/components/send-tokens-modal/SendTokensFailure.tsx +++ b/packages/web/src/components/send-tokens-modal/SendTokensFailure.tsx @@ -3,6 +3,7 @@ import { useCoinBalance, transformArtistCoinToTokenInfo } from '@audius/common/api' +import { User, SquareSizes } from '@audius/common/models' import { FixedDecimal } from '@audius/fixed-decimal' import { Button, @@ -12,15 +13,18 @@ import { CompletionCheck, IconExternalLink, PlainButton, - useMedia + Avatar } from '@audius/harmony' import { CryptoBalanceSection } from 'components/buy-sell-modal/CryptoBalanceSection' +import UserBadges from 'components/user-badges/UserBadges' +import { useProfilePicture } from 'hooks/useProfilePicture' interface SendTokensFailureProps { mint: string amount: bigint destinationAddress: string + selectedUser: User | null error: string onTryAgain: () => void onClose: () => void @@ -28,6 +32,7 @@ interface SendTokensFailureProps { const messages = { failed: 'Failed', + recipient: 'Recipient', destinationAddress: 'Destination Address', viewOnSolana: 'View On Solana Block Explorer', transactionFailed: 'Your transaction failed to complete.', @@ -39,11 +44,11 @@ const SendTokensFailure = ({ mint, amount, destinationAddress, + selectedUser, error, onTryAgain, onClose }: SendTokensFailureProps) => { - const { isMobile } = useMedia() // Get token data and balance using the same hooks as ReceiveTokensModal const { data: coin } = useArtistCoin(mint) const { data: tokenBalance } = useCoinBalance({ @@ -56,6 +61,11 @@ const SendTokensFailure = ({ ? tokenBalance.balance.value : BigInt(0) + const profilePicture = useProfilePicture({ + userId: selectedUser?.user_id, + size: SquareSizes.SIZE_150_BY_150 + }) + const formatAmount = (amount: bigint) => { return new FixedDecimal(amount, tokenInfo?.decimals).toLocaleString( 'en-US', @@ -98,48 +108,83 @@ const SendTokensFailure = ({ - {/* Amount Info */} - + {/* Failed Section */} + {messages.failed} - - -{formatAmount(amount)} ${tokenInfo.symbol} - + + {/* Token logo would go here */} + + + {tokenInfo.name} + + + {formatAmount(amount)} ${tokenInfo.symbol} + + + - {/* Address Container */} - + {/* To Recipient Section */} + - {messages.destinationAddress} - - - {destinationAddress} + {messages.recipient} - { - window.open( - `https://explorer.solana.com/address/${destinationAddress}`, - '_blank' - ) - }} - iconRight={IconExternalLink} - > - {messages.viewOnSolana} - + {selectedUser ? ( + + + + + + {selectedUser.name} + + + + + @{selectedUser.handle} + + + + ) : ( + <> + + {destinationAddress} + + { + window.open( + `https://explorer.solana.com/address/${destinationAddress}`, + '_blank' + ) + }} + iconRight={IconExternalLink} + > + {messages.viewOnSolana} + + + )} {/* Error Message */} diff --git a/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx b/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx index c8cf30bba9c..c2686a1ccea 100644 --- a/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx +++ b/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx @@ -1,78 +1,147 @@ -import { ChangeEvent, useCallback, useState } from 'react' +import { ChangeEvent, useCallback, useMemo, useState } from 'react' import { useArtistCoin, useCoinBalance, - transformArtistCoinToTokenInfo + transformArtistCoinToTokenInfo, + useCurrentUserId, + useTradeableCoins } from '@audius/common/api' +import { useOwnedCoins } from '@audius/common/hooks' +import { buySellMessages } from '@audius/common/messages' +import { User } from '@audius/common/models' import { isValidSolAddress } from '@audius/common/store' +import { route } from '@audius/common/utils' import { FixedDecimal } from '@audius/fixed-decimal' import { Button, - IconValidationX, TokenAmountInput, Text, Flex, - Divider + Divider, + SegmentedControl, + TextLink, + useTheme } from '@audius/harmony' -import { CryptoBalanceSection } from 'components/buy-sell-modal/CryptoBalanceSection' +import { appkitModal } from 'app/ReownAppKitModal' +import { CurrentWalletBanner } from 'components/buy-sell-modal/components/CurrentWalletBanner' +import { StaticTokenDisplay } from 'components/buy-sell-modal/components/StaticTokenDisplay' +import { TokenDropdown } from 'components/buy-sell-modal/components/TokenDropdown' +import { UserSearchAutocomplete } from './UserSearchAutocomplete' import WalletInput from './WalletInput' +type RecipientType = 'user' | 'wallet' + interface SendTokensInputProps { mint: string - onContinue: (amount: bigint, destinationAddress: string) => void + onContinue: ( + amount: bigint, + destinationAddress: string, + selectedUser: User | null, + selectedMint: string, + recipientType: RecipientType, + amountString: string + ) => void initialAmount?: string initialDestinationAddress?: string + initialSelectedUser?: User | null + initialRecipientType?: RecipientType } const messages = { - amount: 'Amount', - amountToSend: 'Amount to Send', - amountDescription: 'How much {symbol} would you like to send?', + sending: 'Sending', destinationAddress: 'Destination Address', - destinationDescription: 'The Solana wallet address to receive funds.', + recipient: 'Recipient', + recipientDescriptionUser: 'Search for an Audius user by name or handle.', + recipientDescriptionWallet: 'The Solana wallet address to receive funds.', + user: 'User', + wallet: 'Wallet', continue: 'Continue', insufficientBalance: 'Insufficient balance', validWalletAddressRequired: 'A valid wallet address is required.', amountRequired: 'Amount is required', amountTooLow: 'Amount is too low to send', - walletAddress: 'Wallet Address' + walletAddress: 'Wallet Address', + userRequired: 'Please select a user', + userNoWallet: + 'This user does not have a wallet address set up. Please send to a different user or use a wallet address instead.' } +const { TERMS_OF_SERVICE } = route + type ValidationError = | 'INSUFFICIENT_BALANCE' | 'INVALID_ADDRESS' | 'AMOUNT_REQUIRED' | 'AMOUNT_TOO_LOW' + | 'USER_REQUIRED' + | 'USER_NO_WALLET' const SendTokensInput = ({ - mint, + mint: initialMint, onContinue, initialAmount = '', - initialDestinationAddress = '' + initialDestinationAddress = '', + initialSelectedUser = null, + initialRecipientType = 'user' }: SendTokensInputProps) => { + const [recipientType, setRecipientType] = + useState(initialRecipientType) + const [selectedMint, setSelectedMint] = useState(initialMint) const [amount, setAmount] = useState(initialAmount) const [destinationAddress, setDestinationAddress] = useState( initialDestinationAddress ) + const [selectedUser, setSelectedUser] = useState( + initialSelectedUser + ) const [amountError, setAmountError] = useState(null) const [addressError, setAddressError] = useState(null) - // Get the coin data and balance using the same hooks as ReceiveTokensModal - const { data: coin } = useArtistCoin(mint) + const { spacing } = useTheme() + const externalWalletAccount = appkitModal.getAccount('solana') + const isUsingExternalWallet = !!externalWalletAccount?.address + + // Get available tokens + const { coinsArray: availableCoins, isLoading: coinsLoading } = + useTradeableCoins({ + includeSol: isUsingExternalWallet + }) + const { ownedCoins, isLoading: isOwnedCoinsLoading } = useOwnedCoins( + availableCoins, + externalWalletAccount?.address + ) + + // Get the coin data and balance for selected token + const { data: coin } = useArtistCoin(selectedMint) const { data: tokenBalance } = useCoinBalance({ - mint, - includeExternalWallets: false, + mint: selectedMint, + includeExternalWallets: false, // CurrentWalletBanner handles external wallet balance includeStaked: false }) + const { data: currentUserId } = useCurrentUserId() const tokenInfo = coin ? transformArtistCoinToTokenInfo(coin) : undefined - const formattedBalance = - tokenBalance?.balance.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }) ?? '' + + // Find the selected token in owned coins for the dropdown + // If not found in owned coins, try to find it in available coins (for initial load) + const selectedToken = useMemo(() => { + const ownedToken = ownedCoins.find( + (token) => token.address === selectedMint + ) + if (ownedToken) return ownedToken + // Fallback to available coins if not in owned coins yet (during initial load) + return availableCoins.find((token) => token.address === selectedMint) + }, [ownedCoins, availableCoins, selectedMint]) + + const handleTokenChange = useCallback((token: typeof selectedToken) => { + if (token) { + setSelectedMint(token.address) + setAmount('') // Reset amount when changing token + setAmountError(null) + } + }, []) const handleAmountChange = useCallback((value: string, weiAmount: bigint) => { setAmount(value) @@ -87,6 +156,25 @@ const SendTokensInput = ({ [] ) + const handleUserChange = useCallback((user: User | null) => { + setSelectedUser(user) + setAddressError(null) + // When sending to a user, we derive their user-bank ATA from their ETH address on the backend + // But we still set spl_wallet for display purposes in the UI + if (user?.spl_wallet) { + setDestinationAddress(user.spl_wallet) + } else { + setDestinationAddress('') + } + }, []) + + const handleRecipientTypeChange = useCallback((type: RecipientType) => { + setRecipientType(type) + setSelectedUser(null) + setDestinationAddress('') + setAddressError(null) + }, []) + const validateInputs = (): boolean => { let isValid = true @@ -109,13 +197,24 @@ const SendTokensInput = ({ } } - // Validate address - if (!destinationAddress) { - setAddressError('INVALID_ADDRESS') - isValid = false - } else if (!isValidSolAddress(destinationAddress as any)) { - setAddressError('INVALID_ADDRESS') - isValid = false + // Validate recipient based on type + if (recipientType === 'user') { + if (!selectedUser) { + setAddressError('USER_REQUIRED') + isValid = false + } else if (!selectedUser.spl_wallet) { + setAddressError('USER_NO_WALLET') + isValid = false + } + } else { + // Validate wallet address + if (!destinationAddress) { + setAddressError('INVALID_ADDRESS') + isValid = false + } else if (!isValidSolAddress(destinationAddress as any)) { + setAddressError('INVALID_ADDRESS') + isValid = false + } } return isValid @@ -124,17 +223,22 @@ const SendTokensInput = ({ const handleContinue = () => { if (validateInputs()) { const amountWei = new FixedDecimal(amount, tokenInfo?.decimals).value - onContinue(amountWei, destinationAddress) + // Use wallet address from user if sending to user, otherwise use input address + const finalAddress = + recipientType === 'user' && selectedUser?.spl_wallet + ? selectedUser.spl_wallet + : destinationAddress + onContinue( + amountWei, + finalAddress, + recipientType === 'user' ? selectedUser : null, + selectedMint, + recipientType, + amount + ) } } - const getAmountDescription = () => { - return messages.amountDescription.replace( - '{symbol}', - tokenInfo?.symbol ? `$${tokenInfo.symbol}` : 'tokens' - ) - } - const getErrorText = (error: ValidationError | null) => { switch (error) { case 'INSUFFICIENT_BALANCE': @@ -145,6 +249,10 @@ const SendTokensInput = ({ return messages.amountRequired case 'AMOUNT_TOO_LOW': return messages.amountTooLow + case 'USER_REQUIRED': + return messages.userRequired + case 'USER_NO_WALLET': + return messages.userNoWallet default: return '' } @@ -153,7 +261,7 @@ const SendTokensInput = ({ const hasErrors = amountError || addressError // Show loading state if we don't have tokenInfo yet - if (!tokenInfo) { + if (!tokenInfo || coinsLoading || isOwnedCoinsLoading) { return ( @@ -163,69 +271,130 @@ const SendTokensInput = ({ ) } + if (!selectedToken) { + return ( + + + Token not found. Please try again. + + + ) + } + return ( - {/* Token Balance Section */} - + handleRecipientTypeChange(value as RecipientType) + } + /> + + {/* Trade with Section */} + - {/* Amount Section */} + {/* Sending Section */} - - - {messages.amountToSend} - - - {getAmountDescription()} + + + {messages.sending} - - - {amountError && ( - - - + + + + + + + {ownedCoins.length > 1 ? ( + + + + ) : ( + + + + )} + + + {amountError && ( + {getErrorText(amountError)} - - )} + )} + - {/* Destination Address Section */} + {/* Destination Address/Recipient Section */} - {messages.destinationAddress} + {recipientType === 'user' + ? messages.recipient + : messages.destinationAddress} - {messages.destinationDescription} + {recipientType === 'user' + ? messages.recipientDescriptionUser + : messages.recipientDescriptionWallet} - + {/* User or Wallet Input */} + {recipientType === 'user' ? ( + + ) : ( + + )} + {/* Terms of Use Link */} + + {buySellMessages.termsAgreement}{' '} + + {buySellMessages.termsOfUse} + + + {/* Continue Button */}