diff --git a/apps/cow-fi/data/cow-swap/const.tsx b/apps/cow-fi/data/cow-swap/const.tsx index dabca1fc406..174f1f73bdd 100644 --- a/apps/cow-fi/data/cow-swap/const.tsx +++ b/apps/cow-fi/data/cow-swap/const.tsx @@ -8,8 +8,9 @@ import IMG_COWSWAP_NOFEES from '@cowprotocol/assets/images/image-cowswap-nofees. import IMG_COWSWAP_SWAPS from '@cowprotocol/assets/images/image-cowswap-swaps.svg' import IMG_COWSWAP_TWAP from '@cowprotocol/assets/images/image-cowswap-twap.svg' import IMG_COWSWAP_UX from '@cowprotocol/assets/images/image-cowswap-ux.svg' -import { Color, UI } from '@cowprotocol/ui' import { getAvailableChainsText } from '@cowprotocol/common-const' +import { Color, UI } from '@cowprotocol/ui' + import { CowFiCategory } from 'src/common/analytics/types' import { Link } from '@/components/Link' diff --git a/apps/cowswap-frontend/src/common/hooks/useGetExecutedBridgeSummary.ts b/apps/cowswap-frontend/src/common/hooks/useGetExecutedBridgeSummary.ts index cd2408034f2..afafc8e622d 100644 --- a/apps/cowswap-frontend/src/common/hooks/useGetExecutedBridgeSummary.ts +++ b/apps/cowswap-frontend/src/common/hooks/useGetExecutedBridgeSummary.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-restricted-imports */ // TODO: Don't use 'modules' import import { useMemo } from 'react' import { useTokenByAddress } from '@cowprotocol/tokens' diff --git a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx new file mode 100644 index 00000000000..85922f346f9 --- /dev/null +++ b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx @@ -0,0 +1,66 @@ +import { AccountType } from '@cowprotocol/types' + +import { render } from '@testing-library/react' + +import { BridgingEnabledUpdater } from './BridgingEnabledUpdater' + +import { Routes } from '../constants/routes' + +jest.mock('@cowprotocol/common-hooks', () => ({ + ...jest.requireActual('@cowprotocol/common-hooks'), + useSetIsBridgingEnabled: jest.fn(), +})) + +jest.mock('@cowprotocol/wallet', () => ({ + ...jest.requireActual('@cowprotocol/wallet'), + useWalletInfo: jest.fn(), + useAccountType: jest.fn(), +})) + +jest.mock('modules/trade', () => ({ + ...jest.requireActual('modules/trade'), + useTradeTypeInfo: jest.fn(), +})) + +const { useSetIsBridgingEnabled } = require('@cowprotocol/common-hooks') +const mockUseSetIsBridgingEnabled = useSetIsBridgingEnabled as jest.MockedFunction +const { useWalletInfo, useAccountType } = require('@cowprotocol/wallet') + +const mockUseWalletInfo = useWalletInfo as jest.MockedFunction +const mockUseAccountType = useAccountType as jest.MockedFunction +const { useTradeTypeInfo } = require('modules/trade') +const mockUseTradeTypeInfo = useTradeTypeInfo as jest.MockedFunction + +describe('BridgingEnabledUpdater', () => { + const setIsBridgingEnabled = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + mockUseSetIsBridgingEnabled.mockReturnValue(setIsBridgingEnabled) + mockUseWalletInfo.mockReturnValue({ account: '0x123' }) + mockUseAccountType.mockReturnValue(AccountType.EOA) + mockUseTradeTypeInfo.mockReturnValue({ route: Routes.SWAP }) + }) + + it('disables bridging for smart contract wallets', () => { + mockUseAccountType.mockReturnValue(AccountType.SMART_CONTRACT) + + render() + + expect(setIsBridgingEnabled).toHaveBeenCalledWith(false) + }) + + it('enables bridging on swap route for a compatible wallet', () => { + render() + + expect(setIsBridgingEnabled).toHaveBeenCalledWith(true) + }) + + it('disables bridging on non-swap routes', () => { + mockUseTradeTypeInfo.mockReturnValue({ route: Routes.LIMIT_ORDER }) + + render() + + expect(setIsBridgingEnabled).toHaveBeenCalledWith(false) + }) +}) diff --git a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts index e314d894610..14b975bdd3d 100644 --- a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts +++ b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts @@ -16,7 +16,6 @@ export function BridgingEnabledUpdater(): null { const setIsBridgingEnabled = useSetIsBridgingEnabled() const isSwapRoute = tradeTypeInfo?.route === Routes.SWAP - const isWalletCompatible = Boolean(account ? accountType !== AccountType.SMART_CONTRACT : true) const shouldEnableBridging = isWalletCompatible && isSwapRoute diff --git a/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts b/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts index d27704e9170..6708e945639 100644 --- a/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts +++ b/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts @@ -1,3 +1,4 @@ export { useBridgeSupportedNetworks, useBridgeSupportedNetwork } from './useBridgeSupportedNetworks' export { useBridgeSupportedTokens } from './useBridgeSupportedTokens' +export { useRoutesAvailability } from './useRoutesAvailability' export { BridgeProvidersUpdater } from './BridgeProvidersUpdater' diff --git a/apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.ts b/apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.ts new file mode 100644 index 00000000000..3edf4fe0aa0 --- /dev/null +++ b/apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.ts @@ -0,0 +1,112 @@ +import { useMemo } from 'react' + +import { SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const' +import { useIsBridgingEnabled } from '@cowprotocol/common-hooks' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import useSWR from 'swr' +import { bridgingSdk } from 'tradingSdk/bridgingSdk' + +import { useBridgeProvidersIds } from './useBridgeProvidersIds' + +export interface RoutesAvailabilityResult { + unavailableChainIds: Set + loadingChainIds: Set + isLoading: boolean +} + +const EMPTY_RESULT: RoutesAvailabilityResult = { + unavailableChainIds: new Set(), + loadingChainIds: new Set(), + isLoading: false, +} + +interface RouteCheckResult { + chainId: number + isAvailable: boolean +} + +/** + * Pre-checks route availability for multiple destination chains from a source chain. + * Returns which chains have unavailable routes and which are still loading. + * + * Note: Fires parallel requests for all destination chains without throttling. + * This is acceptable because: + * - SWR caches results, so repeated opens don't re-fetch + * - Chain count is limited (~10-15 max) + * - Requests are lightweight (token existence checks) + * If this becomes a bottleneck, consider batching or sequential fetching. + */ +export function useRoutesAvailability( + sourceChainId: SupportedChainId | undefined, + destinationChainIds: number[], +): RoutesAvailabilityResult { + const isBridgingEnabled = useIsBridgingEnabled() + const providerIds = useBridgeProvidersIds() + const providersKey = providerIds.join('|') + + // Filter out the source chain (same-chain swaps are always available) + const chainsToCheck = useMemo( + () => destinationChainIds.filter((id) => id !== sourceChainId), + [destinationChainIds, sourceChainId], + ) + + // Create a stable key for the SWR request + const swrKey = useMemo(() => { + if (!isBridgingEnabled || !sourceChainId || chainsToCheck.length === 0) { + return null + } + return [sourceChainId, chainsToCheck.sort().join(','), providersKey, 'useRoutesAvailability'] + }, [isBridgingEnabled, sourceChainId, chainsToCheck, providersKey]) + + const { data, isLoading } = useSWR( + swrKey, + async (key) => { + const [sellChainId, chainIdsString] = key as [SupportedChainId, string, string, string] + const chainIds = chainIdsString.split(',').map(Number) + + // Check routes in parallel for all destination chains + const results = await Promise.all( + chainIds.map(async (buyChainId: number): Promise => { + try { + const result = await bridgingSdk.getBuyTokens({ sellChainId, buyChainId }) + const isAvailable = result.tokens.length > 0 && result.isRouteAvailable + return { chainId: buyChainId, isAvailable } + } catch (error) { + console.warn(`[useRoutesAvailability] Failed to check route ${sellChainId} -> ${buyChainId}`, error) + // Treat errors as unavailable routes + return { chainId: buyChainId, isAvailable: false } + } + }), + ) + + return results + }, + SWR_NO_REFRESH_OPTIONS, + ) + + return useMemo(() => { + if (!swrKey) { + return EMPTY_RESULT + } + + if (isLoading || !data) { + // While loading, mark all chains being checked as loading + return { + unavailableChainIds: new Set(), + loadingChainIds: new Set(chainsToCheck), + isLoading: true, + } + } + + const unavailableChainIds = new Set( + data.filter((result) => !result.isAvailable).map((result) => result.chainId), + ) + + return { + unavailableChainIds, + loadingChainIds: new Set(), + isLoading: false, + } + }, [swrKey, isLoading, data, chainsToCheck]) +} diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po index 70bc27d794e..4d0baf90e3e 100644 --- a/apps/cowswap-frontend/src/locales/es-ES.po +++ b/apps/cowswap-frontend/src/locales/es-ES.po @@ -4212,6 +4212,14 @@ msgstr "Habilitar aprobación parcial" msgid "Version" msgstr "Versión" +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Recent" +msgstr "Recientes" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Clear" +msgstr "Borrar" + #: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx msgid "All tokens" msgstr "Todos los tokens" @@ -5211,7 +5219,7 @@ msgstr "parte" #: apps/cowswap-frontend/src/modules/swap/pure/CrossChainUnlockScreen/index.tsx msgid "Cross-chain swaps are here" -msgstr "Los swaps de cadena media están aquí" +msgstr "Los swaps entre cadenas están aquí" #: apps/cowswap-frontend/src/modules/erc20Approve/containers/ApprovalAmountInput/ApprovalAmountInput.tsx #~ msgid "Approval amount:" @@ -6294,3 +6302,84 @@ msgstr "Aprende más" msgid "Swap and bridge costs are at least {formattedFeePercentage}% of the swap amount" msgstr "Los costos de intercambio y puente son por lo menos {formattedFeePercentage}% del monto de intercambio" +# Receive amount labels +msgid "Receive (incl. fees)" +msgstr "Recibir (incl. comisiones)" + +msgid "From (incl. fees)" +msgstr "De (incl. comisiones)" + +# Notifications / jobs aria labels +msgid "Trade alert settings" +msgstr "Configuración de alertas de trading" + +msgid "View jobs (opens in a new tab)" +msgstr "Ver trabajos (se abre en una pestaña nueva)" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "Search network" +msgstr "Buscar red" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Select token" +msgstr "Seleccionar token" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "From network" +msgstr "Red de origen" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "To network" +msgstr "Red de destino" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Select network" +msgstr "Seleccionar red" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +msgid "Cross chain swap" +msgstr "Swap entre cadenas" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Swap from" +msgstr "Swap desde" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Swap to" +msgstr "Swap a" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Sell token" +msgstr "Vender token" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Buy token" +msgstr "Comprar token" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx +msgid "Manage token lists" +msgstr "Gestionar listas de tokens" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "No networks available for this trade." +msgstr "No hay redes disponibles para este intercambio." + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "No networks match {chainQuery}." +msgstr "No hay redes que coincidan con {chainQuery}." + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "View all ({totalChains})" +msgstr "Ver todas ({totalChains})" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "View all {totalChains} networks" +msgstr "Ver todas las {totalChains} redes" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "Selected network {activeChainLabel}" +msgstr "Red seleccionada {activeChainLabel}" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx +msgid "This destination is not supported for this source chain" +msgstr "Este destino no es compatible con esta red de origen" diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po index 167fbfb56e5..1001a96cd47 100644 --- a/apps/cowswap-frontend/src/locales/ru-RU.po +++ b/apps/cowswap-frontend/src/locales/ru-RU.po @@ -4212,6 +4212,14 @@ msgstr "Включить частичные утверждения" msgid "Version" msgstr "Версии" +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Recent" +msgstr "Недавние" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Clear" +msgstr "Очистить" + #: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx msgid "All tokens" msgstr "Все токены" @@ -5211,7 +5219,7 @@ msgstr "часть" #: apps/cowswap-frontend/src/modules/swap/pure/CrossChainUnlockScreen/index.tsx msgid "Cross-chain swaps are here" -msgstr "Перекрестные цепочки здесь" +msgstr "Межсетевые обмены здесь" #: apps/cowswap-frontend/src/modules/erc20Approve/containers/ApprovalAmountInput/ApprovalAmountInput.tsx #~ msgid "Approval amount:" @@ -6294,3 +6302,84 @@ msgstr "Узнать больше" msgid "Swap and bridge costs are at least {formattedFeePercentage}% of the swap amount" msgstr "Затраты на замену и мост составляют не менее {formattedFeePercentage}% от суммы замены" +# Receive amount labels +msgid "Receive (incl. fees)" +msgstr "К получению (с комиссиями)" + +msgid "From (incl. fees)" +msgstr "Отправить (с комиссиями)" + +# Notifications / jobs aria labels +msgid "Trade alert settings" +msgstr "Настройки уведомлений о сделках" + +msgid "View jobs (opens in a new tab)" +msgstr "Просмотр вакансий (откроется в новой вкладке)" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "Search network" +msgstr "Поиск сети" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Select token" +msgstr "Выбрать токен" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "From network" +msgstr "Исходная сеть" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "To network" +msgstr "Целевая сеть" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Select network" +msgstr "Выбрать сеть" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +msgid "Cross chain swap" +msgstr "Свап между сетями" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Swap from" +msgstr "Свап из сети" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Swap to" +msgstr "Свап в сеть" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Sell token" +msgstr "Продать токен" + +#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +msgid "Buy token" +msgstr "Купить токен" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx +msgid "Manage token lists" +msgstr "Управление списками токенов" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "No networks available for this trade." +msgstr "Нет доступных сетей для этой сделки." + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +msgid "No networks match {chainQuery}." +msgstr "Нет сетей, соответствующих {chainQuery}." + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "View all ({totalChains})" +msgstr "Показать все ({totalChains})" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "View all {totalChains} networks" +msgstr "Показать все сети ({totalChains})" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx +msgid "Selected network {activeChainLabel}" +msgstr "Выбранная сеть {activeChainLabel}" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx +msgid "This destination is not supported for this source chain" +msgstr "Сеть назначения не доступна для выбранной исходной сети" diff --git a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts index 98ebb04b94c..fc8f4e819f0 100644 --- a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts +++ b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' +import { getTokenId } from '@cowprotocol/common-utils' import { CurrencyAmount } from '@uniswap/sdk-core' import { getUsdPriceStateKey, useUsdPrices } from 'modules/usdAmount' @@ -25,7 +26,7 @@ export function useRefundAmounts(): TokenUsdAmounts | null { return tokensToRefund.reduce((acc, { token, balance }) => { const usdPrice = usdPrices[getUsdPriceStateKey(token)] - const tokenKey = token.address.toLowerCase() + const tokenKey = getTokenId(token) acc[tokenKey] = { token, diff --git a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts index d6a425c41a2..efb26014813 100644 --- a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts +++ b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useTokensBalances } from '@cowprotocol/balances-and-allowances' import { TokenWithLogo } from '@cowprotocol/common-const' +import { getTokenAddressKey } from '@cowprotocol/common-utils' import { useTokensByAddressMap } from '@cowprotocol/tokens' import { CurrencyAmount, Token } from '@uniswap/sdk-core' @@ -16,7 +17,7 @@ export function useTokenBalanceAndUsdValue(tokenAddress: string | undefined): To const tokensByAddress = useTokensByAddressMap() const { values: balances } = useTokensBalances() - const tokenKey = tokenAddress?.toLowerCase() || undefined + const tokenKey = tokenAddress ? getTokenAddressKey(tokenAddress) : undefined const token = !!tokenKey && tokensByAddress[tokenKey] const balanceRaw = !!tokenKey && balances[tokenKey] diff --git a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts index acd702340f4..598848a5a31 100644 --- a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts +++ b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useTokensBalances } from '@cowprotocol/balances-and-allowances' import { TokenWithLogo } from '@cowprotocol/common-const' +import { getTokenAddressKey } from '@cowprotocol/common-utils' import { useTokensByAddressMap } from '@cowprotocol/tokens' import { BigNumber } from '@ethersproject/bignumber' @@ -17,7 +18,7 @@ export function useTokensToRefund(): TokenToRefund[] | undefined { return useMemo(() => { return Object.keys(balances.values) .reduce((acc, tokenAddress) => { - const token = tokensByAddress[tokenAddress.toLowerCase()] + const token = tokensByAddress[getTokenAddressKey(tokenAddress)] const balance = balances.values[tokenAddress] if (token && balance?.gt(0)) { diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx index 49ce02305c1..e97ab3b699c 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx @@ -20,7 +20,7 @@ import { Wrapper, } from './styled' -function renderValue(value: T | undefined, template: (v: T) => string, defaultValue?: string): string | undefined { +function formatValue(value: T | undefined, template: (v: T) => string, defaultValue?: string): string | undefined { return value ? template(value) : defaultValue } @@ -84,7 +84,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L Fee tier
- {renderValue(info?.feeTier, (t) => `${t}%`, '-')} + {formatValue(info?.feeTier, (t) => `${t}%`, '-')}
@@ -92,7 +92,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L Volume (24h)
- {renderValue(info?.volume24h, (t) => `$${t}`, '-')} + {formatValue(info?.volume24h, (t) => `$${t}`, '-')}
@@ -100,7 +100,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L APR
- {renderValue(info?.apy, (t) => `${t}%`, '-')} + {formatValue(info?.apy, (t) => `${t}%`, '-')}
@@ -108,7 +108,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L TVL
- {renderValue(info?.tvl, (t) => `$${t}`, '-')} + {formatValue(info?.tvl, (t) => `$${t}`, '-')}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx index 46aff689b97..833b0ba034a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx @@ -17,7 +17,7 @@ export interface ManageListsAndTokensProps { lists: ListState[] customTokens: TokenWithLogo[] onBack(): void - onDismiss(): void + onDismiss?(): void } const tokensInputPlaceholder = '0x0000' @@ -50,20 +50,15 @@ export function ManageListsAndTokens(props: ManageListsAndTokensProps): ReactNod const tokenSearchResponse = useSearchToken(isTokenAddressValid ? tokenInput : null) const listSearchResponse = useSearchList(isListUrlValid ? listInput : null) - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const setListsTab = () => { + const setListsTab = (): void => { setCurrentTab('lists') setInputValue('') } - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const setTokensTab = () => { + const setTokensTab = (): void => { setCurrentTab('tokens') setInputValue('') } - return ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx new file mode 100644 index 00000000000..c85b9f888ed --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx @@ -0,0 +1,45 @@ +import { MouseEvent, ReactNode } from 'react' + +import { createPortal } from 'react-dom' + +import { MobileChainPanelCard, MobileChainPanelOverlay } from './styled' + +import { ChainPanel } from '../../pure/ChainPanel' + +import type { SelectTokenWidgetViewProps } from './controllerProps' + +interface MobileChainPanelPortalProps { + chainsPanelTitle: string + chainsToSelect: SelectTokenWidgetViewProps['chainsToSelect'] + onSelectChain: SelectTokenWidgetViewProps['onSelectChain'] + onClose(): void +} + +export function MobileChainPanelPortal({ + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onClose, +}: MobileChainPanelPortalProps): ReactNode { + if (typeof document === 'undefined') { + return null + } + + return createPortal( + + ) => event.stopPropagation()}> + { + onSelectChain(chain) + onClose() + }} + variant="fullscreen" + onClose={onClose} + /> + + , + document.body, + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts new file mode 100644 index 00000000000..54236010236 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef } from 'react' + +import { useIsBridgingEnabled } from '@cowprotocol/common-hooks' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { Field } from 'legacy/state/types' + +import { useLpTokensWithBalances } from 'modules/yield/shared' + +import { SelectTokenWidgetViewProps } from './controllerProps' +import { + useManageWidgetVisibility, + useTokenAdminActions, + useTokenDataSources, + useWidgetMetadata, +} from './controllerState' +import { useSelectTokenWidgetViewState } from './controllerViewState' + +import { useChainsToSelect } from '../../hooks/useChainsToSelect' +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' +import { useOnSelectChain } from '../../hooks/useOnSelectChain' +import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useResetTokenListViewState } from '../../hooks/useResetTokenListViewState' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +export interface SelectTokenWidgetProps { + displayLpTokenLists?: boolean + standalone?: boolean +} + +export interface SelectTokenWidgetController { + shouldRender: boolean + hasChainPanel: boolean + viewProps: SelectTokenWidgetViewProps +} + +export function useSelectTokenWidgetController({ + displayLpTokenLists, + standalone, +}: SelectTokenWidgetProps): SelectTokenWidgetController { + const widgetState = useSelectTokenWidgetState() + const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances() + const resolvedField = widgetState.field ?? Field.INPUT + const chainsToSelect = useChainsToSelect() + const onSelectChain = useOnSelectChain() + const isBridgeFeatureEnabled = useIsBridgingEnabled() + const manageWidget = useManageWidgetVisibility() + const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const { account, chainId: walletChainId } = useWalletInfo() + const closeTokenSelectWidget = useCloseTokenSelectWidget() + const tokenData = useTokenDataSources() + const onTokenListAddingError = useOnTokenListAddingError() + const tokenAdminActions = useTokenAdminActions() + const widgetMetadata = useWidgetMetadata( + resolvedField, + widgetState.tradeType, + displayLpTokenLists, + widgetState.oppositeToken, + lpTokensWithBalancesCount, + ) + + const { isChainPanelEnabled, viewProps } = useSelectTokenWidgetViewState({ + displayLpTokenLists, + standalone, + widgetState, + chainsToSelect, + onSelectChain, + manageWidget, + updateSelectTokenWidget, + account, + closeTokenSelectWidget, + tokenData, + onTokenListAddingError, + tokenAdminActions, + widgetMetadata, + walletChainId, + isBridgeFeatureEnabled, + }) + + const shouldRender = Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen)) + + // Reset atom when modal closes (shouldRender becomes false) + const resetTokenListView = useResetTokenListViewState() + const prevShouldRenderRef = useRef(shouldRender) + + useEffect(() => { + // Only reset when transitioning from true to false + if (prevShouldRenderRef.current && !shouldRender) { + resetTokenListView() + } + prevShouldRenderRef.current = shouldRender + }, [shouldRender, resetTokenListView]) + + return { + shouldRender, + hasChainPanel: isChainPanelEnabled, + viewProps, + } +} + +export type { SelectTokenWidgetViewProps } from './controllerProps' diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts new file mode 100644 index 00000000000..ed3616f2a12 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts @@ -0,0 +1,121 @@ +import { useHydrateAtoms } from 'jotai/utils' +import { useLayoutEffect, useMemo } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { isInjectedWidget } from '@cowprotocol/common-utils' + +import { useUpdateTokenListViewState } from '../../hooks/useUpdateTokenListViewState' +import { tokenListViewAtom, TokenListViewState } from '../../state/tokenListViewAtom' +import { SelectTokenContext } from '../../types' + +import type { TokenDataSources } from './tokenDataHooks' +import type { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' + +interface HydrateTokenListViewAtomArgs { + shouldRender: boolean + tokenData: TokenDataSources + widgetState: ReturnType + favoriteTokens: TokenWithLogo[] + recentTokens: TokenWithLogo[] | undefined + onClearRecentTokens: (() => void) | undefined + onTokenListItemClick: ((token: TokenWithLogo) => void) | undefined + handleSelectToken: (token: TokenWithLogo) => Promise | void + account: string | undefined + displayLpTokenLists: boolean +} + +// Concrete tuple type for useHydrateAtoms to ensure TypeScript picks the correct overload +type HydrationEntry = readonly [typeof tokenListViewAtom, TokenListViewState] + +/** + * Hydrates the tokenListViewAtom at the controller level. + * This moves hydration responsibility from SelectTokenModal to the controller, + * allowing the modal to receive fewer props while children read from the atom. + * + * Only hydrates when shouldRender is true to avoid unnecessary atom writes + * when the modal isn't supposed to be displayed. + */ +export function useHydrateTokenListViewAtom({ + shouldRender, + tokenData, + widgetState, + favoriteTokens, + recentTokens, + onClearRecentTokens, + onTokenListItemClick, + handleSelectToken, + account, + displayLpTokenLists, +}: HydrateTokenListViewAtomArgs): void { + const updateTokenListView = useUpdateTokenListViewState() + + // Build the selectTokenContext object + const selectTokenContext: SelectTokenContext = useMemo( + () => ({ + balancesState: tokenData.balancesState, + selectedToken: widgetState.selectedToken, + onSelectToken: handleSelectToken, + onTokenListItemClick, + unsupportedTokens: tokenData.unsupportedTokens, + permitCompatibleTokens: tokenData.permitCompatibleTokens, + tokenListTags: tokenData.tokenListTags, + isWalletConnected: !!account, + }), + [ + tokenData.balancesState, + widgetState.selectedToken, + handleSelectToken, + onTokenListItemClick, + tokenData.unsupportedTokens, + tokenData.permitCompatibleTokens, + tokenData.tokenListTags, + account, + ], + ) + + // Compute the full view state to hydrate + // Note: searchInput is handled by the modal (local state + sync effect) + const viewState: Omit = useMemo( + () => ({ + allTokens: tokenData.allTokens, + favoriteTokens, + recentTokens, + areTokensLoading: tokenData.areTokensLoading, + areTokensFromBridge: tokenData.areTokensFromBridge, + hideFavoriteTokensTooltip: isInjectedWidget(), + selectedTargetChainId: widgetState.selectedTargetChainId, + selectTokenContext, + onClearRecentTokens, + displayLpTokenLists, + }), + [ + tokenData.allTokens, + favoriteTokens, + recentTokens, + tokenData.areTokensLoading, + tokenData.areTokensFromBridge, + widgetState.selectedTargetChainId, + selectTokenContext, + onClearRecentTokens, + displayLpTokenLists, + ], + ) + + // Memoize hydration values to ensure stable type inference + const hydrationValues = useMemo( + () => (shouldRender ? [[tokenListViewAtom, { ...viewState, searchInput: '' }]] : []), + [shouldRender, viewState], + ) + + // Hydrate atom SYNCHRONOUSLY on first render (only when modal should render) + useHydrateAtoms(hydrationValues) + + // Keep atom in sync when data changes (after initial render) + // Using useLayoutEffect to ensure atom is updated before paint, avoiding flicker + // Skip when modal isn't rendered to avoid unnecessary atom writes + useLayoutEffect(() => { + if (shouldRender) { + updateTokenListView(viewState) + } + }, [shouldRender, viewState, updateTokenListView]) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts new file mode 100644 index 00000000000..76925a3e776 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts @@ -0,0 +1,84 @@ +import { + useDismissHandler, + useImportFlowCallbacks, + useManageWidgetVisibility, + usePoolPageHandlers, + useRecentTokenSection, + useTokenAdminActions, + useTokenDataSources, + useTokenSelectionHandler, +} from './controllerState' + +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' +import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +export interface WidgetViewDependenciesResult { + isManageWidgetOpen: boolean + openManageWidget: ReturnType['openManageWidget'] + closeManageWidget: ReturnType['closeManageWidget'] + onDismiss(): void + openPoolPage: ReturnType['openPoolPage'] + closePoolPage: ReturnType['closePoolPage'] + recentTokens: ReturnType['recentTokens'] + handleTokenListItemClick: ReturnType['handleTokenListItemClick'] + clearRecentTokens: ReturnType['clearRecentTokens'] + handleSelectToken: ReturnType + importFlows: ReturnType +} + +interface WidgetViewDependenciesArgs { + manageWidget: ReturnType + closeTokenSelectWidget: ReturnType + updateSelectTokenWidget: ReturnType + tokenData: ReturnType + tokenAdminActions: ReturnType + onTokenListAddingError: ReturnType + widgetState: ReturnType + activeChainId: number | undefined +} + +export function useWidgetViewDependencies({ + manageWidget, + closeTokenSelectWidget, + updateSelectTokenWidget, + tokenData, + tokenAdminActions, + onTokenListAddingError, + widgetState, + activeChainId, +}: WidgetViewDependenciesArgs): WidgetViewDependenciesResult { + const { isManageWidgetOpen, openManageWidget, closeManageWidget } = manageWidget + const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget) + const { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget) + const { recentTokens, handleTokenListItemClick, clearRecentTokens } = useRecentTokenSection( + tokenData.allTokens, + tokenData.favoriteTokens, + activeChainId, + ) + const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken, widgetState) + const importFlows = useImportFlowCallbacks( + tokenAdminActions.importTokenCallback, + handleSelectToken, + onDismiss, + tokenAdminActions.addCustomTokenLists, + onTokenListAddingError, + updateSelectTokenWidget, + tokenData.favoriteTokens, + ) + + return { + isManageWidgetOpen, + openManageWidget, + closeManageWidget, + onDismiss, + openPoolPage, + closePoolPage, + recentTokens, + handleTokenListItemClick, + clearRecentTokens, + handleSelectToken, + importFlows, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts new file mode 100644 index 00000000000..8cc9f07f9c2 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts @@ -0,0 +1,132 @@ +import { buildSelectTokenModalPropsInput, SelectTokenWidgetViewProps } from './controllerProps' +import { + useManageWidgetVisibility, + usePoolPageHandlers, + useTokenDataSources, + useTokenSelectionHandler, + useWidgetMetadata, +} from './controllerState' + +import { useChainsToSelect } from '../../hooks/useChainsToSelect' +import { useOnSelectChain } from '../../hooks/useOnSelectChain' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' + +import type { WidgetViewDependenciesResult } from './controllerDependencies' +import type { SelectTokenModalProps } from '../../pure/SelectTokenModal' + +interface WidgetModalPropsArgs { + account: string | undefined + chainsToSelect: ReturnType + displayLpTokenLists?: boolean + widgetDeps: WidgetViewDependenciesResult + hasChainPanel: boolean + onSelectChain: ReturnType + standalone?: boolean + widgetMetadata: ReturnType + widgetState: ReturnType + isRouteAvailable: boolean | undefined +} + +/** + * Builds modal props. + * Token data and context are hydrated to atom by controller - no longer passed as props. + */ +export function useWidgetModalProps({ + account, + chainsToSelect, + displayLpTokenLists, + widgetDeps, + hasChainPanel, + onSelectChain, + standalone, + widgetMetadata, + widgetState, + isRouteAvailable, +}: WidgetModalPropsArgs): SelectTokenModalProps { + return buildSelectTokenModalPropsInput({ + // Layout + standalone, + hasChainPanel, + modalTitle: widgetMetadata.modalTitle, + chainsPanelTitle: widgetMetadata.chainsPanelTitle, + // Chain panel + chainsState: chainsToSelect, + onSelectChain, + // Widget config + displayLpTokenLists, + tokenListCategoryState: widgetMetadata.tokenListCategoryState, + disableErc20: widgetMetadata.disableErc20, + isRouteAvailable, + account, + // Callbacks + handleSelectToken: widgetDeps.handleSelectToken, + onDismiss: widgetDeps.onDismiss, + onOpenManageWidget: widgetDeps.openManageWidget, + openPoolPage: widgetDeps.openPoolPage, + onInputPressEnter: widgetState.onInputPressEnter, + }) +} + +interface BuildViewPropsArgs { + allTokenLists: ReturnType['allTokenLists'] + chainsPanelTitle: string + chainsToSelect: ReturnType + closeManageWidget: ReturnType['closeManageWidget'] + closePoolPage: ReturnType['closePoolPage'] + importFlows: WidgetViewDependenciesResult['importFlows'] + isChainPanelEnabled: boolean + onDismiss: () => void + onSelectChain: ReturnType + selectTokenModalProps: SelectTokenModalProps + selectedPoolAddress: ReturnType['selectedPoolAddress'] + standalone: boolean | undefined + tokenToImport: ReturnType['tokenToImport'] + listToImport: ReturnType['listToImport'] + isManageWidgetOpen: ReturnType['isManageWidgetOpen'] + userAddedTokens: ReturnType['userAddedTokens'] + handleSelectToken: ReturnType +} + +export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): SelectTokenWidgetViewProps { + const { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onDismiss, + importFlows, + allTokenLists, + userAddedTokens, + closeManageWidget, + closePoolPage, + selectTokenModalProps, + handleSelectToken, + } = args + + return { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onDismiss, + onBackFromImport: importFlows.resetTokenImport, + onImportTokens: importFlows.importTokenAndClose, + onImportList: importFlows.importListAndBack, + allTokenLists, + userAddedTokens, + onCloseManageWidget: closeManageWidget, + onClosePoolPage: closePoolPage, + selectTokenModalProps, + onSelectToken: handleSelectToken, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts new file mode 100644 index 00000000000..b8fba121d3c --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts @@ -0,0 +1,111 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { ListState } from '@cowprotocol/tokens' + +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' + +import type { TokenListCategoryState } from './controllerState' +import type { SelectTokenModalProps } from '../../pure/SelectTokenModal' + +export interface SelectTokenWidgetViewProps { + standalone?: boolean + tokenToImport?: TokenWithLogo + listToImport?: ListState + isManageWidgetOpen: boolean + selectedPoolAddress?: string + isChainPanelEnabled: boolean + chainsPanelTitle: string + chainsToSelect: ChainsToSelectState | undefined + onSelectChain(chain: ChainInfo): void + onDismiss(): void + onBackFromImport(): void + onImportTokens(tokens: TokenWithLogo[]): void + onImportList(list: ListState): void + allTokenLists: ListState[] + userAddedTokens: TokenWithLogo[] + onCloseManageWidget(): void + onClosePoolPage(): void + selectTokenModalProps: SelectTokenModalProps + onSelectToken: TokenSelectionHandler +} + +/** + * Arguments for building modal props. + * Token data, context, and atom-backed callbacks are hydrated to atom by controller. + */ +interface BuildModalPropsArgs { + // Layout + standalone?: boolean + hasChainPanel: boolean + modalTitle: string + chainsPanelTitle: string + // Chain panel + chainsState?: ChainsToSelectState + onSelectChain?(chain: ChainInfo): void + // Widget config + displayLpTokenLists?: boolean + tokenListCategoryState: TokenListCategoryState + disableErc20: boolean + isRouteAvailable: boolean | undefined + account: string | undefined + // Callbacks + handleSelectToken: TokenSelectionHandler + onDismiss(): void + onOpenManageWidget(): void + openPoolPage(poolAddress: string): void + onInputPressEnter?(): void +} + +/** + * Builds SelectTokenModalProps. + * Token data and context are now hydrated to atom by controller - modal doesn't receive them. + */ +export function buildSelectTokenModalPropsInput({ + // Layout + standalone, + hasChainPanel, + modalTitle, + chainsPanelTitle, + // Chain panel + chainsState, + onSelectChain, + // Widget config + displayLpTokenLists, + tokenListCategoryState, + disableErc20, + isRouteAvailable, + account, + // Callbacks + handleSelectToken, + onDismiss, + onOpenManageWidget, + openPoolPage, + onInputPressEnter, +}: BuildModalPropsArgs): SelectTokenModalProps { + const selectChainHandler: (chain: ChainInfo) => void = onSelectChain ?? (() => undefined) + + return { + // Layout + standalone, + hasChainPanel, + modalTitle, + chainsPanelTitle, + // Chain panel + chainsToSelect: chainsState, + onSelectChain: selectChainHandler, + mobileChainsState: chainsState, + mobileChainsLabel: chainsPanelTitle, + // Widget config + displayLpTokenLists, + tokenListCategoryState, + disableErc20, + isRouteAvailable, + account, + // Callbacks + onSelectToken: handleSelectToken, + onDismiss, + onOpenManageWidget, + openPoolPage, + onInputPressEnter, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts new file mode 100644 index 00000000000..a460aa9ead8 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts @@ -0,0 +1,14 @@ +// UI state hooks +export { useManageWidgetVisibility, useDismissHandler, usePoolPageHandlers } from './widgetUIState' + +// Token data hooks and types +export { useTokenAdminActions, useTokenDataSources, useWidgetMetadata } from './tokenDataHooks' +export type { TokenListCategoryState, TokenDataSources } from './tokenDataHooks' + +// Token selection hooks +export { + useImportFlowCallbacks, + useRecentTokenSection, + useTokenSelectionHandler, + hasAvailableChains, +} from './tokenSelectionHooks' diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts new file mode 100644 index 00000000000..add67c519b9 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts @@ -0,0 +1,153 @@ +import { TradeType } from 'modules/trade/types' + +import { useHydrateTokenListViewAtom } from './controllerAtomHydration' +import { useWidgetViewDependencies } from './controllerDependencies' +import { getSelectTokenWidgetViewPropsArgs, useWidgetModalProps } from './controllerModalProps' +import { SelectTokenWidgetViewProps } from './controllerProps' +import { + hasAvailableChains, + useManageWidgetVisibility, + useTokenAdminActions, + useTokenDataSources, + useWidgetMetadata, +} from './controllerState' + +import { useChainsToSelect } from '../../hooks/useChainsToSelect' +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' +import { useOnSelectChain } from '../../hooks/useOnSelectChain' +import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +export interface SelectTokenWidgetViewStateArgs { + displayLpTokenLists?: boolean + standalone?: boolean + widgetState: ReturnType + chainsToSelect: ReturnType + onSelectChain: ReturnType + manageWidget: ReturnType + updateSelectTokenWidget: ReturnType + account: string | undefined + closeTokenSelectWidget: ReturnType + tokenData: ReturnType + onTokenListAddingError: ReturnType + tokenAdminActions: ReturnType + widgetMetadata: ReturnType + walletChainId?: number + isBridgeFeatureEnabled: boolean +} + +interface ViewStateResult { + isChainPanelEnabled: boolean + viewProps: SelectTokenWidgetViewProps +} + +// TODO: Re-enable once Yield should support cross-network selection in the modal. +const ENABLE_YIELD_CHAIN_PANEL = false + +export function useSelectTokenWidgetViewState(args: SelectTokenWidgetViewStateArgs): ViewStateResult { + const { + displayLpTokenLists, + standalone, + widgetState, + chainsToSelect, + onSelectChain, + manageWidget, + updateSelectTokenWidget, + account, + closeTokenSelectWidget, + tokenData, + onTokenListAddingError, + tokenAdminActions, + widgetMetadata, + walletChainId, + isBridgeFeatureEnabled, + } = args + + const activeChainId = resolveActiveChainId(widgetState, walletChainId) + const widgetDeps = useWidgetViewDependencies({ + manageWidget, + closeTokenSelectWidget, + updateSelectTokenWidget, + tokenData, + tokenAdminActions, + onTokenListAddingError, + widgetState, + activeChainId, + }) + + // Determine if modal should render (same logic as controller.ts) + const shouldRender = Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen)) + + // Determine favorite tokens (empty for standalone mode) + const favoriteTokens = standalone ? [] : tokenData.favoriteTokens + + // Hydrate the tokenListViewAtom at controller level (only when modal should render) + // This allows children to read from atom instead of receiving props + useHydrateTokenListViewAtom({ + shouldRender, + tokenData, + widgetState, + favoriteTokens, + recentTokens: widgetDeps.recentTokens, + onClearRecentTokens: widgetDeps.clearRecentTokens, + onTokenListItemClick: widgetDeps.handleTokenListItemClick, + handleSelectToken: widgetDeps.handleSelectToken, + account, + displayLpTokenLists: displayLpTokenLists ?? false, + }) + + const shouldDisableChainPanelForYield = widgetState.tradeType === TradeType.YIELD && !ENABLE_YIELD_CHAIN_PANEL + const isChainPanelEnabled = + isBridgeFeatureEnabled && hasAvailableChains(chainsToSelect) && !shouldDisableChainPanelForYield + const selectTokenModalProps = useWidgetModalProps({ + account, + chainsToSelect, + displayLpTokenLists, + widgetDeps, + hasChainPanel: isChainPanelEnabled, + onSelectChain, + standalone, + widgetMetadata, + widgetState, + isRouteAvailable: tokenData.isRouteAvailable, + }) + + const viewProps = getSelectTokenWidgetViewPropsArgs({ + allTokenLists: tokenData.allTokenLists, + chainsPanelTitle: widgetMetadata.chainsPanelTitle, + chainsToSelect, + closeManageWidget: widgetDeps.closeManageWidget, + closePoolPage: widgetDeps.closePoolPage, + importFlows: widgetDeps.importFlows, + isChainPanelEnabled, + onDismiss: widgetDeps.onDismiss, + onSelectChain, + selectTokenModalProps, + selectedPoolAddress: widgetState.selectedPoolAddress, + standalone, + tokenToImport: widgetState.tokenToImport, + listToImport: widgetState.listToImport, + isManageWidgetOpen: widgetDeps.isManageWidgetOpen, + userAddedTokens: tokenData.userAddedTokens, + handleSelectToken: widgetDeps.handleSelectToken, + }) + + return { isChainPanelEnabled, viewProps } +} + +function resolveActiveChainId( + widgetState: ReturnType, + walletChainId?: number, +): number | undefined { + return ( + widgetState.selectedTargetChainId ?? + walletChainId ?? + extractChainId(widgetState.oppositeToken) ?? + extractChainId(widgetState.selectedToken) + ) +} + +function extractChainId(token: { chainId?: number } | undefined | null): number | undefined { + return typeof token?.chainId === 'number' ? token.chainId : undefined +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index 8f09782ae7e..09d6031a2a1 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -1,238 +1,226 @@ -import { ReactNode, useCallback, useState } from 'react' +import { MouseEvent, ReactNode, useEffect, useState } from 'react' -import { useCowAnalytics } from '@cowprotocol/analytics' -import { TokenWithLogo } from '@cowprotocol/common-const' -import { isInjectedWidget } from '@cowprotocol/common-utils' -import { - ListState, - TokenListCategory, - useAddList, - useAddUserToken, - useAllListsList, - useTokenListsTags, - useUnsupportedTokens, - useUserAddedTokens, -} from '@cowprotocol/tokens' -import { useWalletInfo } from '@cowprotocol/wallet' - -import styled from 'styled-components/macro' - -import { Field } from 'legacy/state/types' - -import { useTokensBalancesCombined } from 'modules/combinedBalances' -import { usePermitCompatibleTokens } from 'modules/permit' -import { useLpTokensWithBalances } from 'modules/yield/shared' +import { useMediaQuery } from '@cowprotocol/common-hooks' +import { addBodyClass, removeBodyClass } from '@cowprotocol/common-utils' +import { Media } from '@cowprotocol/ui' -import { CowSwapAnalyticsCategory } from 'common/analytics/types' +import { createPortal } from 'react-dom' -import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' +import { + useSelectTokenWidgetController, + type SelectTokenWidgetProps, + type SelectTokenWidgetViewProps, +} from './controller' +import { MobileChainPanelPortal } from './MobileChainPanelPortal' +import { InnerWrapper, ModalContainer, WidgetCard, WidgetOverlay, Wrapper } from './styled' -import { useChainsToSelect } from '../../hooks/useChainsToSelect' import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' -import { useOnSelectChain } from '../../hooks/useOnSelectChain' -import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' -import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' -import { useTokensToSelect } from '../../hooks/useTokensToSelect' -import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' +import { ChainPanel } from '../../pure/ChainPanel' import { ImportListModal } from '../../pure/ImportListModal' import { ImportTokenModal } from '../../pure/ImportTokenModal' import { SelectTokenModal } from '../../pure/SelectTokenModal' import { LpTokenPage } from '../LpTokenPage' import { ManageListsAndTokens } from '../ManageListsAndTokens' -const Wrapper = styled.div` - width: 100%; - - > div { - height: calc(100vh - 200px); - min-height: 600px; - } -` +export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode { + const { shouldRender, hasChainPanel, viewProps } = useSelectTokenWidgetController(props) + const isCompactLayout = useMediaQuery(Media.upToMedium(false)) + const [isMobileChainPanelOpen, setIsMobileChainPanelOpen] = useState(false) + const isChainPanelVisible = hasChainPanel && !isCompactLayout + const closeTokenSelectWidget = useCloseTokenSelectWidget() -const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] + // Cleanup: reset widget state on unmount + useEffect(() => { + return () => { + closeTokenSelectWidget({ overrideForceLock: true }) + } + }, [closeTokenSelectWidget]) -interface SelectTokenWidgetProps { - displayLpTokenLists?: boolean - standalone?: boolean -} + useEffect(() => { + if (!shouldRender) { + return + } -// TODO: Break down this large function into smaller functions -// eslint-disable-next-line max-lines-per-function -export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTokenWidgetProps): ReactNode { - const { - open, - onSelectToken, - tokenToImport, - listToImport, - selectedToken, - onInputPressEnter, - selectedPoolAddress, - field, - oppositeToken, - } = useSelectTokenWidgetState() - const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances() - const chainsToSelect = useChainsToSelect() - const onSelectChain = useOnSelectChain() - - const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false) - const disableErc20 = field === Field.OUTPUT && !!displayLpTokenLists - - const tokenListCategoryState = useState( - getDefaultTokenListCategories(field, oppositeToken, lpTokensWithBalancesCount), - ) + if (isChainPanelVisible) { + setIsMobileChainPanelOpen(false) + } + }, [isChainPanelVisible, shouldRender]) - const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() - const { account } = useWalletInfo() - - const cowAnalytics = useCowAnalytics() - const trackAddListAnalytics = useCallback( - (source: string) => { - cowAnalytics.sendEvent({ - category: CowSwapAnalyticsCategory.LIST, - action: 'Add List Success', - label: source, - }) - }, - [cowAnalytics], - ) - const addCustomTokenLists = useAddList(trackAddListAnalytics) - const importTokenCallback = useAddUserToken() + useEffect(() => { + if (!shouldRender) { + removeBodyClass('noScroll') + return undefined + } - const { - tokens: allTokens, - isLoading: areTokensLoading, - favoriteTokens, - areTokensFromBridge, - isRouteAvailable, - } = useTokensToSelect() - - const userAddedTokens = useUserAddedTokens() - const allTokenLists = useAllListsList() - const balancesState = useTokensBalancesCombined() - const unsupportedTokens = useUnsupportedTokens() - const permitCompatibleTokens = usePermitCompatibleTokens() - const tokenListTags = useTokenListsTags() - const onTokenListAddingError = useOnTokenListAddingError() - - const isInjectedWidgetMode = isInjectedWidget() + addBodyClass('noScroll') + return () => removeBodyClass('noScroll') + }, [shouldRender]) - const closeTokenSelectWidget = useCloseTokenSelectWidget() + if (!shouldRender) { + return null + } - const openPoolPage = useCallback( - (selectedPoolAddress: string) => { - updateSelectTokenWidget({ selectedPoolAddress }) - }, - [updateSelectTokenWidget], + const widgetContent = ( + + + + + ) - const closePoolPage = useCallback(() => { - updateSelectTokenWidget({ selectedPoolAddress: undefined }) - }, [updateSelectTokenWidget]) + const handleOverlayClick = (event: MouseEvent): void => { + if (event.target !== event.currentTarget) { + return + } - const resetTokenImport = useCallback(() => { - updateSelectTokenWidget({ - tokenToImport: undefined, - }) - }, [updateSelectTokenWidget]) + viewProps.onDismiss() + } - const onDismiss = useCallback(() => { - setIsManageWidgetOpen(false) - closeTokenSelectWidget() - }, [closeTokenSelectWidget]) + const overlay = ( + + + {widgetContent} + + + ) - const importTokenAndClose = (tokens: TokenWithLogo[]): void => { - importTokenCallback(tokens) - onSelectToken?.(tokens[0]) - onDismiss() + if (typeof document === 'undefined') { + return overlay } - const importListAndBack = (list: ListState): void => { - try { - addCustomTokenLists(list) - } catch (error) { - onDismiss() - onTokenListAddingError(error) - } - updateSelectTokenWidget({ listToImport: undefined }) + return createPortal(overlay, document.body) +} + +function SelectTokenWidgetView( + props: SelectTokenWidgetViewProps & { + isChainPanelVisible: boolean + isCompactLayout: boolean + isMobileChainPanelOpen: boolean + setIsMobileChainPanelOpen(value: boolean): void + }, +): ReactNode { + const { + isChainPanelVisible, + isCompactLayout, + isMobileChainPanelOpen, + setIsMobileChainPanelOpen, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + selectTokenModalProps, + } = props + + const blockingView = getBlockingView(props) + + if (blockingView) { + return blockingView } - if (!onSelectToken || !open) return null + const closeMobileChainPanel = (): void => setIsMobileChainPanelOpen(false) + const mobileChainsState = isChainPanelEnabled && !isChainPanelVisible ? chainsToSelect : undefined + const handleOpenMobileChainPanel = mobileChainsState ? () => setIsMobileChainPanelOpen(true) : undefined + const showDesktopChainPanel = isChainPanelVisible && isChainPanelEnabled && chainsToSelect + const showMobileChainPanel = !isChainPanelVisible && isChainPanelEnabled && chainsToSelect && isMobileChainPanelOpen + const modalChainsToSelect = isChainPanelVisible ? undefined : chainsToSelect return ( - - {(() => { - if (tokenToImport && !standalone) { - return ( - - ) - } - - if (listToImport && !standalone) { - return ( - - ) - } - - if (isManageWidgetOpen && !standalone) { - return ( - setIsManageWidgetOpen(false)} - /> - ) - } - - if (selectedPoolAddress) { - return ( - - ) - } - - return ( - setIsManageWidgetOpen(true)} - hideFavoriteTokensTooltip={isInjectedWidgetMode} - openPoolPage={openPoolPage} - tokenListCategoryState={tokenListCategoryState} - disableErc20={disableErc20} - account={account} - chainsToSelect={chainsToSelect} - onSelectChain={onSelectChain} - areTokensLoading={areTokensLoading} - tokenListTags={tokenListTags} - areTokensFromBridge={areTokensFromBridge} - isRouteAvailable={isRouteAvailable} - /> - ) - })()} - + <> + + + + {showDesktopChainPanel && ( + + )} + {showMobileChainPanel && ( + + )} + ) } + +function getBlockingView( + props: SelectTokenWidgetViewProps & { + isChainPanelVisible: boolean + isCompactLayout: boolean + isMobileChainPanelOpen: boolean + setIsMobileChainPanelOpen(value: boolean): void + }, +): ReactNode | null { + const { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + allTokenLists, + userAddedTokens, + onDismiss, + onBackFromImport, + onImportTokens, + onImportList, + onCloseManageWidget, + onClosePoolPage, + onSelectToken, + } = props + + if (tokenToImport && !standalone) { + return ( + + ) + } + + if (listToImport && !standalone) { + return ( + + ) + } + + if (isManageWidgetOpen && !standalone) { + return ( + + ) + } + + if (selectedPoolAddress) { + return ( + + ) + } + + return null +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts new file mode 100644 index 00000000000..04eddcd7d8d --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts @@ -0,0 +1,92 @@ +import { Media } from '@cowprotocol/ui' + +import styled, { css } from 'styled-components/macro' +import { WIDGET_MAX_WIDTH } from 'theme' + +export const Wrapper = styled.div` + width: 100%; + height: 100%; +` + +export const InnerWrapper = styled.div<{ $hasSidebar: boolean; $isMobileOverlay?: boolean }>` + height: 100%; + min-height: ${({ $isMobileOverlay }) => ($isMobileOverlay ? '0' : 'min(600px, 100%)')}; + width: 100%; + margin: 0 auto; + display: flex; + align-items: stretch; + + ${({ $hasSidebar }) => + $hasSidebar && + css` + /* Stack modal + sidebar vertically on narrow screens so neither pane collapses */ + ${Media.upToMedium()} { + flex-direction: column; + height: auto; + min-height: 0; + } + + ${Media.upToSmall()} { + min-height: 0; + } + `} + + ${({ $isMobileOverlay }) => + $isMobileOverlay && + css` + flex-direction: column; + height: 100%; + min-height: 0; + `} +` + +export const ModalContainer = styled.div` + flex: 1; + min-width: 0; + display: flex; + height: 100%; +` + +export const MobileChainPanelOverlay = styled.div` + position: fixed; + inset: 0; + z-index: 1200; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: stretch; + justify-content: center; +` + +export const MobileChainPanelCard = styled.div` + flex: 1; + max-width: 100%; + height: 100%; +` + +export const WidgetOverlay = styled.div` + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + box-sizing: border-box; + + ${Media.upToMedium()} { + padding: 0; + } +` + +export const WidgetCard = styled.div<{ $isCompactLayout: boolean; $hasChainPanel: boolean }>` + width: 100%; + max-width: ${({ $isCompactLayout, $hasChainPanel }) => + $isCompactLayout ? '100%' : $hasChainPanel ? WIDGET_MAX_WIDTH.tokenSelectSidebar : WIDGET_MAX_WIDTH.tokenSelect}; + height: ${({ $isCompactLayout }) => ($isCompactLayout ? '100%' : '90vh')}; + max-height: 100%; + display: flex; + align-items: stretch; + justify-content: center; + box-sizing: border-box; +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenDataHooks.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenDataHooks.ts new file mode 100644 index 00000000000..66604902bda --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenDataHooks.ts @@ -0,0 +1,128 @@ +import { Dispatch, SetStateAction, useState } from 'react' + +import { useCowAnalytics } from '@cowprotocol/analytics' +import { TokenWithLogo } from '@cowprotocol/common-const' +import { + ListState, + TokenListCategory, + useAddList, + useAddUserToken, + useAllListsList, + useTokenListsTags, + useUnsupportedTokens, + useUserAddedTokens, +} from '@cowprotocol/tokens' + +import { t } from '@lingui/core/macro' + +import { Field } from 'legacy/state/types' + +import { useTokensBalancesCombined } from 'modules/combinedBalances' +import { usePermitCompatibleTokens } from 'modules/permit' +import { TradeType } from 'modules/trade/types' + +import { CowSwapAnalyticsCategory } from 'common/analytics/types' + +import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' + +import { useTokensToSelect } from '../../hooks/useTokensToSelect' + +export type TokenListCategoryState = [TokenListCategory[] | null, Dispatch>] + +interface TokenAdminActions { + addCustomTokenLists(list: ListState): void + importTokenCallback(tokens: TokenWithLogo[]): void +} + +export interface TokenDataSources { + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + areTokensLoading: boolean + areTokensFromBridge: boolean + isRouteAvailable: boolean | undefined + userAddedTokens: TokenWithLogo[] + allTokenLists: ListState[] + balancesState: ReturnType + unsupportedTokens: ReturnType + permitCompatibleTokens: ReturnType + tokenListTags: ReturnType +} + +interface WidgetMetadata { + disableErc20: boolean + tokenListCategoryState: TokenListCategoryState + modalTitle: string + chainsPanelTitle: string +} + +export function useTokenAdminActions(): TokenAdminActions { + const cowAnalytics = useCowAnalytics() + + const addCustomTokenLists = useAddList((source) => { + cowAnalytics.sendEvent({ + category: CowSwapAnalyticsCategory.LIST, + action: 'Add List Success', + label: source, + }) + }) + const importTokenCallback = useAddUserToken() + + return { addCustomTokenLists, importTokenCallback } +} + +export function useTokenDataSources(): TokenDataSources { + const tokensState = useTokensToSelect() + const userAddedTokens = useUserAddedTokens() + const allTokenLists = useAllListsList() + const balancesState = useTokensBalancesCombined() + const unsupportedTokens = useUnsupportedTokens() + const permitCompatibleTokens = usePermitCompatibleTokens() + const tokenListTags = useTokenListsTags() + + return { + allTokens: tokensState.tokens, + favoriteTokens: tokensState.favoriteTokens, + areTokensLoading: tokensState.isLoading, + areTokensFromBridge: tokensState.areTokensFromBridge, + isRouteAvailable: tokensState.isRouteAvailable, + userAddedTokens, + allTokenLists, + balancesState, + unsupportedTokens, + permitCompatibleTokens, + tokenListTags, + } +} + +export function useWidgetMetadata( + field: Field, + tradeType: TradeType | undefined, + displayLpTokenLists: boolean | undefined, + oppositeToken: Parameters[1], + lpTokensWithBalancesCount: number, +): WidgetMetadata { + const disableErc20 = field === Field.OUTPUT && !!displayLpTokenLists + const tokenListCategoryState: TokenListCategoryState = useState( + getDefaultTokenListCategories(field, oppositeToken, lpTokensWithBalancesCount), + ) + const modalTitle = resolveModalTitle(field, tradeType) + const chainsPanelTitle = + field === Field.INPUT ? t`From network` : field === Field.OUTPUT ? t`To network` : t`Select network` + + return { disableErc20, tokenListCategoryState, modalTitle, chainsPanelTitle } +} + +function resolveModalTitle(field: Field, tradeType: TradeType | undefined): string { + const isSwapTrade = !tradeType || tradeType === TradeType.SWAP + + if (field === Field.INPUT) { + return isSwapTrade ? t`Swap from` : t`Sell token` + } + + if (field === Field.OUTPUT) { + return isSwapTrade ? t`Swap to` : t`Buy token` + } + + return t`Select token` +} + diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenSelectionHooks.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenSelectionHooks.ts new file mode 100644 index 00000000000..3e057f1aeab --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenSelectionHooks.ts @@ -0,0 +1,143 @@ +import { useCallback } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { log } from '@cowprotocol/sdk-common' +import { ListState, useAddUserToken } from '@cowprotocol/tokens' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { Field } from 'legacy/state/types' + +import { TradeType } from 'modules/trade/types' + +import { useOnSelectNetwork } from 'common/hooks/useOnSelectNetwork' + +import { persistRecentTokenSelection, useRecentTokens } from '../../hooks/useRecentTokens' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' + +import type { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +type UpdateSelectTokenWidgetFn = ReturnType + +interface ImportFlowCallbacks { + importTokenAndClose(tokens: TokenWithLogo[]): void + importListAndBack(list: ListState): void + resetTokenImport(): void +} + +interface RecentTokenSection { + recentTokens: TokenWithLogo[] + handleTokenListItemClick(token: TokenWithLogo): void + clearRecentTokens(): void +} + +export function useImportFlowCallbacks( + importTokenCallback: ReturnType, + onSelectToken: TokenSelectionHandler | undefined, + onDismiss: () => void, + addCustomTokenLists: (list: ListState) => void, + onTokenListAddingError: (error: Error) => void, + updateSelectTokenWidget: UpdateSelectTokenWidgetFn, + favoriteTokens: TokenWithLogo[], +): ImportFlowCallbacks { + const importTokenAndClose = useCallback( + (tokens: TokenWithLogo[]) => { + importTokenCallback(tokens) + const [selectedToken] = tokens + + if (selectedToken) { + persistRecentTokenSelection(selectedToken, favoriteTokens) + onSelectToken?.(selectedToken) + } + + onDismiss() + }, + [importTokenCallback, onSelectToken, onDismiss, favoriteTokens], + ) + + const importListAndBack = useCallback( + (list: ListState) => { + try { + addCustomTokenLists(list) + } catch (error) { + onDismiss() + onTokenListAddingError(error as Error) + } + updateSelectTokenWidget({ listToImport: undefined }) + }, + [addCustomTokenLists, onDismiss, onTokenListAddingError, updateSelectTokenWidget], + ) + + const resetTokenImport = useCallback(() => { + updateSelectTokenWidget({ tokenToImport: undefined }) + }, [updateSelectTokenWidget]) + + return { importTokenAndClose, importListAndBack, resetTokenImport } +} + +export function useRecentTokenSection( + allTokens: TokenWithLogo[], + favoriteTokens: TokenWithLogo[], + activeChainId?: number, +): RecentTokenSection { + const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({ + allTokens, + favoriteTokens, + activeChainId, + }) + + const handleTokenListItemClick = useCallback( + (token: TokenWithLogo) => { + addRecentToken(token) + }, + [addRecentToken], + ) + + return { recentTokens, handleTokenListItemClick, clearRecentTokens } +} + +export function useTokenSelectionHandler( + onSelectToken: TokenSelectionHandler | undefined, + widgetState: ReturnType, +): TokenSelectionHandler { + const { chainId: walletChainId } = useWalletInfo() + const onSelectNetwork = useOnSelectNetwork() + + return useCallback( + async (token: TokenWithLogo) => { + const targetChainId = widgetState.selectedTargetChainId + // SELL-side limit/TWAP orders must run on the picked network, + // so nudge the wallet onto that chain before finalizing selection. + const shouldSwitchWalletNetwork = + widgetState.field === Field.INPUT && + (widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS) && + typeof targetChainId === 'number' && + targetChainId !== walletChainId + + if (shouldSwitchWalletNetwork && targetChainId in SupportedChainId) { + try { + await onSelectNetwork(targetChainId as SupportedChainId, true) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`Failed to switch network after token selection: ${message}`) + } + } + + onSelectToken?.(token) + }, + [ + onSelectToken, + widgetState.field, + widgetState.tradeType, + widgetState.selectedTargetChainId, + walletChainId, + onSelectNetwork, + ], + ) +} + +export function hasAvailableChains(chainsToSelect: ChainsToSelectState | undefined): boolean { + return Boolean(chainsToSelect) +} + diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/widgetUIState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/widgetUIState.ts new file mode 100644 index 00000000000..cec28688c97 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/widgetUIState.ts @@ -0,0 +1,51 @@ +import { useCallback, useState } from 'react' + +import type { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +type UpdateSelectTokenWidgetFn = ReturnType + +interface ManageWidgetVisibility { + isManageWidgetOpen: boolean + openManageWidget(): void + closeManageWidget(): void +} + +interface PoolPageHandlers { + openPoolPage(poolAddress: string): void + closePoolPage(): void +} + +export function useManageWidgetVisibility(): ManageWidgetVisibility { + const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false) + + const openManageWidget = useCallback(() => setIsManageWidgetOpen(true), []) + const closeManageWidget = useCallback(() => setIsManageWidgetOpen(false), []) + + return { isManageWidgetOpen, openManageWidget, closeManageWidget } +} + +export function useDismissHandler( + closeManageWidget: () => void, + closeTokenSelectWidget: (options?: { overrideForceLock?: boolean }) => void, +): () => void { + return useCallback(() => { + closeManageWidget() + closeTokenSelectWidget({ overrideForceLock: true }) + }, [closeManageWidget, closeTokenSelectWidget]) +} + +export function usePoolPageHandlers(updateSelectTokenWidget: UpdateSelectTokenWidgetFn): PoolPageHandlers { + const openPoolPage = useCallback( + (selectedPoolAddress: string) => { + updateSelectTokenWidget({ selectedPoolAddress }) + }, + [updateSelectTokenWidget], + ) + + const closePoolPage = useCallback(() => { + updateSelectTokenWidget({ selectedPoolAddress: undefined }) + }, [updateSelectTokenWidget]) + + return { openPoolPage, closePoolPage } +} + diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx index 4bb84a16f8d..b3fb5817062 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx @@ -1,29 +1,18 @@ import { ReactNode, useCallback, useEffect, useMemo } from 'react' -import { TokenWithLogo } from '@cowprotocol/common-const' import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils' import { getTokenSearchFilter, TokenSearchResponse, useSearchToken } from '@cowprotocol/tokens' import { useAddTokenImportCallback } from '../../hooks/useAddTokenImportCallback' +import { useTokenListViewState } from '../../hooks/useTokenListViewState' import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' import { CommonListContainer } from '../../pure/commonElements' import { TokenSearchContent } from '../../pure/TokenSearchContent' -import { SelectTokenContext } from '../../types' -export interface TokenSearchResultsProps { - searchInput: string - selectTokenContext: SelectTokenContext - areTokensFromBridge: boolean - allTokens: TokenWithLogo[] -} +export function TokenSearchResults(): ReactNode { + const { searchInput, selectTokenContext, areTokensFromBridge, allTokens } = useTokenListViewState() -export function TokenSearchResults({ - searchInput, - selectTokenContext, - areTokensFromBridge, - allTokens, -}: TokenSearchResultsProps): ReactNode { - const { onSelectToken } = selectTokenContext + const { onSelectToken, onTokenListItemClick } = selectTokenContext // Do not make search when tokens are from bridge const defaultSearchResults = useSearchToken(areTokensFromBridge ? null : searchInput) @@ -56,9 +45,14 @@ export function TokenSearchResults({ if (!searchInput || !activeListsResult) return if (activeListsResult.length === 1 || matchedTokens.length === 1) { - onSelectToken(matchedTokens[0] || activeListsResult[0]) + const tokenToSelect = matchedTokens[0] || activeListsResult[0] + + if (tokenToSelect) { + onTokenListItemClick?.(tokenToSelect) + onSelectToken(tokenToSelect) + } } - }, [searchInput, activeListsResult, matchedTokens, onSelectToken]) + }, [searchInput, activeListsResult, matchedTokens, onSelectToken, onTokenListItemClick]) useEffect(() => { updateSelectTokenWidget({ diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts new file mode 100644 index 00000000000..cfa7fd6b415 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts @@ -0,0 +1,220 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { getTokenUniqueKey } from '../utils/tokenKey' + +export const RECENT_TOKENS_LIMIT = 4 +// Storage schema: { [chainId: number]: StoredRecentToken[] } serialized under this key. +// `migrateLegacyStoredTokens` upgrades the legacy array payload into the map format. +export const RECENT_TOKENS_STORAGE_KEY = 'selectTokenWidget:recentTokens:v0' + +export interface StoredRecentToken { + chainId: number + address: string + decimals: number + symbol?: string + name?: string + logoURI?: string + tags?: string[] +} + +export type StoredRecentTokensByChain = Record + +export function buildTokensByKey(tokens: TokenWithLogo[]): Map { + const map = new Map() + + for (const token of tokens) { + map.set(getTokenUniqueKey(token), token) + } + + return map +} + +export function buildFavoriteTokenKeys(tokens: TokenWithLogo[]): Set { + const set = new Set() + + for (const token of tokens) { + set.add(getTokenUniqueKey(token)) + } + + return set +} + +export function hydrateStoredToken(entry: StoredRecentToken, canonical?: TokenWithLogo): TokenWithLogo | null { + if (canonical) { + return canonical + } + + try { + return new TokenWithLogo( + entry.logoURI, + entry.chainId, + entry.address, + entry.decimals, + entry.symbol, + entry.name, + undefined, + entry.tags ?? [], + ) + } catch { + return null + } +} + +export function getStoredTokenKey(token: StoredRecentToken): string { + return getTokenUniqueKey(token) +} + +export function readStoredTokens(limit: number): StoredRecentTokensByChain { + if (!canUseLocalStorage()) { + return {} + } + + try { + const rawValue = window.localStorage.getItem(RECENT_TOKENS_STORAGE_KEY) + + if (!rawValue) { + return {} + } + + const parsed: unknown = JSON.parse(rawValue) + + if (Array.isArray(parsed)) { + return migrateLegacyStoredTokens(parsed, limit) + } + + if (parsed && typeof parsed === 'object') { + return sanitizeStoredTokensMap(parsed as Record, limit) + } + + return {} + } catch { + return {} + } +} + +export function persistStoredTokens(tokens: StoredRecentTokensByChain): void { + if (!canUseLocalStorage()) { + return + } + + try { + window.localStorage.setItem(RECENT_TOKENS_STORAGE_KEY, JSON.stringify(tokens)) + } catch { + // Best effort persistence + } +} + +export function buildNextStoredTokens( + prev: StoredRecentTokensByChain, + token: TokenWithLogo, + maxItems: number, +): StoredRecentTokensByChain { + const chainId = token.chainId + const normalized = toStoredToken(token) + const chainEntries = prev[chainId] ?? [] + const updatedChain = insertToken(chainEntries, normalized, maxItems) + + return { + ...prev, + [chainId]: updatedChain, + } +} + +export function persistRecentTokenSelection( + token: TokenWithLogo, + favoriteTokens: TokenWithLogo[], + maxItems = RECENT_TOKENS_LIMIT, +): void { + const favoriteKeys = buildFavoriteTokenKeys(favoriteTokens) + + if (favoriteKeys.has(getTokenUniqueKey(token))) { + return + } + + const current = readStoredTokens(maxItems) + const next = buildNextStoredTokens(current, token, maxItems) + + persistStoredTokens(next) +} + +function sanitizeStoredTokensMap(record: Record, limit: number): StoredRecentTokensByChain { + const entries: StoredRecentTokensByChain = {} + + for (const [chainKey, tokens] of Object.entries(record)) { + const chainId = Number(chainKey) + + if (Number.isNaN(chainId) || !Array.isArray(tokens)) { + continue + } + + const sanitized = tokens + .map((token) => sanitizeStoredToken(token)) + .filter((token): token is StoredRecentToken => Boolean(token)) + + if (sanitized.length) { + entries[chainId] = sanitized.slice(0, limit) + } + } + + return entries +} + +function migrateLegacyStoredTokens(entries: unknown[], limit: number): StoredRecentTokensByChain { + return entries + .map((entry) => sanitizeStoredToken(entry)) + .filter((entry): entry is StoredRecentToken => Boolean(entry)) + .reverse() + .reduce((acc, sanitized) => { + const chainId = sanitized.chainId + const chain = acc[chainId] ?? [] + + acc[chainId] = insertToken(chain, sanitized, limit) + + return acc + }, {}) +} + +function sanitizeStoredToken(token: unknown): StoredRecentToken | null { + if (!token || typeof token !== 'object') { + return null + } + + const { chainId, address, decimals, symbol, name, logoURI, tags } = token as StoredRecentToken + + if (typeof chainId !== 'number' || typeof address !== 'string' || typeof decimals !== 'number') { + return null + } + + return { + chainId, + address: address.toLowerCase(), + decimals, + symbol: typeof symbol === 'string' ? symbol : undefined, + name: typeof name === 'string' ? name : undefined, + logoURI: typeof logoURI === 'string' ? logoURI : undefined, + tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === 'string') : undefined, + } +} + +function insertToken(tokens: StoredRecentToken[], token: StoredRecentToken, limit: number): StoredRecentToken[] { + const key = getTokenUniqueKey(token) + const withoutToken = tokens.filter((entry) => getTokenUniqueKey(entry) !== key) + + return [token, ...withoutToken].slice(0, limit) +} + +function toStoredToken(token: TokenWithLogo): StoredRecentToken { + return { + chainId: token.chainId, + address: token.address.toLowerCase(), + decimals: token.decimals, + symbol: token.symbol, + name: token.name, + logoURI: token.logoURI, + tags: token.tags, + } +} + +function canUseLocalStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts new file mode 100644 index 00000000000..834230d627e --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts @@ -0,0 +1,399 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { useWalletInfo, WalletInfo } from '@cowprotocol/wallet' + +import { renderHook } from '@testing-library/react' + +import { Field } from 'legacy/state/types' + +import { TradeType } from 'modules/trade/types' + +import { useChainsToSelect, createInputChainsState, createOutputChainsState } from './useChainsToSelect' +import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' + +import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom' +import { createChainInfoForTests } from '../test-utils/createChainInfoForTests' + +// Default routes availability for tests (no unavailable routes, not loading) +const DEFAULT_ROUTES_AVAILABILITY = { + unavailableChainIds: new Set(), + loadingChainIds: new Set(), + isLoading: false, +} + +jest.mock('@cowprotocol/wallet', () => ({ + ...jest.requireActual('@cowprotocol/wallet'), + useWalletInfo: jest.fn(), +})) + +jest.mock('@cowprotocol/common-hooks', () => ({ + ...jest.requireActual('@cowprotocol/common-hooks'), + useIsBridgingEnabled: jest.fn(), + useAvailableChains: jest.fn(), +})) + +jest.mock('entities/bridgeProvider', () => ({ + ...jest.requireActual('entities/bridgeProvider'), + useBridgeSupportedNetworks: jest.fn(), + useRoutesAvailability: jest.fn(), +})) + +jest.mock('./useSelectTokenWidgetState', () => ({ + ...jest.requireActual('./useSelectTokenWidgetState'), + useSelectTokenWidgetState: jest.fn(), +})) + +const mockUseWalletInfo = useWalletInfo as jest.MockedFunction +const mockUseSelectTokenWidgetState = useSelectTokenWidgetState as jest.MockedFunction + +const { useIsBridgingEnabled, useAvailableChains } = require('@cowprotocol/common-hooks') +const mockUseIsBridgingEnabled = useIsBridgingEnabled as jest.MockedFunction +const mockUseAvailableChains = useAvailableChains as jest.MockedFunction + +const { useBridgeSupportedNetworks, useRoutesAvailability } = require('entities/bridgeProvider') +const mockUseBridgeSupportedNetworks = useBridgeSupportedNetworks as jest.MockedFunction< + typeof useBridgeSupportedNetworks +> +const mockUseRoutesAvailability = useRoutesAvailability as jest.MockedFunction + +type WidgetState = ReturnType +const createWidgetState = (override: Partial): WidgetState => { + return { + ...DEFAULT_SELECT_TOKEN_WIDGET_STATE, + ...override, + } +} + +describe('useChainsToSelect state builders', () => { + it('sorts sell-side chains using the canonical order', () => { + const supportedChains = [ + createChainInfoForTests(SupportedChainId.AVALANCHE), + createChainInfoForTests(SupportedChainId.BASE), + createChainInfoForTests(SupportedChainId.MAINNET), + ] + + const state = createInputChainsState(SupportedChainId.BASE, supportedChains) + + expect((state.chains ?? []).map((chain) => chain.id)).toEqual([ + SupportedChainId.MAINNET, + SupportedChainId.BASE, + SupportedChainId.AVALANCHE, + ]) + }) + + it('sorts BUY chains using the canonical order and returns all supportedChains', () => { + const supportedChains = [ + createChainInfoForTests(SupportedChainId.AVALANCHE), + createChainInfoForTests(SupportedChainId.BASE), + createChainInfoForTests(SupportedChainId.ARBITRUM_ONE), + createChainInfoForTests(SupportedChainId.MAINNET), + ] + const bridgeChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BASE), + ] + + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.BASE, + chainId: SupportedChainId.MAINNET, + currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET), + bridgeSupportedNetworks: bridgeChains, + supportedChains, + isLoading: false, + routesAvailability: DEFAULT_ROUTES_AVAILABILITY, + }) + + // Should return all supportedChains, sorted by canonical order + expect((state.chains ?? []).map((chain) => chain.id)).toEqual([ + SupportedChainId.MAINNET, + SupportedChainId.BASE, + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.AVALANCHE, + ]) + }) + + it('disables chains not in bridge destinations when source is bridge-supported', () => { + const supportedChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BASE), + createChainInfoForTests(SupportedChainId.ARBITRUM_ONE), + createChainInfoForTests(SupportedChainId.AVALANCHE), + ] + const bridgeChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BASE), + ] + + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.BASE, + chainId: SupportedChainId.MAINNET, + currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET), + bridgeSupportedNetworks: bridgeChains, + supportedChains, + isLoading: false, + routesAvailability: DEFAULT_ROUTES_AVAILABILITY, + }) + + // Source (Mainnet) and Base are bridge-supported, others should be disabled + expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBeFalsy() + expect(state.disabledChainIds?.has(SupportedChainId.BASE)).toBeFalsy() + expect(state.disabledChainIds?.has(SupportedChainId.ARBITRUM_ONE)).toBe(true) + expect(state.disabledChainIds?.has(SupportedChainId.AVALANCHE)).toBe(true) + }) + + it('disables all chains except source when source is not bridge-supported', () => { + const supportedChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.ARBITRUM_ONE), + createChainInfoForTests(SupportedChainId.SEPOLIA), + ] + const bridgeChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.ARBITRUM_ONE), + ] + + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.BASE, + chainId: SupportedChainId.SEPOLIA, // Sepolia not in bridge destinations + currentChainInfo: createChainInfoForTests(SupportedChainId.SEPOLIA), + bridgeSupportedNetworks: bridgeChains, + supportedChains, + isLoading: false, + routesAvailability: DEFAULT_ROUTES_AVAILABILITY, + }) + + // Default to source chain when the selected target isn't available + expect(state.defaultChainId).toBe(SupportedChainId.SEPOLIA) + // Should return all supportedChains + expect(state.chains?.map((chain) => chain.id)).toEqual([ + SupportedChainId.MAINNET, + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.SEPOLIA, + ]) + // All chains except source should be disabled + expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBe(true) + expect(state.disabledChainIds?.has(SupportedChainId.ARBITRUM_ONE)).toBe(true) + expect(state.disabledChainIds?.has(SupportedChainId.SEPOLIA)).toBeFalsy() + }) + + it('falls back to source when selected target is disabled', () => { + const supportedChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BASE), + createChainInfoForTests(SupportedChainId.AVALANCHE), + ] + const bridgeChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BASE), + ] + + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.AVALANCHE, // Not in bridge destinations + chainId: SupportedChainId.MAINNET, + currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET), + bridgeSupportedNetworks: bridgeChains, + supportedChains, + isLoading: false, + routesAvailability: DEFAULT_ROUTES_AVAILABILITY, + }) + + // Avalanche is disabled, so should fallback to source (Mainnet) + expect(state.defaultChainId).toBe(SupportedChainId.MAINNET) + }) + + it('does not apply disabled state while loading bridge data', () => { + const supportedChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BASE), + createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN), + ] + + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.BASE, + chainId: SupportedChainId.MAINNET, + currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET), + bridgeSupportedNetworks: undefined, + supportedChains, + isLoading: true, // Still loading + routesAvailability: DEFAULT_ROUTES_AVAILABILITY, + }) + + // Should render all supportedChains + expect(state.chains?.length).toBe(3) + // No chains should be disabled while loading + expect(state.disabledChainIds).toBeUndefined() + // Selected target should be valid since nothing is disabled + expect(state.defaultChainId).toBe(SupportedChainId.BASE) + }) + + it('disables all except source when bridge data fails to load', () => { + const supportedChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BASE), + createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN), + ] + + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.BASE, + chainId: SupportedChainId.MAINNET, + currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET), + bridgeSupportedNetworks: undefined, + supportedChains, + isLoading: false, // Finished loading, but no data + routesAvailability: DEFAULT_ROUTES_AVAILABILITY, + }) + + // Should render all supportedChains + expect(state.chains?.length).toBe(3) + // All chains except source should be disabled when bridge data failed + expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBeFalsy() + expect(state.disabledChainIds?.has(SupportedChainId.BASE)).toBe(true) + expect(state.disabledChainIds?.has(SupportedChainId.GNOSIS_CHAIN)).toBe(true) + // Default should fallback to source since selected target is disabled + expect(state.defaultChainId).toBe(SupportedChainId.MAINNET) + }) + + it('injects current chain when not in supportedChains (feature-flagged chain)', () => { + // Simulate a scenario where wallet is on a feature-flagged chain not in supportedChains + const supportedChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BASE), + ] + const bridgeChains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BASE), + ] + + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.BASE, + chainId: SupportedChainId.GNOSIS_CHAIN, // Not in supportedChains + currentChainInfo: createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN), + bridgeSupportedNetworks: bridgeChains, + supportedChains, + isLoading: false, + routesAvailability: DEFAULT_ROUTES_AVAILABILITY, + }) + + // Current chain should be injected into the list + expect(state.chains?.some((c) => c.id === SupportedChainId.GNOSIS_CHAIN)).toBe(true) + // Should have 3 chains: Mainnet, Base, and injected Gnosis + expect(state.chains?.length).toBe(3) + }) +}) + +describe('useChainsToSelect hook', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseWalletInfo.mockReturnValue({ chainId: SupportedChainId.MAINNET } as WalletInfo) + mockUseIsBridgingEnabled.mockReturnValue(true) + mockUseAvailableChains.mockReturnValue([SupportedChainId.MAINNET, SupportedChainId.GNOSIS_CHAIN]) + mockUseBridgeSupportedNetworks.mockReturnValue({ + data: [createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN)], + isLoading: false, + }) + mockUseRoutesAvailability.mockReturnValue(DEFAULT_ROUTES_AVAILABILITY) + }) + + it('returns undefined for LIMIT_ORDER + OUTPUT (buy token)', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.OUTPUT, + tradeType: TradeType.LIMIT_ORDER, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeUndefined() + }) + + it('returns undefined for ADVANCED_ORDERS + OUTPUT (buy token)', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.OUTPUT, + tradeType: TradeType.ADVANCED_ORDERS, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeUndefined() + }) + + it('returns undefined for LIMIT_ORDER + INPUT (sell token)', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.INPUT, + tradeType: TradeType.LIMIT_ORDER, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeUndefined() + }) + + it('returns undefined for ADVANCED_ORDERS + INPUT (sell token)', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.INPUT, + tradeType: TradeType.ADVANCED_ORDERS, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeUndefined() + }) + + it('returns chains for SWAP + OUTPUT (buy token)', () => { + // Include Mainnet in bridge data to exercise bridge destinations path + // Use mockReturnValueOnce for test isolation + mockUseBridgeSupportedNetworks.mockReturnValueOnce({ + data: [createChainInfoForTests(SupportedChainId.MAINNET), createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN)], + isLoading: false, + }) + + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.OUTPUT, + tradeType: TradeType.SWAP, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeDefined() + expect(result.current?.chains).toBeDefined() + expect(result.current?.chains?.length).toBeGreaterThan(0) + // Verify defaultChainId matches selectedTargetChainId (confirms bridge path, not fallback) + expect(result.current?.defaultChainId).toBe(SupportedChainId.MAINNET) + // Verify it returns bridge destinations (Gnosis), not single-chain fallback + expect(result.current?.chains?.some((chain) => chain.id === SupportedChainId.GNOSIS_CHAIN)).toBe(true) + }) + + it('returns chains for SWAP + INPUT (sell token)', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + field: Field.INPUT, + tradeType: TradeType.SWAP, + selectedTargetChainId: SupportedChainId.MAINNET, + }), + ) + + const { result } = renderHook(() => useChainsToSelect()) + + expect(result.current).toBeDefined() + expect(result.current?.chains).toBeDefined() + expect(result.current?.chains?.length).toBeGreaterThan(0) + // Verify defaultChainId matches selectedTargetChainId + expect(result.current?.defaultChainId).toBe(SupportedChainId.MAINNET) + // Verify it returns supported chains (Mainnet, Gnosis) + expect(result.current?.chains?.some((chain) => chain.id === SupportedChainId.MAINNET)).toBe(true) + expect(result.current?.chains?.some((chain) => chain.id === SupportedChainId.GNOSIS_CHAIN)).toBe(true) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts index b781b7c1121..007957cc3bb 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts @@ -1,103 +1,194 @@ import { useMemo } from 'react' import { CHAIN_INFO } from '@cowprotocol/common-const' -import { useAvailableChains, useFeatureFlags, useIsBridgingEnabled } from '@cowprotocol/common-hooks' +import { useAvailableChains, useIsBridgingEnabled } from '@cowprotocol/common-hooks' import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' import { useWalletInfo } from '@cowprotocol/wallet' -import { useBridgeSupportedNetworks } from 'entities/bridgeProvider' +import { useBridgeSupportedNetworks, useRoutesAvailability } from 'entities/bridgeProvider' import { Field } from 'legacy/state/types' +import { TradeType } from 'modules/trade/types' + import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' import { ChainsToSelectState } from '../types' import { mapChainInfo } from '../utils/mapChainInfo' +import { sortChainsByDisplayOrder } from '../utils/sortChainsByDisplayOrder' /** * Returns an array of chains to select in the token selector widget. * The array depends on sell/buy token selection. * For the sell token we return all supported chains. - * For the buy token we return current network + all bridge target networks. + * For the buy token we return all app-supported chains with disabled state for non-bridgeable targets. + * + * Note: `isBridgingEnabled` reads from a Jotai atom, controlled by BridgingEnabledUpdater + * based on runtime checks (swap route + wallet compatibility). */ export function useChainsToSelect(): ChainsToSelectState | undefined { const { chainId } = useWalletInfo() - const { field, selectedTargetChainId = chainId } = useSelectTokenWidgetState() + const { field, selectedTargetChainId = chainId, tradeType } = useSelectTokenWidgetState() const { data: bridgeSupportedNetworks, isLoading } = useBridgeSupportedNetworks() - const { areUnsupportedChainsEnabled } = useFeatureFlags() - const isBridgingEnabled = useIsBridgingEnabled() + const isBridgingEnabled = useIsBridgingEnabled() // Reads from Jotai atom const availableChains = useAvailableChains() + const isAdvancedTradeType = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS const supportedChains = useMemo(() => { return availableChains.reduce((acc, id) => { const info = CHAIN_INFO[id] - - if (info) { - acc.push(mapChainInfo(id, info)) - } - + if (info) acc.push(mapChainInfo(id, info)) return acc }, [] as ChainInfo[]) }, [availableChains]) + const destinationChainIds = useMemo(() => supportedChains.map((c) => c.id), [supportedChains]) + const isBuyField = field === Field.OUTPUT + const routesAvailability = useRoutesAvailability( + isBuyField && isBridgingEnabled ? chainId : undefined, + destinationChainIds, + ) + return useMemo(() => { - if (!field || !isBridgingEnabled) return undefined + // TODO: Limit/TWAP orders currently disable chain selection; revisit when SC wallet bridging supports advanced trades. + if (!field || !chainId || !isBridgingEnabled || isAdvancedTradeType) return undefined - const currentChainInfo = mapChainInfo(chainId, CHAIN_INFO[chainId]) - const isSourceChainSupportedByBridge = Boolean( - bridgeSupportedNetworks?.find((bridgeChain) => bridgeChain.id === chainId), - ) + const chainInfo = CHAIN_INFO[chainId] + if (!chainInfo) return undefined - // For the sell token selector we only display supported chains if (field === Field.INPUT) { - return { - defaultChainId: selectedTargetChainId, - chains: supportedChains, - isLoading: false, - } + return createInputChainsState(selectedTargetChainId, supportedChains) } - /** - * When the source chain is not supported by bridge provider - * We act as non-bridge mode - */ - if (!isSourceChainSupportedByBridge) { - return { - defaultChainId: selectedTargetChainId, - chains: [], - isLoading: false, - } - } - - const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled) - - return { - defaultChainId: selectedTargetChainId, - // Add the source network to the list if it's not supported by bridge provider - chains: [...(isSourceChainSupportedByBridge ? [] : [currentChainInfo]), ...(destinationChains || [])], + // BUY token selection - include disabled chains info + return createOutputChainsState({ + selectedTargetChainId, + chainId, + currentChainInfo: mapChainInfo(chainId, chainInfo), + bridgeSupportedNetworks, + supportedChains, isLoading, - } + routesAvailability, + }) }, [ field, selectedTargetChainId, chainId, bridgeSupportedNetworks, + supportedChains, isLoading, isBridgingEnabled, - areUnsupportedChainsEnabled, - supportedChains, + isAdvancedTradeType, + routesAvailability, ]) } -function filterDestinationChains( - bridgeSupportedNetworks: ChainInfo[] | undefined, - areUnsupportedChainsEnabled: boolean | undefined, -): ChainInfo[] | undefined { - if (areUnsupportedChainsEnabled) { - // Nothing to filter, we return all bridge supported networks - return bridgeSupportedNetworks - } else { - // If unsupported chains are not enabled, we only return the supported networks - return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId) +function filterDestinationChains(bridgeSupportedNetworks: ChainInfo[] | undefined): ChainInfo[] | undefined { + // Show only chains the app supports. + return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId) +} + +// Sell-side selector intentionally limits chains to the wallet-supported list; bridge destinations never appear here. +export function createInputChainsState( + selectedTargetChainId: SupportedChainId | number, + supportedChains: ChainInfo[], +): ChainsToSelectState { + return { + defaultChainId: selectedTargetChainId, + chains: sortChainsByDisplayOrder(supportedChains), + isLoading: false, + } +} + +interface CreateOutputChainsOptions { + selectedTargetChainId: SupportedChainId | number + chainId: SupportedChainId + currentChainInfo: ChainInfo + bridgeSupportedNetworks: ChainInfo[] | undefined + supportedChains: ChainInfo[] + isLoading: boolean + routesAvailability: { + unavailableChainIds: Set + loadingChainIds: Set + isLoading: boolean + } +} + +function computeDisabledChainIds( + orderedChains: ChainInfo[], + chainId: SupportedChainId, + destinationIds: Set, + sourceSupported: boolean, + isLoading: boolean, +): Set { + // During loading, don't apply disabled states - wait for bridge data + if (isLoading) return new Set() + + return new Set( + orderedChains + .filter((chain) => { + if (chain.id === chainId) return false // Source always enabled + if (!sourceSupported) return true // All disabled when source not supported + return !destinationIds.has(chain.id) // Disable if not a valid bridge destination + }) + .map((c) => c.id), + ) +} + +function resolveDefaultChainId( + orderedChains: ChainInfo[], + selectedTargetChainId: number, + chainId: SupportedChainId, + disabledChainIds: Set, +): number { + const isSelectedTargetValid = + orderedChains.some((c) => c.id === selectedTargetChainId) && !disabledChainIds.has(selectedTargetChainId) + if (isSelectedTargetValid) return selectedTargetChainId + + const sourceInList = orderedChains.some((c) => c.id === chainId) + if (sourceInList) return chainId + + const firstEnabledChain = orderedChains.find((c) => !disabledChainIds.has(c.id)) + return firstEnabledChain?.id ?? orderedChains[0]?.id +} + +export function createOutputChainsState({ + selectedTargetChainId, + chainId, + currentChainInfo, + bridgeSupportedNetworks, + supportedChains, + isLoading, + routesAvailability, +}: CreateOutputChainsOptions): ChainsToSelectState { + // Use all app-supported chains as the base list (consistent with SELL selector) + // Always include the current chain for same-chain swaps, even if feature-flagged off + const chainSet = new Set(supportedChains.map((c) => c.id)) + const chainsWithCurrent = chainSet.has(chainId) ? supportedChains : [...supportedChains, currentChainInfo] + const orderedChains = sortChainsByDisplayOrder(chainsWithCurrent) + + const destinationIds = new Set(filterDestinationChains(bridgeSupportedNetworks)?.map((c) => c.id) ?? []) + const sourceSupported = destinationIds.has(chainId) + + // Compute base disabled chains from bridge network data + const baseDisabledChainIds = computeDisabledChainIds( + orderedChains, + chainId, + destinationIds, + sourceSupported, + isLoading, + ) + + // Merge with unavailable routes from pre-check (chains with no valid route) + const disabledChainIds = new Set([...baseDisabledChainIds, ...routesAvailability.unavailableChainIds]) + + const resolvedDefaultChainId = resolveDefaultChainId(orderedChains, selectedTargetChainId, chainId, disabledChainIds) + + return { + defaultChainId: resolvedDefaultChainId, + chains: orderedChains, + isLoading, + disabledChainIds: disabledChainIds.size > 0 ? disabledChainIds : undefined, + loadingChainIds: routesAvailability.loadingChainIds.size > 0 ? routesAvailability.loadingChainIds : undefined, } } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.test.tsx b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.test.tsx new file mode 100644 index 00000000000..b539b789528 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.test.tsx @@ -0,0 +1,117 @@ +import { createStore, Provider } from 'jotai' +import { ReactNode } from 'react' + +import { act, renderHook } from '@testing-library/react' + +import { useCloseTokenSelectWidget } from './useCloseTokenSelectWidget' + +import { selectTokenWidgetAtom, updateSelectTokenWidgetAtom } from '../state/selectTokenWidgetAtom' + +function createTestWrapper(store: ReturnType) { + return function TestWrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +describe('useCloseTokenSelectWidget', () => { + it('returns stable reference when forceOpen toggles', () => { + const store = createStore() + const wrapper = createTestWrapper(store) + + const { result, rerender } = renderHook(() => useCloseTokenSelectWidget(), { wrapper }) + const firstRef = result.current + + // Toggle forceOpen to true + act(() => { + store.set(updateSelectTokenWidgetAtom, { forceOpen: true }) + }) + rerender() + expect(result.current).toBe(firstRef) // Same reference + + // Toggle forceOpen back to false + act(() => { + store.set(updateSelectTokenWidgetAtom, { forceOpen: false }) + }) + rerender() + expect(result.current).toBe(firstRef) // Still same reference + }) + + it('does NOT reset state when forceOpen is true and overrideForceLock is not set', () => { + const store = createStore() + const wrapper = createTestWrapper(store) + + const { result } = renderHook(() => useCloseTokenSelectWidget(), { wrapper }) + + // Set forceOpen = true, open = true + act(() => { + store.set(updateSelectTokenWidgetAtom, { forceOpen: true, open: true }) + }) + + // Call without override - should NOT reset + act(() => { + result.current() + }) + expect(store.get(selectTokenWidgetAtom).open).toBe(true) + }) + + it('resets state when forceOpen is true but overrideForceLock is set', () => { + const store = createStore() + const wrapper = createTestWrapper(store) + + const { result } = renderHook(() => useCloseTokenSelectWidget(), { wrapper }) + + // Set forceOpen = true, open = true + act(() => { + store.set(updateSelectTokenWidgetAtom, { forceOpen: true, open: true }) + }) + + // Call with override - SHOULD reset + act(() => { + result.current({ overrideForceLock: true }) + }) + expect(store.get(selectTokenWidgetAtom).open).toBe(false) + }) + + it('resets state when forceOpen is false', () => { + const store = createStore() + const wrapper = createTestWrapper(store) + + const { result } = renderHook(() => useCloseTokenSelectWidget(), { wrapper }) + + // Set open = true, forceOpen = false + act(() => { + store.set(updateSelectTokenWidgetAtom, { open: true, forceOpen: false }) + }) + + // Call without override - should reset because forceOpen is false + act(() => { + result.current() + }) + expect(store.get(selectTokenWidgetAtom).open).toBe(false) + }) + + it('uses latest forceOpen value immediately (no stale closure)', () => { + const store = createStore() + const wrapper = createTestWrapper(store) + + const { result, rerender } = renderHook(() => useCloseTokenSelectWidget(), { wrapper }) + + // Set open = true, forceOpen = false + act(() => { + store.set(updateSelectTokenWidgetAtom, { open: true, forceOpen: false }) + }) + rerender() + + // Now toggle forceOpen to true in the same test + act(() => { + store.set(updateSelectTokenWidgetAtom, { forceOpen: true }) + }) + rerender() + + // Calling without override should NOT reset because forceOpen is now true + act(() => { + result.current() + }) + expect(store.get(selectTokenWidgetAtom).open).toBe(true) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts index 6434545dfac..4ff78cdf3ca 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts @@ -1,15 +1,33 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' +import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState' import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom' -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function useCloseTokenSelectWidget() { +type CloseTokenSelectWidget = (options?: { overrideForceLock?: boolean }) => void + +export function useCloseTokenSelectWidget(): CloseTokenSelectWidget { const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const widgetState = useSelectTokenWidgetState() + + // Ref to read forceOpen at call-time, not capture-time + // This makes the returned callback referentially stable + const forceOpenRef = useRef(widgetState.forceOpen) + + // Synchronous update during render is intentional here: + // - We need the latest forceOpen value available immediately when closeTokenSelectWidget is called + // - Using useEffect would create a race condition where the ref has stale value during the same render cycle + // - This is safe because we're only reading/writing a ref, not causing side effects + // eslint-disable-next-line react-hooks/refs + forceOpenRef.current = widgetState.forceOpen + + return useCallback( + (options?: { overrideForceLock?: boolean }) => { + if (forceOpenRef.current && !options?.overrideForceLock) return - return useCallback(() => { - updateSelectTokenWidget(DEFAULT_SELECT_TOKEN_WIDGET_STATE) - }, [updateSelectTokenWidget]) + updateSelectTokenWidget(DEFAULT_SELECT_TOKEN_WIDGET_STATE) + }, + [updateSelectTokenWidget], + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts index eaf2b8997f2..f29896d1469 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts @@ -2,17 +2,31 @@ import { useCallback } from 'react' import { ChainInfo } from '@cowprotocol/cow-sdk' +import { Field } from 'legacy/state/types' + +import { TradeType } from 'modules/trade/types' + +import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState' -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function useOnSelectChain() { +type OnSelectChainHandler = (chain: ChainInfo) => void + +export function useOnSelectChain(): OnSelectChainHandler { const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const widgetState = useSelectTokenWidgetState() + const shouldForceOpen = + widgetState.field === Field.INPUT && + (widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS) + // Limit/TWAP sells keep the widget pinned while the user flips chains; forceOpen keeps that behavior intact. return useCallback( (chain: ChainInfo) => { - updateSelectTokenWidget({ selectedTargetChainId: chain.id }) + updateSelectTokenWidget({ + selectedTargetChainId: chain.id, + open: true, + forceOpen: shouldForceOpen, + }) }, - [updateSelectTokenWidget], + [updateSelectTokenWidget, shouldForceOpen], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts index 2f1a24c8abe..1b7de718630 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts @@ -8,6 +8,10 @@ import { Nullish } from 'types' import { Field } from 'legacy/state/types' +import { useTradeTypeInfo } from 'modules/trade/hooks/useTradeTypeInfo' +import { useTradeTypeInfoFromUrl } from 'modules/trade/hooks/useTradeTypeInfoFromUrl' +import { TradeType } from 'modules/trade/types' + import { useCloseTokenSelectWidget } from './useCloseTokenSelectWidget' import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState' @@ -20,28 +24,42 @@ export function useOpenTokenSelectWidget(): ( const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() const closeTokenSelectWidget = useCloseTokenSelectWidget() const isBridgingEnabled = useIsBridgingEnabled() + const tradeTypeInfoFromState = useTradeTypeInfo() + const tradeTypeInfoFromUrl = useTradeTypeInfoFromUrl() + const tradeTypeInfo = tradeTypeInfoFromState ?? tradeTypeInfoFromUrl + const tradeType = tradeTypeInfo?.tradeType + // Advanced trades lock the target chain so price guarantees stay valid while the widget is open. + const shouldLockTargetChain = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS return useCallback( (selectedToken, field, oppositeToken, onSelectToken) => { const isOutputField = field === Field.OUTPUT - const selectedTargetChainId = - isOutputField && selectedToken && isBridgingEnabled ? selectedToken.chainId : undefined + const nextSelectedTargetChainId = + isOutputField && selectedToken && isBridgingEnabled && !shouldLockTargetChain + ? selectedToken.chainId + : undefined updateSelectTokenWidget({ selectedToken, field, oppositeToken, open: true, - selectedTargetChainId, + forceOpen: false, + selectedTargetChainId: nextSelectedTargetChainId, + tradeType, onSelectToken: (currency) => { - // Close the token selector regardless of network switching. - // UX: When a user picks a token (even from another network), - // the selector should close as per issue #6251 expected behavior. - closeTokenSelectWidget() + // Keep selector UX consistent with #6251: always close after a selection, even if a chain switch follows. + closeTokenSelectWidget({ overrideForceLock: true }) onSelectToken(currency) }, }) }, - [closeTokenSelectWidget, updateSelectTokenWidget, isBridgingEnabled], + [ + closeTokenSelectWidget, + updateSelectTokenWidget, + isBridgingEnabled, + shouldLockTargetChain, + tradeType, + ], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts new file mode 100644 index 00000000000..968d5a4de9c --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { + RECENT_TOKENS_LIMIT, + buildFavoriteTokenKeys, + buildNextStoredTokens, + buildTokensByKey, + getStoredTokenKey, + hydrateStoredToken, + persistRecentTokenSelection as persistRecentTokenSelectionInternal, + persistStoredTokens, + readStoredTokens, + type StoredRecentTokensByChain, +} from './recentTokensStorage' + +import { getTokenUniqueKey } from '../utils/tokenKey' + +interface UseRecentTokensParams { + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + activeChainId?: number + maxItems?: number +} + +export interface RecentTokensState { + recentTokens: TokenWithLogo[] + addRecentToken(token: TokenWithLogo): void + clearRecentTokens(): void +} + +export function useRecentTokens({ + allTokens, + favoriteTokens, + activeChainId, + maxItems = RECENT_TOKENS_LIMIT, +}: UseRecentTokensParams): RecentTokensState { + const [storedTokensByChain, setStoredTokensByChain] = useState(() => + readStoredTokens(maxItems), + ) + + useEffect(() => { + persistStoredTokens(storedTokensByChain) + }, [storedTokensByChain]) + + const tokensByKey = useMemo(() => buildTokensByKey(allTokens), [allTokens]) + const favoriteKeys = useMemo(() => buildFavoriteTokenKeys(favoriteTokens), [favoriteTokens]) + + useEffect(() => { + setStoredTokensByChain((prev) => { + const nextEntries: StoredRecentTokensByChain = {} + let didChange = false + + for (const [chainKey, tokens] of Object.entries(prev)) { + const chainId = Number(chainKey) + const filtered = tokens.filter((token) => !favoriteKeys.has(getStoredTokenKey(token))) + + if (filtered.length) { + nextEntries[chainId] = filtered + } + + didChange = didChange || filtered.length !== tokens.length + } + + return didChange ? nextEntries : prev + }) + }, [favoriteKeys]) + + const recentTokens = useMemo(() => { + const chainEntries = activeChainId ? (storedTokensByChain[activeChainId] ?? []) : [] + const seenKeys = new Set() + const result: TokenWithLogo[] = [] + + for (const entry of chainEntries) { + const key = getStoredTokenKey(entry) + + if (seenKeys.has(key) || favoriteKeys.has(key)) { + continue + } + + const hydrated = hydrateStoredToken(entry, tokensByKey.get(key)) + + if (hydrated) { + result.push(hydrated) + seenKeys.add(key) + } + + if (result.length >= maxItems) { + break + } + } + + return result + }, [activeChainId, favoriteKeys, maxItems, storedTokensByChain, tokensByKey]) + + const addRecentToken = useCallback( + (token: TokenWithLogo) => { + if (favoriteKeys.has(getTokenUniqueKey(token))) { + return + } + + setStoredTokensByChain((prev) => { + const next = buildNextStoredTokens(prev, token, maxItems) + + persistStoredTokens(next) + + return next + }) + }, + [favoriteKeys, maxItems], + ) + + const clearRecentTokens = useCallback(() => { + if (!activeChainId) { + return + } + + setStoredTokensByChain((prev) => { + const chainEntries = prev[activeChainId] + + if (!chainEntries?.length) { + return prev + } + + const next: StoredRecentTokensByChain = { ...prev, [activeChainId]: [] } + persistStoredTokens(next) + + return next + }) + }, [activeChainId]) + + return { recentTokens, addRecentToken, clearRecentTokens } +} + +export { persistRecentTokenSelectionInternal as persistRecentTokenSelection } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useResetTokenListViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useResetTokenListViewState.ts new file mode 100644 index 00000000000..f65c1839c80 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useResetTokenListViewState.ts @@ -0,0 +1,14 @@ +import { useSetAtom } from 'jotai' +import { useCallback } from 'react' + +import { tokenListViewAtom, DEFAULT_TOKEN_LIST_VIEW_STATE } from '../state/tokenListViewAtom' + +type ResetTokenListViewState = () => void + +export function useResetTokenListViewState(): ResetTokenListViewState { + const setTokenListView = useSetAtom(tokenListViewAtom) + + return useCallback((): void => { + setTokenListView(DEFAULT_TOKEN_LIST_VIEW_STATE) // Full replacement, not partial merge + }, [setTokenListView]) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSelectTokenContextFromAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSelectTokenContextFromAtom.ts new file mode 100644 index 00000000000..ead57c439ac --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSelectTokenContextFromAtom.ts @@ -0,0 +1,15 @@ +import { useTokenListViewState } from './useTokenListViewState' + +import { SelectTokenContext } from '../types' + +/** + * Reads the SelectTokenContext from the tokenListViewAtom. + * + * This hook replaces the props-based useSelectTokenContext pattern. + * The controller now hydrates the context to the atom, and consumers + * read it directly via this hook. + */ +export function useSelectTokenContextFromAtom(): SelectTokenContext { + const { selectTokenContext } = useTokenListViewState() + return selectTokenContext +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts new file mode 100644 index 00000000000..04a2a1521fc --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts @@ -0,0 +1,95 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { useWalletInfo, WalletInfo } from '@cowprotocol/wallet' + +import { renderHook } from '@testing-library/react' + +import { Field } from 'legacy/state/types' + +import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' +import { useSourceChainId } from './useSourceChainId' + +import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom' + +jest.mock('@cowprotocol/wallet', () => ({ + ...jest.requireActual('@cowprotocol/wallet'), + useWalletInfo: jest.fn(), +})) + +jest.mock('./useSelectTokenWidgetState', () => ({ + useSelectTokenWidgetState: jest.fn(), +})) + +const mockUseWalletInfo = useWalletInfo as jest.MockedFunction +const mockUseSelectTokenWidgetState = useSelectTokenWidgetState as jest.MockedFunction + +type WidgetState = ReturnType +const createWidgetState = (override: Partial): WidgetState => { + return { + ...DEFAULT_SELECT_TOKEN_WIDGET_STATE, + ...override, + } as WidgetState +} + +describe('useSourceChainId', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseWalletInfo.mockReturnValue({ chainId: SupportedChainId.MAINNET } as WalletInfo) + mockUseSelectTokenWidgetState.mockReturnValue(createWidgetState({ open: false })) + }) + + it('returns wallet chain when selector is closed', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + open: false, + field: Field.OUTPUT, + selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN, + }), + ) + + const { result } = renderHook(() => useSourceChainId()) + + expect(result.current).toEqual({ chainId: SupportedChainId.MAINNET, source: 'wallet' }) + }) + + it('keeps wallet chain for output selection even while open', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + open: true, + field: Field.OUTPUT, + selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN, + }), + ) + + const { result } = renderHook(() => useSourceChainId()) + + expect(result.current).toEqual({ chainId: SupportedChainId.MAINNET, source: 'wallet' }) + }) + + it('uses selector chain for input selection when open on a supported chain', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + open: true, + field: Field.INPUT, + selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN, + }), + ) + + const { result } = renderHook(() => useSourceChainId()) + + expect(result.current).toEqual({ chainId: SupportedChainId.GNOSIS_CHAIN, source: 'selector' }) + }) + + it('ignores unsupported chains and falls back to wallet', () => { + mockUseSelectTokenWidgetState.mockReturnValue( + createWidgetState({ + open: true, + field: Field.INPUT, + selectedTargetChainId: 999, + }), + ) + + const { result } = renderHook(() => useSourceChainId()) + + expect(result.current).toEqual({ chainId: SupportedChainId.MAINNET, source: 'wallet' }) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts index 6a3e4ad475d..61f1f41556e 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts @@ -3,15 +3,24 @@ import { useMemo } from 'react' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { useWalletInfo } from '@cowprotocol/wallet' +import { Field } from 'legacy/state/types' + import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' export function useSourceChainId(): { chainId: number; source: 'wallet' | 'selector' } { const { chainId } = useWalletInfo() - const { selectedTargetChainId = chainId, open } = useSelectTokenWidgetState() + const { selectedTargetChainId = chainId, open, field } = useSelectTokenWidgetState() return useMemo(() => { // Source chainId should always be a value from SupportedChainId - if (!open || !(selectedTargetChainId in SupportedChainId) || selectedTargetChainId === chainId) { + const isSelectingSellChain = field === Field.INPUT + + if ( + !open || + !isSelectingSellChain || + !(selectedTargetChainId in SupportedChainId) || + selectedTargetChainId === chainId + ) { return { chainId, source: 'wallet', @@ -22,5 +31,5 @@ export function useSourceChainId(): { chainId: number; source: 'wallet' | 'selec chainId: selectedTargetChainId, source: 'selector', } - }, [open, chainId, selectedTargetChainId]) + }, [open, field, chainId, selectedTargetChainId]) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListViewState.ts new file mode 100644 index 00000000000..f27f075a93e --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListViewState.ts @@ -0,0 +1,7 @@ +import { useAtomValue } from 'jotai' + +import { tokenListViewAtom, TokenListViewState } from '../state/tokenListViewAtom' + +export function useTokenListViewState(): TokenListViewState { + return useAtomValue(tokenListViewAtom) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useUpdateTokenListViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useUpdateTokenListViewState.ts new file mode 100644 index 00000000000..2a90584119b --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useUpdateTokenListViewState.ts @@ -0,0 +1,10 @@ +import { useSetAtom } from 'jotai' +import { SetStateAction } from 'jotai/vanilla' + +import { updateTokenListViewAtom, TokenListViewState } from '../state/tokenListViewAtom' + +type UpdateTokenListViewState = (update: SetStateAction>) => void + +export function useUpdateTokenListViewState(): UpdateTokenListViewState { + return useSetAtom(updateTokenListViewAtom) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/index.ts b/apps/cowswap-frontend/src/modules/tokensList/index.ts index c38c9b46b97..daa859f4099 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/index.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/index.ts @@ -11,3 +11,5 @@ export { useUpdateSelectTokenWidgetState } from './hooks/useUpdateSelectTokenWid export { useOnTokenListAddingError } from './hooks/useOnTokenListAddingError' export { useTokenListAddingError } from './hooks/useTokenListAddingError' export { useSourceChainId } from './hooks/useSourceChainId' +export { useChainsToSelect } from './hooks/useChainsToSelect' +export { useCloseTokenSelectWidget } from './hooks/useCloseTokenSelectWidget' diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx index dea219daaed..afaf243779d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx @@ -36,7 +36,7 @@ export function AddIntermediateTokenModal({ onDismiss, onBack, onImport }: AddIn importTokenCallback([tokenToImport]) onImport(tokenToImport) // when we import the token from here, we don't need to import it again in the SelectTokenWidget - closeTokenSelectWidget() + closeTokenSelectWidget({ overrideForceLock: true }) } }, [onImport, importTokenCallback, closeTokenSelectWidget, tokenToImport]) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx new file mode 100644 index 00000000000..faa001ab4f9 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx @@ -0,0 +1,126 @@ +import { ReactNode, useMemo, useState } from 'react' + +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { BackButton } from '@cowprotocol/ui' + +import { t } from '@lingui/core/macro' +import { Trans } from '@lingui/react/macro' + +import * as styledEl from './styled' + +import { ChainsToSelectState } from '../../types' +import { ChainsSelector } from '../ChainsSelector' + +const EMPTY_CHAINS: ChainInfo[] = [] + +export interface ChainPanelProps { + title: string + chainsState: ChainsToSelectState | undefined + onSelectChain(chain: ChainInfo): void + variant?: 'default' | 'fullscreen' + onClose?(): void +} + +export function ChainPanel({ + title, + chainsState, + onSelectChain, + variant = 'default', + onClose, +}: ChainPanelProps): ReactNode { + const [chainQuery, setChainQuery] = useState('') + const chains = chainsState?.chains ?? EMPTY_CHAINS + const isLoading = chainsState?.isLoading ?? false + const normalizedChainQuery = chainQuery.trim().toLowerCase() + + const filteredChains = useMemo( + () => filterChainsByQuery(chains, normalizedChainQuery), + [chains, normalizedChainQuery], + ) + + const { showSearchEmptyState, showUnavailableState } = getEmptyStateFlags({ + filteredChainsLength: filteredChains.length, + isLoading, + normalizedChainQuery, + totalChains: chains.length, + }) + + return ( + + + + setChainQuery(event.target.value)} + placeholder={t`Search network`} + /> + + + + {showUnavailableState && {t`No networks available for this trade.`}} + {showSearchEmptyState && ( + + No networks match {chainQuery}. + + )} + + + ) +} + +interface ChainPanelHeaderProps { + title: string + variant: 'default' | 'fullscreen' + onClose?: () => void +} + +function ChainPanelHeader({ title, variant, onClose }: ChainPanelHeaderProps): ReactNode { + const isFullscreen = variant === 'fullscreen' + + return ( + + {isFullscreen && onClose ? : null} + {title} + {isFullscreen && onClose ? : null} + + ) +} + +function filterChainsByQuery(chains: ChainInfo[], normalizedChainQuery: string): ChainInfo[] { + if (!chains.length || !normalizedChainQuery) { + return chains + } + + return chains.filter((chain) => { + const labelMatch = chain.label.toLowerCase().includes(normalizedChainQuery) + const idMatch = String(chain.id).includes(normalizedChainQuery) + + return labelMatch || idMatch + }) +} + +function getEmptyStateFlags({ + filteredChainsLength, + isLoading, + normalizedChainQuery, + totalChains, +}: { + filteredChainsLength: number + isLoading: boolean + normalizedChainQuery: string + totalChains: number +}): { showSearchEmptyState: boolean; showUnavailableState: boolean } { + const hasQuery = Boolean(normalizedChainQuery) + + return { + showUnavailableState: !isLoading && totalChains === 0 && !hasQuery, + showSearchEmptyState: !isLoading && filteredChainsLength === 0 && hasQuery, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx new file mode 100644 index 00000000000..c52a99f92c5 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx @@ -0,0 +1,122 @@ +import { Media, SearchInput as UISearchInput, UI } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +import { IconButton } from '../commonElements' + +export const Panel = styled.div<{ $variant: 'default' | 'fullscreen' }>` + width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '200px')}; + height: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : 'auto')}; + flex-shrink: 0; + background: var(${UI.COLOR_PAPER_DARKER}); + border-left: ${({ $variant }) => ($variant === 'fullscreen' ? 'none' : `1px solid var(${UI.COLOR_BORDER})`)}; + padding: ${({ $variant }) => ($variant === 'fullscreen' ? '20px 16px' : '16px 10px')}; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + border-top-right-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '20px')}; + border-bottom-right-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '20px')}; + + ${Media.upToMedium()} { + width: 100%; + border-left: none; + border-top: 1px solid var(${UI.COLOR_BORDER}); + border-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '0 0 20px 20px')}; + } + + ${Media.upToSmall()} { + padding: ${({ $variant }) => ($variant === 'fullscreen' ? '14px' : '16px')}; + background: var(${UI.COLOR_PAPER}); + } +` + +export const PanelHeader = styled.div<{ $isFullscreen?: boolean }>` + display: flex; + align-items: center; + justify-content: ${({ $isFullscreen }) => ($isFullscreen ? 'space-between' : 'space-between')}; + gap: 12px; + padding: ${({ $isFullscreen }) => ($isFullscreen ? '4px 0' : '0')}; +` + +export const PanelTitle = styled.h4<{ $isFullscreen?: boolean }>` + font-size: ${({ $isFullscreen }) => ($isFullscreen ? '18px' : '14px')}; + font-weight: ${({ $isFullscreen }) => ($isFullscreen ? 600 : 500)}; + margin: 0; + flex: 1; + text-align: ${({ $isFullscreen }) => ($isFullscreen ? 'left' : 'center')}; + color: ${({ $isFullscreen }) => ($isFullscreen ? `var(${UI.COLOR_TEXT})` : `var(${UI.COLOR_TEXT_OPACITY_70})`)}; +` + +export const PanelCloseButton = styled(IconButton)` + flex-shrink: 0; + border-radius: 50%; + width: 32px; + height: 32px; + background: var(${UI.COLOR_PAPER}); +` + +export const PanelSearchInputWrapper = styled.div` + --min-height: 36px; + min-height: var(--min-height); + border: 1px solid var(${UI.COLOR_PAPER_DARKEST}); + background: transparent; + border-radius: var(--min-height); + padding: 0 10px; + color: var(${UI.COLOR_TEXT}); + + ${Media.upToSmall()} { + --min-height: 46px; + border: none; + padding: 0; + background: transparent; + color: inherit; + + > div { + width: 100%; + background: var(${UI.COLOR_PAPER_DARKER}); + border-radius: var(--min-height); + height: var(--min-height); + display: flex; + align-items: center; + padding: 0 14px; + font-size: 15px; + color: inherit; + } + + input { + background: transparent; + height: 100%; + } + } +` + +export const PanelSearchInput = styled(UISearchInput)` + width: 100%; + color: inherit; + border: none; + background: transparent; + font-size: 14px; + font-weight: 400; + + ${Media.upToSmall()} { + font-size: 16px; + } +` + +export const PanelList = styled.div` + flex: 1; + overflow-y: auto; + padding-right: 8px; + margin-right: -8px; + box-sizing: content-box; + ${({ theme }) => theme.colorScrollbar}; + scrollbar-gutter: stable; +` + +export const EmptyState = styled.div` + text-align: center; + font-size: 14px; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + padding: 32px 8px; +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts new file mode 100644 index 00000000000..12ae8beb2f3 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts @@ -0,0 +1,156 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { getChainAccentColors } from '@cowprotocol/ui' + +import { getChainAccent } from './getChainAccent' + +jest.mock('@cowprotocol/ui', () => ({ + ...jest.requireActual('@cowprotocol/ui'), + getChainAccentColors: jest.fn(), +})) + +const mockGetChainAccentColors = getChainAccentColors as jest.MockedFunction + +describe('getChainAccent', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return ChainAccentVars for valid chain ID', () => { + const mockAccentConfig = { + chainId: SupportedChainId.MAINNET, + bgVar: '--cow-color-chain-ethereum-bg', + borderVar: '--cow-color-chain-ethereum-border', + accentVar: '--cow-color-chain-ethereum-accent', + lightBg: 'rgba(98, 126, 234, 0.22)', + darkBg: 'rgba(98, 126, 234, 0.32)', + lightBorder: 'rgba(98, 126, 234, 0.45)', + darkBorder: 'rgba(98, 126, 234, 0.65)', + lightColor: '#627EEA', + darkColor: '#627EEA', + } + + mockGetChainAccentColors.mockReturnValue(mockAccentConfig) + + const result = getChainAccent(SupportedChainId.MAINNET) + + expect(result).toEqual({ + backgroundVar: '--cow-color-chain-ethereum-bg', + borderVar: '--cow-color-chain-ethereum-border', + accentColorVar: '--cow-color-chain-ethereum-accent', + }) + expect(mockGetChainAccentColors).toHaveBeenCalledWith(SupportedChainId.MAINNET) + }) + + it('should return ChainAccentVars for different chain IDs', () => { + const mockAccentConfig = { + chainId: SupportedChainId.POLYGON, + bgVar: '--cow-color-chain-polygon-bg', + borderVar: '--cow-color-chain-polygon-border', + accentVar: '--cow-color-chain-polygon-accent', + lightBg: 'rgba(130, 71, 229, 0.22)', + darkBg: 'rgba(130, 71, 229, 0.32)', + lightBorder: 'rgba(130, 71, 229, 0.45)', + darkBorder: 'rgba(130, 71, 229, 0.65)', + lightColor: '#8247E5', + darkColor: '#8247E5', + } + + mockGetChainAccentColors.mockReturnValue(mockAccentConfig) + + const result = getChainAccent(SupportedChainId.POLYGON) + + expect(result).toEqual({ + backgroundVar: '--cow-color-chain-polygon-bg', + borderVar: '--cow-color-chain-polygon-border', + accentColorVar: '--cow-color-chain-polygon-accent', + }) + expect(mockGetChainAccentColors).toHaveBeenCalledWith(SupportedChainId.POLYGON) + }) + + it('should return undefined when getChainAccentColors returns undefined', () => { + mockGetChainAccentColors.mockReturnValue(undefined as unknown as ReturnType) + + const result = getChainAccent(999 as SupportedChainId) + + expect(result).toBeUndefined() + expect(mockGetChainAccentColors).toHaveBeenCalledWith(999) + }) + + it('should return undefined when getChainAccentColors returns null', () => { + mockGetChainAccentColors.mockReturnValue(null as unknown as ReturnType) + + const result = getChainAccent(SupportedChainId.MAINNET) + + expect(result).toBeUndefined() + }) + + it('should correctly map all ChainAccentConfig properties to ChainAccentVars', () => { + const mockAccentConfig = { + chainId: SupportedChainId.ARBITRUM_ONE, + bgVar: '--cow-color-chain-arbitrum-bg', + borderVar: '--cow-color-chain-arbitrum-border', + accentVar: '--cow-color-chain-arbitrum-accent', + lightBg: 'rgba(27, 74, 221, 0.22)', + darkBg: 'rgba(27, 74, 221, 0.32)', + lightBorder: 'rgba(27, 74, 221, 0.45)', + darkBorder: 'rgba(27, 74, 221, 0.65)', + lightColor: '#1B4ADD', + darkColor: '#1B4ADD', + } + + mockGetChainAccentColors.mockReturnValue(mockAccentConfig) + + const result = getChainAccent(SupportedChainId.ARBITRUM_ONE) + + expect(result).toBeDefined() + expect(result).toHaveProperty('backgroundVar', mockAccentConfig.bgVar) + expect(result).toHaveProperty('borderVar', mockAccentConfig.borderVar) + expect(result).toHaveProperty('accentColorVar', mockAccentConfig.accentVar) + expect(result).not.toHaveProperty('chainId') + expect(result).not.toHaveProperty('lightBg') + expect(result).not.toHaveProperty('darkBg') + }) + + it('should handle all supported chain IDs', () => { + const supportedChains = [ + SupportedChainId.MAINNET, + SupportedChainId.BNB, + SupportedChainId.BASE, + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.POLYGON, + SupportedChainId.AVALANCHE, + SupportedChainId.GNOSIS_CHAIN, + SupportedChainId.LENS, + SupportedChainId.SEPOLIA, + SupportedChainId.LINEA, + SupportedChainId.PLASMA, + ] + + supportedChains.forEach((chainId) => { + const mockAccentConfig = { + chainId, + bgVar: `--cow-color-chain-test-bg`, + borderVar: `--cow-color-chain-test-border`, + accentVar: `--cow-color-chain-test-accent`, + lightBg: 'rgba(0, 0, 0, 0.22)', + darkBg: 'rgba(0, 0, 0, 0.32)', + lightBorder: 'rgba(0, 0, 0, 0.45)', + darkBorder: 'rgba(0, 0, 0, 0.65)', + lightColor: '#000000', + darkColor: '#000000', + } + + mockGetChainAccentColors.mockReturnValue(mockAccentConfig) + + const result = getChainAccent(chainId) + + expect(result).toBeDefined() + expect(result).toHaveProperty('backgroundVar') + expect(result).toHaveProperty('borderVar') + expect(result).toHaveProperty('accentColorVar') + expect(typeof result?.backgroundVar).toBe('string') + expect(typeof result?.borderVar).toBe('string') + expect(typeof result?.accentColorVar).toBe('string') + }) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.ts new file mode 100644 index 00000000000..7abc8ffbc13 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.ts @@ -0,0 +1,17 @@ +import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' +import { getChainAccentColors } from '@cowprotocol/ui' + +import type { ChainAccentVars } from './styled' + +export function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined { + const accentConfig = getChainAccentColors(chainId as SupportedChainId) + if (!accentConfig) { + return undefined + } + + return { + backgroundVar: accentConfig.bgVar, + borderVar: accentConfig.borderVar, + accentColorVar: accentConfig.accentVar, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx index 0d84c815ad6..18e22153db9 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx @@ -1,5 +1,5 @@ import { CHAIN_INFO } from '@cowprotocol/common-const' -import { SupportedChainId, ChainInfo } from '@cowprotocol/cow-sdk' +import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' import styled from 'styled-components/macro' @@ -7,17 +7,15 @@ import { mapChainInfo } from '../../utils/mapChainInfo' import { ChainsSelector } from './index' -const chains: ChainInfo[] = [ - ...Object.keys(CHAIN_INFO).map((chainId) => { - const supportedChainId = +chainId as SupportedChainId - const info = CHAIN_INFO[supportedChainId] +const chains: ChainInfo[] = Object.keys(CHAIN_INFO).map((chainId) => { + const supportedChainId = Number(chainId) as SupportedChainId + const info = CHAIN_INFO[supportedChainId] - return mapChainInfo(supportedChainId, info) - }), -] + return mapChainInfo(supportedChainId, info) +}) const Wrapper = styled.div` - width: 450px; + width: 320px; ` const Fixtures = { @@ -26,10 +24,15 @@ const Fixtures = { console.log('Chain selected: ', chainId)} + onSelectChain={(chain) => console.log('Chain selected: ', chain.label)} /> ), + loading: () => ( + + undefined} /> + + ), } export default Fixtures diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx index c41203af129..ca69c882d2b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx @@ -1,32 +1,28 @@ import { ReactNode } from 'react' -import { useMediaQuery, useTheme } from '@cowprotocol/common-hooks' +import OrderCheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg' +import { useTheme } from '@cowprotocol/common-hooks' import { ChainInfo } from '@cowprotocol/cow-sdk' -import { HoverTooltip, Media } from '@cowprotocol/ui' -import { Trans } from '@lingui/react/macro' -import { Menu, MenuButton, MenuItem } from '@reach/menu-button' -import { Check, ChevronDown, ChevronUp } from 'react-feather' +import { msg } from '@lingui/core/macro' +import { useLingui } from '@lingui/react/macro' +import SVG from 'react-inlinesvg' +import { getChainAccent } from './getChainAccent' import * as styledEl from './styled' -// Number of skeleton shimmers to show during loading state -const LOADING_ITEMS_COUNT = 10 +export { getChainAccent } from './getChainAccent' -const LoadingShimmerElements = ( - - {Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => ( - - ))} - -) +const LOADING_ITEMS_COUNT = 10 +const LOADING_SKELETON_INDICES = Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => index) export interface ChainsSelectorProps { chains: ChainInfo[] onSelectChain: (chainId: ChainInfo) => void defaultChainId?: ChainInfo['id'] - visibleNetworkIcons?: number // Number of network icons to display before showing "More" dropdown isLoading: boolean + disabledChainIds?: Set + loadingChainIds?: Set } export function ChainsSelector({ @@ -34,79 +30,159 @@ export function ChainsSelector({ onSelectChain, defaultChainId, isLoading, - visibleNetworkIcons = LOADING_ITEMS_COUNT, + disabledChainIds, + loadingChainIds, }: ChainsSelectorProps): ReactNode { - const isMobile = useMediaQuery(Media.upToSmall(false)) - - const theme = useTheme() + const { darkMode } = useTheme() if (isLoading) { - return LoadingShimmerElements + return } - const shouldDisplayMore = !isMobile && chains.length > visibleNetworkIcons - const visibleChains = isMobile ? chains : chains.slice(0, visibleNetworkIcons) - // Find the selected chain that isn't visible in the main row (so we can display it in the dropdown) - const selectedMenuChain = !isMobile && chains.find((i) => i.id === defaultChainId && !visibleChains.includes(i)) + return ( + + ) +} + +function ChainsLoadingList(): ReactNode { + return ( + + + + ) +} + +function ChainsSkeletonList(): ReactNode { + return ( + <> + {LOADING_SKELETON_INDICES.map((index) => ( + + + + + ))} + + ) +} + +interface ChainsListProps { + chains: ChainInfo[] + defaultChainId?: ChainInfo['id'] + onSelectChain(chain: ChainInfo): void + isDarkMode: boolean + disabledChainIds?: Set + loadingChainIds?: Set +} + +function ChainsList({ + chains, + defaultChainId, + onSelectChain, + isDarkMode, + disabledChainIds, + loadingChainIds, +}: ChainsListProps): ReactNode { + return ( + + + + ) +} +function ChainsButtonsList({ + chains, + defaultChainId, + onSelectChain, + isDarkMode, + disabledChainIds, + loadingChainIds, +}: ChainsListProps): ReactNode { return ( - - {visibleChains.map((chain) => ( - + {chains.map((chain) => ( + - onSelectChain(chain)} iconOnly> - {chain.label} - - + chain={chain} + isActive={defaultChainId === chain.id} + onSelectChain={onSelectChain} + isDarkMode={isDarkMode} + isDisabled={disabledChainIds?.has(chain.id) ?? false} + isLoading={loadingChainIds?.has(chain.id) ?? false} + /> ))} - {shouldDisplayMore && ( - - {({ isOpen }) => ( - <> - - {selectedMenuChain ? ( - {selectedMenuChain.label} - ) : isOpen ? ( - - Less - - ) : ( - - More - - )} - {isOpen ? : } - - - {chains.map((chain) => ( - onSelectChain(chain)} - active$={defaultChainId === chain.id} - iconSize={21} - tabIndex={0} - borderless - > - {chain.label} - {chain.label} - {chain.id === defaultChainId && } - - ))} - - - )} - + + ) +} + +interface ChainButtonProps { + chain: ChainInfo + isActive: boolean + isDarkMode: boolean + onSelectChain(chain: ChainInfo): void + isDisabled: boolean + isLoading: boolean +} + +function ChainButton({ + chain, + isActive, + isDarkMode, + onSelectChain, + isDisabled, + isLoading, +}: ChainButtonProps): ReactNode { + const { i18n } = useLingui() + const logoSrc = isDarkMode ? chain.logo.dark : chain.logo.light + const accent = getChainAccent(chain.id) + const disabledTooltip = i18n._(msg`This destination is not supported for this source chain`) + const loadingTooltip = i18n._(msg`Checking route availability...`) + + const handleClick = (): void => { + if (!isDisabled && !isLoading) { + onSelectChain(chain) + } + } + + const tooltip = isLoading ? loadingTooltip : isDisabled ? disabledTooltip : undefined + + return ( + + + + {chain.label} + + + {chain.label} + + + {isActive && !isLoading && ( + + + )} - + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx index 7b8260b2e89..d8e99d1f24d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx @@ -1,118 +1,174 @@ import { UI } from '@cowprotocol/ui' -import { Media } from '@cowprotocol/ui' -import { MenuList } from '@reach/menu-button' import styled from 'styled-components/macro' -export const Wrapper = styled.div` - display: flex; - flex-flow: row; - gap: 8px; - width: 100%; +import { blankButtonMixin } from '../commonElements' - ${Media.upToSmall()} { - overflow-x: auto; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE and Edge */ +export interface ChainAccentVars { + backgroundVar: string + borderVar: string + accentColorVar: string +} - &::-webkit-scrollbar { - display: none; - } - } +const fallbackBackground = `var(${UI.COLOR_PRIMARY_OPACITY_10})` +const fallbackBorder = `var(${UI.COLOR_PRIMARY_OPACITY_80})` +const fallbackHoverBorder = `var(${UI.COLOR_PRIMARY_OPACITY_70})` + +const getBackground = (accent$?: ChainAccentVars, fallback = fallbackBackground): string => + accent$ ? `var(${accent$.backgroundVar})` : fallback + +const getBorder = (accent$?: ChainAccentVars, fallback = fallbackBorder): string => + accent$ ? `var(${accent$.borderVar})` : fallback + +const getAccentColor = (accent$?: ChainAccentVars): string | undefined => + accent$ ? `var(${accent$.accentColorVar})` : undefined + +export const List = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; ` -export const ChainItem = styled.button<{ +export const ChainButton = styled.button<{ active$?: boolean - iconOnly?: boolean - iconSize?: number - borderless?: boolean - isLoading?: boolean + accent$?: ChainAccentVars + disabled$?: boolean + loading$?: boolean }>` - --itemSize: 38px; - width: ${({ iconOnly }) => (iconOnly ? 'var(--itemSize)' : 'auto')}; - height: var(--itemSize); + --min-height: 46px; + ${blankButtonMixin}; + + position: relative; + overflow: hidden; + width: 100%; display: flex; align-items: center; - justify-content: ${({ iconOnly }) => (iconOnly ? 'center' : 'flex-start')}; - gap: 4px; - font-weight: 500; - font-size: 13px; - border-radius: 14px; - padding: 6px; - border: ${({ active$, borderless }) => - borderless ? 'none' : `1px solid var(${active$ ? UI.COLOR_PRIMARY_OPACITY_70 : UI.COLOR_TEXT_OPACITY_10})`}; - cursor: ${({ isLoading }) => (isLoading ? 'default' : 'pointer')}; - line-height: 1; - outline: none; - margin: 0; - vertical-align: top; - background: ${({ active$ }) => (active$ ? `var(${UI.COLOR_PAPER_DARKER})` : 'transparent')}; - color: var(${UI.COLOR_TEXT_OPACITY_70}); - box-shadow: ${({ active$ }) => - active$ - ? `0px -1px 0px 0px var(${UI.COLOR_TEXT_OPACITY_10}) inset, - 0px 0px 0px 1px var(${UI.COLOR_PRIMARY_OPACITY_10}) inset, - 0px 1px 3px 0px var(${UI.COLOR_TEXT_OPACITY_10})` - : '0'}; + justify-content: space-between; + gap: 16px; + padding: 8px 12px; + min-height: var(--min-height); + border-radius: var(--min-height); + border: 1px solid ${({ active$, accent$ }) => (active$ ? getBorder(accent$) : 'transparent')}; + background: ${({ active$, accent$ }) => (active$ ? getBackground(accent$) : 'transparent')}; + box-shadow: ${({ active$, accent$ }) => (active$ ? `0 0 0 1px ${getBackground(accent$)} inset` : 'none')}; + cursor: ${({ disabled$, loading$ }) => (disabled$ || loading$ ? 'not-allowed' : 'pointer')}; + opacity: ${({ disabled$ }) => (disabled$ ? 0.5 : 1)}; transition: - color 0.2s ease-in-out, - background 0.2s ease-in-out, - box-shadow 0.2s ease-in-out; - overflow: ${({ isLoading }) => (isLoading ? 'hidden' : 'visible')}; - position: relative; + border 0.2s ease, + background 0.2s ease, + box-shadow 0.2s ease, + opacity 0.2s ease; &:hover { - border-color: ${({ isLoading }) => - isLoading ? `var(${UI.COLOR_TEXT_OPACITY_10})` : `var(${UI.COLOR_TEXT_OPACITY_25})`}; - background: ${({ isLoading }) => (isLoading ? 'transparent' : `var(${UI.COLOR_PAPER_DARKER})`)}; - color: ${({ isLoading }) => (isLoading ? `var(${UI.COLOR_TEXT_OPACITY_70})` : `var(${UI.COLOR_TEXT})`)}; - } - - > img { - width: ${({ iconOnly, iconSize = 24 }) => (iconOnly ? '100%' : `${iconSize}px`)}; - height: ${({ iconOnly, iconSize = 24 }) => (iconOnly ? '100%' : `${iconSize}px`)}; - border-radius: 100%; + border-color: ${({ accent$, disabled$, loading$ }) => + disabled$ || loading$ ? 'transparent' : getBorder(accent$, fallbackHoverBorder)}; + background: ${({ accent$, disabled$, loading$ }) => + disabled$ || loading$ ? 'transparent' : getBackground(accent$)}; } - > span { - padding: 0 4px; + &:focus-visible { + outline: none; + border-color: ${({ accent$, disabled$, loading$ }) => + disabled$ || loading$ ? 'transparent' : getBorder(accent$, fallbackHoverBorder)}; } - &:before { - content: ''; - width: var(--itemSize); - height: var(--itemSize); - display: ${({ isLoading }) => (isLoading ? 'block' : 'none')}; - transform: translateX(-100%); + /* Shimmer overlay for loading state - aligned with theme.shimmer */ + &::after { + content: ${({ loading$ }) => (loading$ ? '""' : 'none')}; position: absolute; - left: 0; top: 0; - ${({ theme, isLoading }) => isLoading && theme.shimmer}; + left: 0; + right: 0; + bottom: 0; + background-image: linear-gradient( + 90deg, + transparent 0, + var(${UI.COLOR_PAPER_DARKER}) 20%, + var(${UI.COLOR_PAPER_DARKER}) 60%, + transparent + ); + animation: shimmer 2s infinite; + pointer-events: none; + } + + @keyframes shimmer { + 100% { + transform: translateX(100%); + } } ` -export const MenuWrapper = styled.div` - position: relative; +export const ChainInfo = styled.div` + display: flex; + align-items: center; + gap: 12px; ` -export const MenuListStyled = styled(MenuList)` +export const ChainLogo = styled.div` + --size: 28px; + width: var(--size); + height: var(--size); + overflow: hidden; + background: transparent; display: flex; - justify-content: flex-start; - align-items: stretch; - flex-direction: column; - gap: 4px; - position: absolute; - right: 0; - top: 40px; - z-index: 12; - border-radius: 12px; - padding: 10px; - background: var(${UI.COLOR_PAPER}); - box-shadow: var(${UI.BOX_SHADOW}); + align-items: center; + justify-content: center; + + > img { + width: 100%; + height: 100%; + object-fit: contain; + } +` + +export const ChainText = styled.span<{ disabled$?: boolean; loading$?: boolean }>` + font-weight: 500; + font-size: 15px; + color: ${({ disabled$, loading$ }) => + disabled$ || loading$ ? `var(${UI.COLOR_TEXT_OPACITY_50})` : `var(${UI.COLOR_TEXT})`}; +` + +export const ActiveIcon = styled.span<{ accent$?: ChainAccentVars; color$?: string }>` + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: ${({ color$, accent$ }) => + getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)}; + + > svg { + width: 16px; + height: 16px; + display: block; + } + + > svg > path { + fill: currentColor; + } +` + +export const LoadingRow = styled.div` + width: 100%; + display: flex; + align-items: center; + gap: 16px; + padding: 10px 14px; + border-radius: 18px; border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); - outline: none; - overflow: hidden; - min-width: 200px; +` + +export const LoadingCircle = styled.div` + width: 36px; + height: 36px; + border-radius: 50%; + ${({ theme }) => theme.shimmer}; +` + +export const LoadingBar = styled.div` + flex: 1; + height: 14px; + border-radius: 8px; + ${({ theme }) => theme.shimmer}; ` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx index b7c3623b896..01660bf674d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx @@ -1,61 +1,110 @@ import { ReactNode } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' +import { areAddressesEqual, getCurrencyAddress } from '@cowprotocol/common-utils' import { TokenLogo } from '@cowprotocol/tokens' -import { HelpTooltip, TokenSymbol } from '@cowprotocol/ui' +import { TokenSymbol } from '@cowprotocol/ui' import { Trans } from '@lingui/react/macro' import { Link } from 'react-router' import * as styledEl from './styled' +import { SelectTokenContext } from '../../types' + export interface FavoriteTokensListProps { tokens: TokenWithLogo[] + selectTokenContext: SelectTokenContext hideTooltip?: boolean - selectedToken?: string - - onSelectToken(token: TokenWithLogo): void } export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode { - const { tokens, hideTooltip, selectedToken, onSelectToken } = props + const { tokens, selectTokenContext, hideTooltip } = props + + if (!tokens.length) { + return null + } return ( -
- -

+ + + Favorite tokens -

- {!hideTooltip && ( - - Your favorite saved tokens. Edit this list in the Tokens page. - - } - /> - )} -
+ + {!hideTooltip && } + - {tokens.map((token) => { - const isTokenSelected = token.address.toLowerCase() === selectedToken?.toLowerCase() - - return ( - onSelectToken(token)} - > - - - - ) - })} + -
+ + ) +} + +function FavoriteTokensTooltip(): ReactNode { + return ( + + Your favorite saved tokens. Edit this list in the Tokens page. + + } + /> + ) +} + +interface FavoriteTokenItemProps { + token: TokenWithLogo + selectTokenContext: SelectTokenContext +} + +function FavoriteTokenItem({ token, selectTokenContext }: FavoriteTokenItemProps): ReactNode { + const { selectedToken, onTokenListItemClick, onSelectToken } = selectTokenContext + const selectedAddress = selectedToken ? getCurrencyAddress(selectedToken) : undefined + + const isSelected = + !!selectedToken && + token.chainId === selectedToken.chainId && + !!selectedAddress && + areAddressesEqual(token.address, selectedAddress) + + const handleClick = (): void => { + if (isSelected) { + return + } + onTokenListItemClick?.(token) + onSelectToken(token) + } + + return ( + + + + + ) +} + +interface FavoriteTokenItemsProps { + tokens: TokenWithLogo[] + selectTokenContext: SelectTokenContext +} + +function FavoriteTokenItems({ tokens, selectTokenContext }: FavoriteTokenItemsProps): ReactNode { + return ( + <> + {tokens.map((token) => ( + + ))} + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts index ee278a509a1..ad258cd2cc2 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts @@ -1,18 +1,25 @@ -import { Media, UI } from '@cowprotocol/ui' +import { HelpTooltip, Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' -export const Header = styled.div` +export const Section = styled.div` + padding: 0 14px 14px; + + ${Media.upToSmall()} { + padding: 8px 14px 4px; + } +` + +export const TitleRow = styled.div` display: flex; - gap: 5px; - flex-direction: row; align-items: center; +` - > h4 { - font-size: 14px; - font-weight: 500; - margin: 0; - } +export const Title = styled.h4` + font-size: 14px; + font-weight: 500; + margin: 0; + color: var(${UI.COLOR_TEXT_OPACITY_70}); ` export const List = styled.div` @@ -25,9 +32,8 @@ export const List = styled.div` width: 0; min-width: 100%; flex-wrap: nowrap; - overflow-x: scroll; + overflow-x: auto; overflow-y: hidden; - padding: 10px 0; -webkit-overflow-scrolling: touch; @@ -44,9 +50,8 @@ export const List = styled.div` } ` -export const TokensItem = styled.button` +export const TokenButton = styled.button` display: inline-flex; - flex-direction: row; align-items: center; gap: 6px; justify-content: center; @@ -58,9 +63,9 @@ export const TokensItem = styled.button` border: 1px solid var(${UI.COLOR_PAPER_DARKER}); font-weight: 500; font-size: 16px; - cursor: ${({ disabled }) => (disabled ? '' : 'pointer')}; - background: ${({ disabled }) => disabled && `var(${UI.COLOR_PAPER_DARKER})`}; - opacity: ${({ disabled }) => (disabled ? 0.6 : 1)}; + cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; + background: ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : 'transparent')}; + opacity: ${({ disabled }) => (disabled ? 0.65 : 1)}; transition: border var(${UI.ANIMATION_DURATION}) ease-in-out; white-space: nowrap; @@ -72,3 +77,13 @@ export const TokensItem = styled.button` border: 1px solid ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : `var(${UI.COLOR_PRIMARY})`)}; } ` + +export const FavoriteTooltip = styled(HelpTooltip)` + color: var(${UI.COLOR_TEXT_OPACITY_50}); + transition: color 0.2s ease-in-out; + margin-left: 6px; + + &:hover { + color: var(${UI.COLOR_TEXT}); + } +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx new file mode 100644 index 00000000000..d72d9b6cf06 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx @@ -0,0 +1,222 @@ +import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useTheme } from '@cowprotocol/common-hooks' +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { Tooltip } from '@cowprotocol/ui' + +import { msg } from '@lingui/core/macro' +import { Trans, useLingui } from '@lingui/react/macro' +import { ChevronDown } from 'react-feather' + +import * as styledEl from './mobileChainSelector.styled' + +import { ChainsToSelectState } from '../../types' +import { sortChainsByDisplayOrder } from '../../utils/sortChainsByDisplayOrder' +import { getChainAccent } from '../ChainsSelector' + +const DISABLED_CHAIN_TOOLTIP_DURATION_MS = 2500 + +function useDisabledChainTooltip(durationMs: number): { + activeTooltipChainId: number | null + toggleTooltip(chainId: number): void + hideTooltip(): void +} { + const [activeTooltipChainId, setActiveTooltipChainId] = useState(null) + const hideTimerRef = useRef(null) + + const hideTooltip = useCallback((): void => { + if (hideTimerRef.current) { + window.clearTimeout(hideTimerRef.current) + hideTimerRef.current = null + } + setActiveTooltipChainId(null) + }, []) + + const toggleTooltip = useCallback( + (chainId: number): void => { + setActiveTooltipChainId((prev) => { + if (hideTimerRef.current) { + window.clearTimeout(hideTimerRef.current) + hideTimerRef.current = null + } + + const next = prev === chainId ? null : chainId + if (next !== null) { + hideTimerRef.current = window.setTimeout(() => { + setActiveTooltipChainId(null) + hideTimerRef.current = null + }, durationMs) + } + + return next + }) + }, + [durationMs], + ) + + useEffect(() => { + return hideTooltip + }, [hideTooltip]) + + return { activeTooltipChainId, toggleTooltip, hideTooltip } +} + +interface MobileChainSelectorProps { + chainsState: ChainsToSelectState + label?: string + onSelectChain(chain: ChainInfo): void + onOpenPanel(): void +} + +export function MobileChainSelector({ + chainsState, + label, + onSelectChain, + onOpenPanel, +}: MobileChainSelectorProps): ReactNode { + const { i18n } = useLingui() + const scrollRef = useRef(null) + const { activeTooltipChainId, toggleTooltip, hideTooltip } = useDisabledChainTooltip( + DISABLED_CHAIN_TOOLTIP_DURATION_MS, + ) + const orderedChains = useMemo( + () => + sortChainsByDisplayOrder(chainsState.chains ?? [], { + pinChainId: chainsState.defaultChainId, + }), + [chainsState.chains, chainsState.defaultChainId], + ) + + const totalChains = chainsState.chains?.length ?? 0 + const canRenderChains = orderedChains.length > 0 + const activeChainLabel = orderedChains.find((chain) => chain.id === chainsState.defaultChainId)?.label + + useEffect(() => { + if (!scrollRef.current) { + return + } + + scrollRef.current.scrollTo({ left: 0, behavior: 'auto' }) + }, [chainsState.defaultChainId]) + + return ( + + {label ? ( + + {label} + {activeChainLabel ? ( + + {activeChainLabel} + + ) : null} + + ) : null} + + {canRenderChains ? ( + + {orderedChains.map((chain) => ( + + ))} + + ) : null} + {totalChains > 0 ? ( + + + + View all ({totalChains}) + + + + + ) : null} + + + ) +} + +interface ChainChipProps { + chain: ChainInfo + isActive: boolean + onSelectChain(chain: ChainInfo): void + isDisabled: boolean + isLoading: boolean + isTooltipVisible: boolean + onDisabledClick(chainId: number): void + onHideTooltip(): void +} + +function ChainChip({ + chain, + isActive, + onSelectChain, + isDisabled, + isLoading, + isTooltipVisible, + onDisabledClick, + onHideTooltip, +}: ChainChipProps): ReactNode { + const { i18n } = useLingui() + const { darkMode } = useTheme() + const accent = getChainAccent(chain.id) + const logoSrc = darkMode ? chain.logo.dark : chain.logo.light + const disabledTooltip = i18n._(msg`This destination is not supported for this source chain`) + const loadingTooltip = i18n._(msg`Checking route availability...`) + const chipRef = useRef(null) + + const handleClick = (): void => { + if (isLoading) { + return + } + + if (!isDisabled) { + onHideTooltip() + onSelectChain(chain) + return + } + + onDisabledClick(chain.id) + } + + const tooltip = isLoading ? loadingTooltip : disabledTooltip + + const chipButton = ( + + {chain.label} + + ) + + return isDisabled ? ( + + {chipButton} + + ) : ( + chipButton + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalShell.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalShell.tsx new file mode 100644 index 00000000000..4892bbbe9b2 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalShell.tsx @@ -0,0 +1,70 @@ +import { ComponentProps, ReactNode } from 'react' + +import { SearchInput } from '@cowprotocol/ui' + +import { t } from '@lingui/core/macro' + +import { TitleBarActions } from './helpers' +import { MobileChainSelector } from './MobileChainSelector' +import * as styledEl from './styled' + +export interface SelectTokenModalShellProps { + children: ReactNode + hasChainPanel: boolean + isFullScreenMobile?: boolean + title: string + showManageButton: boolean + onDismiss(): void + onOpenManageWidget: () => void + searchValue: string + onSearchChange(value: string): void + onSearchEnter?: () => void + mobileChainSelector?: ComponentProps + sideContent?: ReactNode +} + +export function SelectTokenModalShell({ + children, + hasChainPanel, + isFullScreenMobile, + title, + showManageButton, + onDismiss, + onOpenManageWidget, + searchValue, + onSearchChange, + onSearchEnter, + mobileChainSelector, + sideContent, +}: SelectTokenModalShellProps): ReactNode { + return ( + + + + + { + if (event.key === 'Enter') { + onSearchEnter?.() + } + }} + onChange={(event) => onSearchChange(event.target.value)} + placeholder={t`Search name or paste address...`} + /> + + + {mobileChainSelector ? : null} + + {children} + {sideContent} + + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx new file mode 100644 index 00000000000..82fe22ccbc7 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx @@ -0,0 +1,83 @@ +import { ReactNode } from 'react' + +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { TokenListCategory } from '@cowprotocol/tokens' + +import { SelectTokenModalContent } from './SelectTokenModalContent' + +import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' +import { ChainsSelector } from '../ChainsSelector' + +type TokenListCategoryState = [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void] + +export interface TokenColumnContentProps { + displayLpTokenLists?: boolean + account: string | undefined + inputValue: string + onSelectToken: TokenSelectionHandler + openPoolPage(poolAddress: string): void + disableErc20?: boolean + tokenListCategoryState: TokenListCategoryState + isRouteAvailable: boolean | undefined + chainsToSelect?: ChainsToSelectState + onSelectChain: (chain: ChainInfo) => void + children: ReactNode +} + +export function TokenColumnContent({ + displayLpTokenLists, + account, + inputValue, + onSelectToken, + openPoolPage, + disableErc20, + tokenListCategoryState, + isRouteAvailable, + chainsToSelect, + onSelectChain, + children, +}: TokenColumnContentProps): ReactNode { + if (displayLpTokenLists) { + return ( + + {children} + + ) + } + + return ( + <> + + {children} + + ) +} + +interface LegacyChainSelectorProps { + chainsToSelect: ChainsToSelectState | undefined + onSelectChain: (chain: ChainInfo) => void +} + +function LegacyChainSelector({ chainsToSelect, onSelectChain }: LegacyChainSelectorProps): ReactNode { + if (!chainsToSelect?.chains?.length) { + return null + } + + return ( + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx new file mode 100644 index 00000000000..b74d81cb5db --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx @@ -0,0 +1,85 @@ +import { ComponentProps, ReactNode, useState } from 'react' + +import { BackButton } from '@cowprotocol/ui' + +import { t } from '@lingui/core/macro' + +import { SettingsIcon } from 'modules/trade/pure/Settings' + +import { MobileChainSelector } from './MobileChainSelector' +import * as styledEl from './styled' + +import type { SelectTokenModalProps } from './types' + +export function useTokenSearchInput(defaultInputValue = ''): [string, (value: string) => void, string] { + const [inputValue, setInputValue] = useState(defaultInputValue) + + return [inputValue, setInputValue, inputValue.trim()] +} + +export function getMobileChainSelectorConfig({ + showChainPanel, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, +}: { + showChainPanel: boolean + mobileChainsState: SelectTokenModalProps['mobileChainsState'] + mobileChainsLabel: SelectTokenModalProps['mobileChainsLabel'] + onSelectChain?: SelectTokenModalProps['onSelectChain'] + onOpenMobileChainPanel?: SelectTokenModalProps['onOpenMobileChainPanel'] +}): ComponentProps | undefined { + const canRender = + !showChainPanel && + mobileChainsState && + onSelectChain && + onOpenMobileChainPanel && + (mobileChainsState.chains?.length ?? 0) > 0 + + if (!canRender) { + return undefined + } + + return { + chainsState: mobileChainsState, + label: mobileChainsLabel, + onSelectChain, + onOpenPanel: onOpenMobileChainPanel, + } +} + +interface TitleBarActionsProps { + showManageButton: boolean + onDismiss(): void + onOpenManageWidget(): void + title: string +} + +export function TitleBarActions({ + showManageButton, + onDismiss, + onOpenManageWidget, + title, +}: TitleBarActionsProps): ReactNode { + return ( + + + + {title} + + {showManageButton && ( + + + + + + )} + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx index 4a432e5c98a..86a64191e4f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx @@ -1,4 +1,8 @@ +import { useHydrateAtoms } from 'jotai/utils' +import { ReactNode } from 'react' + import { BalancesState } from '@cowprotocol/balances-and-allowances' +import { CHAIN_INFO, TokenWithLogo } from '@cowprotocol/common-const' import { getRandomInt } from '@cowprotocol/common-utils' import { SupportedChainId, ChainInfo } from '@cowprotocol/cow-sdk' import { BigNumber } from '@ethersproject/bignumber' @@ -6,6 +10,10 @@ import { BigNumber } from '@ethersproject/bignumber' import styled from 'styled-components/macro' import { allTokensMock, favoriteTokensMock } from '../../mocks' +import { tokenListViewAtom, TokenListViewState } from '../../state/tokenListViewAtom' +import { SelectTokenContext } from '../../types' +import { mapChainInfo } from '../../utils/mapChainInfo' +import { ChainPanel } from '../ChainPanel' import { SelectTokenModal, SelectTokenModalProps } from './index' @@ -13,80 +21,178 @@ const Wrapper = styled.div` max-height: 90vh; margin: 20px auto; display: flex; - width: 450px; + gap: 0; + width: 960px; + border-radius: 20px; + overflow: hidden; + border: 1px solid rgba(0, 0, 0, 0.05); ` -const unsupportedTokens = {} - const selectedToken = favoriteTokensMock[0] const balances = allTokensMock.reduce((acc, token) => { acc[token.address] = BigNumber.from(getRandomInt(20_000, 120_000_000) + '0'.repeat(token.decimals)) - return acc }, {}) -const defaultProps: SelectTokenModalProps = { - tokenListTags: {}, - account: undefined, +const balancesState: BalancesState = { + values: balances, + isLoading: false, + chainId: SupportedChainId.SEPOLIA, + fromCache: false, +} + +const chainsMock: ChainInfo[] = [ + SupportedChainId.MAINNET, + SupportedChainId.BASE, + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.POLYGON, + SupportedChainId.AVALANCHE, + SupportedChainId.GNOSIS_CHAIN, +].reduce((acc, id) => { + const info = CHAIN_INFO[id] + if (info) { + acc.push(mapChainInfo(id, info)) + } + return acc +}, []) + +const favoriteTokenAddresses = new Set(favoriteTokensMock.map((token) => token.address.toLowerCase())) +const recentTokensMock = allTokensMock + .filter((token) => !favoriteTokenAddresses.has(token.address.toLowerCase())) + .slice(0, 3) + +// Mock SelectTokenContext for atom hydration +const mockSelectTokenContext: SelectTokenContext = { + balancesState, + selectedToken, + onSelectToken: (token: TokenWithLogo) => { + console.log('onSelectToken', token.symbol) + }, + onTokenListItemClick: (token: TokenWithLogo) => { + console.log('onTokenListItemClick', token.symbol) + }, + unsupportedTokens: {}, permitCompatibleTokens: {}, - unsupportedTokens, + tokenListTags: {}, + isWalletConnected: false, +} + +// Mock atom state for Cosmos fixtures +const mockAtomState: TokenListViewState = { allTokens: allTokensMock, favoriteTokens: favoriteTokensMock, + recentTokens: recentTokensMock, + searchInput: '', areTokensLoading: false, areTokensFromBridge: false, - chainsToSelect: undefined, - onSelectChain(chain: ChainInfo) { - console.log('onSelectChain', chain) - }, + hideFavoriteTokensTooltip: false, + displayLpTokenLists: false, + selectedTargetChainId: undefined, + selectTokenContext: mockSelectTokenContext, + onClearRecentTokens: () => console.log('onClearRecentTokens'), +} + +// Wrapper component that hydrates the atom for Cosmos fixtures +function CosmosAtomProvider({ + children, + atomState, +}: { + children: ReactNode + atomState: TokenListViewState +}): ReactNode { + useHydrateAtoms([[tokenListViewAtom, atomState]]) + return <>{children} +} + +// Slim modal props (new ~17 prop interface) +const defaultModalProps: SelectTokenModalProps = { + // Layout + standalone: false, + hasChainPanel: false, + modalTitle: 'Swap from', + chainsPanelTitle: 'Cross chain swap', + // Chain panel + onSelectChain: () => undefined, + // Widget config tokenListCategoryState: [null, () => void 0], - balancesState: { - values: balances, - isLoading: false, - chainId: SupportedChainId.SEPOLIA, - fromCache: false, - }, - selectedToken, + disableErc20: false, isRouteAvailable: true, - onSelectToken() { - console.log('onSelectToken') - }, - onOpenManageWidget() { - console.log('onOpenManageWidget') - }, - onDismiss() { - console.log('onDismiss') + account: undefined, + displayLpTokenLists: false, + // Callbacks + onSelectToken: (token) => console.log('onSelectToken', token.symbol), + openPoolPage: () => console.log('openPoolPage'), + onOpenManageWidget: () => console.log('onOpenManageWidget'), + onDismiss: () => console.log('onDismiss'), +} + +const defaultChainPanelProps = { + title: 'Cross chain swap', + chainsState: { + defaultChainId: SupportedChainId.MAINNET, + chains: chainsMock, + isLoading: false, }, - openPoolPage() { - console.log('openPoolPage') + onSelectChain(chain: ChainInfo) { + console.log('onSelectChain', chain) }, } const Fixtures = { default: () => ( - - - + + + + + + + ), + loadingSidebar: () => ( + + + + + + + ), + noSidebar: () => ( + + + + + ), importByAddress: () => ( - - - + + + + + ), NoTokenFound: () => ( - - - + + + + + ), searchFromInactiveLists: () => ( - - - + + + + + ), searchFromExternalSources: () => ( - - - + + + + + ), } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index f1092ce7ec9..a6347711c52 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -1,153 +1,120 @@ -import React, { ReactNode, useMemo, useState } from 'react' - -import { BalancesState } from '@cowprotocol/balances-and-allowances' -import { TokenWithLogo } from '@cowprotocol/common-const' -import { ChainInfo } from '@cowprotocol/cow-sdk' -import { TokenListCategory, TokenListTags, UnsupportedTokensState } from '@cowprotocol/tokens' -import { SearchInput } from '@cowprotocol/ui' -import { Currency } from '@uniswap/sdk-core' +import { ReactNode, useLayoutEffect, useMemo } from 'react' import { t } from '@lingui/core/macro' -import { X } from 'react-feather' -import { Nullish } from 'types' - -import { PermitCompatibleTokens } from 'modules/permit' -import { SelectTokenModalContent } from './SelectTokenModalContent' -import * as styledEl from './styled' +import { getMobileChainSelectorConfig, useTokenSearchInput } from './helpers' +import { SelectTokenModalShell } from './SelectTokenModalShell' +import { TokenColumnContent } from './TokenColumnContent' -import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' -import { ChainsToSelectState, SelectTokenContext } from '../../types' -import { ChainsSelector } from '../ChainsSelector' -import { IconButton } from '../commonElements' +import { useUpdateTokenListViewState } from '../../hooks/useUpdateTokenListViewState' +import { ChainPanel } from '../ChainPanel' import { TokensContent } from '../TokensContent' -export interface SelectTokenModalProps { - allTokens: TokenWithLogo[] - favoriteTokens: TokenWithLogo[] - balancesState: BalancesState - unsupportedTokens: UnsupportedTokensState - selectedToken?: Nullish - permitCompatibleTokens: PermitCompatibleTokens - hideFavoriteTokensTooltip?: boolean - displayLpTokenLists?: boolean - disableErc20?: boolean - account: string | undefined - chainsToSelect: ChainsToSelectState | undefined - tokenListCategoryState: [T, (category: T) => void] - defaultInputValue?: string - areTokensLoading: boolean - tokenListTags: TokenListTags - standalone?: boolean - areTokensFromBridge: boolean - isRouteAvailable: boolean | undefined - - onSelectToken(token: TokenWithLogo): void - openPoolPage(poolAddress: string): void - onInputPressEnter?(): void - onOpenManageWidget(): void - onDismiss(): void - onSelectChain(chain: ChainInfo): void -} +import type { SelectTokenModalProps } from './types' -function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext { - const { - selectedToken, - balancesState, - unsupportedTokens, - permitCompatibleTokens, - onSelectToken, - account, - tokenListTags, - } = props - - return useMemo( - () => ({ - balancesState, - selectedToken, - onSelectToken, - unsupportedTokens, - permitCompatibleTokens, - tokenListTags, - isWalletConnected: !!account, - }), - [balancesState, selectedToken, onSelectToken, unsupportedTokens, permitCompatibleTokens, tokenListTags, account], - ) -} +export type { SelectTokenModalProps } +/** + * SelectTokenModal - Token selection modal component. + * + * Data flow: + * - Controller hydrates tokenListViewAtom with token data, context, and callbacks + * - This modal receives only UI/callback props + * - Children (TokensContent, TokensVirtualList, etc.) read from atom + * - searchInput is local state, synced to atom for children to read + */ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { const { + // Layout + standalone, + hasChainPanel = false, + isFullScreenMobile, + modalTitle, + chainsPanelTitle, defaultInputValue = '', - onSelectToken, - onDismiss, - onInputPressEnter, - account, - displayLpTokenLists, - openPoolPage, - tokenListCategoryState, - disableErc20, + // Chain panel chainsToSelect, onSelectChain, - areTokensFromBridge, + mobileChainsState, + mobileChainsLabel, + onOpenMobileChainPanel, + // Widget config + tokenListCategoryState, + disableErc20, isRouteAvailable, + account, + displayLpTokenLists, + // Callbacks + onSelectToken, + openPoolPage, + onInputPressEnter, + onOpenManageWidget, + onDismiss, } = props - const [inputValue, setInputValue] = useState(defaultInputValue) - const selectTokenContext = useSelectTokenContext(props) + // Local search state - synced to atom for children to read + const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) - const trimmedInputValue = inputValue.trim() + // Sync ONLY searchInput to atom (controller handles all other hydration) + const updateTokenListView = useUpdateTokenListViewState() + useLayoutEffect(() => { + updateTokenListView({ searchInput: trimmedInputValue }) + }, [trimmedInputValue, updateTokenListView]) - const allListsContent = ( - + // Resolve layout state + const showChainPanel = hasChainPanel + const resolvedModalTitle = modalTitle ?? t`Select token` + const legacyChainsState = + !showChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined + const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap` + + // Build chain panel component + const chainPanel = useMemo( + () => + showChainPanel && chainsToSelect ? ( + + ) : null, + [chainsToSelect, onSelectChain, resolvedChainPanelTitle, showChainPanel], ) + // Build mobile chain selector config + const mobileChainSelector = getMobileChainSelectorConfig({ + showChainPanel, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, + }) + const chainsForTokenColumn = mobileChainSelector ? undefined : legacyChainsState + return ( - - - e.key === 'Enter' && onInputPressEnter?.()} - onChange={(e) => setInputValue(e.target.value)} - placeholder={t`Search name or paste address...`} - /> - - - - - {displayLpTokenLists ? ( - - {allListsContent} - - ) : ( - <> - {!!chainsToSelect?.chains?.length && ( - <> - - - - - )} - {allListsContent} - - )} - + + + + + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts new file mode 100644 index 00000000000..77722406bd3 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts @@ -0,0 +1,181 @@ +import { UI } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +import { ListTitle } from './styled' + +import type { ChainAccentVars } from '../ChainsSelector/styled' + +const fallbackBackground = `var(${UI.COLOR_PRIMARY_OPACITY_10})` +const fallbackBorder = `var(${UI.COLOR_PRIMARY_OPACITY_50})` + +const getBackground = (accent?: ChainAccentVars): string => + accent ? `var(${accent.backgroundVar})` : fallbackBackground +const getBorder = (accent?: ChainAccentVars): string => (accent ? `var(${accent.borderVar})` : fallbackBorder) + +export const MobileSelectorRow = styled.div` + padding: 0 14px 12px; + display: flex; + flex-direction: column; + gap: 8px; +` + +export const MobileSelectorLabel = styled(ListTitle)` + padding: 4px 0; + justify-content: flex-start; + gap: 6px; + flex-wrap: wrap; +` + +export const ActiveChainLabel = styled.span` + color: var(${UI.COLOR_TEXT}); + font-weight: 600; + font-size: 14px; +` + +export const ScrollContainer = styled.div` + --cta-width: min(45vw, 130px); + --fade-width: clamp(14px, 6vw, 32px); + --cta-gap: 2px; + --cta-offset: calc(var(--cta-width) + var(--cta-gap)); + position: relative; + min-height: 44px; + overflow: hidden; + padding-right: var(--cta-offset); +` + +export const ScrollArea = styled.div` + display: flex; + align-items: center; + gap: 6px; + overflow-x: auto; + overflow-y: hidden; + padding-right: var(--fade-width); + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + scroll-snap-type: x proximity; + + &::-webkit-scrollbar { + display: none; + } + + mask-image: linear-gradient( + 90deg, + #000 0%, + #000 calc(100% - var(--cta-offset) - var(--fade-width)), + rgba(0, 0, 0, 0) 100% + ); + -webkit-mask-image: linear-gradient( + 90deg, + #000 0%, + #000 calc(100% - var(--cta-offset) - var(--fade-width)), + rgba(0, 0, 0, 0) 100% + ); +` + +export const FixedAllNetworks = styled.div` + pointer-events: none; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: var(--cta-width); + display: flex; + align-items: center; + justify-content: flex-end; + + > button { + pointer-events: auto; + width: 100%; + position: relative; + z-index: 1; + } +` + +export const ChainChipButton = styled.button<{ + $active?: boolean + $accent?: ChainAccentVars + $disabled?: boolean + $loading?: boolean +}>` + --size: 44px; + position: relative; + overflow: hidden; + width: var(--size); + height: var(--size); + border-radius: 10px; + border: 2px solid ${({ $active, $accent }) => ($active ? getBorder($accent) : 'transparent')}; + background: ${({ $active, $accent }) => ($active ? getBackground($accent) : 'transparent')}; + display: flex; + align-items: center; + justify-content: center; + cursor: ${({ $disabled, $loading }) => ($disabled || $loading ? 'not-allowed' : 'pointer')}; + opacity: ${({ $disabled, $loading }) => ($disabled || $loading ? 0.5 : 1)}; + transition: + border 0.2s ease, + background 0.2s ease, + opacity 0.2s ease; + flex-shrink: 0; + scroll-snap-align: start; + + > img { + --size: 100%; + width: var(--size); + height: var(--size); + object-fit: contain; + } + + /* Shimmer overlay for loading state - aligned with theme.shimmer */ + &::after { + content: ${({ $loading }) => ($loading ? '""' : 'none')}; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: linear-gradient( + 90deg, + transparent 0, + var(${UI.COLOR_PAPER_DARKER}) 20%, + var(${UI.COLOR_PAPER_DARKER}) 60%, + transparent + ); + animation: chipShimmer 2s infinite; + pointer-events: none; + } + + @keyframes chipShimmer { + 100% { + transform: translateX(100%); + } + } +` + +export const MoreChipButton = styled.button` + --size: 44px; + height: var(--size); + padding: 0 12px; + border-radius: var(--size); + border: 1px solid var(${UI.COLOR_PAPER_DARKEST}); + background: var(${UI.COLOR_PAPER}); + color: var(${UI.COLOR_TEXT}); + font-weight: 600; + font-size: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + + svg { + --size: 18px; + stroke: var(${UI.COLOR_TEXT_OPACITY_50}); + width: var(--size); + height: var(--size); + min-width: var(--size); + min-height: var(--size); + } +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts index 3016d33f0c9..4dae09d3ba6 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts @@ -1,67 +1,160 @@ -import { UI } from '@cowprotocol/ui' +import { Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' import { blankButtonMixin } from '../commonElements' -export const Wrapper = styled.div` +export const Wrapper = styled.div<{ $hasChainPanel?: boolean; $isFullScreen?: boolean }>` display: flex; flex-direction: column; background: var(${UI.COLOR_PAPER}); - border-radius: 20px; + border-radius: ${({ $isFullScreen }) => ($isFullScreen ? '0' : '20px')}; width: 100%; -` + overflow: hidden; + border-top-right-radius: ${({ $hasChainPanel, $isFullScreen }) => + $isFullScreen ? '0' : $hasChainPanel ? '0' : '20px'}; + border-bottom-right-radius: ${({ $hasChainPanel, $isFullScreen }) => + $isFullScreen ? '0' : $hasChainPanel ? '0' : '20px'}; -export const Row = styled.div` - margin: 0 20px 20px; + ${Media.upToMedium()} { + border-radius: ${({ $isFullScreen }) => ($isFullScreen ? '0' : '20px')}; + } ` -export const ChainsSelectorWrapper = styled.div` - border-bottom: 1px solid var(${UI.COLOR_BORDER}); - padding: 2px 16px 10px 20px; - margin-bottom: 20px; -` +export const TitleBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + gap: 12px; -export const Separator = styled.div` - width: 100%; - border-bottom: 1px solid var(${UI.COLOR_BORDER}); + ${Media.upToSmall()} { + padding: 14px 14px 8px; + } ` -export const Header = styled.div` +export const TitleGroup = styled.div` display: flex; - flex-direction: row; - padding: 10px 16px; - margin-bottom: 8px; align-items: center; - border-bottom: 1px solid var(${UI.COLOR_BORDER}); + gap: 8px; +` + +export const ModalTitle = styled.h3` + font-size: 20px; + font-weight: 600; + margin: 0; - > h3 { - font-size: 16px; - font-weight: 500; - margin: 0; + ${Media.upToSmall()} { + font-size: 18px; } ` -export const ActionButton = styled.button` +export const TitleActions = styled.div` + display: flex; + align-items: center; + gap: 8px; +` + +export const TitleActionButton = styled.button` ${blankButtonMixin}; display: flex; - width: 100%; align-items: center; - flex-direction: row; justify-content: center; - gap: 10px; + padding: 2px; + border-radius: 8px; cursor: pointer; - padding: 20px 0; - margin: 0; - font-size: 16px; - font-weight: 500; color: inherit; - opacity: 0.6; - transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; + transition: background var(${UI.ANIMATION_DURATION}) ease-in-out; + + &:hover { + background: var(${UI.COLOR_PAPER_DARKER}); + } +` + +export const SearchRow = styled.div` + padding: 0 14px 14px; + display: flex; + align-items: center; +` + +export const SearchInputWrapper = styled.div` + --input-height: 46px; + width: 100%; + + > div { + width: 100%; + background: var(${UI.COLOR_PAPER_DARKER}); + border-radius: var(--input-height); + height: var(--input-height); + display: flex; + align-items: center; + padding: 0 14px; + font-size: 15px; + } + + input { + background: transparent; + height: 100%; + } +` + +export const Body = styled.div` + display: flex; + flex: 1; + min-height: 0; + + ${Media.upToMedium()} { + flex-direction: column; + } +` + +export const TokenColumn = styled.div` + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0; +` + +export const Row = styled.div` + padding: 0 24px; + margin-bottom: 16px; + + ${Media.upToSmall()} { + padding: 0 16px; + } +` + +export const Separator = styled.div` + width: 100%; + border-bottom: 1px solid var(${UI.COLOR_BORDER}); + margin: 0 0 16px; +` + +export const ListTitle = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 14px; + font-weight: 500; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + padding: 8px 16px 4px; +` + +export const ListTitleActionButton = styled.button` + ${blankButtonMixin}; + font-size: 13px; + font-weight: 600; + color: var(${UI.COLOR_TEXT}); + padding: 2px 6px; + border-radius: 6px; + transition: color var(${UI.ANIMATION_DURATION}) ease-in-out; &:hover { - opacity: 1; + color: var(${UI.COLOR_TEXT_OPACITY_70}); } ` @@ -69,7 +162,7 @@ export const TokensLoader = styled.div` width: 100%; height: 100%; overflow: auto; - padding: 20px 0; + padding: 40px 0; text-align: center; ` @@ -77,6 +170,6 @@ export const RouteNotAvailable = styled.div` width: 100%; height: 100%; overflow: auto; - padding: 20px 0; + padding: 40px 0; text-align: center; ` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts new file mode 100644 index 00000000000..61a9c6731b1 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -0,0 +1,54 @@ +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { TokenListCategory } from '@cowprotocol/tokens' + +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' + +/** + * Props that remain on SelectTokenModal after moving data to atom. + * Children read token data, context, and callbacks from tokenListViewAtom. + * + * Props categories: + * - Callbacks: onDismiss, onOpenManageWidget, onInputPressEnter, onSelectToken, onSelectChain, openPoolPage + * - Layout: standalone, hasChainPanel, isFullScreenMobile + * - Strings: modalTitle, chainsPanelTitle, defaultInputValue + * - Chain panel: chainsToSelect, mobileChainsState, mobileChainsLabel, onOpenMobileChainPanel + * - Widget config: tokenListCategoryState, disableErc20, isRouteAvailable, account, displayLpTokenLists + */ + +export interface ModalLayoutProps { + standalone?: boolean + hasChainPanel?: boolean + isFullScreenMobile?: boolean + modalTitle?: string + chainsPanelTitle?: string + defaultInputValue?: string +} + +export interface ChainSelectionProps { + chainsToSelect?: ChainsToSelectState + onSelectChain(chain: ChainInfo): void + mobileChainsState?: ChainsToSelectState + mobileChainsLabel?: string + onOpenMobileChainPanel?(): void +} + +export interface WidgetConfigProps { + tokenListCategoryState: [T, (category: T) => void] + disableErc20?: boolean + isRouteAvailable: boolean | undefined + account: string | undefined + displayLpTokenLists?: boolean +} + +export interface ModalCallbackProps { + onSelectToken: TokenSelectionHandler + openPoolPage(poolAddress: string): void + onInputPressEnter?(): void + onOpenManageWidget(): void + onDismiss(): void +} + +export type SelectTokenModalProps = ModalLayoutProps & + ChainSelectionProps & + WidgetConfigProps & + ModalCallbackProps diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx index 9e141417f3e..3c6d27dda1c 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx @@ -13,6 +13,8 @@ import { Nullish } from 'types' import * as styledEl from './styled' import { useDeferredVisibility } from '../../hooks/useDeferredVisibility' +import { TokenSelectionHandler } from '../../types' +import { getTokenUniqueKey } from '../../utils/tokenKey' import { TokenInfo } from '../TokenInfo' import { TokenTags } from '../TokenTags' @@ -28,7 +30,7 @@ export interface TokenListItemProps { balance: BigNumber | undefined usdAmount?: CurrencyAmount | null - onSelectToken?(token: TokenWithLogo): void + onSelectToken?: TokenSelectionHandler isWalletConnected: boolean isUnsupported?: boolean @@ -59,7 +61,7 @@ export function TokenListItem(props: TokenListItemProps): ReactNode { className, } = props - const tokenKey = `${token.chainId}:${token.address.toLowerCase()}` + const tokenKey = getTokenUniqueKey(token) // Defer heavyweight UI (tooltips, formatted numbers) until the row is about to enter the viewport. const { ref: visibilityRef, isVisible: hasIntersected } = useDeferredVisibility({ resetKey: tokenKey, diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx index 4dd4ca71e46..b7d8c72506f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, useCallback } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' @@ -14,6 +14,7 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine const { unsupportedTokens, onSelectToken, + onTokenListItemClick, selectedToken, tokenListTags, permitCompatibleTokens, @@ -22,6 +23,13 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine } = context const addressLowerCase = token.address.toLowerCase() + const handleSelectToken = useCallback( + (tokenToSelect: TokenWithLogo) => { + onTokenListItemClick?.(tokenToSelect) + onSelectToken(tokenToSelect) + }, + [onSelectToken, onTokenListItemClick], + ) return ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx index fa6b1ccf049..73c3520d902 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx @@ -1,100 +1,64 @@ -import React, { ReactNode } from 'react' +import { ReactNode, useMemo } from 'react' -import { TokenWithLogo } from '@cowprotocol/common-const' -import { getCurrencyAddress } from '@cowprotocol/common-utils' -import { Nullish } from '@cowprotocol/types' import { Loader } from '@cowprotocol/ui' -import { Currency } from '@uniswap/sdk-core' - -import { Trans } from '@lingui/react/macro' -import { Edit } from 'react-feather' import { TokenSearchResults } from '../../containers/TokenSearchResults' -import { SelectTokenContext } from '../../types' -import { FavoriteTokensList } from '../FavoriteTokensList' +import { useTokenListViewState } from '../../hooks/useTokenListViewState' +import { getTokenUniqueKey } from '../../utils/tokenKey' import * as styledEl from '../SelectTokenModal/styled' import { TokensVirtualList } from '../TokensVirtualList' -export interface TokensContentProps { - displayLpTokenLists?: boolean - selectTokenContext: SelectTokenContext - favoriteTokens: TokenWithLogo[] - selectedToken?: Nullish - hideFavoriteTokensTooltip?: boolean - areTokensLoading: boolean - allTokens: TokenWithLogo[] - searchInput: string - standalone?: boolean - areTokensFromBridge: boolean +export function TokensContent(): ReactNode { + const { favoriteTokens, recentTokens, areTokensLoading, allTokens, searchInput } = useTokenListViewState() - onSelectToken(token: TokenWithLogo): void - onOpenManageWidget(): void -} + const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0 + const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0 + + const pinnedTokenKeys = useMemo(() => { + // Only hide "Recent" tokens from the main list. + // Favorite tokens should still appear in "All tokens" so they participate + // in balance-based sorting and show their balances. + if (!shouldShowRecentsInline) { + return undefined + } + + const pinned = new Set() + + if (shouldShowRecentsInline && recentTokens) { + recentTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) + } + + return pinned + }, [recentTokens, shouldShowRecentsInline]) + + const tokensWithoutPinned = useMemo(() => { + if (!pinnedTokenKeys) { + return allTokens + } + + return allTokens.filter((token) => !pinnedTokenKeys.has(getTokenUniqueKey(token))) + }, [allTokens, pinnedTokenKeys]) + + const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined + const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined + + if (areTokensLoading) { + return ( + + + + ) + } + + if (searchInput) { + return + } -export function TokensContent({ - selectTokenContext, - onSelectToken, - onOpenManageWidget, - selectedToken, - favoriteTokens, - hideFavoriteTokensTooltip, - areTokensLoading, - allTokens, - displayLpTokenLists, - searchInput, - standalone, - areTokensFromBridge, -}: TokensContentProps): ReactNode { return ( - <> - {!areTokensLoading && !!favoriteTokens.length && ( - <> - - - - - - )} - {areTokensLoading ? ( - - - - ) : ( - <> - {searchInput ? ( - - ) : ( - - )} - - )} - {!standalone && ( - <> - -
- - {' '} - - Manage Token Lists - - -
- - )} - + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx index f657eb27c19..dbbd2937542 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx @@ -2,45 +2,145 @@ import { ReactNode, useCallback, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' +import { getIsNativeToken } from '@cowprotocol/common-utils' +import { t } from '@lingui/core/macro' import { VirtualItem } from '@tanstack/react-virtual' import { CoWAmmBanner } from 'common/containers/CoWAmmBanner' import { VirtualList } from 'common/pure/VirtualList' +import { useTokenListViewState } from '../../hooks/useTokenListViewState' import { SelectTokenContext } from '../../types' import { tokensListSorter } from '../../utils/tokensListSorter' +import { FavoriteTokensList } from '../FavoriteTokensList' +import * as modalStyled from '../SelectTokenModal/styled' import { TokenListItemContainer } from '../TokenListItemContainer' export interface TokensVirtualListProps { - allTokens: TokenWithLogo[] - displayLpTokenLists?: boolean - selectTokenContext: SelectTokenContext + tokensToDisplay: TokenWithLogo[] // Pre-filtered by parent + favoriteTokens?: TokenWithLogo[] + recentTokens?: TokenWithLogo[] } -export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { - const { allTokens, selectTokenContext, displayLpTokenLists } = props +type TokensVirtualRow = + | { type: 'favorite-section'; tokens: TokenWithLogo[]; hideTooltip?: boolean } + | { type: 'title'; label: string; actionLabel?: string; onAction?: () => void } + | { type: 'token'; token: TokenWithLogo } + +export function TokensVirtualList({ + tokensToDisplay, + favoriteTokens, + recentTokens, +}: TokensVirtualListProps): ReactNode { + const { + selectTokenContext, + displayLpTokenLists, + hideFavoriteTokensTooltip, + selectedTargetChainId, + onClearRecentTokens, + } = useTokenListViewState() + const { values: balances } = selectTokenContext.balancesState const { isYieldEnabled } = useFeatureFlags() - const sortedTokens = useMemo( - () => (balances ? allTokens.sort(tokensListSorter(balances)) : allTokens), - [allTokens, balances], - ) + const sortedTokens = useMemo(() => { + if (!balances) { + return tokensToDisplay + } - const getItemView = useCallback( - (sortedTokens: TokenWithLogo[], virtualRow: VirtualItem) => { - const token = sortedTokens[virtualRow.index] + const prioritized: TokenWithLogo[] = [] + const remainder: TokenWithLogo[] = [] + + for (const token of tokensToDisplay) { + const hasBalance = Boolean(balances[token.address.toLowerCase()]) + if (hasBalance || getIsNativeToken(token)) { + prioritized.push(token) + } else { + remainder.push(token) + } + } + + const sortedPrioritized = prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized + + return [...sortedPrioritized, ...remainder] + }, [tokensToDisplay, balances]) + + const rows = useMemo(() => { + const tokenRows = sortedTokens.map((token) => ({ type: 'token', token })) + const composedRows: TokensVirtualRow[] = [] + + if (favoriteTokens?.length) { + composedRows.push({ + type: 'favorite-section', + tokens: favoriteTokens, + hideTooltip: hideFavoriteTokensTooltip, + }) + } - return - }, + if (recentTokens?.length) { + composedRows.push({ + type: 'title', + label: t`Recent`, + actionLabel: onClearRecentTokens ? t`Clear` : undefined, + onAction: onClearRecentTokens, + }) + recentTokens.forEach((token) => composedRows.push({ type: 'token', token })) + } + + if (favoriteTokens?.length || recentTokens?.length) { + composedRows.push({ type: 'title', label: t`All tokens` }) + } + + return [...composedRows, ...tokenRows] + }, [favoriteTokens, hideFavoriteTokensTooltip, onClearRecentTokens, recentTokens, sortedTokens]) + + const virtualListKey = selectedTargetChainId ?? 'tokens-list' + + const getItemView = useCallback( + (virtualRows: TokensVirtualRow[], virtualItem: VirtualItem) => ( + + ), [selectTokenContext], ) return ( - + {displayLpTokenLists || !isYieldEnabled ? null : } ) } + +interface TokensVirtualRowRendererProps { + row: TokensVirtualRow + selectTokenContext: SelectTokenContext +} + +function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowRendererProps): ReactNode { + switch (row.type) { + case 'favorite-section': + return ( + + ) + case 'title': + return ( + + {row.label} + {row.actionLabel && row.onAction ? ( + + {row.actionLabel} + + ) : null} + + ) + default: + return + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts index d8cb8eadc39..fda562f5a37 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts @@ -10,6 +10,8 @@ import { Nullish } from 'types' import { Field } from 'legacy/state/types' +import { TradeType } from 'modules/trade/types' + interface SelectTokenWidgetState { open: boolean field?: Field @@ -21,6 +23,8 @@ interface SelectTokenWidgetState { onSelectToken?: (currency: Currency) => void onInputPressEnter?: Command selectedTargetChainId?: number + tradeType?: TradeType + forceOpen?: boolean } export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = { @@ -32,6 +36,8 @@ export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = { listToImport: undefined, selectedPoolAddress: undefined, selectedTargetChainId: undefined, + tradeType: undefined, + forceOpen: false, } export const { atom: selectTokenWidgetAtom, updateAtom: updateSelectTokenWidgetAtom } = atomWithPartialUpdate( diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/tokenListViewAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/state/tokenListViewAtom.ts new file mode 100644 index 00000000000..55926ed3631 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/state/tokenListViewAtom.ts @@ -0,0 +1,63 @@ +import { atom } from 'jotai' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { atomWithPartialUpdate } from '@cowprotocol/common-utils' + +import { SelectTokenContext } from '../types' + +export interface TokenListViewState { + // Token data + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + recentTokens: TokenWithLogo[] | undefined + + // UI state + searchInput: string + areTokensLoading: boolean + areTokensFromBridge: boolean + hideFavoriteTokensTooltip: boolean + displayLpTokenLists: boolean + selectedTargetChainId: number | undefined + + // Context - never null, use safe empty defaults + selectTokenContext: SelectTokenContext + + // Callbacks + onClearRecentTokens: (() => void) | undefined +} + +// Safe empty context that won't crash on access +// BalancesState requires: values, isLoading, chainId, fromCache +const EMPTY_SELECT_TOKEN_CONTEXT: SelectTokenContext = { + balancesState: { + values: {}, + isLoading: false, + chainId: null, + fromCache: false, + }, + selectedToken: undefined, + onSelectToken: () => {}, + onTokenListItemClick: undefined, + unsupportedTokens: {}, + permitCompatibleTokens: {}, + tokenListTags: {}, + isWalletConnected: false, +} + +export const DEFAULT_TOKEN_LIST_VIEW_STATE: TokenListViewState = { + allTokens: [], + favoriteTokens: [], + recentTokens: undefined, + searchInput: '', + areTokensLoading: true, // Default to loading to avoid flash of empty state + areTokensFromBridge: false, + hideFavoriteTokensTooltip: false, + displayLpTokenLists: false, + selectedTargetChainId: undefined, + selectTokenContext: EMPTY_SELECT_TOKEN_CONTEXT, + onClearRecentTokens: undefined, +} + +export const { atom: tokenListViewAtom, updateAtom: updateTokenListViewAtom } = atomWithPartialUpdate( + atom(DEFAULT_TOKEN_LIST_VIEW_STATE), +) diff --git a/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts b/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts new file mode 100644 index 00000000000..05c88af1966 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts @@ -0,0 +1,103 @@ +import { ALL_SUPPORTED_CHAINS_MAP, ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' + +export function createChainInfoForTests(baseChainId: SupportedChainId, overrides?: Partial): ChainInfo { + const base = ALL_SUPPORTED_CHAINS_MAP[baseChainId] + + if (!base) { + throw new Error(`Missing base chain definition for ${baseChainId}`) + } + + return buildChainInfo(base, overrides) +} + +function buildChainInfo(base: ChainInfo, overrides: Partial | undefined): ChainInfo { + const chainId = resolveChainId(base, overrides) + + return { + ...base, + ...overrides, + id: chainId, + contracts: resolveContracts(base, overrides), + bridges: resolveBridges(base, overrides), + rpcUrls: resolveRpcUrls(base, overrides), + logo: resolveLogo(base, overrides), + docs: resolveDocs(base, overrides), + website: resolveWebsite(base, overrides), + blockExplorer: resolveBlockExplorer(base, overrides), + nativeCurrency: resolveNativeCurrency(base, overrides, chainId), + } +} + +function resolveChainId(base: ChainInfo, overrides: Partial | undefined): ChainInfo['id'] { + return overrides?.id ?? base.id +} + +function resolveContracts(base: ChainInfo, overrides: Partial | undefined): ChainInfo['contracts'] { + const merged = overrides?.contracts + + return merged ? { ...base.contracts, ...merged } : { ...base.contracts } +} + +function resolveBridges(base: ChainInfo, overrides: Partial | undefined): ChainInfo['bridges'] { + const bridges = overrides?.bridges ?? base.bridges + + return bridges?.map(cloneBridge) +} + +function resolveRpcUrls(base: ChainInfo, overrides: Partial | undefined): ChainInfo['rpcUrls'] { + return cloneRpcUrls(overrides?.rpcUrls ?? base.rpcUrls) +} + +function resolveLogo(base: ChainInfo, overrides: Partial | undefined): ChainInfo['logo'] { + return cloneThemedImage(overrides?.logo ?? base.logo) +} + +function resolveDocs(base: ChainInfo, overrides: Partial | undefined): ChainInfo['docs'] { + return cloneWebUrl(overrides?.docs ?? base.docs) +} + +function resolveWebsite(base: ChainInfo, overrides: Partial | undefined): ChainInfo['website'] { + return cloneWebUrl(overrides?.website ?? base.website) +} + +function resolveBlockExplorer(base: ChainInfo, overrides: Partial | undefined): ChainInfo['blockExplorer'] { + return cloneWebUrl(overrides?.blockExplorer ?? base.blockExplorer) +} + +function resolveNativeCurrency( + base: ChainInfo, + overrides: Partial | undefined, + chainId: ChainInfo['id'], +): ChainInfo['nativeCurrency'] { + return { + ...base.nativeCurrency, + ...(overrides?.nativeCurrency ?? {}), + chainId, + } +} + +function cloneBridge(bridge: NonNullable[number]): NonNullable[number] { + return { ...bridge } +} + +function cloneRpcUrls(rpcUrls: ChainInfo['rpcUrls']): ChainInfo['rpcUrls'] { + return Object.entries(rpcUrls).reduce( + (acc, [key, value]) => { + acc[key] = { + http: [...value.http], + ...(value.webSocket ? { webSocket: [...value.webSocket] } : {}), + } + + return acc + }, + {} as ChainInfo['rpcUrls'], + ) +} + +function cloneThemedImage(image: ChainInfo['logo']): ChainInfo['logo'] { + return { ...image } +} + +function cloneWebUrl(webUrl: T): T { + return { ...webUrl } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/types.ts b/apps/cowswap-frontend/src/modules/tokensList/types.ts index 5c775d8e0a7..8446f18d61e 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/types.ts @@ -8,11 +8,14 @@ import { Nullish } from 'types' import { PermitCompatibleTokens } from 'modules/permit' +export type TokenSelectionHandler = (token: TokenWithLogo) => Promise | void + export interface SelectTokenContext { balancesState: BalancesState selectedToken?: Nullish - onSelectToken(token: TokenWithLogo): void + onSelectToken: TokenSelectionHandler + onTokenListItemClick?(token: TokenWithLogo): void unsupportedTokens: { [tokenAddress: string]: { dateAdded: number } } permitCompatibleTokens: PermitCompatibleTokens @@ -24,4 +27,6 @@ export interface ChainsToSelectState { chains: ChainInfo[] | undefined defaultChainId?: number isLoading?: boolean + disabledChainIds?: Set + loadingChainIds?: Set } diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts new file mode 100644 index 00000000000..59d30cd945d --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts @@ -0,0 +1,52 @@ +import { SORTED_CHAIN_IDS } from '@cowprotocol/common-const' +import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' + +const CHAIN_ORDER = SORTED_CHAIN_IDS.reduce>((acc, chainId, index) => { + acc[chainId] = index + return acc +}, {} as Record) + +interface SortOptions { + pinChainId?: ChainInfo['id'] +} + +/** + * Sorts a list of chains so it matches the canonical network selector order. + * Optionally promotes the provided `pinChainId` to the first position. + */ +export function sortChainsByDisplayOrder(chains: ChainInfo[], options?: SortOptions): ChainInfo[] { + if (chains.length <= 1) { + return chains.slice() + } + + const weightedChains = chains.map((chain, index) => ({ + chain, + weight: CHAIN_ORDER[chain.id as SupportedChainId] ?? Number.MAX_SAFE_INTEGER, + index, + })) + + weightedChains.sort((a, b) => { + if (a.weight === b.weight) { + return a.index - b.index + } + + return a.weight - b.weight + }) + + const orderedChains = weightedChains.map((entry) => entry.chain) + + if (!options?.pinChainId) { + return orderedChains + } + + const pinIndex = orderedChains.findIndex((chain) => chain.id === options.pinChainId) + + if (pinIndex <= 0) { + return orderedChains + } + + const [pinnedChain] = orderedChains.splice(pinIndex, 1) + orderedChains.unshift(pinnedChain) + + return orderedChains +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts new file mode 100644 index 00000000000..5abaeeaeea3 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts @@ -0,0 +1,8 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' +import { getTokenId } from '@cowprotocol/common-utils' + +type TokenIdentifier = Pick + +export function getTokenUniqueKey(token: TokenIdentifier): string { + return getTokenId(token) +} diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx index 7ef3d22c605..33f00a1c901 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx @@ -1,7 +1,7 @@ import React, { ReactNode, useCallback, useMemo } from 'react' import ICON_ORDERS from '@cowprotocol/assets/svg/orders.svg' -import { useFeatureFlags, useTheme } from '@cowprotocol/common-hooks' +import { useFeatureFlags, useIsBridgingEnabled, useTheme } from '@cowprotocol/common-hooks' import { isInjectedWidget, maxAmountSpend } from '@cowprotocol/common-utils' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { ButtonOutlined, Media, MY_ORDERS_ID, SWAP_HEADER_OFFSET } from '@cowprotocol/ui' @@ -69,7 +69,8 @@ export function TradeWidgetForm(props: TradeWidgetProps): ReactNode { const isLimitOrderTrade = tradeTypeInfo?.tradeType === TradeType.LIMIT_ORDER const shouldLockForAlternativeOrder = isAlternativeOrderModalVisible && isLimitOrderTrade const isWrapOrUnwrap = useIsWrapOrUnwrap() - const { isLimitOrdersUpgradeBannerEnabled, isBridgingEnabled } = useFeatureFlags() + const { isLimitOrdersUpgradeBannerEnabled } = useFeatureFlags() + const isBridgingEnabled = useIsBridgingEnabled() const isCurrentTradeBridging = useIsCurrentTradeBridging() const { darkMode } = useTheme() diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx index d1a1a34f8af..8052c0f2ad3 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx @@ -1,5 +1,6 @@ import { ReactNode, useCallback, useEffect, useRef } from 'react' +import { usePrevious } from '@cowprotocol/common-hooks' import { useAddUserToken } from '@cowprotocol/tokens' import { useWalletInfo } from '@cowprotocol/wallet' @@ -15,10 +16,9 @@ import { import { useTradeApproveState } from 'modules/erc20Approve/state/useTradeApproveState' import { ImportTokenModal, - SelectTokenWidget, + useCloseTokenSelectWidget, useSelectTokenWidgetState, useTokenListAddingError, - useUpdateSelectTokenWidgetState, } from 'modules/tokensList' import { useZeroApproveModalState, ZeroApprovalModal } from 'modules/zeroApproval' @@ -34,22 +34,17 @@ import { WrapNativeModal } from '../WrapNativeModal' interface TradeWidgetModalsProps { confirmModal: ReactNode | undefined genericModal: ReactNode | undefined - selectTokenWidget: ReactNode | undefined } // todo refactor it -// eslint-disable-next-line complexity,max-lines-per-function -export function TradeWidgetModals({ - confirmModal, - genericModal, - selectTokenWidget = , -}: TradeWidgetModalsProps): ReactNode { +// eslint-disable-next-line max-lines-per-function +export function TradeWidgetModals({ confirmModal, genericModal }: TradeWidgetModalsProps): ReactNode { const { chainId, account } = useWalletInfo() const { state: rawState } = useTradeState() const importTokenCallback = useAddUserToken() const { isOpen: isTradeReviewOpen, error: confirmError, pendingTrade } = useTradeConfirmState() - const { open: isTokenSelectOpen, field } = useSelectTokenWidgetState() + const { field } = useSelectTokenWidgetState() const [{ isOpen: isWrapNativeOpen }, setWrapNativeScreenState] = useWrapNativeScreenState() const { approveInProgress, @@ -67,16 +62,16 @@ export function TradeWidgetModals({ } = useAutoImportTokensState(rawState?.inputCurrencyId, rawState?.outputCurrencyId) const { onDismiss: closeTradeConfirm } = useTradeConfirmActions() - const updateSelectTokenWidgetState = useUpdateSelectTokenWidgetState() + const closeTokenSelectWidget = useCloseTokenSelectWidget() const resetApproveModalState = useResetApproveProgressModalState() const updateApproveAmountState = useSetUserApproveAmountModalState() const resetAllScreens = useCallback( - (closeTokenSelectWidget = true, shouldCloseAutoImportModal = true) => { + (shouldCloseTokenSelectWidget = true, shouldCloseAutoImportModal = true) => { closeTradeConfirm() closeZeroApprovalModal() if (shouldCloseAutoImportModal) closeAutoImportModal() - if (closeTokenSelectWidget) updateSelectTokenWidgetState({ open: false }) + if (shouldCloseTokenSelectWidget) closeTokenSelectWidget() setWrapNativeScreenState({ isOpen: false }) resetApproveModalState() setTokenListAddingError(null) @@ -86,7 +81,7 @@ export function TradeWidgetModals({ closeTradeConfirm, closeZeroApprovalModal, closeAutoImportModal, - updateSelectTokenWidgetState, + closeTokenSelectWidget, setWrapNativeScreenState, resetApproveModalState, updateApproveAmountState, @@ -95,8 +90,9 @@ export function TradeWidgetModals({ ) const isOutputTokenSelector = field === Field.OUTPUT - const isOutputTokenSelectorRef = useRef(isOutputTokenSelector) - isOutputTokenSelectorRef.current = isOutputTokenSelector + const previousIsOutputTokenSelector = usePrevious(isOutputTokenSelector) + const previousChainId = usePrevious(chainId) + const isInitialRenderRef = useRef(true) const error = tokenListAddingError || approveError || confirmError @@ -112,8 +108,20 @@ export function TradeWidgetModals({ * Because network might be changed from the widget inside */ useEffect(() => { - resetAllScreens(isOutputTokenSelectorRef.current) - }, [chainId, resetAllScreens]) + const chainChanged = previousChainId !== chainId + + if (!chainChanged && !isInitialRenderRef.current) { + return + } + + isInitialRenderRef.current = false + + const shouldCloseTokenSelectWidget = chainChanged + ? isOutputTokenSelector + : (previousIsOutputTokenSelector ?? isOutputTokenSelector) + + resetAllScreens(shouldCloseTokenSelectWidget) + }, [chainId, isOutputTokenSelector, previousChainId, previousIsOutputTokenSelector, resetAllScreens]) if (genericModal) { return genericModal @@ -127,10 +135,6 @@ export function TradeWidgetModals({ return } - if (isTokenSelectOpen) { - return selectTokenWidget - } - if (isAutoImportModalOpen) { return } diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx index a2fa5606d57..804ecf787b3 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx @@ -1,6 +1,6 @@ import { JSX, useEffect } from 'react' -import { useSelectTokenWidgetState } from 'modules/tokensList' +import { SelectTokenWidget, useChainsToSelect, useSelectTokenWidgetState } from 'modules/tokensList' import { useSetShouldUseAutoSlippage } from 'modules/tradeSlippage' import * as styledEl from './styled' @@ -17,8 +17,13 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element { disableSuggestedSlippageApi = false, enableSmartSlippage, } = params - const modals = TradeWidgetModals({ confirmModal, genericModal, selectTokenWidget: slots.selectTokenWidget }) + const modals = TradeWidgetModals({ confirmModal, genericModal }) const { open: isTokenSelectOpen } = useSelectTokenWidgetState() + const chainsToSelect = useChainsToSelect() + const isTokenSelectWide = + isTokenSelectOpen && !!chainsToSelect && (chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0) + + const selectTokenWidgetNode = slots.selectTokenWidget ?? const setShouldUseAutoSlippage = useSetShouldUseAutoSlippage() @@ -27,17 +32,21 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element { }, [enableSmartSlippage, setShouldUseAutoSlippage]) return ( - - - {slots.updaters} - - - {modals || } - + <> + + + {slots.updaters} + + + {modals || } + + + {selectTokenWidgetNode} + ) } diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx index 2e93ac29d82..a58cfce14d0 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx @@ -3,9 +3,19 @@ import { UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' import { WIDGET_MAX_WIDTH } from 'theme' -export const Container = styled.div<{ isTokenSelectOpen?: boolean }>` +type ContainerSizeProps = { isTokenSelectOpen?: boolean; isTokenSelectWide?: boolean } + +const getContainerMaxWidth = ({ isTokenSelectOpen, isTokenSelectWide }: ContainerSizeProps): string => { + if (!isTokenSelectOpen) { + return WIDGET_MAX_WIDTH.swap + } + + return isTokenSelectWide ? WIDGET_MAX_WIDTH.tokenSelectSidebar : WIDGET_MAX_WIDTH.tokenSelect +} + +export const Container = styled.div` width: 100%; - max-width: ${({ isTokenSelectOpen }) => (isTokenSelectOpen ? WIDGET_MAX_WIDTH.tokenSelect : WIDGET_MAX_WIDTH.swap)}; + max-width: ${getContainerMaxWidth}; margin: 0 auto; position: relative; ` diff --git a/apps/explorer/src/hooks/useTokenList.ts b/apps/explorer/src/hooks/useTokenList.ts index 8d3625dc338..ca47e4a0e2d 100644 --- a/apps/explorer/src/hooks/useTokenList.ts +++ b/apps/explorer/src/hooks/useTokenList.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { COW_CDN, SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const' +import { getTokenAddressKey } from '@cowprotocol/common-utils' import { ALL_SUPPORTED_CHAIN_IDS, mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk' import type { TokenInfo, TokenList } from '@uniswap/token-lists' @@ -65,7 +66,7 @@ export function useTokenList(chainId: SupportedChainId | undefined): { data: Tok const nativeToken = NATIVE_TOKEN_PER_NETWORK[chainId] - data[NATIVE_TOKEN_ADDRESS.toLowerCase()] = { + data[getTokenAddressKey(NATIVE_TOKEN_ADDRESS)] = { ...nativeToken, name: nativeToken.name || '', symbol: nativeToken.symbol || '', @@ -93,7 +94,7 @@ function fetcher(tokenListUrl: string): Promise { tokens.reduce((acc, token) => { // Pick only supported chains if (SUPPORTED_CHAIN_IDS_SET.has(token.chainId)) { - acc[token.chainId][token.address.toLowerCase()] = token + acc[token.chainId][getTokenAddressKey(token.address)] = token } return acc }, INITIAL_TOKEN_LIST_PER_NETWORK), diff --git a/libs/common-utils/src/areAddressesEqual.ts b/libs/common-utils/src/areAddressesEqual.ts index 41765bee726..5cd80b3e9b5 100644 --- a/libs/common-utils/src/areAddressesEqual.ts +++ b/libs/common-utils/src/areAddressesEqual.ts @@ -1,7 +1,10 @@ import { Nullish } from '@cowprotocol/types' +import { getTokenAddressKey } from './tokens' + export function areAddressesEqual(a: Nullish, b: Nullish): boolean { - if ((a && !b) || (!a && b)) return false + if (!a && !b) return true + if (!a || !b) return false - return a?.toLowerCase() === b?.toLowerCase() + return getTokenAddressKey(a) === getTokenAddressKey(b) } diff --git a/libs/common-utils/src/tokens.ts b/libs/common-utils/src/tokens.ts index a3a5287030d..53a69fb205f 100644 --- a/libs/common-utils/src/tokens.ts +++ b/libs/common-utils/src/tokens.ts @@ -14,3 +14,16 @@ export function isNativeAddress(tokenAddress: string, chainId: ChainId): boolean return native && doesTokenMatchSymbolOrAddress(native, tokenAddressLower) } + +export function getTokenAddressKey(address: string): string { + return address.toLowerCase() +} + +export interface TokenIdentifier { + address: string + chainId: number +} + +export function getTokenId(token: TokenIdentifier): string { + return `${token.chainId}:${getTokenAddressKey(token.address)}` +}