diff --git a/.changeset/plain-oranges-repair.md b/.changeset/plain-oranges-repair.md new file mode 100644 index 000000000..8d4a1f069 --- /dev/null +++ b/.changeset/plain-oranges-repair.md @@ -0,0 +1,5 @@ +--- +'@relayprotocol/relay-kit-ui': patch +--- + +Stabilised tab switches diff --git a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx index 7a49dfbc7..d0557b7d8 100644 --- a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx +++ b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx @@ -6,14 +6,7 @@ import { useMemo, useState } from 'react' -import { - Flex, - Text, - Input, - Box, - Button, - ChainIcon -} from '../../primitives/index.js' +import { Flex, Text, Input, Box, Button } from '../../primitives/index.js' import { Modal } from '../Modal.js' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { @@ -24,14 +17,13 @@ import { import type { Token } from '../../../types/index.js' import { type ChainFilterValue } from './ChainFilter.js' import useRelayClient from '../../../hooks/useRelayClient.js' -import { isAddress, type Address } from 'viem' +import { type Address } from 'viem' import { useDebounceState, useDuneBalances } from '../../../hooks/index.js' import { useMediaQuery } from 'usehooks-ts' import { useTokenList } from '@relayprotocol/relay-kit-hooks' import { EventNames } from '../../../constants/events.js' import { UnverifiedTokenModal } from '../UnverifiedTokenModal.js' import { useEnhancedTokensList } from '../../../hooks/useEnhancedTokensList.js' -import ChainFilter from './ChainFilter.js' import { TokenList } from './TokenList.js' import { UnsupportedDepositAddressChainIds } from '../../../constants/depositAddresses.js' import { getRelayUiKitData } from '../../../utils/localStorage.js' diff --git a/packages/ui/src/components/widgets/TokenWidget/widget/index.tsx b/packages/ui/src/components/widgets/TokenWidget/widget/index.tsx index 6d2478a38..7c5a248cd 100644 --- a/packages/ui/src/components/widgets/TokenWidget/widget/index.tsx +++ b/packages/ui/src/components/widgets/TokenWidget/widget/index.tsx @@ -223,6 +223,7 @@ const TokenWidget: FC = ({ buy: { fromToken?: Token; toToken?: Token } sell: { fromToken?: Token; toToken?: Token } }>({ buy: {}, sell: {} }) + const prevActiveTabRef = useRef<'buy' | 'sell'>(activeTab) const autoSelectedFromTokenRef = useRef(false) const tabRecipientRef = useRef<{ buy: { override?: string; custom?: string } @@ -398,6 +399,9 @@ const TokenWidget: FC = ({ tradeTypeRef.current = tradeType useEffect(() => { + if (prevActiveTabRef.current !== activeTab) { + return + } tabTokenStateRef.current[activeTab] = { fromToken, toToken @@ -970,6 +974,153 @@ const TokenWidget: FC = ({ walletsLoading ]) + const handleTabChange = useCallback( + (nextTab: 'buy' | 'sell', updateActiveTab: boolean) => { + const prevTab = prevActiveTabRef.current ?? activeTab + + if (nextTab === prevTab && !updateActiveTab) { + return + } + + setAllowUnsupportedOrigin(nextTab === 'buy') + setAllowUnsupportedRecipient(nextTab === 'sell') + + if (nextTab !== prevTab) { + const storedNextState = tabTokenStateRef.current[nextTab] ?? {} + const storedNextRecipient = tabRecipientRef.current[nextTab] ?? {} + + const prevFromToken = fromToken + const prevToToken = toToken + + tabTokenStateRef.current[prevTab] = { + fromToken: prevFromToken, + toToken: prevToToken + } + tabRecipientRef.current[prevTab] = { + override: + typeof destinationAddressOverride === 'string' + ? destinationAddressOverride + : undefined, + custom: + typeof customToAddress === 'string' + ? customToAddress + : undefined + } + + let nextFromToken: Token | undefined + let nextToToken: Token | undefined + + if (nextTab === 'sell') { + // Selling the page token: default to previously viewed token (prevToToken) + nextFromToken = + storedNextState.fromToken ?? + prevToToken ?? + prevFromToken ?? + undefined + // Payout token should remain empty unless user explicitly selected it on sell + nextToToken = storedNextState.toToken ?? undefined + } else { + // Buying the page token: default output token is prev page token + nextToToken = + storedNextState.toToken ?? + prevFromToken ?? + prevToToken ?? + undefined + // Payment method stays empty unless explicitly chosen on buy + nextFromToken = storedNextState.fromToken ?? undefined + } + + handleSetFromToken(nextFromToken) + handleSetToToken(nextToToken) + setDestinationAddressOverride(storedNextRecipient.override) + setCustomToAddress(storedNextRecipient.custom) + + // Auto-select first compatible wallet in buy tab if no destination is set + if ( + nextTab === 'buy' && + multiWalletSupportEnabled && + linkedWallets && + linkedWallets.length > 0 && + !storedNextRecipient.override && + !storedNextRecipient.custom + ) { + const toChainForRecipient = relayClient?.chains?.find( + (c) => c.id === nextToToken?.chainId + ) + + if (toChainForRecipient) { + const compatibleWallets = linkedWallets.filter( + (wallet) => wallet.vmType === toChainForRecipient.vmType + ) + + if (compatibleWallets.length > 0) { + setDestinationAddressOverride(compatibleWallets[0].address) + } + } + } + + setAmountInputValue('') + setAmountOutputValue('') + setUsdInputValue('') + setUsdOutputValue('') + setTokenInputCache('') + setIsUsdInputMode(nextTab === 'buy') + debouncedAmountInputControls.cancel() + debouncedAmountOutputControls.cancel() + setOriginAddressOverride(undefined) + } + + if (updateActiveTab) { + setActiveTab(nextTab) + } + + const desiredTradeType: TradeType = + nextTab === 'buy' ? 'EXPECTED_OUTPUT' : 'EXACT_INPUT' + + if (tradeType !== desiredTradeType) { + setTradeType(desiredTradeType) + } + + prevActiveTabRef.current = nextTab + }, + [ + activeTab, + customToAddress, + debouncedAmountInputControls, + debouncedAmountOutputControls, + destinationAddressOverride, + fromToken, + handleSetFromToken, + handleSetToToken, + linkedWallets, + multiWalletSupportEnabled, + relayClient?.chains, + setActiveTab, + setAllowUnsupportedOrigin, + setAllowUnsupportedRecipient, + setAmountInputValue, + setAmountOutputValue, + setCustomToAddress, + setDestinationAddressOverride, + setIsUsdInputMode, + setOriginAddressOverride, + setTokenInputCache, + setTradeType, + setUsdInputValue, + setUsdOutputValue, + toToken, + tradeType + ] + ) + + useEffect(() => { + if (prevActiveTabRef.current === activeTab) { + return + } + + handleTabChange(activeTab, false) + }, [activeTab, handleTabChange]) + return ( <> = ({ value={activeTab} onValueChange={(value) => { const nextTab = value as 'buy' | 'sell' - - setAllowUnsupportedOrigin(nextTab === 'buy') - setAllowUnsupportedRecipient(nextTab === 'sell') - - if (nextTab !== activeTab) { - tabTokenStateRef.current[activeTab] = { - fromToken, - toToken - } - tabRecipientRef.current[activeTab] = { - override: - typeof destinationAddressOverride === 'string' - ? destinationAddressOverride - : undefined, - custom: - typeof customToAddress === 'string' - ? customToAddress - : undefined - } - - const currentState = - tabTokenStateRef.current[activeTab] ?? {} - const storedNextState = - tabTokenStateRef.current[nextTab] ?? {} - const storedNextRecipient = - tabRecipientRef.current[nextTab] ?? {} - - const hasStoredNextFromToken = - 'fromToken' in storedNextState - const hasStoredNextToToken = - 'toToken' in storedNextState - - let nextFromToken: Token | undefined - let nextToToken: Token | undefined - - if (nextTab === 'sell') { - const sellToken = hasStoredNextFromToken - ? storedNextState.fromToken - : (currentState.toToken ?? toToken ?? fromToken) - const receiveToken = hasStoredNextToToken - ? storedNextState.toToken - : (currentState.fromToken ?? fromToken) - - nextFromToken = sellToken ?? undefined - nextToToken = receiveToken ?? undefined - } else { - const buyToken = hasStoredNextToToken - ? storedNextState.toToken - : (currentState.toToken ?? toToken ?? fromToken) - const payToken = hasStoredNextFromToken - ? storedNextState.fromToken - : (currentState.fromToken ?? fromToken) - - nextFromToken = payToken ?? undefined - nextToToken = buyToken ?? undefined - } - - tabTokenStateRef.current[nextTab] = { - fromToken: nextFromToken, - toToken: nextToToken - } - tabRecipientRef.current[nextTab] = storedNextRecipient - - handleSetFromToken(nextFromToken) - handleSetToToken(nextToToken) - setDestinationAddressOverride( - storedNextRecipient.override - ) - setCustomToAddress(storedNextRecipient.custom) - - // Auto-select first compatible wallet in buy tab if no destination is set - if ( - nextTab === 'buy' && - multiWalletSupportEnabled && - linkedWallets && - linkedWallets.length > 0 && - !storedNextRecipient.override && - !storedNextRecipient.custom - ) { - // Find the destination chain for filtering compatible wallets - const toChain = relayClient?.chains?.find( - (c) => c.id === nextToToken?.chainId - ) - - if (toChain) { - // Filter wallets compatible with the destination chain VM type - const compatibleWallets = linkedWallets.filter( - (wallet) => { - return wallet.vmType === toChain.vmType - } - ) - - // Auto-select the first compatible wallet - if (compatibleWallets.length > 0) { - setDestinationAddressOverride( - compatibleWallets[0].address - ) - } - } - } - - setAmountInputValue('') - setAmountOutputValue('') - setUsdInputValue('') - setUsdOutputValue('') - setTokenInputCache('') - setIsUsdInputMode(nextTab === 'buy') - debouncedAmountInputControls.cancel() - debouncedAmountOutputControls.cancel() - setOriginAddressOverride(undefined) - } - - setActiveTab(nextTab) - - const desiredTradeType: TradeType = - nextTab === 'buy' ? 'EXPECTED_OUTPUT' : 'EXACT_INPUT' - - if (tradeType !== desiredTradeType) { - setTradeType(desiredTradeType) - } + handleTabChange(nextTab, true) onAnalyticEvent?.('TAB_SWITCHED', { tab: value