diff --git a/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts b/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts
index 55445e7a628..f4d44e216b0 100644
--- a/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts
+++ b/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts
@@ -1,4 +1,5 @@
export { useBridgeSupportedNetworks, useBridgeSupportedNetwork } from './useBridgeSupportedNetworks'
export { useBridgeSupportedTokens } from './useBridgeSupportedTokens'
+export { useRoutesAvailability } from './useRoutesAvailability'
export { useHasHookBridgeProvidersEnabled } from './useHasHookBridgeProvidersEnabled'
export { BridgeProvidersUpdater } from './BridgeProvidersUpdater'
diff --git a/apps/cowswap-frontend/src/entities/bridgeProvider/routesAvailabilityUtils.ts b/apps/cowswap-frontend/src/entities/bridgeProvider/routesAvailabilityUtils.ts
new file mode 100644
index 00000000000..f3d45940515
--- /dev/null
+++ b/apps/cowswap-frontend/src/entities/bridgeProvider/routesAvailabilityUtils.ts
@@ -0,0 +1,107 @@
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { bridgingSdk } from 'tradingSdk/bridgingSdk'
+
+export interface RoutesAvailabilityResult {
+ unavailableChainIds: Set
+ loadingChainIds: Set
+ isLoading: boolean
+}
+
+export interface RouteCheckResult {
+ chainId: number
+ isAvailable: boolean
+}
+
+export const EMPTY_ROUTES_RESULT: RoutesAvailabilityResult = {
+ unavailableChainIds: new Set(),
+ loadingChainIds: new Set(),
+ isLoading: false,
+}
+
+export function filterDestinationChains(destinationChainIds: number[], sourceChainId: number | undefined): number[] {
+ return destinationChainIds.filter((id) => id !== sourceChainId)
+}
+
+export type RoutesAvailabilityKey = [SupportedChainId, string, string, string]
+
+export interface RoutesAvailabilityKeyParams {
+ isBridgingEnabled: boolean
+ sourceChainId: SupportedChainId | undefined
+ chainsToCheck: number[]
+ providersKey: string
+}
+
+export function createAvailabilitySwrKey(params: RoutesAvailabilityKeyParams): RoutesAvailabilityKey | null {
+ const { isBridgingEnabled, sourceChainId, chainsToCheck, providersKey } = params
+
+ if (!isBridgingEnabled || !sourceChainId || chainsToCheck.length === 0) {
+ return null
+ }
+
+ return [
+ sourceChainId,
+ chainsToCheck
+ .slice()
+ .sort((a, b) => a - b)
+ .join(','),
+ providersKey,
+ 'routesAvailability',
+ ]
+}
+
+export async function fetchRoutesAvailability(key: RoutesAvailabilityKey): Promise {
+ const [sellChainId, chainIdsString] = key
+ const chainIds = chainIdsString.split(',').map(Number)
+
+ return Promise.all(chainIds.map((buyChainId) => checkSingleRouteAvailability(sellChainId, buyChainId)))
+}
+
+async function checkSingleRouteAvailability(
+ sellChainId: SupportedChainId,
+ 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(`[routesAvailability] Failed to check route ${sellChainId} -> ${buyChainId}`, error)
+
+ return { chainId: buyChainId, isAvailable: false }
+ }
+}
+
+export interface BuildResultParams {
+ swrKey: RoutesAvailabilityKey | null
+ isLoading: boolean
+ data: RouteCheckResult[] | undefined
+ chainsToCheck: number[]
+}
+
+export function buildRoutesAvailabilityResult(params: BuildResultParams): RoutesAvailabilityResult {
+ const { swrKey, isLoading, data, chainsToCheck } = params
+
+ if (!swrKey) {
+ return EMPTY_ROUTES_RESULT
+ }
+
+ if (isLoading || !data) {
+ 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,
+ }
+}
diff --git a/apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.test.ts b/apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.test.ts
new file mode 100644
index 00000000000..7ec2fbaf9a8
--- /dev/null
+++ b/apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.test.ts
@@ -0,0 +1,164 @@
+import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { renderHook, waitFor } from '@testing-library/react'
+import { bridgingSdk } from 'tradingSdk/bridgingSdk'
+
+import { useBridgeProvidersIds } from './useBridgeProvidersIds'
+import { useRoutesAvailability } from './useRoutesAvailability'
+
+// Mock dependencies
+jest.mock('@cowprotocol/common-hooks', () => ({
+ useIsBridgingEnabled: jest.fn(),
+}))
+
+jest.mock('./useBridgeProvidersIds', () => ({
+ useBridgeProvidersIds: jest.fn(),
+}))
+
+jest.mock('tradingSdk/bridgingSdk', () => ({
+ bridgingSdk: {
+ getBuyTokens: jest.fn(),
+ },
+}))
+
+const mockUseIsBridgingEnabled = useIsBridgingEnabled as jest.Mock
+const mockUseBridgeProvidersIds = useBridgeProvidersIds as jest.Mock
+const mockGetBuyTokens = bridgingSdk.getBuyTokens as jest.Mock
+
+let testId = 0
+
+describe('useRoutesAvailability', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseIsBridgingEnabled.mockReturnValue(true)
+ // Use unique provider IDs per test to avoid SWR cache conflicts
+ testId++
+ mockUseBridgeProvidersIds.mockReturnValue([`provider-${testId}`])
+ })
+
+ it('returns empty result when bridging is disabled', () => {
+ mockUseIsBridgingEnabled.mockReturnValue(false)
+
+ const { result } = renderHook(() =>
+ useRoutesAvailability(SupportedChainId.MAINNET, [SupportedChainId.GNOSIS_CHAIN]),
+ )
+
+ expect(result.current).toEqual({
+ unavailableChainIds: new Set(),
+ loadingChainIds: new Set(),
+ isLoading: false,
+ })
+ })
+
+ it('returns empty result when sourceChainId is undefined', () => {
+ const { result } = renderHook(() => useRoutesAvailability(undefined, [SupportedChainId.GNOSIS_CHAIN]))
+
+ expect(result.current).toEqual({
+ unavailableChainIds: new Set(),
+ loadingChainIds: new Set(),
+ isLoading: false,
+ })
+ })
+
+ it('returns empty result when destinationChainIds is empty', () => {
+ const { result } = renderHook(() => useRoutesAvailability(SupportedChainId.MAINNET, []))
+
+ expect(result.current).toEqual({
+ unavailableChainIds: new Set(),
+ loadingChainIds: new Set(),
+ isLoading: false,
+ })
+ })
+
+ it('excludes source chain from chains to check', async () => {
+ mockGetBuyTokens.mockResolvedValue({ tokens: ['token1'], isRouteAvailable: true })
+
+ renderHook(() =>
+ useRoutesAvailability(SupportedChainId.MAINNET, [
+ SupportedChainId.MAINNET, // same as source, should be excluded
+ SupportedChainId.GNOSIS_CHAIN,
+ ]),
+ )
+
+ await waitFor(() => {
+ expect(mockGetBuyTokens).toHaveBeenCalledTimes(1)
+ expect(mockGetBuyTokens).toHaveBeenCalledWith({
+ sellChainId: SupportedChainId.MAINNET,
+ buyChainId: SupportedChainId.GNOSIS_CHAIN,
+ })
+ })
+ })
+
+ it('marks chains as unavailable when route check fails', async () => {
+ mockGetBuyTokens.mockRejectedValue(new Error('Network error'))
+
+ const { result } = renderHook(() =>
+ useRoutesAvailability(SupportedChainId.MAINNET, [SupportedChainId.GNOSIS_CHAIN]),
+ )
+
+ await waitFor(() => {
+ expect(result.current.unavailableChainIds.has(SupportedChainId.GNOSIS_CHAIN)).toBe(true)
+ expect(result.current.isLoading).toBe(false)
+ })
+ })
+
+ it('marks chains as unavailable when no tokens available', async () => {
+ mockGetBuyTokens.mockResolvedValue({ tokens: [], isRouteAvailable: true })
+
+ const { result } = renderHook(() =>
+ useRoutesAvailability(SupportedChainId.MAINNET, [SupportedChainId.GNOSIS_CHAIN]),
+ )
+
+ await waitFor(() => {
+ expect(result.current.unavailableChainIds.has(SupportedChainId.GNOSIS_CHAIN)).toBe(true)
+ expect(result.current.isLoading).toBe(false)
+ })
+ })
+
+ it('marks chains as unavailable when route is not available', async () => {
+ mockGetBuyTokens.mockResolvedValue({ tokens: ['token1'], isRouteAvailable: false })
+
+ const { result } = renderHook(() =>
+ useRoutesAvailability(SupportedChainId.MAINNET, [SupportedChainId.GNOSIS_CHAIN]),
+ )
+
+ await waitFor(() => {
+ expect(result.current.unavailableChainIds.has(SupportedChainId.GNOSIS_CHAIN)).toBe(true)
+ expect(result.current.isLoading).toBe(false)
+ })
+ })
+
+ it('marks chains as available when route exists', async () => {
+ mockGetBuyTokens.mockResolvedValue({ tokens: ['token1'], isRouteAvailable: true })
+
+ const { result } = renderHook(() =>
+ useRoutesAvailability(SupportedChainId.MAINNET, [SupportedChainId.GNOSIS_CHAIN]),
+ )
+
+ await waitFor(() => {
+ expect(result.current.unavailableChainIds.has(SupportedChainId.GNOSIS_CHAIN)).toBe(false)
+ expect(result.current.isLoading).toBe(false)
+ })
+ })
+
+ it('checks multiple destination chains in parallel', async () => {
+ // Mock based on buyChainId parameter
+ mockGetBuyTokens.mockImplementation(({ buyChainId }) => {
+ if (buyChainId === SupportedChainId.GNOSIS_CHAIN) {
+ return Promise.resolve({ tokens: ['token1'], isRouteAvailable: true })
+ }
+ return Promise.resolve({ tokens: [], isRouteAvailable: false })
+ })
+
+ const { result } = renderHook(() =>
+ useRoutesAvailability(SupportedChainId.MAINNET, [SupportedChainId.GNOSIS_CHAIN, SupportedChainId.ARBITRUM_ONE]),
+ )
+
+ await waitFor(() => {
+ expect(mockGetBuyTokens).toHaveBeenCalledTimes(2)
+ expect(result.current.unavailableChainIds.has(SupportedChainId.ARBITRUM_ONE)).toBe(true)
+ expect(result.current.unavailableChainIds.has(SupportedChainId.GNOSIS_CHAIN)).toBe(false)
+ })
+ })
+})
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..ae8549a4b22
--- /dev/null
+++ b/apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.ts
@@ -0,0 +1,54 @@
+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 {
+ buildRoutesAvailabilityResult,
+ createAvailabilitySwrKey,
+ fetchRoutesAvailability,
+ filterDestinationChains,
+ RouteCheckResult,
+ RoutesAvailabilityKey,
+ RoutesAvailabilityResult,
+} from './routesAvailabilityUtils'
+import { useBridgeProvidersIds } from './useBridgeProvidersIds'
+
+export type { RoutesAvailabilityResult } from './routesAvailabilityUtils'
+
+/**
+ * Pre-checks route availability for multiple destination chains from a source chain.
+ * Returns which chains have unavailable routes and which are still loading.
+ */
+export function useRoutesAvailability(
+ sourceChainId: SupportedChainId | undefined,
+ destinationChainIds: number[],
+): RoutesAvailabilityResult {
+ const isBridgingEnabled = useIsBridgingEnabled()
+ const providerIds = useBridgeProvidersIds()
+ const providersKey = providerIds.join('|')
+
+ const chainsToCheck = useMemo(
+ () => filterDestinationChains(destinationChainIds, sourceChainId),
+ [destinationChainIds, sourceChainId],
+ )
+
+ const swrKey = useMemo(
+ () => createAvailabilitySwrKey({ isBridgingEnabled, sourceChainId, chainsToCheck, providersKey }),
+ [isBridgingEnabled, sourceChainId, chainsToCheck, providersKey],
+ )
+
+ const { data, isLoading } = useSWR(
+ swrKey,
+ fetchRoutesAvailability,
+ SWR_NO_REFRESH_OPTIONS,
+ )
+
+ return useMemo(
+ () => buildRoutesAvailabilityResult({ swrKey, isLoading, data, chainsToCheck }),
+ [swrKey, isLoading, data, chainsToCheck],
+ )
+}
diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po
index ff2fb779bcb..f0c6751d30b 100644
--- a/apps/cowswap-frontend/src/locales/en-US.po
+++ b/apps/cowswap-frontend/src/locales/en-US.po
@@ -279,6 +279,12 @@ msgstr "replaced"
msgid "Bridge via"
msgstr "Bridge via"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useHeaderState.ts
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/Header.tsx
+msgid "Select token"
+msgstr "Select token"
+
#: apps/cowswap-frontend/src/modules/trade/pure/LimitOrdersPromoBanner/index.tsx
msgid "Trade your way - personalize the interface and customize your limit orders"
msgstr "Trade your way - personalize the interface and customize your limit orders"
@@ -295,6 +301,10 @@ msgstr "EOA wallets"
msgid "Unwrapping {amountStr} {wrapped} to {native}"
msgstr "Unwrapping {amountStr} {wrapped} to {native}"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
+msgid "From network"
+msgstr "From network"
+
#: apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/index.tsx
msgid "Set any limit price and time horizon"
msgstr "Set any limit price and time horizon"
@@ -473,7 +483,6 @@ msgid "View details"
msgstr "View details"
#: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
-#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
msgid "More"
msgstr "More"
@@ -634,7 +643,7 @@ msgstr "I received surplus on"
msgid "View jobs"
msgstr "View jobs"
-#: apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/FavoriteTokensTooltip.tsx
msgid "Your favorite saved tokens. Edit this list in the <0>Tokens page0>."
msgstr "Your favorite saved tokens. Edit this list in the <0>Tokens page0>."
@@ -654,6 +663,10 @@ msgstr "Adding this app/hook grants it access to your wallet actions and trading
msgid "(View on Explorer)"
msgstr "(View on Explorer)"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+msgid "View all {totalChains} networks"
+msgstr "View all {totalChains} networks"
+
#: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx
#~ msgid "Loading {ACCOUNT_PROXY_LABEL}"
#~ msgstr "Loading {ACCOUNT_PROXY_LABEL}"
@@ -856,6 +869,10 @@ msgstr "Tokens"
msgid "Copied"
msgstr "Copied"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
+msgid "To network"
+msgstr "To network"
+
#: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
#~ msgid "Can't find your token on the list?"
#~ msgstr "Can't find your token on the list?"
@@ -864,6 +881,11 @@ msgstr "Copied"
msgid "icon"
msgstr "icon"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/Header.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/Header.tsx
+msgid "Manage token lists"
+msgstr "Manage token lists"
+
#: apps/cowswap-frontend/src/common/pure/CancellationModal/index.tsx
#~ msgid "Cancelling order with id {shortId}:<0/><1>{summary}1>"
#~ msgstr "Cancelling order with id {shortId}:<0/><1>{summary}1>"
@@ -981,7 +1003,7 @@ msgstr "Actions"
msgid "Min received (incl. costs)"
msgstr "Min received (incl. costs)"
-#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/Search.tsx
msgid "Search name or paste address..."
msgstr "Search name or paste address..."
@@ -1267,8 +1289,8 @@ msgid "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
-msgid "Manage Token Lists"
-msgstr "Manage Token Lists"
+#~ msgid "Manage Token Lists"
+#~ msgstr "Manage Token Lists"
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
msgid "No results found"
@@ -1416,6 +1438,10 @@ msgstr "Help Center"
#~ msgid "Bungee is a liquidity marketplace that lets you swap into any token on any chain in a fully abstracted manner. Trade any token with the best quotes and a gasless UX!"
#~ msgstr "Bungee is a liquidity marketplace that lets you swap into any token on any chain in a fully abstracted manner. Trade any token with the best quotes and a gasless UX!"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/tokensVirtualListUtils.ts
+msgid "Recent"
+msgstr "Recent"
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/tableHeaders.tsx
msgid "Market price"
msgstr "Market price"
@@ -1726,7 +1752,7 @@ msgstr "Unlimited one-time"
msgid "Use Safe web app"
msgstr "Use Safe web app"
-#: apps/cowswap-frontend/src/modules/orders/pure/ReceiverInfo/index.tsx
+#: apps/cowswap-frontend/src/common/pure/ReceiverInfo/index.tsx
msgid "Receiver"
msgstr "Receiver"
@@ -1925,6 +1951,10 @@ msgstr "Create a pool"
#~ msgid "Please connect your wallet to one of our supported networks:<0/>{chainLabels}"
#~ msgstr "Please connect your wallet to one of our supported networks:<0/>{chainLabels}"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+msgid "View all ({totalChains})"
+msgstr "View all ({totalChains})"
+
#: apps/cowswap-frontend/src/modules/ethFlow/pure/EthFlowModalContent/configs.ts
msgid "To continue, click SWAP below to use your existing {wrappedSymbol} balance and trade."
msgstr "To continue, click SWAP below to use your existing {wrappedSymbol} balance and trade."
@@ -2103,10 +2133,18 @@ msgstr "Reset USDT allowance to 0 before setting new spending cap"
#~ msgid "Enable notifications"
#~ msgstr "Enable notifications"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
+msgid "Sell token"
+msgstr "Sell token"
+
#: apps/cowswap-frontend/src/modules/account/containers/AccountDetails/AccountIcon.tsx
msgid "Warning sign. Wallet not supported"
msgstr "Warning sign. Wallet not supported"
+#: apps/cowswap-frontend/src/common/containers/CancellationModal/index.tsx
+msgid "to receiver"
+msgstr "to receiver"
+
#: apps/cowswap-frontend/src/modules/account/containers/Transaction/StatusDetails.tsx
msgid "Show progress"
msgstr "Show progress"
@@ -2139,6 +2177,10 @@ msgstr "available for your {poolName} pool"
msgid "Native"
msgstr "Native"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/AllTokens.tsx
+#~ msgid "Loading tokens..."
+#~ msgstr "Loading tokens..."
+
#: apps/cowswap-frontend/src/common/pure/CoWAmmBannerContent/GlobalContent/index.tsx
msgid "Pool analytics"
msgstr "Pool analytics"
@@ -2338,6 +2380,10 @@ msgstr "Dismiss hiring message"
msgid "dialog content"
msgstr "dialog content"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+#~ msgid "Cross chain swap"
+#~ msgstr "Cross chain swap"
+
#: apps/cowswap-frontend/src/modules/accountProxy/pure/FAQContent/index.tsx
msgid "Since {accountProxyLabelString} is not an upgradeable smart-contract, it can be versioned and there are"
msgstr "Since {accountProxyLabelString} is not an upgradeable smart-contract, it can be versioned and there are"
@@ -2539,8 +2585,8 @@ msgid "No order history"
msgstr "No order history"
#: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx
-msgid "Bridging without swapping is not yet supported. Let us know if you want this feature!"
-msgstr "Bridging without swapping is not yet supported. Let us know if you want this feature!"
+#~ msgid "Bridging without swapping is not yet supported. Let us know if you want this feature!"
+#~ msgstr "Bridging without swapping is not yet supported. Let us know if you want this feature!"
#: libs/common-utils/src/swapErrorToUserReadableMessage.tsx
msgid "The input token cannot be transferred. There may be an issue with the input token."
@@ -2558,6 +2604,10 @@ msgstr "All"
msgid "batched together"
msgstr "batched together"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
+msgid "Swap to"
+msgstr "Swap to"
+
#: apps/cowswap-frontend/src/modules/yield/containers/YieldWidget/elements.tsx
msgid "Select a pool"
msgstr "Select a pool"
@@ -3203,6 +3253,11 @@ msgstr "Search hooks"
msgid "down"
msgstr "down"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainButton.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/ChainChip.tsx
+msgid "This destination is not supported for this source chain"
+msgstr "This destination is not supported for this source chain"
+
#: apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/swapTradeButtonsMap.tsx
#~ msgid "Wrap <0/> and Swap"
#~ msgstr "Wrap <0/> and Swap"
@@ -3600,6 +3655,11 @@ msgstr "CoW Protocol covers the fees and costs by executing your order at a slig
msgid "With hooks you can add specific actions <0>before0> and <1>after1> your swap."
msgstr "With hooks you can add specific actions <0>before0> and <1>after1> your swap."
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/FavoriteTokens.tsx
+#~ msgid "Favorites"
+#~ msgstr "Favorites"
+
+#: apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenConsentFlow.tsx
#: apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokenImportStatus.ts
#: apps/cowswap-frontend/src/modules/tokensList/hooks/useRestrictedTokensImportStatus.ts
msgid "This token is not available in your region."
@@ -3862,6 +3922,10 @@ msgstr "connect wallet"
msgid "Transaction expiration"
msgstr "Transaction expiration"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "Search network"
+msgstr "Search network"
+
#: apps/cowswap-frontend/src/legacy/components/ErrorBoundary/ChunkLoadError.tsx
msgid "CowSwap no connection"
msgstr "CowSwap no connection"
@@ -4318,8 +4382,8 @@ msgid "View transaction"
msgstr "View transaction"
#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
-msgid "This list requires consent before importing."
-msgstr "This list requires consent before importing."
+#~ msgid "This list requires consent before importing."
+#~ msgstr "This list requires consent before importing."
#: apps/cowswap-frontend/src/modules/erc20Approve/containers/ActiveOrdersWithAffectedPermit/ActiveOrdersWithAffectedPermit.tsx
msgid "is"
@@ -4342,6 +4406,7 @@ msgstr "Enable partial approvals"
msgid "Version"
msgstr "Version"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/tokensVirtualListUtils.ts
#: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx
msgid "All tokens"
msgstr "All tokens"
@@ -4502,6 +4567,11 @@ msgstr "Your Metamask extension (<0>v{currentVersion}0>) is out of date. "
msgid "Turn on Expert mode?"
msgstr "Turn on Expert mode?"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainButton.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/ChainChip.tsx
+msgid "Checking route availability..."
+msgstr "Checking route availability..."
+
#: apps/cowswap-frontend/src/modules/hooksStore/pure/HookDappDetails/index.tsx
#: apps/cowswap-frontend/src/modules/hooksStore/pure/HookDetailHeader/index.tsx
#: apps/cowswap-frontend/src/modules/hooksStore/pure/HookListItem/index.tsx
@@ -4546,6 +4616,10 @@ msgstr "Decrease Value"
msgid "Balance"
msgstr "Balance"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "No networks match {chainQuery}."
+msgstr "No networks match {chainQuery}."
+
#: libs/common-utils/src/swapErrorToUserReadableMessage.tsx
msgid "The Uniswap invariant x*y=k was not satisfied by the swap. This usually means one of the tokens you are swapping incorporates custom behavior on transfer."
msgstr "The Uniswap invariant x*y=k was not satisfied by the swap. This usually means one of the tokens you are swapping incorporates custom behavior on transfer."
@@ -4944,6 +5018,10 @@ msgstr "Execution price"
msgid "No tokens found"
msgstr "No tokens found"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "No networks available for this trade."
+msgstr "No networks available for this trade."
+
#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenTags/index.tsx
msgid "Unsupported"
msgstr "Unsupported"
@@ -5028,6 +5106,10 @@ msgstr "Page {page} of {maxPage}"
msgid "Each time you claim, you will receive the entire claimable amount."
msgstr "Each time you claim, you will receive the entire claimable amount."
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+msgid "Selected network {activeChainLabel}"
+msgstr "Selected network {activeChainLabel}"
+
#: apps/cowswap-frontend/src/modules/tokensList/pure/ImportListModal/index.tsx
#: apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/index.tsx
#: apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenListItem/index.tsx
@@ -5183,6 +5265,10 @@ msgstr "Post Hooks"
msgid "Not compatible with current wallet type"
msgstr "Not compatible with current wallet type"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
+msgid "Swap from"
+msgstr "Swap from"
+
#: apps/cowswap-frontend/src/modules/twap/containers/TwapFormWidget/tooltips.tsx
#~ msgid "The \"Total duration\" is the duration it takes to execute all parts of your TWAP order.<0/><1/>For instance, your order consists of<2>{parts} parts2>placed every<3>{partDurationDisplay}3>, the total time to complete the order is<4>{totalDurationDisplay}4>. Each limit order remains open for<5>{partDurationDisplay}5>until the next part becomes active."
#~ msgstr "The \"Total duration\" is the duration it takes to execute all parts of your TWAP order.<0/><1/>For instance, your order consists of<2>{parts} parts2>placed every<3>{partDurationDisplay}3>, the total time to complete the order is<4>{totalDurationDisplay}4>. Each limit order remains open for<5>{partDurationDisplay}5>until the next part becomes active."
@@ -5312,6 +5398,10 @@ msgstr "The good news"
msgid "yield"
msgstr "yield"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
+msgid "Buy token"
+msgstr "Buy token"
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/TransactionSubmittedContent/SurplusModal.tsx
msgid "Swap completed"
msgstr "Swap completed"
@@ -5426,7 +5516,7 @@ msgstr "Error loading the claimable amount"
msgid "Attention"
msgstr "Attention"
-#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalContent.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/TokenList.tsx
msgid "This route is not yet supported."
msgstr "This route is not yet supported."
@@ -5499,6 +5589,13 @@ msgstr "Limit price"
msgid "Buy"
msgstr "Buy"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ChainSelector.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/DesktopChainPanel.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/NetworkPanel.tsx
+msgid "Select network"
+msgstr "Select network"
+
#: apps/cowswap-frontend/src/modules/hooksStore/dapps/BuildHookApp/index.tsx
#: apps/cowswap-frontend/src/modules/hooksStore/dapps/BuildHookApp/index.tsx
msgid "is required"
@@ -5686,8 +5783,8 @@ msgid "Advanced"
msgstr "Advanced"
#: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx
-msgid "Not yet supported"
-msgstr "Not yet supported"
+#~ msgid "Not yet supported"
+#~ msgstr "Not yet supported"
#: apps/cowswap-frontend/src/modules/hooksStore/pure/HookListItem/index.tsx
msgid "Remove"
@@ -6085,6 +6182,10 @@ msgstr "Simulate"
msgid "Start time"
msgstr "Start time"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/tokensVirtualListUtils.ts
+msgid "Clear"
+msgstr "Clear"
+
#: apps/cowswap-frontend/src/common/pure/OrderSubmittedContent/index.tsx
msgid "Continue"
msgstr "Continue"
@@ -6159,8 +6260,8 @@ msgid "You sold <0/>"
msgstr "You sold <0/>"
#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
-msgid "Less"
-msgstr "Less"
+#~ msgid "Less"
+#~ msgstr "Less"
#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
#~ msgid "Claim your LlamaPay vesting contract funds"
diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po
index 027137287db..cc3e65f30d2 100644
--- a/apps/cowswap-frontend/src/locales/es-ES.po
+++ b/apps/cowswap-frontend/src/locales/es-ES.po
@@ -4333,6 +4333,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"
@@ -5339,7 +5347,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:"
@@ -6449,3 +6457,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 631aae4bedb..1357ec457e2 100644
--- a/apps/cowswap-frontend/src/locales/ru-RU.po
+++ b/apps/cowswap-frontend/src/locales/ru-RU.po
@@ -4333,6 +4333,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 "Все токены"
@@ -5339,7 +5347,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:"
@@ -6449,3 +6457,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/application/containers/App/Updaters.tsx b/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx
index d8138a358d6..e3306058ef8 100644
--- a/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx
+++ b/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx
@@ -33,7 +33,7 @@ import {
} from 'modules/orderProgressBar'
import { OrdersNotificationsUpdater } from 'modules/orders'
import { GeoDataUpdater } from 'modules/rwa'
-import { BlockedListSourcesUpdater, useSourceChainId } from 'modules/tokensList'
+import { BlockedListSourcesUpdater, RecentTokensStorageUpdater, useSourceChainId } from 'modules/tokensList'
import { TradeType, useTradeTypeInfo } from 'modules/trade'
import { UsdPricesUpdater } from 'modules/usdAmount'
import { LpTokensWithBalancesUpdater, PoolsInfoUpdater, VampireAttackUpdater } from 'modules/yield/shared'
@@ -118,6 +118,7 @@ export function Updaters(): ReactNode {
/>
+
diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenConsentFlow.tsx b/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenConsentFlow.tsx
new file mode 100644
index 00000000000..af24fa92d86
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenConsentFlow.tsx
@@ -0,0 +1,65 @@
+import { useCallback, useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import { t } from '@lingui/core/macro'
+
+import { CustomFlowContext, CustomFlowResult, TokenSelectorView, ViewFlowConfig } from 'modules/tokensList'
+
+import { useImportTokenRwaCheck } from './useImportTokenRwaCheck'
+import { useImportTokenWithConsent } from './useImportTokenWithConsent'
+
+import { RwaConsentModal } from '../pure/RwaConsentModal'
+
+function getRestrictedFlowResult(): CustomFlowResult {
+ return {
+ content: null,
+ data: {
+ restriction: {
+ isBlocked: true,
+ message: t`This token is not available in your region.`,
+ },
+ },
+ }
+}
+
+/**
+ * Hook that provides preFlow for ImportToken view.
+ * Handles consent modal and restriction data for RWA tokens.
+ */
+export function useImportTokenConsentFlow(): ViewFlowConfig | null {
+ const { tokenToImport, rwaStatus, rwaTokenInfo } = useImportTokenRwaCheck()
+ const { importWithConsent } = useImportTokenWithConsent({ consentHash: rwaTokenInfo?.consentHash })
+
+ const preFlow = useCallback(
+ (context: CustomFlowContext): CustomFlowResult | null => {
+ if (!tokenToImport) return null
+
+ if (rwaStatus === 'restricted') {
+ return getRestrictedFlowResult()
+ }
+
+ if (rwaStatus === 'requires-consent' && rwaTokenInfo) {
+ const displayToken = TokenWithLogo.fromToken(tokenToImport)
+ return {
+ content: (
+ importWithConsent(tokenToImport)}
+ token={displayToken}
+ consentHash={rwaTokenInfo.consentHash}
+ />
+ ),
+ }
+ }
+
+ return null
+ },
+ [tokenToImport, rwaStatus, rwaTokenInfo, importWithConsent],
+ )
+
+ return useMemo(() => {
+ if (!tokenToImport) return null
+ return { preFlow }
+ }, [tokenToImport, preFlow])
+}
diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenRwaCheck.ts b/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenRwaCheck.ts
new file mode 100644
index 00000000000..abd5d596351
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenRwaCheck.ts
@@ -0,0 +1,35 @@
+import { useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import { useSelectTokenWidgetState } from 'modules/tokensList'
+
+import { useRwaTokenStatus, RwaTokenStatus, RwaTokenInfo } from './useRwaTokenStatus'
+
+type ImportTokenRwaStatus = 'allowed' | 'restricted' | 'requires-consent' | null
+
+interface UseImportTokenRwaCheckResult {
+ tokenToImport: TokenWithLogo | undefined
+ rwaStatus: ImportTokenRwaStatus
+ rwaTokenInfo: RwaTokenInfo | null
+}
+
+const RWA_STATUS_MAP: Readonly> = {
+ [RwaTokenStatus.Allowed]: 'allowed',
+ [RwaTokenStatus.Restricted]: 'restricted',
+ [RwaTokenStatus.RequiredConsent]: 'requires-consent',
+ [RwaTokenStatus.ConsentIsSigned]: 'allowed',
+}
+
+export function useImportTokenRwaCheck(): UseImportTokenRwaCheckResult {
+ const { tokenToImport } = useSelectTokenWidgetState()
+
+ const { status, rwaTokenInfo } = useRwaTokenStatus({
+ inputCurrency: tokenToImport,
+ outputCurrency: undefined,
+ })
+
+ const rwaStatus = RWA_STATUS_MAP[status] ?? null
+
+ return useMemo(() => ({ tokenToImport, rwaStatus, rwaTokenInfo }), [tokenToImport, rwaStatus, rwaTokenInfo])
+}
diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenWithConsent.ts b/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenWithConsent.ts
new file mode 100644
index 00000000000..6dc93fd9951
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useImportTokenWithConsent.ts
@@ -0,0 +1,53 @@
+import { useCallback, useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { useAddUserToken } from '@cowprotocol/tokens'
+import { useWalletInfo } from '@cowprotocol/wallet'
+
+import { useCloseTokenSelectWidget, useSelectTokenWidgetState } from 'modules/tokensList'
+
+import { useRwaConsentStatus } from './useRwaConsentStatus'
+
+import { RwaConsentKey } from '../types/rwaConsent'
+
+interface UseImportTokenWithConsentParams {
+ consentHash: string | undefined
+}
+
+interface UseImportTokenWithConsentResult {
+ importWithConsent: (token: TokenWithLogo) => void
+}
+
+/**
+ * Hook that handles importing a token with consent confirmation.
+ * Combines: save consent + import token + select token + close widget
+ */
+export function useImportTokenWithConsent({
+ consentHash,
+}: UseImportTokenWithConsentParams): UseImportTokenWithConsentResult {
+ const { account } = useWalletInfo()
+ const { onSelectToken } = useSelectTokenWidgetState()
+ const closeWidget = useCloseTokenSelectWidget()
+ const importToken = useAddUserToken()
+
+ const consentKey: RwaConsentKey | null = useMemo(() => {
+ if (!account || !consentHash) return null
+ return { wallet: account, ipfsHash: consentHash }
+ }, [account, consentHash])
+
+ const { confirmConsent } = useRwaConsentStatus(consentKey)
+
+ const importWithConsent = useCallback(
+ (token: TokenWithLogo) => {
+ if (!account || !consentKey) return
+
+ confirmConsent()
+ importToken([token])
+ onSelectToken?.(token)
+ closeWidget()
+ },
+ [account, consentKey, confirmConsent, importToken, onSelectToken, closeWidget],
+ )
+
+ return useMemo(() => ({ importWithConsent }), [importWithConsent])
+}
diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useListToggleConsentFlow.tsx b/apps/cowswap-frontend/src/modules/rwa/hooks/useListToggleConsentFlow.tsx
new file mode 100644
index 00000000000..4275ac73d1b
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useListToggleConsentFlow.tsx
@@ -0,0 +1,74 @@
+import { useCallback, useMemo } from 'react'
+
+import { useToggleList } from '@cowprotocol/tokens'
+import { useWalletInfo } from '@cowprotocol/wallet'
+
+import {
+ CustomFlowResult,
+ TokenSelectorView,
+ useSelectTokenWidgetState,
+ useUpdateSelectTokenWidgetState,
+ ViewFlowConfig,
+} from 'modules/tokensList'
+
+import { useRwaConsentStatus } from './useRwaConsentStatus'
+
+import { RwaConsentModal } from '../pure/RwaConsentModal'
+import { RwaConsentKey } from '../types/rwaConsent'
+
+/**
+ * Hook that provides postFlow for Manage view.
+ * Shows consent modal when user tries to toggle on a restricted list.
+ */
+export function useListToggleConsentFlow(): ViewFlowConfig | null {
+ const { account } = useWalletInfo()
+ const { listToToggle } = useSelectTokenWidgetState()
+ const updateWidgetState = useUpdateSelectTokenWidgetState()
+ const toggleList = useToggleList(() => {})
+
+ const consentKey: RwaConsentKey | null = useMemo(() => {
+ if (!account || !listToToggle) return null
+ return { wallet: account, ipfsHash: listToToggle.consentHash }
+ }, [account, listToToggle])
+
+ const { confirmConsent } = useRwaConsentStatus(consentKey)
+
+ const postFlow = useCallback((): CustomFlowResult | null => {
+ if (!listToToggle) return null
+
+ const clearPendingState = (): void => {
+ updateWidgetState({ listToToggle: undefined })
+ }
+
+ const handleDismiss = (): void => {
+ // don't toggle, just clear the pending state
+ clearPendingState()
+ }
+
+ const handleConfirm = (): void => {
+ if (!account || !consentKey) {
+ clearPendingState()
+ return
+ }
+ confirmConsent()
+ toggleList(listToToggle.list, true)
+ clearPendingState()
+ }
+
+ return {
+ content: (
+
+ ),
+ }
+ }, [listToToggle, account, consentKey, confirmConsent, toggleList, updateWidgetState])
+
+ return useMemo(() => {
+ if (!listToToggle) return null
+ return { postFlow }
+ }, [listToToggle, postFlow])
+}
diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentModalState.ts b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentModalState.ts
index 5785a5da993..6182c3be6b5 100644
--- a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentModalState.ts
+++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentModalState.ts
@@ -1,5 +1,5 @@
import { useAtomValue, useSetAtom } from 'jotai'
-import { useCallback } from 'react'
+import { useCallback, useMemo } from 'react'
import {
rwaConsentModalStateAtom,
@@ -39,10 +39,13 @@ export function useRwaConsentModalState(): {
})
}, [updateState])
- return {
- isModalOpen: state.isModalOpen,
- context: state.context,
- openModal,
- closeModal,
- }
+ return useMemo(
+ () => ({
+ isModalOpen: state.isModalOpen,
+ context: state.context,
+ openModal,
+ closeModal,
+ }),
+ [state.isModalOpen, state.context, openModal, closeModal],
+ )
}
diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentStatus.ts b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentStatus.ts
index ec744c1cb46..41fe5a31dbe 100644
--- a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentStatus.ts
+++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaConsentStatus.ts
@@ -46,10 +46,13 @@ export function useRwaConsentStatus(key: RwaConsentKey | null): UseRwaConsentSta
removeConsent(key)
}, [removeConsent, key])
- return {
- consentStatus,
- consentRecord,
- confirmConsent,
- resetConsent,
- }
+ return useMemo(
+ () => ({
+ consentStatus,
+ consentRecord,
+ confirmConsent,
+ resetConsent,
+ }),
+ [consentStatus, consentRecord, confirmConsent, resetConsent],
+ )
}
diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts
index c673a7f1c6c..1b58dcc6c41 100644
--- a/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts
+++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useRwaTokenStatus.ts
@@ -108,8 +108,5 @@ export function useRwaTokenStatus({ inputCurrency, outputCurrency }: UseRwaToken
return RwaTokenStatus.RequiredConsent
}, [isRwaGeoblockEnabled, rwaTokenInfo, geoStatus.country, consentStatus])
- return {
- status,
- rwaTokenInfo,
- }
+ return useMemo(() => ({ status, rwaTokenInfo }), [status, rwaTokenInfo])
}
diff --git a/apps/cowswap-frontend/src/modules/rwa/hooks/useTokenSelectorConsentFlow.ts b/apps/cowswap-frontend/src/modules/rwa/hooks/useTokenSelectorConsentFlow.ts
new file mode 100644
index 00000000000..e8c44e55e03
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/rwa/hooks/useTokenSelectorConsentFlow.ts
@@ -0,0 +1,29 @@
+import { useMemo } from 'react'
+
+import { CustomFlowsRegistry, TokenSelectorView } from 'modules/tokensList'
+
+import { useImportTokenConsentFlow } from './useImportTokenConsentFlow'
+import { useListToggleConsentFlow } from './useListToggleConsentFlow'
+
+/**
+ * Hook that combines all RWA consent flows for the token selector.
+ * Each individual flow is defined in its own hook.
+ */
+export function useTokenSelectorConsentFlow(): CustomFlowsRegistry {
+ const importTokenFlow = useImportTokenConsentFlow()
+ const listToggleFlow = useListToggleConsentFlow()
+
+ return useMemo((): CustomFlowsRegistry => {
+ const registry: CustomFlowsRegistry = {}
+
+ if (importTokenFlow) {
+ registry[TokenSelectorView.ImportToken] = importTokenFlow
+ }
+
+ if (listToggleFlow) {
+ registry[TokenSelectorView.Manage] = listToggleFlow
+ }
+
+ return registry
+ }, [importTokenFlow, listToggleFlow])
+}
diff --git a/apps/cowswap-frontend/src/modules/rwa/index.ts b/apps/cowswap-frontend/src/modules/rwa/index.ts
index d53031811fa..b974457a014 100644
--- a/apps/cowswap-frontend/src/modules/rwa/index.ts
+++ b/apps/cowswap-frontend/src/modules/rwa/index.ts
@@ -7,6 +7,7 @@ export * from './hooks/useRwaConsentModalState'
export * from './hooks/useGeoCountry'
export * from './hooks/useGeoStatus'
export * from './hooks/useRwaTokenStatus'
+export * from './hooks/useTokenSelectorConsentFlow'
export * from './pure/RwaConsentModal'
export * from './containers/RwaConsentModalContainer'
export * from './updaters/GeoDataUpdater'
diff --git a/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx b/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx
index 13417f0b1c0..f9ba2749899 100644
--- a/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx
@@ -10,17 +10,22 @@ export interface RwaConsentModalProps {
onDismiss(): void
onConfirm(): void
token?: TokenWithLogo
+ listName?: string
consentHash?: string
}
export function RwaConsentModal(props: RwaConsentModalProps): ReactNode {
- const { onDismiss, onConfirm, token } = props
+ const { onDismiss, onConfirm, token, listName } = props
- const displaySymbol = token?.symbol || 'this token'
+ const isListConsent = !!listName
+ const displayName = listName || token?.symbol || 'this token'
+ const title = isListConsent
+ ? 'Additional confirmation required for this token list'
+ : 'Additional confirmation required for this token'
return (
- Additional confirmation required for this token
+ {title}
{token && (
@@ -36,10 +41,17 @@ export function RwaConsentModal(props: RwaConsentModalProps): ReactNode {
)}
+ {listName && !token && (
+
+
+ {listName}
+
+
+ )}
We could not reliably determine your location (e.g., due to VPN or privacy settings). Access to{' '}
- {displaySymbol} is strictly limited to specific regions.
+ {displayName} is strictly limited to specific regions.
By clicking Confirm, you expressly represent and warrant that you are NOT:
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/ManageTokens/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageTokens/index.tsx
index 728fcdd4433..285dbd4a797 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageTokens/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageTokens/index.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
-import { ExplorerDataType, getExplorerLink } from '@cowprotocol/common-utils'
+import { ExplorerDataType, getExplorerLink, getTokenId } from '@cowprotocol/common-utils'
import { TokenLogo, TokenSearchResponse, useRemoveUserToken, useResetUserTokens } from '@cowprotocol/tokens'
import { TokenSymbol } from '@cowprotocol/ui'
@@ -55,11 +55,11 @@ export function ManageTokens(props: ManageTokensProps) {
{(!!activeListsResult?.length || !!tokensToImport?.length) && (
{activeListsResult?.map((token) => {
- return
+ return
})}
{!activeListsResult?.length &&
tokensToImport?.map((token) => {
- return
+ return
})}
)}
@@ -76,7 +76,7 @@ export function ManageTokens(props: ManageTokensProps) {
{tokens.map((token) => {
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..dcf23cfc217
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx
@@ -0,0 +1,46 @@
+import { MouseEvent, ReactNode } from 'react'
+
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+import { createPortal } from 'react-dom'
+
+import { MobileChainPanelCard, MobileChainPanelOverlay } from './styled'
+
+import { ChainPanel } from '../../pure/ChainPanel'
+import { ChainsToSelectState } from '../../types'
+
+interface MobileChainPanelPortalProps {
+ chainsPanelTitle: string
+ chainsToSelect: ChainsToSelectState | undefined
+ onSelectChain: (chain: ChainInfo) => void
+ 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/hooks/index.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/index.ts
new file mode 100644
index 00000000000..047e4a44c84
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/index.ts
@@ -0,0 +1,38 @@
+// Widget core
+export { useWidgetOpenState } from './useWidgetOpenState'
+export { useWidgetEffects } from './useWidgetEffects'
+export { useActiveBlockingView } from './useActiveBlockingView'
+export { useViewWithFlows, type ViewWithFlowsResult } from './useViewWithFlows'
+
+// Slot state hooks
+export { useHeaderState, type HeaderState } from './useHeaderState'
+export { useChainPanelState, type ChainPanelState } from './useChainPanelState'
+
+// Blocking view state hooks
+export { useImportTokenViewState, type ImportTokenViewState } from './useImportTokenViewState'
+export { useImportListViewState, type ImportListViewState } from './useImportListViewState'
+export { useManageViewState, type ManageViewState } from './useManageViewState'
+export { useLpTokenViewState, type LpTokenViewState } from './useLpTokenViewState'
+
+// Token data hooks
+export { useTokenAdminActions, type TokenAdminActions } from './useTokenAdminActions'
+export { useTokenDataSources, type TokenDataSources } from './useTokenDataSources'
+export {
+ useWidgetMetadata,
+ resolveModalTitle,
+ type TokenListCategoryState,
+ type WidgetMetadata,
+} from './useWidgetMetadata'
+
+// Token selection hooks
+export { useImportTokenAndClose } from './useImportTokenAndClose'
+export { useImportListAndBack } from './useImportListAndBack'
+export { useResetTokenImport } from './useResetTokenImport'
+export { useResetListImport } from './useResetListImport'
+export { useRecentTokenSection, type RecentTokenSection } from './useRecentTokenSection'
+export { useTokenSelectionHandler } from './useTokenSelectionHandler'
+
+// UI state hooks
+export { useManageWidgetVisibility, type ManageWidgetVisibility } from './useManageWidgetVisibility'
+export { useDismissHandler } from './useDismissHandler'
+export { usePoolPageHandlers, type PoolPageHandlers } from './usePoolPageHandlers'
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useActiveBlockingView.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useActiveBlockingView.ts
new file mode 100644
index 00000000000..aa51a33a16a
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useActiveBlockingView.ts
@@ -0,0 +1,17 @@
+import { useManageWidgetVisibility } from './useManageWidgetVisibility'
+
+import { useSelectTokenWidgetState } from '../../../hooks/useSelectTokenWidgetState'
+import { TokenSelectorView } from '../types'
+
+export function useActiveBlockingView(): TokenSelectorView {
+ const widgetState = useSelectTokenWidgetState()
+ const { isManageWidgetOpen } = useManageWidgetVisibility()
+ const isStandalone = widgetState.standalone ?? false
+
+ if (widgetState.tokenToImport && !isStandalone) return TokenSelectorView.ImportToken
+ if (widgetState.listToImport && !isStandalone) return TokenSelectorView.ImportList
+ if (isManageWidgetOpen && !isStandalone) return TokenSelectorView.Manage
+ if (widgetState.selectedPoolAddress) return TokenSelectorView.LpToken
+
+ return TokenSelectorView.Main
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useChainPanelState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useChainPanelState.ts
new file mode 100644
index 00000000000..afb05d6f08c
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useChainPanelState.ts
@@ -0,0 +1,40 @@
+/**
+ * useChainPanelState - Chain panel visibility and handlers
+ */
+import { useMemo } from 'react'
+
+import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+import { TradeType } from 'modules/trade/types'
+
+import { useChainsToSelect } from '../../../hooks/useChainsToSelect'
+import { useOnSelectChain } from '../../../hooks/useOnSelectChain'
+import { ChainsToSelectState } from '../../../types'
+
+// TODO: Re-enable once Yield should support cross-network selection in the modal
+const ENABLE_YIELD_CHAIN_PANEL = false
+
+export interface ChainPanelState {
+ isEnabled: boolean
+ chainsToSelect: ChainsToSelectState | undefined
+ onSelectChain: (chain: ChainInfo) => void
+}
+
+export function useChainPanelState(tradeType: TradeType | undefined): ChainPanelState {
+ const chainsToSelect = useChainsToSelect()
+ const onSelectChain = useOnSelectChain()
+ const isBridgeFeatureEnabled = useIsBridgingEnabled()
+
+ const shouldDisableForYield = tradeType === TradeType.YIELD && !ENABLE_YIELD_CHAIN_PANEL
+ const isEnabled = isBridgeFeatureEnabled && Boolean(chainsToSelect?.chains?.length) && !shouldDisableForYield
+
+ return useMemo(
+ () => ({
+ isEnabled,
+ chainsToSelect,
+ onSelectChain,
+ }),
+ [isEnabled, chainsToSelect, onSelectChain],
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useDismissHandler.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useDismissHandler.ts
new file mode 100644
index 00000000000..7d179e0d25a
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useDismissHandler.ts
@@ -0,0 +1,11 @@
+import { useCallback } from 'react'
+
+export function useDismissHandler(
+ closeManageWidget: () => void,
+ closeTokenSelectWidget: (options?: { overrideForceLock?: boolean }) => void,
+): () => void {
+ return useCallback(() => {
+ closeManageWidget()
+ closeTokenSelectWidget({ overrideForceLock: true })
+ }, [closeManageWidget, closeTokenSelectWidget])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useHeaderState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useHeaderState.ts
new file mode 100644
index 00000000000..2c70dc233f7
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useHeaderState.ts
@@ -0,0 +1,34 @@
+import { useMemo } from 'react'
+
+import { t } from '@lingui/core/macro'
+
+import { Field } from 'legacy/state/types'
+
+import { useManageWidgetVisibility } from './useManageWidgetVisibility'
+import { resolveModalTitle } from './useWidgetMetadata'
+
+import { useSelectTokenWidgetState } from '../../../hooks/useSelectTokenWidgetState'
+
+export interface HeaderState {
+ title: string
+ showManageButton: boolean
+ onOpenManageWidget: () => void
+}
+
+export function useHeaderState(): HeaderState {
+ const widgetState = useSelectTokenWidgetState()
+ const { openManageWidget } = useManageWidgetVisibility()
+ const { standalone } = widgetState
+ const resolvedField = widgetState.field ?? Field.INPUT
+
+ const title = resolveModalTitle(resolvedField, widgetState.tradeType) ?? t`Select token`
+
+ return useMemo(
+ () => ({
+ title,
+ showManageButton: !standalone,
+ onOpenManageWidget: openManageWidget,
+ }),
+ [title, standalone, openManageWidget],
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportListAndBack.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportListAndBack.ts
new file mode 100644
index 00000000000..57416245ccb
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportListAndBack.ts
@@ -0,0 +1,30 @@
+import { useCallback } from 'react'
+
+import { ListState } from '@cowprotocol/tokens'
+
+import { useTokenAdminActions } from './useTokenAdminActions'
+
+import { useCloseTokenSelectWidget } from '../../../hooks/useCloseTokenSelectWidget'
+import { useOnTokenListAddingError } from '../../../hooks/useOnTokenListAddingError'
+import { useUpdateSelectTokenWidgetState } from '../../../hooks/useUpdateSelectTokenWidgetState'
+
+export function useImportListAndBack(): (list: ListState) => void {
+ const closeWidget = useCloseTokenSelectWidget()
+ const updateWidget = useUpdateSelectTokenWidgetState()
+ const onTokenListAddingError = useOnTokenListAddingError()
+ const { addCustomTokenLists } = useTokenAdminActions()
+
+ return useCallback(
+ (list: ListState) => {
+ try {
+ addCustomTokenLists(list)
+ } catch (error) {
+ closeWidget()
+ const errorToReport = error instanceof Error ? error : new Error(String(error))
+ onTokenListAddingError(errorToReport)
+ }
+ updateWidget({ listToImport: undefined })
+ },
+ [addCustomTokenLists, closeWidget, onTokenListAddingError, updateWidget],
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportListViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportListViewState.ts
new file mode 100644
index 00000000000..18249bbf5b0
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportListViewState.ts
@@ -0,0 +1,39 @@
+import { useMemo } from 'react'
+
+import { ListState } from '@cowprotocol/tokens'
+
+import { useDismissHandler } from './useDismissHandler'
+import { useImportListAndBack } from './useImportListAndBack'
+import { useManageWidgetVisibility } from './useManageWidgetVisibility'
+import { useResetListImport } from './useResetListImport'
+
+import { useCloseTokenSelectWidget } from '../../../hooks/useCloseTokenSelectWidget'
+import { useSelectTokenWidgetState } from '../../../hooks/useSelectTokenWidgetState'
+
+export interface ImportListViewState {
+ list: ListState
+ onDismiss: () => void
+ onBack: () => void
+ onImport: (list: ListState) => void
+}
+
+export function useImportListViewState(): ImportListViewState | null {
+ const widgetState = useSelectTokenWidgetState()
+ const { closeManageWidget } = useManageWidgetVisibility()
+ const closeTokenSelectWidget = useCloseTokenSelectWidget()
+
+ const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
+ const importListAndBack = useImportListAndBack()
+ const resetListImport = useResetListImport()
+
+ return useMemo(() => {
+ if (!widgetState.listToImport) return null
+
+ return {
+ list: widgetState.listToImport,
+ onDismiss,
+ onBack: resetListImport,
+ onImport: importListAndBack,
+ }
+ }, [widgetState.listToImport, onDismiss, resetListImport, importListAndBack])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportTokenAndClose.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportTokenAndClose.ts
new file mode 100644
index 00000000000..eca6d752e5e
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportTokenAndClose.ts
@@ -0,0 +1,31 @@
+import { useCallback } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { useAddUserToken } from '@cowprotocol/tokens'
+
+import { useCloseTokenSelectWidget } from '../../../hooks/useCloseTokenSelectWidget'
+import { persistRecentTokenSelection } from '../../../hooks/useRecentTokens'
+import { useSelectTokenWidgetState } from '../../../hooks/useSelectTokenWidgetState'
+import { useTokensToSelect } from '../../../hooks/useTokensToSelect'
+
+export function useImportTokenAndClose(): (tokens: TokenWithLogo[]) => void {
+ const { onSelectToken } = useSelectTokenWidgetState()
+ const closeWidget = useCloseTokenSelectWidget()
+ const importToken = useAddUserToken()
+ const { favoriteTokens } = useTokensToSelect()
+
+ return useCallback(
+ (tokens: TokenWithLogo[]) => {
+ importToken(tokens)
+ const [selectedToken] = tokens
+
+ if (selectedToken) {
+ persistRecentTokenSelection(selectedToken, favoriteTokens)
+ onSelectToken?.(selectedToken)
+ }
+
+ closeWidget()
+ },
+ [importToken, onSelectToken, closeWidget, favoriteTokens],
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportTokenViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportTokenViewState.ts
new file mode 100644
index 00000000000..6b37be403be
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useImportTokenViewState.ts
@@ -0,0 +1,39 @@
+import { useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import { useDismissHandler } from './useDismissHandler'
+import { useImportTokenAndClose } from './useImportTokenAndClose'
+import { useManageWidgetVisibility } from './useManageWidgetVisibility'
+import { useResetTokenImport } from './useResetTokenImport'
+
+import { useCloseTokenSelectWidget } from '../../../hooks/useCloseTokenSelectWidget'
+import { useSelectTokenWidgetState } from '../../../hooks/useSelectTokenWidgetState'
+
+export interface ImportTokenViewState {
+ token: TokenWithLogo
+ onDismiss: () => void
+ onBack: () => void
+ onImport: (tokens: TokenWithLogo[]) => void
+}
+
+export function useImportTokenViewState(): ImportTokenViewState | null {
+ const widgetState = useSelectTokenWidgetState()
+ const { closeManageWidget } = useManageWidgetVisibility()
+ const closeTokenSelectWidget = useCloseTokenSelectWidget()
+
+ const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
+ const importTokenAndClose = useImportTokenAndClose()
+ const resetTokenImport = useResetTokenImport()
+
+ return useMemo(() => {
+ if (!widgetState.tokenToImport) return null
+
+ return {
+ token: widgetState.tokenToImport,
+ onDismiss,
+ onBack: resetTokenImport,
+ onImport: importTokenAndClose,
+ }
+ }, [widgetState.tokenToImport, onDismiss, resetTokenImport, importTokenAndClose])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useLpTokenViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useLpTokenViewState.ts
new file mode 100644
index 00000000000..659fa860ee2
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useLpTokenViewState.ts
@@ -0,0 +1,41 @@
+import { useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import { useDismissHandler } from './useDismissHandler'
+import { useManageWidgetVisibility } from './useManageWidgetVisibility'
+import { usePoolPageHandlers } from './usePoolPageHandlers'
+import { useTokenSelectionHandler } from './useTokenSelectionHandler'
+
+import { useCloseTokenSelectWidget } from '../../../hooks/useCloseTokenSelectWidget'
+import { useSelectTokenWidgetState } from '../../../hooks/useSelectTokenWidgetState'
+import { useUpdateSelectTokenWidgetState } from '../../../hooks/useUpdateSelectTokenWidgetState'
+
+export interface LpTokenViewState {
+ poolAddress: string
+ onDismiss: () => void
+ onBack: () => void
+ onSelectToken: (token: TokenWithLogo) => void
+}
+
+export function useLpTokenViewState(): LpTokenViewState | null {
+ const widgetState = useSelectTokenWidgetState()
+ const { closeManageWidget } = useManageWidgetVisibility()
+ const closeTokenSelectWidget = useCloseTokenSelectWidget()
+ const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
+
+ const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken, widgetState)
+ const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
+ const { closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget)
+
+ return useMemo(() => {
+ if (!widgetState.selectedPoolAddress) return null
+
+ return {
+ poolAddress: widgetState.selectedPoolAddress,
+ onDismiss,
+ onBack: closePoolPage,
+ onSelectToken: handleSelectToken,
+ }
+ }, [widgetState.selectedPoolAddress, onDismiss, closePoolPage, handleSelectToken])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useManageViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useManageViewState.ts
new file mode 100644
index 00000000000..7591fcdd32c
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useManageViewState.ts
@@ -0,0 +1,28 @@
+import { useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { ListState } from '@cowprotocol/tokens'
+
+import { useManageWidgetVisibility } from './useManageWidgetVisibility'
+import { useTokenDataSources } from './useTokenDataSources'
+
+export interface ManageViewState {
+ lists: ListState[]
+ customTokens: TokenWithLogo[]
+ onBack: () => void
+}
+
+export function useManageViewState(): ManageViewState | null {
+ const { isManageWidgetOpen, closeManageWidget } = useManageWidgetVisibility()
+ const tokenData = useTokenDataSources()
+
+ return useMemo(() => {
+ if (!isManageWidgetOpen) return null
+
+ return {
+ lists: tokenData.allTokenLists,
+ customTokens: tokenData.userAddedTokens,
+ onBack: closeManageWidget,
+ }
+ }, [isManageWidgetOpen, tokenData.allTokenLists, tokenData.userAddedTokens, closeManageWidget])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useManageWidgetVisibility.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useManageWidgetVisibility.ts
new file mode 100644
index 00000000000..83b80e57c86
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useManageWidgetVisibility.ts
@@ -0,0 +1,23 @@
+import { useAtomValue, useSetAtom } from 'jotai'
+import { useCallback, useMemo } from 'react'
+
+import { selectTokenModalUIAtom, updateSelectTokenModalUIAtom } from '../state'
+
+export interface ManageWidgetVisibility {
+ isManageWidgetOpen: boolean
+ openManageWidget(): void
+ closeManageWidget(): void
+}
+
+export function useManageWidgetVisibility(): ManageWidgetVisibility {
+ const { isManageWidgetOpen } = useAtomValue(selectTokenModalUIAtom)
+ const updateModalUI = useSetAtom(updateSelectTokenModalUIAtom)
+
+ const openManageWidget = useCallback(() => updateModalUI({ isManageWidgetOpen: true }), [updateModalUI])
+ const closeManageWidget = useCallback(() => updateModalUI({ isManageWidgetOpen: false }), [updateModalUI])
+
+ return useMemo(
+ () => ({ isManageWidgetOpen, openManageWidget, closeManageWidget }),
+ [isManageWidgetOpen, openManageWidget, closeManageWidget],
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/usePoolPageHandlers.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/usePoolPageHandlers.ts
new file mode 100644
index 00000000000..360c625dc61
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/usePoolPageHandlers.ts
@@ -0,0 +1,25 @@
+import { useCallback, useMemo } from 'react'
+
+import type { useUpdateSelectTokenWidgetState } from '../../../hooks/useUpdateSelectTokenWidgetState'
+
+type UpdateSelectTokenWidgetFn = ReturnType
+
+export interface PoolPageHandlers {
+ openPoolPage(poolAddress: string): void
+ closePoolPage(): void
+}
+
+export function usePoolPageHandlers(updateSelectTokenWidget: UpdateSelectTokenWidgetFn): PoolPageHandlers {
+ const openPoolPage = useCallback(
+ (selectedPoolAddress: string) => {
+ updateSelectTokenWidget({ selectedPoolAddress })
+ },
+ [updateSelectTokenWidget],
+ )
+
+ const closePoolPage = useCallback(() => {
+ updateSelectTokenWidget({ selectedPoolAddress: undefined })
+ }, [updateSelectTokenWidget])
+
+ return useMemo(() => ({ openPoolPage, closePoolPage }), [openPoolPage, closePoolPage])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useRecentTokenSection.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useRecentTokenSection.ts
new file mode 100644
index 00000000000..121eece306e
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useRecentTokenSection.ts
@@ -0,0 +1,35 @@
+import { useCallback, useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import { useRecentTokens } from '../../../hooks/useRecentTokens'
+
+export interface RecentTokenSection {
+ recentTokens: TokenWithLogo[]
+ handleTokenListItemClick(token: TokenWithLogo): void
+ clearRecentTokens(): void
+}
+
+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 useMemo(
+ () => ({ recentTokens, handleTokenListItemClick, clearRecentTokens }),
+ [recentTokens, handleTokenListItemClick, clearRecentTokens],
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useResetListImport.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useResetListImport.ts
new file mode 100644
index 00000000000..7c83f87f4fc
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useResetListImport.ts
@@ -0,0 +1,11 @@
+import { useCallback } from 'react'
+
+import { useUpdateSelectTokenWidgetState } from '../../../hooks/useUpdateSelectTokenWidgetState'
+
+export function useResetListImport(): () => void {
+ const updateWidget = useUpdateSelectTokenWidgetState()
+
+ return useCallback(() => {
+ updateWidget({ listToImport: undefined })
+ }, [updateWidget])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useResetTokenImport.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useResetTokenImport.ts
new file mode 100644
index 00000000000..a47b98b5154
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useResetTokenImport.ts
@@ -0,0 +1,11 @@
+import { useCallback } from 'react'
+
+import { useUpdateSelectTokenWidgetState } from '../../../hooks/useUpdateSelectTokenWidgetState'
+
+export function useResetTokenImport(): () => void {
+ const updateWidget = useUpdateSelectTokenWidgetState()
+
+ return useCallback(() => {
+ updateWidget({ tokenToImport: undefined })
+ }, [updateWidget])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useTokenAdminActions.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useTokenAdminActions.ts
new file mode 100644
index 00000000000..b3f32d7d8e7
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useTokenAdminActions.ts
@@ -0,0 +1,27 @@
+import { useMemo } from 'react'
+
+import { useCowAnalytics } from '@cowprotocol/analytics'
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { ListState, useAddList, useAddUserToken } from '@cowprotocol/tokens'
+
+import { CowSwapAnalyticsCategory } from 'common/analytics/types'
+
+export interface TokenAdminActions {
+ addCustomTokenLists(list: ListState): void
+ importTokenCallback(tokens: TokenWithLogo[]): void
+}
+
+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 useMemo(() => ({ addCustomTokenLists, importTokenCallback }), [addCustomTokenLists, importTokenCallback])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useTokenDataSources.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useTokenDataSources.ts
new file mode 100644
index 00000000000..1151bbc72c2
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useTokenDataSources.ts
@@ -0,0 +1,43 @@
+import { useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import {
+ ListState,
+ useAllListsList,
+ useTokenListsTags,
+ useUnsupportedTokens,
+ useUserAddedTokens,
+} from '@cowprotocol/tokens'
+
+import { useTokensBalancesCombined } from 'modules/combinedBalances'
+import { usePermitCompatibleTokens } from 'modules/permit'
+
+export interface TokenDataSources {
+ userAddedTokens: TokenWithLogo[]
+ allTokenLists: ListState[]
+ balancesState: ReturnType
+ unsupportedTokens: ReturnType
+ permitCompatibleTokens: ReturnType
+ tokenListTags: ReturnType
+}
+
+export function useTokenDataSources(): TokenDataSources {
+ const userAddedTokens = useUserAddedTokens()
+ const allTokenLists = useAllListsList()
+ const balancesState = useTokensBalancesCombined()
+ const unsupportedTokens = useUnsupportedTokens()
+ const permitCompatibleTokens = usePermitCompatibleTokens()
+ const tokenListTags = useTokenListsTags()
+
+ return useMemo(
+ () => ({
+ userAddedTokens,
+ allTokenLists,
+ balancesState,
+ unsupportedTokens,
+ permitCompatibleTokens,
+ tokenListTags,
+ }),
+ [userAddedTokens, allTokenLists, balancesState, unsupportedTokens, permitCompatibleTokens, tokenListTags],
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useTokenSelectionHandler.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useTokenSelectionHandler.ts
new file mode 100644
index 00000000000..c77c34663a7
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useTokenSelectionHandler.ts
@@ -0,0 +1,73 @@
+import { useCallback } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { isSupportedChainId } from '@cowprotocol/common-utils'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+import { log } from '@cowprotocol/sdk-common'
+import { useWalletInfo } from '@cowprotocol/wallet'
+
+import { Field } from 'legacy/state/types'
+
+import { TradeType } from 'modules/trade/types'
+
+import { useOnSelectNetwork } from 'common/hooks/useOnSelectNetwork'
+
+import { useSelectTokenWidgetState } from '../../../hooks/useSelectTokenWidgetState'
+import { TokenSelectionHandler } from '../../../types'
+
+interface ShouldSwitchNetworkParams {
+ field: Field | undefined
+ tradeType: TradeType | undefined
+ targetChainId: number | undefined
+ walletChainId: SupportedChainId
+}
+
+function getNetworkToSwitch(params: ShouldSwitchNetworkParams): SupportedChainId | null {
+ const { field, tradeType, targetChainId, walletChainId } = params
+
+ const shouldSwitch =
+ field === Field.INPUT &&
+ (tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS) &&
+ isSupportedChainId(targetChainId) &&
+ targetChainId !== walletChainId
+
+ return shouldSwitch ? targetChainId : null
+}
+
+export function useTokenSelectionHandler(
+ onSelectToken: TokenSelectionHandler | undefined,
+ widgetState: ReturnType,
+): TokenSelectionHandler {
+ const { chainId: walletChainId } = useWalletInfo()
+ const onSelectNetwork = useOnSelectNetwork()
+
+ return useCallback(
+ async (token: TokenWithLogo) => {
+ const chainToSwitch = getNetworkToSwitch({
+ field: widgetState.field,
+ tradeType: widgetState.tradeType,
+ targetChainId: widgetState.selectedTargetChainId,
+ walletChainId,
+ })
+
+ if (chainToSwitch) {
+ try {
+ await onSelectNetwork(chainToSwitch, 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,
+ ],
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useViewWithFlows.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useViewWithFlows.ts
new file mode 100644
index 00000000000..4a0a7b2fffb
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useViewWithFlows.ts
@@ -0,0 +1,73 @@
+import { useAtomValue } from 'jotai'
+import { useCallback, useMemo } from 'react'
+
+import { useActiveBlockingView } from './useActiveBlockingView'
+
+import { useUpdateSelectTokenWidgetState } from '../../../hooks/useUpdateSelectTokenWidgetState'
+import { customFlowsRegistryAtom } from '../state'
+import { CustomFlowContext, CustomFlowResult, TokenSelectorView } from '../types'
+
+/**
+ * Helper type to create a view result with proper typing.
+ */
+type ViewResult = {
+ baseView: TView
+ preFlowResult: CustomFlowResult | null
+ postFlowResult: CustomFlowResult | null
+}
+
+export type ViewWithFlowsResult =
+ | ViewResult
+ | ViewResult
+ | ViewResult
+ | ViewResult
+ | ViewResult
+
+/**
+ * Hook that determines the current view and checks for custom pre/post flows.
+ *
+ * The custom flows are provided externally via props.
+ * This hook checks the registry and returns the flow results.
+ */
+export function useViewWithFlows(): ViewWithFlowsResult {
+ const baseView = useActiveBlockingView()
+ const registry = useAtomValue(customFlowsRegistryAtom)
+ const updateWidgetState = useUpdateSelectTokenWidgetState()
+
+ // create the flow context with callbacks
+ const onDone = useCallback(() => {
+ // pre-flow completed - the main view will now render
+ // nothing to do here, the flow slot returns null when done
+ }, [])
+
+ const onCancel = useCallback(() => {
+ // go back to main token list
+ updateWidgetState({ tokenToImport: undefined, listToImport: undefined })
+ }, [updateWidgetState])
+
+ const flowContext: CustomFlowContext = useMemo(
+ () => ({
+ targetView: baseView,
+ onDone,
+ onCancel,
+ }),
+ [baseView, onDone, onCancel],
+ )
+
+ // check for custom flows
+ const flowConfig = registry[baseView]
+ const preFlowResult = flowConfig?.preFlow?.(flowContext) ?? null
+ const postFlowResult = flowConfig?.postFlow?.(flowContext) ?? null
+
+ // The cast is safe here because we're creating a consistent tuple:
+ // baseView determines the type, and flow results come from the same view's config
+ return useMemo(
+ () =>
+ ({
+ baseView,
+ preFlowResult,
+ postFlowResult,
+ }) as ViewWithFlowsResult,
+ [baseView, preFlowResult, postFlowResult],
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetEffects.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetEffects.ts
new file mode 100644
index 00000000000..65d33f942c7
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetEffects.ts
@@ -0,0 +1,24 @@
+import { useSetAtom } from 'jotai'
+import { useEffect } from 'react'
+
+import { addBodyClass, removeBodyClass } from '@cowprotocol/common-utils'
+
+import { useCloseTokenSelectWidget } from '../../../hooks/useCloseTokenSelectWidget'
+import { DEFAULT_MODAL_UI_STATE, updateSelectTokenModalUIAtom } from '../state'
+
+export function useWidgetEffects(isOpen: boolean): void {
+ const closeTokenSelectWidget = useCloseTokenSelectWidget()
+ const updateModalUI = useSetAtom(updateSelectTokenModalUIAtom)
+
+ useEffect(() => () => updateModalUI(DEFAULT_MODAL_UI_STATE), [updateModalUI])
+ useEffect(() => () => closeTokenSelectWidget({ overrideForceLock: true }), [closeTokenSelectWidget])
+
+ useEffect(() => {
+ if (!isOpen) {
+ removeBodyClass('noScroll')
+ return
+ }
+ addBodyClass('noScroll')
+ return () => removeBodyClass('noScroll')
+ }, [isOpen])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
new file mode 100644
index 00000000000..a1b31fe9789
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
@@ -0,0 +1,55 @@
+import { Dispatch, SetStateAction, useMemo, useState } from 'react'
+
+import { TokenListCategory } from '@cowprotocol/tokens'
+
+import { t } from '@lingui/core/macro'
+
+import { Field } from 'legacy/state/types'
+
+import { TradeType } from 'modules/trade/types'
+
+import { getDefaultTokenListCategories } from '../getDefaultTokenListCategories'
+
+export type TokenListCategoryState = [TokenListCategory[] | null, Dispatch>]
+
+export interface WidgetMetadata {
+ disableErc20: boolean
+ tokenListCategoryState: TokenListCategoryState
+ modalTitle: string
+ chainsPanelTitle: string
+}
+
+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 useMemo(
+ () => ({ disableErc20, tokenListCategoryState, modalTitle, chainsPanelTitle }),
+ [disableErc20, tokenListCategoryState, modalTitle, chainsPanelTitle],
+ )
+}
+
+export 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/hooks/useWidgetOpenState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetOpenState.ts
new file mode 100644
index 00000000000..b77cb2d44bb
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetOpenState.ts
@@ -0,0 +1,25 @@
+/**
+ * useWidgetOpenState - Returns widget visibility and resets on close
+ */
+import { useEffect, useRef } from 'react'
+
+import { useResetTokenListViewState } from '../../../hooks/useResetTokenListViewState'
+import { useSelectTokenWidgetState } from '../../../hooks/useSelectTokenWidgetState'
+
+export function useWidgetOpenState(): boolean {
+ const widgetState = useSelectTokenWidgetState()
+ const isOpen = Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen))
+
+ // Reset search state when modal closes
+ const resetTokenListView = useResetTokenListViewState()
+ const prevIsOpenRef = useRef(isOpen)
+
+ useEffect(() => {
+ if (prevIsOpenRef.current && !isOpen) {
+ resetTokenListView()
+ }
+ prevIsOpenRef.current = isOpen
+ }, [isOpen, resetTokenListView])
+
+ return isOpen
+}
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 3cf8e634145..43182929b27 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -1,269 +1,111 @@
-import { ReactNode, useCallback, useState } from 'react'
+import { useSetAtom } from 'jotai'
+import { ReactNode, useEffect } 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,
- useIsListBlocked,
- useTokenListsTags,
- useUnsupportedTokens,
- useUserAddedTokens,
-} from '@cowprotocol/tokens'
-import { useWalletInfo } from '@cowprotocol/wallet'
+import { useChainPanelState, useViewWithFlows } from './hooks'
+import { SelectTokenModal } from './internal'
+import { customFlowsRegistryAtom } from './state'
+import { CustomFlowsRegistry, TokenSelectorView } from './types'
-import { t } from '@lingui/core/macro'
-import styled from 'styled-components/macro'
-
-import { Field } from 'legacy/state/types'
-
-import { useTokensBalancesCombined } from 'modules/combinedBalances'
-import { usePermitCompatibleTokens } from 'modules/permit'
-import { useGeoCountry } from 'modules/rwa'
-import { useLpTokensWithBalances } from 'modules/yield/shared'
-
-import { CowSwapAnalyticsCategory } from 'common/analytics/types'
-
-import { getDefaultTokenListCategories } from './getDefaultTokenListCategories'
-
-import { useChainsToSelect } from '../../hooks/useChainsToSelect'
-import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
-import { useIsListRequiresConsent } from '../../hooks/useIsListRequiresConsent'
-import { useOnSelectChain } from '../../hooks/useOnSelectChain'
-import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError'
-import { useRestrictedTokenImportStatus } from '../../hooks/useRestrictedTokenImportStatus'
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
-import { useTokensToSelect } from '../../hooks/useTokensToSelect'
-import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
-import { ImportListModal } from '../../pure/ImportListModal'
-import { ImportTokenModal } from '../../pure/ImportTokenModal'
-import { SelectTokenModal } from '../../pure/SelectTokenModal'
-import { LpTokenPage } from '../LpTokenPage'
-import { ManageListsAndTokens } from '../ManageListsAndTokens'
+import * as styledEl from '../../pure/SelectTokenModal/styled'
+import { updateSelectTokenWidgetAtom } from '../../state/selectTokenWidgetAtom'
-const Wrapper = styled.div`
- width: 100%;
-
- > div {
- height: calc(100vh - 200px);
- min-height: 600px;
- }
-`
-
-const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
-
-interface SelectTokenWidgetProps {
+export interface SelectTokenWidgetProps {
displayLpTokenLists?: boolean
standalone?: boolean
+ /**
+ * Custom flows registry - allows injecting pre/post flow views from outside.
+ * This keeps the token selector domain-agnostic.
+ *
+ * @example
+ * ```tsx
+ * const customFlows: CustomFlowsRegistry = {
+ * [TokenSelectorView.ImportToken]: {
+ * preFlow: (context) => needsConsent
+ * ?
+ * : null
+ * }
+ * }
+ *
+ *
+ * ```
+ */
+ customFlows?: CustomFlowsRegistry
}
-// 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,
- 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),
- )
-
- 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()
-
- 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()
-
- const closeTokenSelectWidget = useCloseTokenSelectWidget()
-
- const { isImportDisabled, blockReason } = useRestrictedTokenImportStatus(tokenToImport)
- const country = useGeoCountry()
- const { isBlocked } = useIsListBlocked(listToImport?.source, country)
- const { requiresConsent } = useIsListRequiresConsent(listToImport?.source)
+/**
+ * SelectTokenWidget - Token selector modal
+ *
+ * Uses slot-based composition with configurable custom flows.
+ * Custom flows (like consent) are provided externally via the customFlows prop.
+ */
+export function SelectTokenWidget({ displayLpTokenLists, standalone, customFlows }: SelectTokenWidgetProps): ReactNode {
+ const updateWidgetState = useSetAtom(updateSelectTokenWidgetAtom)
+ const setCustomFlows = useSetAtom(customFlowsRegistryAtom)
+
+ // Sync config props to atoms
+ useEffect(() => {
+ updateWidgetState({ displayLpTokenLists, standalone })
+ }, [displayLpTokenLists, standalone, updateWidgetState])
+
+ // Sync custom flows to atom
+ useEffect(() => {
+ setCustomFlows(customFlows ?? {})
+ }, [customFlows, setCustomFlows])
- // without wallet: only block if country is restricted, otherwise list is always visible
- // with wallet: block if country is restricted or if consent is required (unknown country)
- const isListBlocked = isBlocked || (!!account && requiresConsent)
-
- const openPoolPage = useCallback(
- (selectedPoolAddress: string) => {
- updateSelectTokenWidget({ selectedPoolAddress })
- },
- [updateSelectTokenWidget],
- )
-
- const closePoolPage = useCallback(() => {
- updateSelectTokenWidget({ selectedPoolAddress: undefined })
- }, [updateSelectTokenWidget])
-
- const resetTokenImport = useCallback(() => {
- updateSelectTokenWidget({
- tokenToImport: undefined,
- })
- }, [updateSelectTokenWidget])
-
- const onDismiss = useCallback(() => {
- setIsManageWidgetOpen(false)
- closeTokenSelectWidget()
- }, [closeTokenSelectWidget])
-
- const selectAndClose = useCallback(
- (token: TokenWithLogo): void => {
- onSelectToken?.(token)
- onDismiss()
- },
- [onSelectToken, onDismiss],
+ return (
+
+
+
)
+}
- const importTokenAndClose = useCallback(
- (tokens: TokenWithLogo[]): void => {
- importTokenCallback(tokens)
- if (tokens[0]) {
- selectAndClose(tokens[0])
- }
- },
- [importTokenCallback, selectAndClose],
- )
+function SelectTokenWidgetContent(): ReactNode {
+ const flowResult = useViewWithFlows()
+ const { tradeType } = useSelectTokenWidgetState()
+ const { isEnabled: isChainPanelEnabled } = useChainPanelState(tradeType)
- const importListAndBack = (list: ListState): void => {
- try {
- addCustomTokenLists(list)
- } catch (error) {
- onDismiss()
- onTokenListAddingError(error)
- }
- updateSelectTokenWidget({ listToImport: undefined })
+ // Generic flow content checks - applies to ALL views
+ // If there's pre-flow content, render it instead of the base view
+ if (flowResult.preFlowResult?.content) {
+ return flowResult.preFlowResult.content
}
- if (!onSelectToken || !open) return null
-
- return (
-
- {(() => {
- if (tokenToImport && !standalone) {
- return (
-
- )
- }
-
- if (listToImport && !standalone) {
- // only show consent message when wallet is connected and consent is required
- const listBlockReason =
- account && requiresConsent ? t`This list requires consent before importing.` : undefined
-
- return (
-
- )
- }
-
- if (isManageWidgetOpen && !standalone) {
- return (
- setIsManageWidgetOpen(false)}
- />
- )
- }
-
- if (selectedPoolAddress) {
- return (
-
- )
- }
+ // If there's post-flow content, render it instead of the base view
+ if (flowResult.postFlowResult?.content) {
+ return flowResult.postFlowResult.content
+ }
- 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}
- />
- )
- })()}
-
- )
+ // Render the base view with optional flow data
+
+ switch (flowResult.baseView) {
+ case TokenSelectorView.ImportToken:
+ return
+ case TokenSelectorView.ImportList:
+ return
+ case TokenSelectorView.Manage:
+ return
+ case TokenSelectorView.LpToken:
+ return
+ default:
+ // Main token list view
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }
}
+
+// re-export for external use
+export { SelectTokenModal }
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/SelectTokenModal.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/SelectTokenModal.tsx
new file mode 100644
index 00000000000..f3b59174d86
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/SelectTokenModal.tsx
@@ -0,0 +1,86 @@
+import { MouseEvent, ReactNode } from 'react'
+
+import { useMediaQuery } from '@cowprotocol/common-hooks'
+import { Media } from '@cowprotocol/ui'
+
+import { createPortal } from 'react-dom'
+
+import {
+ ConnectedChainSelector,
+ ConnectedDesktopChainPanel,
+ ConnectedHeader,
+ ConnectedSearch,
+ ConnectedTokenList,
+ ImportListView,
+ ImportTokenView,
+ LpTokenView,
+ ManageView,
+ NetworkPanel,
+} from './slots'
+
+import { useCloseTokenSelectWidget } from '../../../hooks/useCloseTokenSelectWidget'
+import { useSelectTokenWidgetState } from '../../../hooks/useSelectTokenWidgetState'
+import {
+ useChainPanelState,
+ useDismissHandler,
+ useManageWidgetVisibility,
+ useWidgetEffects,
+ useWidgetOpenState,
+} from '../hooks'
+import { InnerWrapper, ModalContainer, WidgetCard, WidgetOverlay, Wrapper } from '../styled'
+
+export interface SelectTokenModalProps {
+ children: ReactNode
+}
+
+export function SelectTokenModal({ children }: SelectTokenModalProps): ReactNode {
+ const isOpen = useWidgetOpenState()
+ const isCompactLayout = useMediaQuery(Media.upToMedium(false))
+ const widgetState = useSelectTokenWidgetState()
+ const { closeManageWidget } = useManageWidgetVisibility()
+ const closeTokenSelectWidget = useCloseTokenSelectWidget()
+ const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
+
+ const chainPanel = useChainPanelState(widgetState.tradeType)
+ const isChainPanelVisible = chainPanel.isEnabled && !isCompactLayout
+
+ useWidgetEffects(isOpen)
+
+ if (!isOpen) return null
+
+ const handleOverlayClick = (event: MouseEvent): void => {
+ if (event.target === event.currentTarget) onDismiss()
+ }
+
+ const content = (
+
+
+ {children}
+
+
+ )
+
+ const overlay = (
+
+
+ {content}
+
+
+ )
+
+ return typeof document === 'undefined' ? overlay : createPortal(overlay, document.body)
+}
+
+// Slot components
+SelectTokenModal.Header = ConnectedHeader
+SelectTokenModal.Search = ConnectedSearch
+SelectTokenModal.TokenList = ConnectedTokenList
+SelectTokenModal.Panel = NetworkPanel
+SelectTokenModal.ChainSelector = ConnectedChainSelector
+SelectTokenModal.DesktopChainPanel = ConnectedDesktopChainPanel
+
+// Blocking views
+SelectTokenModal.ImportToken = ImportTokenView
+SelectTokenModal.ImportList = ImportListView
+SelectTokenModal.Manage = ManageView
+SelectTokenModal.LpToken = LpTokenView
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/index.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/index.ts
new file mode 100644
index 00000000000..cd1a822f839
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/index.ts
@@ -0,0 +1,26 @@
+export { SelectTokenModal } from './SelectTokenModal'
+export type { SelectTokenModalProps } from './SelectTokenModal'
+
+export {
+ Header,
+ ConnectedHeader,
+ Search,
+ ConnectedSearch,
+ TokenList,
+ ConnectedTokenList,
+ NetworkPanel,
+ ChainSelector,
+ ConnectedChainSelector,
+ DesktopChainPanel,
+ ConnectedDesktopChainPanel,
+ ImportTokenView,
+ ImportListView,
+ ManageView,
+ LpTokenView,
+} from './slots'
+
+export type { HeaderProps } from './slots/Header'
+export type { SearchProps } from './slots/Search'
+export type { ChainSelectorProps } from './slots/ChainSelector'
+export type { DesktopChainPanelProps } from './slots/DesktopChainPanel'
+export type { TokenListProps } from './slots/TokenList'
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ChainSelector.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ChainSelector.tsx
new file mode 100644
index 00000000000..5bf41b81050
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ChainSelector.tsx
@@ -0,0 +1,57 @@
+import { ReactNode, useState, useCallback } from 'react'
+
+import { useMediaQuery } from '@cowprotocol/common-hooks'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { Media } from '@cowprotocol/ui'
+
+import { t } from '@lingui/core/macro'
+
+import { MobileChainSelector } from '../../../../pure/SelectTokenModal/MobileChainSelector'
+import { ChainsToSelectState } from '../../../../types'
+import { MobileChainPanelPortal } from '../../MobileChainPanelPortal'
+
+export interface ChainSelectorProps {
+ chains?: ChainsToSelectState
+ title?: string
+ onSelectChain: (chain: ChainInfo) => void
+}
+
+export function ChainSelector({ chains, title = t`Select network`, onSelectChain }: ChainSelectorProps): ReactNode {
+ const [isMobilePanelOpen, setMobilePanelOpen] = useState(false)
+ const isCompactLayout = useMediaQuery(Media.upToMedium(false))
+
+ const openPanel = useCallback(() => setMobilePanelOpen(true), [])
+ const closePanel = useCallback(() => setMobilePanelOpen(false), [])
+
+ const handleSelectChain = useCallback(
+ (chain: ChainInfo) => {
+ onSelectChain(chain)
+ closePanel()
+ },
+ [onSelectChain, closePanel],
+ )
+
+ if (!isCompactLayout || !chains) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ {isMobilePanelOpen && (
+
+ )}
+ >
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedChainSelector.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedChainSelector.tsx
new file mode 100644
index 00000000000..6fee7956a12
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedChainSelector.tsx
@@ -0,0 +1,15 @@
+import { ReactNode } from 'react'
+
+import { ChainSelector } from './ChainSelector'
+
+import { useSelectTokenWidgetState } from '../../../../hooks/useSelectTokenWidgetState'
+import { useChainPanelState } from '../../hooks'
+
+export function ConnectedChainSelector(): ReactNode {
+ const widgetState = useSelectTokenWidgetState()
+ const chainPanel = useChainPanelState(widgetState.tradeType)
+
+ if (!chainPanel.isEnabled) return null
+
+ return
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedDesktopChainPanel.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedDesktopChainPanel.tsx
new file mode 100644
index 00000000000..55559f7a8b4
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedDesktopChainPanel.tsx
@@ -0,0 +1,19 @@
+import { ReactNode } from 'react'
+
+import { useMediaQuery } from '@cowprotocol/common-hooks'
+import { Media } from '@cowprotocol/ui'
+
+import { DesktopChainPanel } from './DesktopChainPanel'
+
+import { useSelectTokenWidgetState } from '../../../../hooks/useSelectTokenWidgetState'
+import { useChainPanelState } from '../../hooks'
+
+export function ConnectedDesktopChainPanel(): ReactNode {
+ const widgetState = useSelectTokenWidgetState()
+ const chainPanel = useChainPanelState(widgetState.tradeType)
+ const isCompactLayout = useMediaQuery(Media.upToMedium(false))
+
+ if (!chainPanel.isEnabled || isCompactLayout) return null
+
+ return
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedHeader.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedHeader.tsx
new file mode 100644
index 00000000000..d408fa0cb8a
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedHeader.tsx
@@ -0,0 +1,22 @@
+import { ReactNode } from 'react'
+
+import { Header } from './Header'
+
+import { useCloseTokenSelectWidget } from '../../../../hooks/useCloseTokenSelectWidget'
+import { useDismissHandler, useHeaderState, useManageWidgetVisibility } from '../../hooks'
+
+export function ConnectedHeader(): ReactNode {
+ const { closeManageWidget } = useManageWidgetVisibility()
+ const closeTokenSelectWidget = useCloseTokenSelectWidget()
+ const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
+ const { title, showManageButton, onOpenManageWidget } = useHeaderState()
+
+ return (
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedSearch.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedSearch.tsx
new file mode 100644
index 00000000000..72907fc1bcb
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedSearch.tsx
@@ -0,0 +1,11 @@
+import { ReactNode } from 'react'
+
+import { Search } from './Search'
+
+import { useSelectTokenWidgetState } from '../../../../hooks/useSelectTokenWidgetState'
+
+export function ConnectedSearch(): ReactNode {
+ const { onInputPressEnter } = useSelectTokenWidgetState()
+
+ return
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedTokenList.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedTokenList.tsx
new file mode 100644
index 00000000000..61a37967c14
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ConnectedTokenList.tsx
@@ -0,0 +1,10 @@
+import { ReactNode } from 'react'
+
+import { TokenList } from './TokenList'
+
+import { useTokensToSelect } from '../../../../hooks/useTokensToSelect'
+
+export function ConnectedTokenList(): ReactNode {
+ const { isRouteAvailable } = useTokensToSelect()
+ return
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/DesktopChainPanel.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/DesktopChainPanel.tsx
new file mode 100644
index 00000000000..36794a95c25
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/DesktopChainPanel.tsx
@@ -0,0 +1,30 @@
+import { ReactNode } from 'react'
+
+import { useMediaQuery } from '@cowprotocol/common-hooks'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { Media } from '@cowprotocol/ui'
+
+import { t } from '@lingui/core/macro'
+
+import { ChainPanel } from '../../../../pure/ChainPanel'
+import { ChainsToSelectState } from '../../../../types'
+
+export interface DesktopChainPanelProps {
+ chains?: ChainsToSelectState
+ title?: string
+ onSelectChain: (chain: ChainInfo) => void
+}
+
+export function DesktopChainPanel({
+ chains,
+ title = t`Select network`,
+ onSelectChain,
+}: DesktopChainPanelProps): ReactNode {
+ const isCompactLayout = useMediaQuery(Media.upToMedium(false))
+
+ if (isCompactLayout || !chains) {
+ return null
+ }
+
+ return
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/Header.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/Header.tsx
new file mode 100644
index 00000000000..c299089b130
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/Header.tsx
@@ -0,0 +1,44 @@
+import { ReactNode } from 'react'
+
+import { BackButton } from '@cowprotocol/ui'
+
+import { t } from '@lingui/core/macro'
+
+import { SettingsIcon } from 'modules/trade/pure/Settings'
+
+import * as styledEl from '../../../../pure/SelectTokenModal/styled'
+
+export interface HeaderProps {
+ title?: string
+ showManageButton?: boolean
+ onDismiss: () => void
+ onOpenManageWidget?: () => void
+}
+
+export function Header({
+ title = t`Select token`,
+ showManageButton = false,
+ onDismiss,
+ onOpenManageWidget,
+}: HeaderProps): ReactNode {
+ return (
+
+
+
+ {title}
+
+ {showManageButton && onOpenManageWidget && (
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ImportListView.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ImportListView.tsx
new file mode 100644
index 00000000000..98a36dac5df
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ImportListView.tsx
@@ -0,0 +1,14 @@
+import { ReactNode } from 'react'
+
+import { ImportListModal } from '../../../../pure/ImportListModal'
+import { useImportListViewState } from '../../hooks'
+
+export function ImportListView(): ReactNode {
+ const state = useImportListViewState()
+
+ if (!state) return null
+
+ return (
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ImportTokenView.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ImportTokenView.tsx
new file mode 100644
index 00000000000..dfd19c0cb7c
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ImportTokenView.tsx
@@ -0,0 +1,24 @@
+import { ReactNode } from 'react'
+
+import { ImportTokenModal, ImportTokenModalProps } from '../../../../pure/ImportTokenModal'
+import { useImportTokenViewState } from '../../hooks'
+
+export interface ImportTokenViewProps {
+ flowData?: Partial
+}
+
+export function ImportTokenView({ flowData }: ImportTokenViewProps): ReactNode {
+ const state = useImportTokenViewState()
+
+ if (!state) return null
+
+ return (
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/LpTokenView.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/LpTokenView.tsx
new file mode 100644
index 00000000000..6b0a84ee339
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/LpTokenView.tsx
@@ -0,0 +1,19 @@
+import { ReactNode } from 'react'
+
+import { LpTokenPage } from '../../../LpTokenPage'
+import { useLpTokenViewState } from '../../hooks'
+
+export function LpTokenView(): ReactNode {
+ const state = useLpTokenViewState()
+
+ if (!state) return null
+
+ return (
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ManageView.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ManageView.tsx
new file mode 100644
index 00000000000..166b01c7cc3
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/ManageView.tsx
@@ -0,0 +1,12 @@
+import { ReactNode } from 'react'
+
+import { ManageListsAndTokens } from '../../../ManageListsAndTokens'
+import { useManageViewState } from '../../hooks'
+
+export function ManageView(): ReactNode {
+ const state = useManageViewState()
+
+ if (!state) return null
+
+ return
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/NetworkPanel.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/NetworkPanel.tsx
new file mode 100644
index 00000000000..e32ef2af7d5
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/NetworkPanel.tsx
@@ -0,0 +1,22 @@
+import { ReactNode } from 'react'
+
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+import { t } from '@lingui/core/macro'
+
+import { ChainPanel } from '../../../../pure/ChainPanel'
+import { ChainsToSelectState } from '../../../../types'
+
+export interface NetworkPanelProps {
+ chains?: ChainsToSelectState
+ title?: string
+ onSelectChain: (chain: ChainInfo) => void
+}
+
+export function NetworkPanel({ chains, title = t`Select network`, onSelectChain }: NetworkPanelProps): ReactNode {
+ if (!chains) {
+ return null
+ }
+
+ return
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/Search.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/Search.tsx
new file mode 100644
index 00000000000..09e9a8a69e5
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/Search.tsx
@@ -0,0 +1,48 @@
+import { useAtomValue } from 'jotai'
+import { ReactNode, useCallback } from 'react'
+
+import { SearchInput as SearchInputUI } from '@cowprotocol/ui'
+
+import { t } from '@lingui/core/macro'
+
+import { useUpdateTokenListViewState } from '../../../../hooks/useUpdateTokenListViewState'
+import * as styledEl from '../../../../pure/SelectTokenModal/styled'
+import { tokenListViewAtom } from '../../../../state/tokenListViewAtom'
+
+export interface SearchProps {
+ onPressEnter?: () => void
+ placeholder?: string
+}
+
+export function Search({ onPressEnter, placeholder }: SearchProps): ReactNode {
+ const { searchInput } = useAtomValue(tokenListViewAtom)
+ const updateTokenListView = useUpdateTokenListViewState()
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ updateTokenListView({ searchInput: e.target.value.trim() })
+ },
+ [updateTokenListView],
+ )
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') onPressEnter?.()
+ },
+ [onPressEnter],
+ )
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/TokenList.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/TokenList.tsx
new file mode 100644
index 00000000000..a2aa64cb63e
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/TokenList.tsx
@@ -0,0 +1,22 @@
+import { ReactNode } from 'react'
+
+import { Trans } from '@lingui/react/macro'
+
+import * as styledEl from '../../../../pure/SelectTokenModal/styled'
+import { TokensContent } from '../../../../pure/TokensContent'
+
+export interface TokenListProps {
+ isRouteAvailable?: boolean
+}
+
+export function TokenList({ isRouteAvailable = true }: TokenListProps): ReactNode {
+ if (!isRouteAvailable) {
+ return (
+
+ This route is not yet supported.
+
+ )
+ }
+
+ return
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/index.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/index.ts
new file mode 100644
index 00000000000..471058f1268
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/internal/slots/index.ts
@@ -0,0 +1,24 @@
+// Header slot
+export { Header } from './Header'
+export { ConnectedHeader } from './ConnectedHeader'
+
+// Search slot
+export { Search } from './Search'
+export { ConnectedSearch } from './ConnectedSearch'
+
+// Chain panel slots
+export { NetworkPanel } from './NetworkPanel'
+export { ChainSelector } from './ChainSelector'
+export { ConnectedChainSelector } from './ConnectedChainSelector'
+export { DesktopChainPanel } from './DesktopChainPanel'
+export { ConnectedDesktopChainPanel } from './ConnectedDesktopChainPanel'
+
+// Token list slot
+export { TokenList } from './TokenList'
+export { ConnectedTokenList } from './ConnectedTokenList'
+
+// Blocking view slots
+export { ImportTokenView } from './ImportTokenView'
+export { ImportListView } from './ImportListView'
+export { ManageView } from './ManageView'
+export { LpTokenView } from './LpTokenView'
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/state/index.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/state/index.ts
new file mode 100644
index 00000000000..c5b4cab2e22
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/state/index.ts
@@ -0,0 +1,27 @@
+import { atom } from 'jotai'
+
+import { atomWithPartialUpdate } from '@cowprotocol/common-utils'
+
+import { CustomFlowsRegistry } from '../types'
+
+/**
+ * Modal-level UI state for SelectTokenWidget.
+ */
+export interface SelectTokenModalUIState {
+ /** Whether the manage lists/tokens widget is open */
+ isManageWidgetOpen: boolean
+}
+
+export const DEFAULT_MODAL_UI_STATE: SelectTokenModalUIState = {
+ isManageWidgetOpen: false,
+}
+
+export const { atom: selectTokenModalUIAtom, updateAtom: updateSelectTokenModalUIAtom } = atomWithPartialUpdate(
+ atom(DEFAULT_MODAL_UI_STATE),
+)
+
+/**
+ * Custom flows registry atom.
+ * Allows external code to inject pre/post flows for any token selector view.
+ */
+export const customFlowsRegistryAtom = atom({})
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/types/TokenSelectorView.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types/TokenSelectorView.ts
new file mode 100644
index 00000000000..8f5ca927019
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types/TokenSelectorView.ts
@@ -0,0 +1,22 @@
+/**
+ * All possible views in the token selector widget.
+ *
+ * Custom flows (like consent) can be configured externally via CustomFlowsRegistry.
+ * This keeps the token selector domain-agnostic.
+ */
+export enum TokenSelectorView {
+ /** Main token list view */
+ Main,
+
+ /** Import a single token */
+ ImportToken,
+
+ /** Import a token list */
+ ImportList,
+
+ /** Manage tokens/lists */
+ Manage,
+
+ /** LP token view */
+ LpToken,
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types/ViewFlowConfig.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types/ViewFlowConfig.ts
new file mode 100644
index 00000000000..8cc06f8b8b8
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types/ViewFlowConfig.ts
@@ -0,0 +1,70 @@
+import { ReactNode } from 'react'
+
+import { TokenSelectorView } from './TokenSelectorView'
+
+import { ImportListModalProps } from '../../../pure/ImportListModal'
+import { ImportTokenModalProps } from '../../../pure/ImportTokenModal'
+
+/**
+ * Context passed to custom flow components.
+ * Contains information about the current flow and callbacks to control it.
+ */
+export interface CustomFlowContext {
+ /** The target view this flow is associated with */
+ targetView: TokenSelectorView
+ /** Callback to proceed to the target view (complete the custom flow) */
+ onDone: () => void
+ /** Callback to cancel and go back to main token list */
+ onCancel: () => void
+}
+
+/**
+ * Maps each view to its modal props type.
+ */
+export interface ViewPropsMap {
+ [TokenSelectorView.ImportToken]: ImportTokenModalProps
+ [TokenSelectorView.ImportList]: ImportListModalProps
+ [TokenSelectorView.Main]: never
+ [TokenSelectorView.Manage]: never
+ [TokenSelectorView.LpToken]: never
+}
+
+/**
+ * result of a custom flow slot.
+ * - content: component to render (or null to show base view)
+ * - data: additional props to pass to the modal
+ */
+export interface CustomFlowResult {
+ content: ReactNode | null
+ /** Additional props to merge into the modal */
+ data?: Partial
+}
+
+/**
+ * A custom flow slot that can render before or after a target view.
+ * returns CustomFlowResult or null to skip this flow entirely.
+ */
+export type CustomFlowSlot = (
+ context: CustomFlowContext,
+) => CustomFlowResult | null
+
+/**
+ * Configuration for custom flows for a specific view.
+ */
+export interface ViewFlowConfig {
+ /** Custom flow to show before the main view. */
+ preFlow?: CustomFlowSlot
+ /** Custom flow to show after the main view. */
+ postFlow?: CustomFlowSlot
+}
+
+/**
+ * Registry mapping view names to their custom flow configurations.
+ */
+export type CustomFlowsRegistry = {
+ [TokenSelectorView.ImportToken]?: ViewFlowConfig
+ [TokenSelectorView.ImportList]?: ViewFlowConfig
+ [TokenSelectorView.Main]?: ViewFlowConfig
+ [TokenSelectorView.Manage]?: ViewFlowConfig
+ [TokenSelectorView.LpToken]?: ViewFlowConfig
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types/index.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types/index.ts
new file mode 100644
index 00000000000..d34961709a0
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types/index.ts
@@ -0,0 +1,9 @@
+export { TokenSelectorView } from './TokenSelectorView'
+export type {
+ CustomFlowContext,
+ CustomFlowResult,
+ CustomFlowSlot,
+ ViewPropsMap,
+ ViewFlowConfig,
+ CustomFlowsRegistry,
+} from './ViewFlowConfig'
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 030c8cbfdc4..fdee8a42cf1 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,23 @@
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 { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
+import { useTokenListContext } from '../../hooks/useTokenListContext'
+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 } = useTokenListViewState()
+
+ const { selectTokenContext, areTokensFromBridge, allTokens } = useTokenListContext()
+
+ const { onTokenListItemClick } = selectTokenContext
-export function TokenSearchResults({
- searchInput,
- selectTokenContext,
- areTokensFromBridge,
- allTokens,
-}: TokenSearchResultsProps): ReactNode {
const { onSelectToken } = useSelectTokenWidgetState()
// Do not make search when tokens are from bridge
@@ -57,9 +51,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/useAddListImport.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts
index 8980e6da9a0..737f1650d4f 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddListImport.ts
@@ -1,72 +1,20 @@
-import { useAtomValue } from 'jotai'
import { useCallback } from 'react'
-import { getSourceAsKey, ListState, restrictedListsAtom } from '@cowprotocol/tokens'
-import { useWalletInfo } from '@cowprotocol/wallet'
-
-import {
- getConsentFromCache,
- rwaConsentCacheAtom,
- RwaConsentKey,
- useGeoStatus,
- useRwaConsentModalState,
-} from 'modules/rwa'
+import { ListState } from '@cowprotocol/tokens'
import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState'
+/**
+ * Callback to set a list for import.
+ * The actual consent/restriction logic is handled by the token selector's customFlows.
+ */
export function useAddListImport(): (listToImport: ListState) => void {
- const { account } = useWalletInfo()
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
- const { openModal: openRwaConsentModal } = useRwaConsentModalState()
- const restrictedLists = useAtomValue(restrictedListsAtom)
- const consentCache = useAtomValue(rwaConsentCacheAtom)
- const geoStatus = useGeoStatus()
return useCallback(
(listToImport: ListState) => {
- // If restricted lists not loaded or geo is loading, just proceed
- if (!restrictedLists.isLoaded || geoStatus.isLoading) {
- updateSelectTokenWidget({ listToImport })
- return
- }
-
- const sourceKey = getSourceAsKey(listToImport.source)
- const consentHash = restrictedLists.consentHashPerList[sourceKey]
-
- // If list is not in restricted lists, proceed normally
- if (!consentHash) {
- updateSelectTokenWidget({ listToImport })
- return
- }
-
- // If country is known, allow import (blocked check happens in import modal)
- if (geoStatus.country) {
- updateSelectTokenWidget({ listToImport })
- return
- }
-
- // Country unknown - if no wallet, allow import (consent check deferred to trade time)
- if (!account) {
- updateSelectTokenWidget({ listToImport })
- return
- }
-
- // Wallet connected - check if consent already given
- const consentKey: RwaConsentKey = { wallet: account, ipfsHash: consentHash }
- const existingConsent = getConsentFromCache(consentCache, consentKey)
- if (existingConsent?.acceptedAt) {
- updateSelectTokenWidget({ listToImport })
- return
- }
-
- // Wallet connected but no consent - open modal
- openRwaConsentModal({
- consentHash,
- onImportSuccess: () => {
- updateSelectTokenWidget({ listToImport })
- },
- })
+ updateSelectTokenWidget({ listToImport })
},
- [account, updateSelectTokenWidget, openRwaConsentModal, restrictedLists, consentCache, geoStatus],
+ [updateSelectTokenWidget],
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts
index bd3115670d5..6304a81aa09 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useAddTokenImportCallback.ts
@@ -1,88 +1,20 @@
-import { useAtomValue } from 'jotai'
import { useCallback } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
-import { useFeatureFlags } from '@cowprotocol/common-hooks'
-import { findRestrictedToken, restrictedTokensAtom } from '@cowprotocol/tokens'
-import { useWalletInfo } from '@cowprotocol/wallet'
-
-import {
- getConsentFromCache,
- rwaConsentCacheAtom,
- RwaConsentKey,
- useGeoStatus,
- useRwaConsentModalState,
-} from 'modules/rwa'
import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState'
+/**
+ * Callback to set a token for import.
+ * The actual consent/restriction logic is handled by the token selector's customFlows.
+ */
export function useAddTokenImportCallback(): (tokenToImport: TokenWithLogo) => void {
- const { account } = useWalletInfo()
- const { isRwaGeoblockEnabled } = useFeatureFlags()
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
- const { openModal: openRwaConsentModal } = useRwaConsentModalState()
- const restrictedList = useAtomValue(restrictedTokensAtom)
- const consentCache = useAtomValue(rwaConsentCacheAtom)
- const geoStatus = useGeoStatus()
return useCallback(
(tokenToImport: TokenWithLogo) => {
- // skip rwa checks if ff is disabled
- if (!isRwaGeoblockEnabled) {
- updateSelectTokenWidget({ tokenToImport })
- return
- }
-
- if (!restrictedList.isLoaded || geoStatus.isLoading) {
- updateSelectTokenWidget({ tokenToImport })
- return
- }
-
- const restrictedInfo = findRestrictedToken(tokenToImport, restrictedList)
-
- if (!restrictedInfo) {
- updateSelectTokenWidget({ tokenToImport })
- return
- }
-
- // if country is known, allow import (blocked check happens in import modal)
- if (geoStatus.country) {
- updateSelectTokenWidget({ tokenToImport })
- return
- }
-
- // country unknown - if no wallet allow import
- if (!account) {
- updateSelectTokenWidget({ tokenToImport })
- return
- }
-
- // wallet connected - check if consent already given
- const consentKey: RwaConsentKey = { wallet: account, ipfsHash: restrictedInfo.consentHash }
- const existingConsent = getConsentFromCache(consentCache, consentKey)
- if (existingConsent?.acceptedAt) {
- updateSelectTokenWidget({ tokenToImport })
- return
- }
-
- // wallet connected but no consent - open modal
- openRwaConsentModal({
- consentHash: restrictedInfo.consentHash,
- token: tokenToImport,
- pendingImportTokens: [tokenToImport],
- onImportSuccess: () => {
- updateSelectTokenWidget({ tokenToImport })
- },
- })
+ updateSelectTokenWidget({ tokenToImport })
},
- [
- account,
- isRwaGeoblockEnabled,
- updateSelectTokenWidget,
- openRwaConsentModal,
- restrictedList,
- consentCache,
- geoStatus,
- ],
+ [updateSelectTokenWidget],
)
}
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..3d6dce3ff74
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
@@ -0,0 +1,409 @@
+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(),
+}))
+
+jest.mock('common/hooks/useShouldHideNetworkSelector', () => ({
+ useShouldHideNetworkSelector: 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
+
+const { useShouldHideNetworkSelector } = require('common/hooks/useShouldHideNetworkSelector')
+const mockUseShouldHideNetworkSelector = useShouldHideNetworkSelector as jest.MockedFunction<
+ typeof useShouldHideNetworkSelector
+>
+
+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)
+ mockUseShouldHideNetworkSelector.mockReturnValue(false)
+ })
+
+ 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 23d2eaa806c..c96b04c9f90 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
@@ -1,107 +1,94 @@
import { useMemo } from 'react'
import { CHAIN_INFO } from '@cowprotocol/common-const'
-import { useAvailableChains, useFeatureFlags, useIsBridgingEnabled } from '@cowprotocol/common-hooks'
-import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
+import { useAvailableChains, useIsBridgingEnabled } from '@cowprotocol/common-hooks'
+import { ChainInfo } 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 { useShouldHideNetworkSelector } from 'common/hooks/useShouldHideNetworkSelector'
import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
import { ChainsToSelectState } from '../types'
+import { createOutputChainsState } from '../utils/chainsState'
import { mapChainInfo } from '../utils/mapChainInfo'
+import { sortChainsByDisplayOrder } from '../utils/sortChainsByDisplayOrder'
+
+// Re-export for tests and external usage
+export { createInputChainsState, createOutputChainsState } from '../utils/chainsState'
/**
* 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.
+ * 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 shouldHideNetworkSelector = useShouldHideNetworkSelector()
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: shouldHideNetworkSelector ? [] : supportedChains,
- isLoading: false,
- }
- }
-
- /**
- * When the source chain is not supported by bridge provider
- * We act as non-bridge mode
- */
- if (!isSourceChainSupportedByBridge) {
- return {
- defaultChainId: selectedTargetChainId,
- chains: [],
+ chains: shouldHideNetworkSelector ? [] : sortChainsByDisplayOrder(supportedChains),
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,
shouldHideNetworkSelector,
])
}
-
-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)
- }
-}
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/useConsentAwareToggleList.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts
index c92aaea75f7..28b4ae7dd58 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useConsentAwareToggleList.ts
@@ -6,24 +6,24 @@ import { useFeatureFlags } from '@cowprotocol/common-hooks'
import { getSourceAsKey, ListState, restrictedListsAtom, useToggleList } from '@cowprotocol/tokens'
import { useWalletInfo } from '@cowprotocol/wallet'
-import {
- getConsentFromCache,
- rwaConsentCacheAtom,
- RwaConsentKey,
- useGeoStatus,
- useRwaConsentModalState,
-} from 'modules/rwa'
+import { getConsentFromCache, rwaConsentCacheAtom, RwaConsentKey, useGeoStatus } from 'modules/rwa'
import { CowSwapAnalyticsCategory } from 'common/analytics/types'
-// wrap toggle list functionality with consent checking
+import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState'
+
+/**
+ * Wrap toggle list functionality with consent checking.
+ * When consent is required, sets listToToggle in widget state
+ * which triggers the consent modal inside the token selector.
+ */
export function useConsentAwareToggleList(): (list: ListState, enabled: boolean) => void {
const { account } = useWalletInfo()
const { isRwaGeoblockEnabled } = useFeatureFlags()
const geoStatus = useGeoStatus()
const restrictedLists = useAtomValue(restrictedListsAtom)
const consentCache = useAtomValue(rwaConsentCacheAtom)
- const { openModal: openRwaConsentModal } = useRwaConsentModalState()
+ const updateWidgetState = useUpdateSelectTokenWidgetState()
const cowAnalytics = useCowAnalytics()
const baseToggleList = useToggleList((enable, source) => {
@@ -66,14 +66,9 @@ export function useConsentAwareToggleList(): (list: ListState, enabled: boolean)
const existingConsent = getConsentFromCache(consentCache, consentKey)
if (!existingConsent?.acceptedAt) {
- // wallet connected but no consent - open modal
- openRwaConsentModal({
- consentHash,
- onImportSuccess: () => {
- // after consent, toggle the list on
- baseToggleList(list, enabled)
- },
- })
+ // wallet connected but no consent - set pending state (data only, no callbacks)
+ // the token selector's postFlow will show the consent modal
+ updateWidgetState({ listToToggle: { list, consentHash } })
return
}
}
@@ -89,7 +84,7 @@ export function useConsentAwareToggleList(): (list: ListState, enabled: boolean)
restrictedLists,
account,
consentCache,
- openRwaConsentModal,
+ updateWidgetState,
],
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts
index 34e330b60fe..98e6498e2bb 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
interface DeferredVisibilityOptions {
/**
@@ -64,5 +64,5 @@ export function useDeferredVisibility(
setElement(node)
}, [])
- return { ref, isVisible }
+ return useMemo(() => ({ ref, isVisible }), [ref, isVisible])
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useHydratedRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useHydratedRecentTokens.ts
new file mode 100644
index 00000000000..1cbbe1cab72
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useHydratedRecentTokens.ts
@@ -0,0 +1,53 @@
+import { useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import {
+ getStoredTokenKey,
+ hydrateStoredToken,
+ RECENT_TOKENS_LIMIT,
+ type StoredRecentTokensByChain,
+} from '../utils/recentTokensStorage'
+
+interface UseHydratedRecentTokensParams {
+ storedTokensByChain: StoredRecentTokensByChain
+ tokensByKey: Map
+ favoriteKeys: Set
+ activeChainId?: number
+ maxItems?: number
+}
+
+export function useHydratedRecentTokens({
+ storedTokensByChain,
+ tokensByKey,
+ favoriteKeys,
+ activeChainId,
+ maxItems = RECENT_TOKENS_LIMIT,
+}: UseHydratedRecentTokensParams): TokenWithLogo[] {
+ return 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])
+}
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..34c555636e2 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,36 @@ 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.test.tsx b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.test.tsx
new file mode 100644
index 00000000000..5e9c4ca189b
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.test.tsx
@@ -0,0 +1,250 @@
+import { createStore, Provider } from 'jotai'
+import { ReactNode } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { act, renderHook } from '@testing-library/react'
+
+import { useRecentTokens } from './useRecentTokens'
+
+import { recentTokensStorageAtom } from '../state/recentTokensStorageAtom'
+import {
+ RECENT_TOKENS_STORAGE_KEY,
+ readStoredTokens,
+ RECENT_TOKENS_LIMIT,
+ StoredRecentToken,
+} from '../utils/recentTokensStorage'
+
+// Test addresses
+const ADDRESS_1 = '0x1111111111111111111111111111111111111111'
+const ADDRESS_2 = '0x2222222222222222222222222222222222222222'
+const ADDRESS_3 = '0x3333333333333333333333333333333333333333'
+
+const DEFAULT_DECIMALS = 18
+
+function createTestToken(chainId: number, address: string, symbol: string): TokenWithLogo {
+ return new TokenWithLogo(undefined, chainId, address, DEFAULT_DECIMALS, symbol, `${symbol} Token`, undefined, [])
+}
+
+function createStoredToken(chainId: number, address: string, symbol?: string): StoredRecentToken {
+ return { chainId, address, decimals: DEFAULT_DECIMALS, symbol }
+}
+
+function setStoredTokens(tokens: Record): void {
+ localStorage.setItem(RECENT_TOKENS_STORAGE_KEY, JSON.stringify(tokens))
+}
+
+function createTestWrapper(store: ReturnType) {
+ return function TestWrapper({ children }: { children: ReactNode }) {
+ return {children}
+ }
+}
+
+function createStoreWithLocalStorage(): ReturnType {
+ const store = createStore()
+ // Initialize atom with current localStorage state
+ store.set(recentTokensStorageAtom, readStoredTokens(RECENT_TOKENS_LIMIT))
+ return store
+}
+
+describe('useRecentTokens', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('returns empty array when no stored tokens', () => {
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokens({
+ allTokens: [],
+ favoriteTokens: [],
+ activeChainId: SupportedChainId.MAINNET,
+ }),
+ { wrapper },
+ )
+
+ expect(result.current.recentTokens).toEqual([])
+ })
+
+ it('returns recent tokens for the active chain', () => {
+ const token = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'TKN')
+
+ setStoredTokens({
+ [SupportedChainId.MAINNET]: [createStoredToken(SupportedChainId.MAINNET, ADDRESS_1, 'TKN')],
+ })
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokens({
+ allTokens: [token],
+ favoriteTokens: [],
+ activeChainId: SupportedChainId.MAINNET,
+ }),
+ { wrapper },
+ )
+
+ expect(result.current.recentTokens).toHaveLength(1)
+ expect(result.current.recentTokens[0].symbol).toBe('TKN')
+ })
+
+ it('does not return tokens from other chains', () => {
+ setStoredTokens({
+ [SupportedChainId.GNOSIS_CHAIN]: [createStoredToken(SupportedChainId.GNOSIS_CHAIN, ADDRESS_1, 'TKN')],
+ })
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokens({
+ allTokens: [],
+ favoriteTokens: [],
+ activeChainId: SupportedChainId.MAINNET,
+ }),
+ { wrapper },
+ )
+
+ expect(result.current.recentTokens).toEqual([])
+ })
+
+ it('filters out favorite tokens from recent tokens', () => {
+ const favoriteToken = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'FAV')
+ const regularToken = createTestToken(SupportedChainId.MAINNET, ADDRESS_2, 'REG')
+
+ setStoredTokens({
+ [SupportedChainId.MAINNET]: [
+ createStoredToken(SupportedChainId.MAINNET, ADDRESS_1),
+ createStoredToken(SupportedChainId.MAINNET, ADDRESS_2),
+ ],
+ })
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokens({
+ allTokens: [favoriteToken, regularToken],
+ favoriteTokens: [favoriteToken],
+ activeChainId: SupportedChainId.MAINNET,
+ }),
+ { wrapper },
+ )
+
+ expect(result.current.recentTokens).toHaveLength(1)
+ expect(result.current.recentTokens[0].address.toLowerCase()).toBe(ADDRESS_2)
+ })
+
+ it('addRecentToken adds a token to the list', () => {
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const token = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'NEW')
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokens({
+ allTokens: [token],
+ favoriteTokens: [],
+ activeChainId: SupportedChainId.MAINNET,
+ }),
+ { wrapper },
+ )
+
+ expect(result.current.recentTokens).toHaveLength(0)
+
+ act(() => {
+ result.current.addRecentToken(token)
+ })
+
+ expect(result.current.recentTokens).toHaveLength(1)
+ expect(result.current.recentTokens[0].symbol).toBe('NEW')
+ })
+
+ it('addRecentToken does not add favorite tokens', () => {
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const favoriteToken = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'FAV')
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokens({
+ allTokens: [favoriteToken],
+ favoriteTokens: [favoriteToken],
+ activeChainId: SupportedChainId.MAINNET,
+ }),
+ { wrapper },
+ )
+
+ act(() => {
+ result.current.addRecentToken(favoriteToken)
+ })
+
+ expect(result.current.recentTokens).toHaveLength(0)
+ })
+
+ it('clearRecentTokens clears tokens for the active chain', () => {
+ const token = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'TKN')
+
+ setStoredTokens({
+ [SupportedChainId.MAINNET]: [createStoredToken(SupportedChainId.MAINNET, ADDRESS_1)],
+ })
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokens({
+ allTokens: [token],
+ favoriteTokens: [],
+ activeChainId: SupportedChainId.MAINNET,
+ }),
+ { wrapper },
+ )
+
+ expect(result.current.recentTokens).toHaveLength(1)
+
+ act(() => {
+ result.current.clearRecentTokens()
+ })
+
+ expect(result.current.recentTokens).toHaveLength(0)
+ })
+
+ it('respects maxItems limit', () => {
+ setStoredTokens({
+ [SupportedChainId.MAINNET]: [
+ createStoredToken(SupportedChainId.MAINNET, ADDRESS_1),
+ createStoredToken(SupportedChainId.MAINNET, ADDRESS_2),
+ createStoredToken(SupportedChainId.MAINNET, ADDRESS_3),
+ ],
+ })
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const tokens = [
+ createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'T1'),
+ createTestToken(SupportedChainId.MAINNET, ADDRESS_2, 'T2'),
+ createTestToken(SupportedChainId.MAINNET, ADDRESS_3, 'T3'),
+ ]
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokens({
+ allTokens: tokens,
+ favoriteTokens: [],
+ activeChainId: SupportedChainId.MAINNET,
+ maxItems: 2,
+ }),
+ { wrapper },
+ )
+
+ expect(result.current.recentTokens).toHaveLength(2)
+ })
+})
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..c9d41c34e03
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
@@ -0,0 +1,66 @@
+import { useCallback, useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import { useHydratedRecentTokens } from './useHydratedRecentTokens'
+import { useRecentTokensStorage } from './useRecentTokensStorage'
+
+import {
+ buildFavoriteTokenKeys,
+ buildTokensByKey,
+ persistRecentTokenSelection as persistRecentTokenSelectionInternal,
+ RECENT_TOKENS_LIMIT,
+} from '../utils/recentTokensStorage'
+
+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 tokensByKey = useMemo(() => buildTokensByKey(allTokens), [allTokens])
+ const favoriteKeys = useMemo(() => buildFavoriteTokenKeys(favoriteTokens), [favoriteTokens])
+
+ const {
+ storedTokensByChain,
+ addRecentToken,
+ clearRecentTokens: clearForChain,
+ } = useRecentTokensStorage({
+ favoriteKeys,
+ maxItems,
+ })
+
+ const recentTokens = useHydratedRecentTokens({
+ storedTokensByChain,
+ tokensByKey,
+ favoriteKeys,
+ activeChainId,
+ maxItems,
+ })
+
+ const clearRecentTokens = useCallback(() => {
+ if (activeChainId) {
+ clearForChain(activeChainId)
+ }
+ }, [activeChainId, clearForChain])
+
+ return useMemo(
+ () => ({ recentTokens, addRecentToken, clearRecentTokens }),
+ [recentTokens, addRecentToken, clearRecentTokens],
+ )
+}
+
+export { persistRecentTokenSelectionInternal as persistRecentTokenSelection }
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokensStorage.test.tsx b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokensStorage.test.tsx
new file mode 100644
index 00000000000..77521d0f572
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokensStorage.test.tsx
@@ -0,0 +1,305 @@
+import { createStore, Provider } from 'jotai'
+import { ReactNode } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { getTokenId } from '@cowprotocol/common-utils'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { act, renderHook } from '@testing-library/react'
+
+import { useRecentTokensStorage } from './useRecentTokensStorage'
+
+import { recentTokensStorageAtom } from '../state/recentTokensStorageAtom'
+import {
+ RECENT_TOKENS_STORAGE_KEY,
+ readStoredTokens,
+ RECENT_TOKENS_LIMIT,
+ StoredRecentToken,
+ StoredRecentTokensByChain,
+} from '../utils/recentTokensStorage'
+
+// Test addresses
+const ADDRESS_1 = '0x1111111111111111111111111111111111111111'
+const ADDRESS_2 = '0x2222222222222222222222222222222222222222'
+const ADDRESS_3 = '0x3333333333333333333333333333333333333333'
+
+const DEFAULT_DECIMALS = 18
+
+function createTestToken(chainId: number, address: string, symbol: string): TokenWithLogo {
+ return new TokenWithLogo(undefined, chainId, address, DEFAULT_DECIMALS, symbol, `${symbol} Token`, undefined, [])
+}
+
+function createStoredToken(chainId: number, address: string, symbol?: string): StoredRecentToken {
+ return { chainId, address, decimals: DEFAULT_DECIMALS, symbol }
+}
+
+function setStoredTokens(tokens: StoredRecentTokensByChain): void {
+ localStorage.setItem(RECENT_TOKENS_STORAGE_KEY, JSON.stringify(tokens))
+}
+
+function getTokenKey(chainId: number, address: string): string {
+ return getTokenId({ chainId, address })
+}
+
+function createTestWrapper(store: ReturnType) {
+ return function TestWrapper({ children }: { children: ReactNode }) {
+ return {children}
+ }
+}
+
+function createStoreWithLocalStorage(): ReturnType {
+ const store = createStore()
+ // Initialize atom with current localStorage state
+ store.set(recentTokensStorageAtom, readStoredTokens(RECENT_TOKENS_LIMIT))
+ return store
+}
+
+describe('useRecentTokensStorage', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ describe('initial state', () => {
+ it('returns empty object when localStorage is empty', () => {
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokensStorage({
+ favoriteKeys: new Set(),
+ }),
+ { wrapper },
+ )
+
+ expect(result.current.storedTokensByChain).toEqual({})
+ })
+
+ it('loads stored tokens from localStorage', () => {
+ const storedData: StoredRecentTokensByChain = {
+ [SupportedChainId.MAINNET]: [createStoredToken(SupportedChainId.MAINNET, ADDRESS_1, 'TKN')],
+ }
+ setStoredTokens(storedData)
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokensStorage({
+ favoriteKeys: new Set(),
+ }),
+ { wrapper },
+ )
+
+ expect(result.current.storedTokensByChain[SupportedChainId.MAINNET]).toHaveLength(1)
+ expect(result.current.storedTokensByChain[SupportedChainId.MAINNET][0].address).toBe(ADDRESS_1)
+ })
+
+ it('loads tokens from multiple chains', () => {
+ const storedData: StoredRecentTokensByChain = {
+ [SupportedChainId.MAINNET]: [createStoredToken(SupportedChainId.MAINNET, ADDRESS_1)],
+ [SupportedChainId.GNOSIS_CHAIN]: [createStoredToken(SupportedChainId.GNOSIS_CHAIN, ADDRESS_2)],
+ }
+ setStoredTokens(storedData)
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokensStorage({
+ favoriteKeys: new Set(),
+ }),
+ { wrapper },
+ )
+
+ expect(result.current.storedTokensByChain[SupportedChainId.MAINNET]).toHaveLength(1)
+ expect(result.current.storedTokensByChain[SupportedChainId.GNOSIS_CHAIN]).toHaveLength(1)
+ })
+ })
+
+ describe('addRecentToken', () => {
+ it('adds a token to the stored tokens', () => {
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokensStorage({
+ favoriteKeys: new Set(),
+ }),
+ { wrapper },
+ )
+
+ const token = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'NEW')
+
+ act(() => {
+ result.current.addRecentToken(token)
+ })
+
+ expect(result.current.storedTokensByChain[SupportedChainId.MAINNET]).toHaveLength(1)
+ expect(result.current.storedTokensByChain[SupportedChainId.MAINNET][0].address).toBe(ADDRESS_1.toLowerCase())
+ })
+
+ it('does not add favorite tokens', () => {
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const favoriteKeys = new Set([getTokenKey(SupportedChainId.MAINNET, ADDRESS_1)])
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokensStorage({
+ favoriteKeys,
+ }),
+ { wrapper },
+ )
+
+ const token = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'FAV')
+
+ act(() => {
+ result.current.addRecentToken(token)
+ })
+
+ expect(result.current.storedTokensByChain[SupportedChainId.MAINNET]).toBeUndefined()
+ })
+
+ it('moves existing token to the front', () => {
+ const storedData: StoredRecentTokensByChain = {
+ [SupportedChainId.MAINNET]: [
+ createStoredToken(SupportedChainId.MAINNET, ADDRESS_1, 'T1'),
+ createStoredToken(SupportedChainId.MAINNET, ADDRESS_2, 'T2'),
+ ],
+ }
+ setStoredTokens(storedData)
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokensStorage({
+ favoriteKeys: new Set(),
+ }),
+ { wrapper },
+ )
+
+ // Add TOKEN_2 again - should move to front
+ const token = createTestToken(SupportedChainId.MAINNET, ADDRESS_2, 'T2')
+
+ act(() => {
+ result.current.addRecentToken(token)
+ })
+
+ const tokens = result.current.storedTokensByChain[SupportedChainId.MAINNET]
+ expect(tokens).toHaveLength(2)
+ expect(tokens[0].address).toBe(ADDRESS_2.toLowerCase())
+ expect(tokens[1].address).toBe(ADDRESS_1)
+ })
+
+ it('respects maxItems limit', () => {
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokensStorage({
+ favoriteKeys: new Set(),
+ maxItems: 2,
+ }),
+ { wrapper },
+ )
+
+ const token1 = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'T1')
+ const token2 = createTestToken(SupportedChainId.MAINNET, ADDRESS_2, 'T2')
+ const token3 = createTestToken(SupportedChainId.MAINNET, ADDRESS_3, 'T3')
+
+ act(() => {
+ result.current.addRecentToken(token1)
+ result.current.addRecentToken(token2)
+ result.current.addRecentToken(token3)
+ })
+
+ const tokens = result.current.storedTokensByChain[SupportedChainId.MAINNET]
+ expect(tokens).toHaveLength(2)
+ // Most recent should be first
+ expect(tokens[0].address).toBe(ADDRESS_3.toLowerCase())
+ expect(tokens[1].address).toBe(ADDRESS_2.toLowerCase())
+ })
+
+ it('adds tokens to different chains independently', () => {
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokensStorage({
+ favoriteKeys: new Set(),
+ }),
+ { wrapper },
+ )
+
+ const mainnetToken = createTestToken(SupportedChainId.MAINNET, ADDRESS_1, 'MAIN')
+ const gnosisToken = createTestToken(SupportedChainId.GNOSIS_CHAIN, ADDRESS_2, 'GNOSIS')
+
+ act(() => {
+ result.current.addRecentToken(mainnetToken)
+ result.current.addRecentToken(gnosisToken)
+ })
+
+ expect(result.current.storedTokensByChain[SupportedChainId.MAINNET]).toHaveLength(1)
+ expect(result.current.storedTokensByChain[SupportedChainId.GNOSIS_CHAIN]).toHaveLength(1)
+ })
+ })
+
+ describe('clearRecentTokens', () => {
+ it('clears tokens for a specific chain', () => {
+ const storedData: StoredRecentTokensByChain = {
+ [SupportedChainId.MAINNET]: [
+ createStoredToken(SupportedChainId.MAINNET, ADDRESS_1),
+ createStoredToken(SupportedChainId.MAINNET, ADDRESS_2),
+ ],
+ [SupportedChainId.GNOSIS_CHAIN]: [createStoredToken(SupportedChainId.GNOSIS_CHAIN, ADDRESS_3)],
+ }
+ setStoredTokens(storedData)
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokensStorage({
+ favoriteKeys: new Set(),
+ }),
+ { wrapper },
+ )
+
+ act(() => {
+ result.current.clearRecentTokens(SupportedChainId.MAINNET)
+ })
+
+ expect(result.current.storedTokensByChain[SupportedChainId.MAINNET]).toEqual([])
+ // Other chain should be unaffected
+ expect(result.current.storedTokensByChain[SupportedChainId.GNOSIS_CHAIN]).toHaveLength(1)
+ })
+
+ it('does nothing when chain has no tokens', () => {
+ const store = createStoreWithLocalStorage()
+ const wrapper = createTestWrapper(store)
+
+ const { result } = renderHook(
+ () =>
+ useRecentTokensStorage({
+ favoriteKeys: new Set(),
+ }),
+ { wrapper },
+ )
+
+ const initialState = result.current.storedTokensByChain
+
+ act(() => {
+ result.current.clearRecentTokens(SupportedChainId.MAINNET)
+ })
+
+ // State reference should be the same (no unnecessary update)
+ expect(result.current.storedTokensByChain).toBe(initialState)
+ })
+ })
+})
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokensStorage.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokensStorage.ts
new file mode 100644
index 00000000000..aa886742249
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokensStorage.ts
@@ -0,0 +1,58 @@
+import { useAtom } from 'jotai'
+import { useCallback, useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { getTokenId } from '@cowprotocol/common-utils'
+
+import { recentTokensStorageAtom } from '../state/recentTokensStorageAtom'
+import {
+ buildNextStoredTokens,
+ RECENT_TOKENS_LIMIT,
+ type StoredRecentTokensByChain,
+} from '../utils/recentTokensStorage'
+
+export interface RecentTokensStorageState {
+ storedTokensByChain: StoredRecentTokensByChain
+ addRecentToken: (token: TokenWithLogo) => void
+ clearRecentTokens: (chainId: number) => void
+}
+
+interface UseRecentTokensStorageParams {
+ favoriteKeys: Set
+ maxItems?: number
+}
+
+/**
+ * Hook that provides recent tokens storage state and callbacks.
+ * Side-effects (persistence, favorites sync) are handled by RecentTokensStorageUpdater.
+ */
+export function useRecentTokensStorage({
+ favoriteKeys,
+ maxItems = RECENT_TOKENS_LIMIT,
+}: UseRecentTokensStorageParams): RecentTokensStorageState {
+ const [storedTokensByChain, setStoredTokensByChain] = useAtom(recentTokensStorageAtom)
+
+ const addRecentToken = useCallback(
+ (token: TokenWithLogo) => {
+ if (favoriteKeys.has(getTokenId(token))) return
+
+ setStoredTokensByChain((prev) => buildNextStoredTokens(prev, token, maxItems))
+ },
+ [favoriteKeys, maxItems, setStoredTokensByChain],
+ )
+
+ const clearRecentTokens = useCallback(
+ (chainId: number) => {
+ setStoredTokensByChain((prev) => {
+ if (!prev[chainId]?.length) return prev
+ return { ...prev, [chainId]: [] }
+ })
+ },
+ [setStoredTokensByChain],
+ )
+
+ return useMemo(
+ () => ({ storedTokensByChain, addRecentToken, clearRecentTokens }),
+ [storedTokensByChain, addRecentToken, clearRecentTokens],
+ )
+}
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/useSelectTokenContext.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSelectTokenContext.ts
new file mode 100644
index 00000000000..da0b8d7df7b
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSelectTokenContext.ts
@@ -0,0 +1,45 @@
+import { useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { useWalletInfo } from '@cowprotocol/wallet'
+
+import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
+
+import { useTokenDataSources } from '../containers/SelectTokenWidget/hooks/useTokenDataSources'
+import { useTokenSelectionHandler } from '../containers/SelectTokenWidget/hooks/useTokenSelectionHandler'
+import { SelectTokenContext } from '../types'
+
+interface UseSelectTokenContextParams {
+ onTokenListItemClick?: (token: TokenWithLogo) => void
+}
+
+export function useSelectTokenContext(params?: UseSelectTokenContextParams): SelectTokenContext {
+ const { account } = useWalletInfo()
+ const widgetState = useSelectTokenWidgetState()
+ const tokenData = useTokenDataSources()
+
+ const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken, widgetState)
+
+ return useMemo(
+ () => ({
+ balancesState: tokenData.balancesState,
+ selectedToken: widgetState.selectedToken,
+ onSelectToken: handleSelectToken,
+ onTokenListItemClick: params?.onTokenListItemClick,
+ unsupportedTokens: tokenData.unsupportedTokens,
+ permitCompatibleTokens: tokenData.permitCompatibleTokens,
+ tokenListTags: tokenData.tokenListTags,
+ isWalletConnected: !!account,
+ }),
+ [
+ tokenData.balancesState,
+ widgetState.selectedToken,
+ handleSelectToken,
+ params?.onTokenListItemClick,
+ tokenData.unsupportedTokens,
+ tokenData.permitCompatibleTokens,
+ tokenData.tokenListTags,
+ account,
+ ],
+ )
+}
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..7825dbe6db1 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts
@@ -1,26 +1,29 @@
import { useMemo } from 'react'
-import { SupportedChainId } from '@cowprotocol/cow-sdk'
+import { isSupportedChainId } from '@cowprotocol/common-utils'
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) {
- return {
- chainId,
- source: 'wallet',
- }
- }
+ const isSelectingSellChain = field === Field.INPUT
- return {
- chainId: selectedTargetChainId,
- source: 'selector',
+ if (
+ !open ||
+ !isSelectingSellChain ||
+ !isSupportedChainId(selectedTargetChainId) ||
+ selectedTargetChainId === chainId
+ ) {
+ return { chainId, source: 'wallet' }
}
- }, [open, chainId, selectedTargetChainId])
+
+ return { chainId: selectedTargetChainId, source: 'selector' }
+ }, [open, field, chainId, selectedTargetChainId])
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.ts
new file mode 100644
index 00000000000..4ab20fb9328
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.ts
@@ -0,0 +1,92 @@
+/**
+ * useTokenListContext - Direct hook that combines token data from source hooks
+ *
+ * Replaces the atom hydration pattern. Components call this directly
+ * instead of reading from a hydrated atom.
+ */
+import { useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { isInjectedWidget } from '@cowprotocol/common-utils'
+import { useWalletInfo } from '@cowprotocol/wallet'
+
+import { useSelectTokenContext } from './useSelectTokenContext'
+import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
+import { useTokensToSelect } from './useTokensToSelect'
+
+import { useRecentTokenSection } from '../containers/SelectTokenWidget/hooks/useRecentTokenSection'
+import { SelectTokenContext } from '../types'
+
+export interface TokenListContext {
+ // Token lists
+ allTokens: TokenWithLogo[]
+ favoriteTokens: TokenWithLogo[]
+ recentTokens: TokenWithLogo[]
+
+ // Loading state
+ areTokensLoading: boolean
+ areTokensFromBridge: boolean
+
+ // UI config
+ hideFavoriteTokensTooltip: boolean
+ selectedTargetChainId: number | undefined
+
+ // Callbacks
+ onClearRecentTokens: () => void
+ onTokenListItemClick: (token: TokenWithLogo) => void
+
+ // Context for token items
+ selectTokenContext: SelectTokenContext
+}
+
+export function useTokenListContext(): TokenListContext {
+ const { chainId: walletChainId } = useWalletInfo()
+ const widgetState = useSelectTokenWidgetState()
+ const tokensState = useTokensToSelect()
+ const standalone = widgetState.standalone ?? false
+
+ // Active chain for recent tokens
+ const activeChainId = widgetState.selectedTargetChainId ?? walletChainId
+
+ // Recent tokens section
+ const { recentTokens, handleTokenListItemClick, clearRecentTokens } = useRecentTokenSection(
+ tokensState.tokens,
+ tokensState.favoriteTokens,
+ activeChainId,
+ )
+
+ // Favorite tokens (empty in standalone mode)
+ const favoriteTokens = useMemo(
+ () => (standalone ? [] : tokensState.favoriteTokens),
+ [standalone, tokensState.favoriteTokens],
+ )
+
+ // Build context for token list items
+ const selectTokenContext = useSelectTokenContext({ onTokenListItemClick: handleTokenListItemClick })
+
+ return useMemo(
+ () => ({
+ allTokens: tokensState.tokens,
+ favoriteTokens,
+ recentTokens,
+ areTokensLoading: tokensState.isLoading,
+ areTokensFromBridge: tokensState.areTokensFromBridge,
+ hideFavoriteTokensTooltip: isInjectedWidget(),
+ selectedTargetChainId: widgetState.selectedTargetChainId,
+ onClearRecentTokens: clearRecentTokens,
+ onTokenListItemClick: handleTokenListItemClick,
+ selectTokenContext,
+ }),
+ [
+ tokensState.tokens,
+ tokensState.isLoading,
+ tokensState.areTokensFromBridge,
+ favoriteTokens,
+ recentTokens,
+ widgetState.selectedTargetChainId,
+ clearRecentTokens,
+ handleTokenListItemClick,
+ selectTokenContext,
+ ],
+ )
+}
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 544088469e6..9b63e540f8c 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/index.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/index.ts
@@ -1,5 +1,19 @@
export { SelectTokenWidget } from './containers/SelectTokenWidget'
+export type { SelectTokenWidgetProps } from './containers/SelectTokenWidget'
+
+export { TokenSelectorView } from './containers/SelectTokenWidget/types'
+export type {
+ CustomFlowContext,
+ CustomFlowResult,
+ CustomFlowSlot,
+ ViewPropsMap,
+ ViewFlowConfig,
+ CustomFlowsRegistry,
+} from './containers/SelectTokenWidget/types'
+export { customFlowsRegistryAtom } from './containers/SelectTokenWidget/state'
+
export { BlockedListSourcesUpdater } from './updaters/BlockedListSourcesUpdater'
+export { RecentTokensStorageUpdater } from './updaters/RecentTokensStorageUpdater'
export { ImportTokenModal } from './pure/ImportTokenModal'
export { AddIntermediateToken } from './pure/AddIntermediateToken'
@@ -12,4 +26,6 @@ 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'
export { useRestrictedTokensImportStatus } from './hooks/useRestrictedTokensImportStatus'
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateToken/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateToken/index.tsx
index dbb11eeccfc..7c16a3043b3 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateToken/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateToken/index.tsx
@@ -1,6 +1,7 @@
import { ReactNode, useCallback } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
+import { getTokenId } from '@cowprotocol/common-utils'
import { useAddTokenImportCallback } from 'modules/tokensList/hooks/useAddTokenImportCallback'
import { ImportTokenItem } from 'modules/tokensList/pure/ImportTokenItem'
@@ -25,7 +26,7 @@ export function AddIntermediateToken({ intermediateBuyToken, onImport }: AddInte
return (
-
+
)
}
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/ChainPanelHeader.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/ChainPanelHeader.tsx
new file mode 100644
index 00000000000..4743a7c3e92
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/ChainPanelHeader.tsx
@@ -0,0 +1,23 @@
+import { ReactNode } from 'react'
+
+import { BackButton } from '@cowprotocol/ui'
+
+import * as styledEl from './styled'
+
+export interface ChainPanelHeaderProps {
+ title: string
+ variant: 'default' | 'fullscreen'
+ onClose?: () => void
+}
+
+export function ChainPanelHeader({ title, variant, onClose }: ChainPanelHeaderProps): ReactNode {
+ const isFullscreen = variant === 'fullscreen'
+
+ return (
+
+ {isFullscreen && onClose ? : null}
+ {title}
+ {isFullscreen && onClose ? : null}
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/chainPanelUtils.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/chainPanelUtils.ts
new file mode 100644
index 00000000000..348dd1e329d
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/chainPanelUtils.ts
@@ -0,0 +1,40 @@
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+export 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
+ })
+}
+
+interface EmptyStateFlagsParams {
+ filteredChainsLength: number
+ isLoading: boolean
+ normalizedChainQuery: string
+ totalChains: number
+}
+
+export interface EmptyStateFlags {
+ showSearchEmptyState: boolean
+ showUnavailableState: boolean
+}
+
+export function getEmptyStateFlags({
+ filteredChainsLength,
+ isLoading,
+ normalizedChainQuery,
+ totalChains,
+}: EmptyStateFlagsParams): EmptyStateFlags {
+ 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/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
new file mode 100644
index 00000000000..7b76a1eaa54
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
@@ -0,0 +1,77 @@
+import { ReactNode, useMemo, useState } from 'react'
+
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+import { t } from '@lingui/core/macro'
+import { Trans } from '@lingui/react/macro'
+
+import { ChainPanelHeader } from './ChainPanelHeader'
+import { filterChainsByQuery, getEmptyStateFlags } from './chainPanelUtils'
+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}.
+
+ )}
+
+
+ )
+}
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/ChainButton.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainButton.tsx
new file mode 100644
index 00000000000..d629c940c91
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainButton.tsx
@@ -0,0 +1,69 @@
+import { ReactNode } from 'react'
+
+import OrderCheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+import { useLingui } from '@lingui/react/macro'
+import SVG from 'react-inlinesvg'
+
+import { getChainAccent } from './getChainAccent'
+import * as styledEl from './styled'
+
+export interface ChainButtonProps {
+ chain: ChainInfo
+ isActive: boolean
+ isDarkMode: boolean
+ onSelectChain(chain: ChainInfo): void
+ isDisabled: boolean
+ isLoading: boolean
+}
+
+export function ChainButton({
+ chain,
+ isActive,
+ isDarkMode,
+ onSelectChain,
+ isDisabled,
+ isLoading,
+}: ChainButtonProps): ReactNode {
+ const { t } = useLingui()
+ const logoSrc = isDarkMode ? chain.logo.dark : chain.logo.light
+ const accent = getChainAccent(chain.id)
+ const disabledTooltip = t`This destination is not supported for this source chain`
+ const loadingTooltip = t`Checking route availability...`
+
+ const handleClick = (): void => {
+ if (!isDisabled && !isLoading) {
+ onSelectChain(chain)
+ }
+ }
+
+ const tooltip = isLoading ? loadingTooltip : isDisabled ? disabledTooltip : undefined
+
+ return (
+
+
+
+
+
+
+ {chain.label}
+
+
+ {isActive && !isLoading && (
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainsList.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainsList.tsx
new file mode 100644
index 00000000000..bf651b71e90
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainsList.tsx
@@ -0,0 +1,40 @@
+import { ReactNode } from 'react'
+
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+import { ChainButton } from './ChainButton'
+import * as styledEl from './styled'
+
+export interface ChainsListProps {
+ chains: ChainInfo[]
+ defaultChainId?: ChainInfo['id']
+ onSelectChain(chain: ChainInfo): void
+ isDarkMode: boolean
+ disabledChainIds?: Set
+ loadingChainIds?: Set
+}
+
+export function ChainsList({
+ chains,
+ defaultChainId,
+ onSelectChain,
+ isDarkMode,
+ disabledChainIds,
+ loadingChainIds,
+}: ChainsListProps): ReactNode {
+ return (
+
+ {chains.map((chain) => (
+
+ ))}
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainsLoadingList.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainsLoadingList.tsx
new file mode 100644
index 00000000000..4c9821a0af7
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainsLoadingList.tsx
@@ -0,0 +1,19 @@
+import { ReactNode } from 'react'
+
+import * as styledEl from './styled'
+
+const LOADING_ITEMS_COUNT = 10
+const LOADING_SKELETON_INDICES = Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => index)
+
+export function ChainsLoadingList(): ReactNode {
+ return (
+
+ {LOADING_SKELETON_INDICES.map((index) => (
+
+
+
+
+ ))}
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainsSelector.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainsSelector.tsx
new file mode 100644
index 00000000000..ae10d0ce55a
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/ChainsSelector.tsx
@@ -0,0 +1,26 @@
+import { ReactNode } from 'react'
+
+import { useTheme } from '@cowprotocol/common-hooks'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+import { ChainsList } from './ChainsList'
+import { ChainsLoadingList } from './ChainsLoadingList'
+
+export interface ChainsSelectorProps {
+ chains: ChainInfo[]
+ onSelectChain: (chainId: ChainInfo) => void
+ defaultChainId?: ChainInfo['id']
+ isLoading: boolean
+ disabledChainIds?: Set
+ loadingChainIds?: Set
+}
+
+export function ChainsSelector({ isLoading, ...props }: ChainsSelectorProps): ReactNode {
+ const { darkMode } = useTheme()
+
+ if (isLoading) {
+ return
+ }
+
+ return
+}
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..b55741aa1e2 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -1,112 +1,5 @@
-import { ReactNode } from 'react'
-
-import { useMediaQuery, 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 * as styledEl from './styled'
-
-// Number of skeleton shimmers to show during loading state
-const LOADING_ITEMS_COUNT = 10
-
-const LoadingShimmerElements = (
-
- {Array.from({ length: LOADING_ITEMS_COUNT }, (_, 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
-}
-
-export function ChainsSelector({
- chains,
- onSelectChain,
- defaultChainId,
- isLoading,
- visibleNetworkIcons = LOADING_ITEMS_COUNT,
-}: ChainsSelectorProps): ReactNode {
- const isMobile = useMediaQuery(Media.upToSmall(false))
-
- const theme = useTheme()
-
- if (isLoading) {
- return LoadingShimmerElements
- }
-
- 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 (
-
- {visibleChains.map((chain) => (
-
- onSelectChain(chain)} iconOnly>
-
-
-
- ))}
- {shouldDisplayMore && (
-
- )}
-
- )
-}
+export { ChainsSelector, type ChainsSelectorProps } from './ChainsSelector'
+export { ChainButton, type ChainButtonProps } from './ChainButton'
+export { ChainsList, type ChainsListProps } from './ChainsList'
+export { ChainsLoadingList } from './ChainsLoadingList'
+export { getChainAccent } from './getChainAccent'
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/FavoriteTokenItem.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/FavoriteTokenItem.tsx
new file mode 100644
index 00000000000..03981c29570
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/FavoriteTokenItem.tsx
@@ -0,0 +1,46 @@
+import { ReactNode } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { getTokenId } from '@cowprotocol/common-utils'
+import { TokenLogo } from '@cowprotocol/tokens'
+import { TokenSymbol } from '@cowprotocol/ui'
+
+import * as styledEl from './styled'
+
+import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
+import { SelectTokenContext } from '../../types'
+
+export interface FavoriteTokenItemProps {
+ token: TokenWithLogo
+ selectTokenContext: SelectTokenContext
+}
+
+export function FavoriteTokenItem({ token, selectTokenContext }: FavoriteTokenItemProps): ReactNode {
+ const { onTokenListItemClick } = selectTokenContext
+
+ const { selectedToken, onSelectToken } = useSelectTokenWidgetState()
+
+ const isSelected = selectedToken?.isToken && getTokenId(token) === getTokenId(selectedToken)
+
+ const handleClick = (): void => {
+ if (isSelected) {
+ return
+ }
+ onTokenListItemClick?.(token)
+ onSelectToken?.(token)
+ }
+
+ return (
+
+
+
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/FavoriteTokensTooltip.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/FavoriteTokensTooltip.tsx
new file mode 100644
index 00000000000..cf7f8c7a4cd
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/FavoriteTokensTooltip.tsx
@@ -0,0 +1,18 @@
+import { ReactNode } from 'react'
+
+import { Trans } from '@lingui/react/macro'
+import { Link } from 'react-router'
+
+import * as styledEl from './styled'
+
+export function FavoriteTokensTooltip(): ReactNode {
+ return (
+
+ Your favorite saved tokens. Edit this list in the Tokens page.
+
+ }
+ />
+ )
+}
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 4b6292e1c53..a8bb0665a28 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
@@ -1,62 +1,42 @@
import { ReactNode } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
-import { areAddressesEqual } from '@cowprotocol/common-utils'
-import { TokenLogo } from '@cowprotocol/tokens'
-import { HelpTooltip, TokenSymbol } from '@cowprotocol/ui'
+import { getTokenId } from '@cowprotocol/common-utils'
import { Trans } from '@lingui/react/macro'
-import { Link } from 'react-router'
+import { FavoriteTokenItem } from './FavoriteTokenItem'
+import { FavoriteTokensTooltip } from './FavoriteTokensTooltip'
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 = areAddressesEqual(token.address, selectedToken)
-
- return (
- onSelectToken?.(token)}
- >
-
-
-
- )
- })}
+ {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/ImportTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx
index 4c9b0f1e68e..e79b241c209 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenModal/index.tsx
@@ -1,12 +1,12 @@
-import { ReactElement } from 'react'
+import { ReactNode } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
-import { ExplorerDataType, getExplorerLink } from '@cowprotocol/common-utils'
+import { ExplorerDataType, getExplorerLink, getTokenId } from '@cowprotocol/common-utils'
import { TokenLogo } from '@cowprotocol/tokens'
-import { ButtonPrimary, ExternalLink, ModalHeader } from '@cowprotocol/ui'
+import { ButtonPrimary, ExternalLink, ModalHeader, UI } from '@cowprotocol/ui'
import { Trans } from '@lingui/react/macro'
-import { AlertCircle } from 'react-feather'
+import { AlertCircle, AlertTriangle } from 'react-feather'
import styled from 'styled-components/macro'
import * as styledEl from './styled'
@@ -16,10 +16,29 @@ const ExternalLinkStyled = styled(ExternalLink)`
color: inherit;
`
+const BlockedAlert = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ background: var(${UI.COLOR_DANGER_BG});
+ color: var(${UI.COLOR_DANGER_TEXT});
+ border-radius: 10px;
+ font-size: 13px;
+
+ > svg {
+ flex-shrink: 0;
+ }
+`
+
+export interface ImportRestriction {
+ isBlocked: boolean
+ message: string
+}
+
export interface ImportTokenModalProps {
tokens: TokenWithLogo[]
- isImportDisabled?: boolean
- blockReason?: string | null
+ restriction?: ImportRestriction | null
onBack?(): void
@@ -28,8 +47,8 @@ export interface ImportTokenModalProps {
onImport(tokens: TokenWithLogo[]): void
}
-export function ImportTokenModal(props: ImportTokenModalProps): ReactElement {
- const { tokens, onBack, onDismiss, onImport, isImportDisabled, blockReason } = props
+export function ImportTokenModal(props: ImportTokenModalProps): ReactNode {
+ const { tokens, restriction, onBack, onDismiss, onImport } = props
return (
@@ -44,7 +63,7 @@ export function ImportTokenModal(props: ImportTokenModalProps): ReactElement {
{tokens.map((token) => (
-
+
@@ -63,13 +82,13 @@ export function ImportTokenModal(props: ImportTokenModalProps): ReactElement {
))}
- {blockReason && (
-
-
- {blockReason}
-
+ {restriction?.isBlocked && (
+
+
+ {restriction.message}
+
)}
- onImport(tokens)} disabled={isImportDisabled}>
+ onImport(tokens)} disabled={restriction?.isBlocked}>
Import
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
index 028e0883d24..61b445f204b 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
@@ -3,6 +3,7 @@ import { MouseEventHandler, ReactNode, useCallback } from 'react'
import { BalancesState } from '@cowprotocol/balances-and-allowances'
import { LpToken } from '@cowprotocol/common-const'
import { useMediaQuery } from '@cowprotocol/common-hooks'
+import { getTokenId } from '@cowprotocol/common-utils'
import { TokenLogo } from '@cowprotocol/tokens'
import { LoadingRows, LoadingRowSmall, Media, TokenAmount, TokenName, TokenSymbol } from '@cowprotocol/ui'
import { CurrencyAmount } from '@uniswap/sdk-core'
@@ -108,7 +109,7 @@ export function LpTokenLists({
if (isMobile) {
return (
(null)
+
+ const handleClick = (): void => {
+ if (isLoading) {
+ return
+ }
+
+ if (!isDisabled) {
+ onHideTooltip()
+ onSelectChain(chain)
+ return
+ }
+
+ onDisabledClick(chain.id)
+ }
+
+ const tooltip = isLoading ? loadingTooltip : disabledTooltip
+
+ const chipButton = (
+
+
+
+ )
+
+ return isDisabled ? (
+
+ {chipButton}
+
+ ) : (
+ chipButton
+ )
+}
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..bab5e082508
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
@@ -0,0 +1,98 @@
+import { ReactNode, useEffect, useMemo, useRef } from 'react'
+
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+import { Trans, useLingui } from '@lingui/react/macro'
+import { ChevronDown } from 'react-feather'
+
+import { ChainChip } from './ChainChip'
+import * as styledEl from './mobileChainSelector.styled'
+import { useDisabledChainTooltip } from './useDisabledChainTooltip'
+
+import { ChainsToSelectState } from '../../types'
+import { sortChainsByDisplayOrder } from '../../utils/sortChainsByDisplayOrder'
+
+const DISABLED_CHAIN_TOOLTIP_DURATION_MS = 2500
+
+interface MobileChainSelectorProps {
+ chainsState: ChainsToSelectState
+ label?: string
+ onSelectChain(chain: ChainInfo): void
+ onOpenPanel(): void
+}
+
+export function MobileChainSelector({
+ chainsState,
+ label,
+ onSelectChain,
+ onOpenPanel,
+}: MobileChainSelectorProps): ReactNode {
+ const { t } = 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}
+
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalContent.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalContent.tsx
deleted file mode 100644
index 5c0d5578198..00000000000
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalContent.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { ReactNode } from 'react'
-
-import { Trans } from '@lingui/react/macro'
-
-import * as styledEl from './styled'
-
-interface SelectTokenModalContentProps {
- isRouteAvailable: boolean | undefined
- children: ReactNode
-}
-
-export function SelectTokenModalContent({ isRouteAvailable, children }: SelectTokenModalContentProps): ReactNode {
- return (
- <>
- {isRouteAvailable === false ? (
-
- This route is not yet supported.
-
- ) : (
- children
- )}
- >
- )
-}
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
deleted file mode 100644
index 8e12d4a05e9..00000000000
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { BalancesState } from '@cowprotocol/balances-and-allowances'
-import { getRandomInt } from '@cowprotocol/common-utils'
-import { SupportedChainId, ChainInfo } from '@cowprotocol/cow-sdk'
-import { BigNumber } from '@ethersproject/bignumber'
-
-import styled from 'styled-components/macro'
-
-import { allTokensMock, favoriteTokensMock } from '../../mocks'
-
-import { SelectTokenModal, SelectTokenModalProps } from './index'
-
-const Wrapper = styled.div`
- max-height: 90vh;
- margin: 20px auto;
- display: flex;
- width: 450px;
-`
-
-const unsupportedTokens = {}
-
-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,
- permitCompatibleTokens: {},
- unsupportedTokens,
- allTokens: allTokensMock,
- favoriteTokens: favoriteTokensMock,
- areTokensLoading: false,
- areTokensFromBridge: false,
- chainsToSelect: undefined,
- onSelectChain(chain: ChainInfo) {
- console.log('onSelectChain', chain)
- },
- tokenListCategoryState: [null, () => void 0],
- balancesState: {
- values: balances,
- isLoading: false,
- chainId: SupportedChainId.SEPOLIA,
- fromCache: false,
- hasFirstLoad: true,
- error: null,
- },
- isRouteAvailable: true,
- onOpenManageWidget() {
- console.log('onOpenManageWidget')
- },
- onDismiss() {
- console.log('onDismiss')
- },
- openPoolPage() {
- console.log('openPoolPage')
- },
-}
-
-const Fixtures = {
- default: () => (
-
-
-
- ),
- importByAddress: () => (
-
-
-
- ),
- NoTokenFound: () => (
-
-
-
- ),
- searchFromInactiveLists: () => (
-
-
-
- ),
- searchFromExternalSources: () => (
-
-
-
- ),
-}
-
-export default Fixtures
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
deleted file mode 100644
index 3f9de46e1ff..00000000000
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-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 { t } from '@lingui/core/macro'
-import { X } from 'react-feather'
-
-import { PermitCompatibleTokens } from 'modules/permit'
-
-import { SelectTokenModalContent } from './SelectTokenModalContent'
-import * as styledEl from './styled'
-
-import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget'
-import { ChainsToSelectState, SelectTokenContext } from '../../types'
-import { ChainsSelector } from '../ChainsSelector'
-import { IconButton } from '../commonElements'
-import { TokensContent } from '../TokensContent'
-
-export interface SelectTokenModalProps {
- allTokens: TokenWithLogo[]
- favoriteTokens: TokenWithLogo[]
- balancesState: BalancesState
- unsupportedTokens: UnsupportedTokensState
- 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
-
- openPoolPage(poolAddress: string): void
- onInputPressEnter?(): void
- onOpenManageWidget(): void
- onDismiss(): void
- onSelectChain(chain: ChainInfo): void
-}
-
-function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext {
- const { balancesState, unsupportedTokens, permitCompatibleTokens, account, tokenListTags } = props
-
- return useMemo(
- () => ({
- balancesState,
- unsupportedTokens,
- permitCompatibleTokens,
- tokenListTags,
- isWalletConnected: !!account,
- }),
- [balancesState, unsupportedTokens, permitCompatibleTokens, tokenListTags, account],
- )
-}
-
-export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
- const {
- defaultInputValue = '',
- onDismiss,
- onInputPressEnter,
- account,
- displayLpTokenLists,
- openPoolPage,
- tokenListCategoryState,
- disableErc20,
- chainsToSelect,
- onSelectChain,
- areTokensFromBridge,
- isRouteAvailable,
- } = props
- const [inputValue, setInputValue] = useState(defaultInputValue)
-
- const selectTokenContext = useSelectTokenContext(props)
-
- const trimmedInputValue = inputValue.trim()
-
- const allListsContent = (
-
- )
-
- 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/useDisabledChainTooltip.test.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/useDisabledChainTooltip.test.ts
new file mode 100644
index 00000000000..c804ac2537a
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/useDisabledChainTooltip.test.ts
@@ -0,0 +1,195 @@
+import { act, renderHook } from '@testing-library/react'
+
+import { useDisabledChainTooltip } from './useDisabledChainTooltip'
+
+describe('useDisabledChainTooltip', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ const DURATION_MS = 3000
+ const CHAIN_ID_1 = 1
+ const CHAIN_ID_2 = 100
+
+ describe('initial state', () => {
+ it('starts with no active tooltip', () => {
+ const { result } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ expect(result.current.activeTooltipChainId).toBeNull()
+ })
+ })
+
+ describe('toggleTooltip', () => {
+ it('shows tooltip for the given chain id', () => {
+ const { result } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1)
+ })
+
+ expect(result.current.activeTooltipChainId).toBe(CHAIN_ID_1)
+ })
+
+ it('hides tooltip when toggling the same chain id', () => {
+ const { result } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1)
+ })
+ expect(result.current.activeTooltipChainId).toBe(CHAIN_ID_1)
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1)
+ })
+ expect(result.current.activeTooltipChainId).toBeNull()
+ })
+
+ it('switches to different chain when toggling a new chain id', () => {
+ const { result } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1)
+ })
+ expect(result.current.activeTooltipChainId).toBe(CHAIN_ID_1)
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_2)
+ })
+ expect(result.current.activeTooltipChainId).toBe(CHAIN_ID_2)
+ })
+
+ it('auto-hides tooltip after specified duration', () => {
+ const { result } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1)
+ })
+ expect(result.current.activeTooltipChainId).toBe(CHAIN_ID_1)
+
+ act(() => {
+ jest.advanceTimersByTime(DURATION_MS - 1)
+ })
+ expect(result.current.activeTooltipChainId).toBe(CHAIN_ID_1)
+
+ act(() => {
+ jest.advanceTimersByTime(1)
+ })
+ expect(result.current.activeTooltipChainId).toBeNull()
+ })
+
+ it('resets auto-hide timer when switching to a different chain', () => {
+ const { result } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1)
+ })
+
+ act(() => {
+ jest.advanceTimersByTime(DURATION_MS - 500)
+ })
+ expect(result.current.activeTooltipChainId).toBe(CHAIN_ID_1)
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_2)
+ })
+ expect(result.current.activeTooltipChainId).toBe(CHAIN_ID_2)
+
+ // Original timer would have fired by now, but it was cleared
+ act(() => {
+ jest.advanceTimersByTime(500)
+ })
+ expect(result.current.activeTooltipChainId).toBe(CHAIN_ID_2)
+
+ // New timer fires after full duration from switch
+ act(() => {
+ jest.advanceTimersByTime(DURATION_MS - 500)
+ })
+ expect(result.current.activeTooltipChainId).toBeNull()
+ })
+
+ it('clears timer when toggling off', () => {
+ const { result } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1)
+ })
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1) // Toggle off
+ })
+ expect(result.current.activeTooltipChainId).toBeNull()
+
+ // Timer should not cause any state changes
+ act(() => {
+ jest.advanceTimersByTime(DURATION_MS * 2)
+ })
+ expect(result.current.activeTooltipChainId).toBeNull()
+ })
+ })
+
+ describe('hideTooltip', () => {
+ it('hides the active tooltip', () => {
+ const { result } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1)
+ })
+ expect(result.current.activeTooltipChainId).toBe(CHAIN_ID_1)
+
+ act(() => {
+ result.current.hideTooltip()
+ })
+ expect(result.current.activeTooltipChainId).toBeNull()
+ })
+
+ it('clears pending auto-hide timer', () => {
+ const { result } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1)
+ })
+
+ act(() => {
+ result.current.hideTooltip()
+ })
+
+ // Timer should have been cleared, no state changes expected
+ act(() => {
+ jest.advanceTimersByTime(DURATION_MS * 2)
+ })
+ expect(result.current.activeTooltipChainId).toBeNull()
+ })
+
+ it('is safe to call when no tooltip is active', () => {
+ const { result } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ expect(() => {
+ act(() => {
+ result.current.hideTooltip()
+ })
+ }).not.toThrow()
+
+ expect(result.current.activeTooltipChainId).toBeNull()
+ })
+ })
+
+ describe('cleanup', () => {
+ it('clears timer on unmount', () => {
+ const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout')
+ const { result, unmount } = renderHook(() => useDisabledChainTooltip(DURATION_MS))
+
+ act(() => {
+ result.current.toggleTooltip(CHAIN_ID_1)
+ })
+
+ unmount()
+
+ expect(clearTimeoutSpy).toHaveBeenCalled()
+ clearTimeoutSpy.mockRestore()
+ })
+ })
+})
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/useDisabledChainTooltip.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/useDisabledChainTooltip.ts
new file mode 100644
index 00000000000..7e6e104abf4
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/useDisabledChainTooltip.ts
@@ -0,0 +1,61 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+export interface DisabledChainTooltipState {
+ activeTooltipChainId: number | null
+ toggleTooltip(chainId: number): void
+ hideTooltip(): void
+}
+
+/**
+ * Manages tooltip visibility for disabled chain selectors.
+ *
+ * Provides toggle behavior where clicking the same chain hides the tooltip,
+ * and clicking a different chain switches to that tooltip. The tooltip
+ * automatically hides after the specified duration.
+ *
+ * @param durationMs - milliseconds before the tooltip auto-hides
+ * @returns State and handlers for controlling tooltip visibility
+ */
+export function useDisabledChainTooltip(durationMs: number): DisabledChainTooltipState {
+ 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 useMemo(
+ () => ({ activeTooltipChainId, toggleTooltip, hideTooltip }),
+ [activeTooltipChainId, toggleTooltip, hideTooltip],
+ )
+}
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..e56fcb83fea 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
@@ -1,7 +1,7 @@
import { MouseEventHandler, ReactNode } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
-import { areAddressesEqual, getCurrencyAddress } from '@cowprotocol/common-utils'
+import { areAddressesEqual, getCurrencyAddress, getTokenId } from '@cowprotocol/common-utils'
import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { TokenListTags } from '@cowprotocol/tokens'
import { FiatAmount, LoadingRows, LoadingRowSmall, TokenAmount } from '@cowprotocol/ui'
@@ -13,6 +13,7 @@ import { Nullish } from 'types'
import * as styledEl from './styled'
import { useDeferredVisibility } from '../../hooks/useDeferredVisibility'
+import { TokenSelectionHandler } from '../../types'
import { TokenInfo } from '../TokenInfo'
import { TokenTags } from '../TokenTags'
@@ -28,7 +29,7 @@ export interface TokenListItemProps {
balance: BigNumber | undefined
usdAmount?: CurrencyAmount | null
- onSelectToken?(token: TokenWithLogo): void
+ onSelectToken?: TokenSelectionHandler
isWalletConnected: boolean
isUnsupported?: boolean
@@ -59,7 +60,7 @@ export function TokenListItem(props: TokenListItemProps): ReactNode {
className,
} = props
- const tokenKey = `${token.chainId}:${token.address.toLowerCase()}`
+ const tokenKey = getTokenId(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 bd8e6d1e38c..c4496f686d6 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx
@@ -1,6 +1,7 @@
-import { ReactNode } from 'react'
+import { ReactNode, useCallback } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
+import { getTokenAddressKey } from '@cowprotocol/common-utils'
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
import { SelectTokenContext } from '../../types'
@@ -14,6 +15,7 @@ interface TokenListItemContainerProps {
export function TokenListItemContainer({ token, context }: TokenListItemContainerProps): ReactNode {
const {
unsupportedTokens,
+ onTokenListItemClick,
tokenListTags,
permitCompatibleTokens,
balancesState: { values: balances },
@@ -22,16 +24,23 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine
const { onSelectToken, selectedToken } = useSelectTokenWidgetState()
- const addressLowerCase = token.address.toLowerCase()
+ const addressKey = getTokenAddressKey(token.address)
+ 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 e631218bfad..a3bc7786cfc 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -1,96 +1,70 @@
-import React, { ReactNode } from 'react'
+import { ReactNode, useMemo } from 'react'
-import { TokenWithLogo } from '@cowprotocol/common-const'
-import { getCurrencyAddress } from '@cowprotocol/common-utils'
+import { getTokenId } from '@cowprotocol/common-utils'
import { Loader } from '@cowprotocol/ui'
-import { Trans } from '@lingui/react/macro'
-import { Edit } from 'react-feather'
-
import { TokenSearchResults } from '../../containers/TokenSearchResults'
-import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
-import { SelectTokenContext } from '../../types'
-import { FavoriteTokensList } from '../FavoriteTokensList'
+import { useTokenListContext } from '../../hooks/useTokenListContext'
+import { useTokenListViewState } from '../../hooks/useTokenListViewState'
import * as styledEl from '../SelectTokenModal/styled'
import { TokensVirtualList } from '../TokensVirtualList'
-export interface TokensContentProps {
- displayLpTokenLists?: boolean
- selectTokenContext: SelectTokenContext
- favoriteTokens: TokenWithLogo[]
- hideFavoriteTokensTooltip?: boolean
- areTokensLoading: boolean
- allTokens: TokenWithLogo[]
- searchInput: string
- standalone?: boolean
- areTokensFromBridge: boolean
- onOpenManageWidget(): void
-}
+export function TokensContent(): ReactNode {
+ // UI state (searchInput) from atom
+ const { searchInput } = useTokenListViewState()
+
+ // Token data directly from source hooks
+ const { favoriteTokens, recentTokens, areTokensLoading, allTokens, onClearRecentTokens } = useTokenListContext()
+
+ const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0
+ const shouldShowRecentsInline = !areTokensLoading && !searchInput && recentTokens.length > 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(getTokenId(token)))
+ }
+
+ return pinned
+ }, [recentTokens, shouldShowRecentsInline])
+
+ const tokensWithoutPinned = useMemo(() => {
+ if (!pinnedTokenKeys) {
+ return allTokens
+ }
+
+ return allTokens.filter((token) => !pinnedTokenKeys.has(getTokenId(token)))
+ }, [allTokens, pinnedTokenKeys])
+
+ const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined
+ const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined
+
+ if (areTokensLoading) {
+ return (
+
+
+
+ )
+ }
-export function TokensContent({
- selectTokenContext,
- onOpenManageWidget,
- favoriteTokens,
- hideFavoriteTokensTooltip,
- areTokensLoading,
- allTokens,
- displayLpTokenLists,
- searchInput,
- standalone,
- areTokensFromBridge,
-}: TokensContentProps): ReactNode {
- const { onSelectToken, selectedToken } = useSelectTokenWidgetState()
+ if (searchInput) {
+ return
+ }
return (
- <>
- {!areTokensLoading && !!favoriteTokens.length && (
- <>
-
-
-
-
- >
- )}
- {areTokensLoading ? (
-
-
-
- ) : (
- <>
- {searchInput ? (
-
- ) : (
-
- )}
- >
- )}
- {!standalone && (
- <>
-
-
-
- {' '}
-
- Manage Token Lists
-
-
-
- >
- )}
- >
+
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/TokensVirtualRowRenderer.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/TokensVirtualRowRenderer.tsx
new file mode 100644
index 00000000000..d42c90a4df3
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/TokensVirtualRowRenderer.tsx
@@ -0,0 +1,35 @@
+import { ReactNode } from 'react'
+
+import { TokensVirtualRow } from './types'
+
+import { SelectTokenContext } from '../../types'
+import { FavoriteTokensList } from '../FavoriteTokensList'
+import * as modalStyled from '../SelectTokenModal/styled'
+import { TokenListItemContainer } from '../TokenListItemContainer'
+
+interface TokensVirtualRowRendererProps {
+ row: TokensVirtualRow
+ selectTokenContext: SelectTokenContext
+}
+
+export 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/pure/TokensVirtualList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
index f657eb27c19..324258d4685 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -8,39 +8,61 @@ import { VirtualItem } from '@tanstack/react-virtual'
import { CoWAmmBanner } from 'common/containers/CoWAmmBanner'
import { VirtualList } from 'common/pure/VirtualList'
-import { SelectTokenContext } from '../../types'
-import { tokensListSorter } from '../../utils/tokensListSorter'
-import { TokenListItemContainer } from '../TokenListItemContainer'
+import { buildVirtualRows, sortTokensByBalance } from './tokensVirtualListUtils'
+import { TokensVirtualRowRenderer } from './TokensVirtualRowRenderer'
+import { TokensVirtualRow } from './types'
+
+import { useTokenListContext } from '../../hooks/useTokenListContext'
export interface TokensVirtualListProps {
- allTokens: TokenWithLogo[]
- displayLpTokenLists?: boolean
- selectTokenContext: SelectTokenContext
+ tokensToDisplay: TokenWithLogo[]
+ favoriteTokens?: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
+ onClearRecentTokens: () => void
}
-export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
- const { allTokens, selectTokenContext, displayLpTokenLists } = props
+export function TokensVirtualList({
+ tokensToDisplay,
+ favoriteTokens,
+ recentTokens,
+ onClearRecentTokens,
+}: TokensVirtualListProps): ReactNode {
+ const { selectTokenContext, hideFavoriteTokensTooltip, selectedTargetChainId } = useTokenListContext()
const { values: balances } = selectTokenContext.balancesState
-
const { isYieldEnabled } = useFeatureFlags()
- const sortedTokens = useMemo(
- () => (balances ? allTokens.sort(tokensListSorter(balances)) : allTokens),
- [allTokens, balances],
+ const sortedTokens = useMemo(() => sortTokensByBalance(tokensToDisplay, balances), [tokensToDisplay, balances])
+
+ const rows = useMemo(
+ () =>
+ buildVirtualRows({
+ sortedTokens,
+ favoriteTokens,
+ recentTokens,
+ hideFavoriteTokensTooltip,
+ onClearRecentTokens,
+ }),
+ [favoriteTokens, hideFavoriteTokensTooltip, onClearRecentTokens, recentTokens, sortedTokens],
)
const getItemView = useCallback(
- (sortedTokens: TokenWithLogo[], virtualRow: VirtualItem) => {
- const token = sortedTokens[virtualRow.index]
-
- return
- },
+ (virtualRows: TokensVirtualRow[], virtualItem: VirtualItem) => (
+
+ ),
[selectTokenContext],
)
+ const virtualListKey = selectedTargetChainId ?? 'tokens-list'
+
return (
-
- {displayLpTokenLists || !isYieldEnabled ? null : }
+
+ {isYieldEnabled ? : null}
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/tokensVirtualListUtils.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/tokensVirtualListUtils.ts
new file mode 100644
index 00000000000..bc87003b9c1
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/tokensVirtualListUtils.ts
@@ -0,0 +1,72 @@
+import { BalancesState } from '@cowprotocol/balances-and-allowances'
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { getIsNativeToken } from '@cowprotocol/common-utils'
+
+import { t } from '@lingui/core/macro'
+
+import { TokensVirtualRow } from './types'
+
+import { tokensListSorter } from '../../utils/tokensListSorter'
+
+type BalancesMap = BalancesState['values'] | undefined
+
+export function sortTokensByBalance(tokens: TokenWithLogo[], balances: BalancesMap): TokenWithLogo[] {
+ if (!balances) {
+ return tokens
+ }
+
+ const prioritized: TokenWithLogo[] = []
+ const remainder: TokenWithLogo[] = []
+
+ for (const token of tokens) {
+ 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]
+}
+
+export interface BuildVirtualRowsParams {
+ sortedTokens: TokenWithLogo[]
+ favoriteTokens: TokenWithLogo[] | undefined
+ recentTokens: TokenWithLogo[] | undefined
+ hideFavoriteTokensTooltip: boolean
+ onClearRecentTokens: () => void
+}
+
+export function buildVirtualRows(params: BuildVirtualRowsParams): TokensVirtualRow[] {
+ const { sortedTokens, favoriteTokens, recentTokens, hideFavoriteTokensTooltip, onClearRecentTokens } = params
+
+ const tokenRows = sortedTokens.map((token) => ({ type: 'token', token }))
+ const composedRows: TokensVirtualRow[] = []
+
+ if (favoriteTokens?.length) {
+ composedRows.push({
+ type: 'favorite-section',
+ tokens: favoriteTokens,
+ hideTooltip: hideFavoriteTokensTooltip,
+ })
+ }
+
+ if (recentTokens?.length) {
+ composedRows.push({
+ type: 'title',
+ label: t`Recent`,
+ actionLabel: t`Clear`,
+ 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]
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/types.ts
new file mode 100644
index 00000000000..137957b40e0
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/types.ts
@@ -0,0 +1,6 @@
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+export type TokensVirtualRow =
+ | { type: 'favorite-section'; tokens: TokenWithLogo[]; hideTooltip?: boolean }
+ | { type: 'title'; label: string; actionLabel?: string; onAction?: () => void }
+ | { type: 'token'; token: TokenWithLogo }
diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/recentTokensStorageAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/state/recentTokensStorageAtom.ts
new file mode 100644
index 00000000000..eaf180053e5
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/state/recentTokensStorageAtom.ts
@@ -0,0 +1,9 @@
+import { atom } from 'jotai'
+
+import { readStoredTokens, RECENT_TOKENS_LIMIT, StoredRecentTokensByChain } from '../utils/recentTokensStorage'
+
+/**
+ * Atom holding the stored recent tokens by chain.
+ * Initialized from localStorage on first read.
+ */
+export const recentTokensStorageAtom = atom(readStoredTokens(RECENT_TOKENS_LIMIT))
diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts
index 573beda03ab..0333d9b6fbf 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts
@@ -10,6 +10,17 @@ import { Nullish } from 'types'
import { Field } from 'legacy/state/types'
+import { TradeType } from 'modules/trade/types'
+
+/**
+ * Pending list toggle state.
+ * Set when user tries to enable a restricted list and consent is required.
+ */
+export interface ListToToggle {
+ list: ListState
+ consentHash: string
+}
+
export interface SelectTokenWidgetState {
open: boolean
field?: Field
@@ -18,9 +29,15 @@ export interface SelectTokenWidgetState {
selectedPoolAddress?: string
tokenToImport?: TokenWithLogo
listToImport?: ListState
+ listToToggle?: ListToToggle
onSelectToken?: (currency: Currency) => void
onInputPressEnter?: Command
selectedTargetChainId?: number
+ tradeType?: TradeType
+ forceOpen?: boolean
+ // UI config
+ standalone?: boolean
+ displayLpTokenLists?: boolean
}
export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = {
@@ -30,8 +47,13 @@ export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = {
onSelectToken: undefined,
tokenToImport: undefined,
listToImport: undefined,
+ listToToggle: undefined,
selectedPoolAddress: undefined,
selectedTargetChainId: undefined,
+ tradeType: undefined,
+ forceOpen: false,
+ standalone: false,
+ displayLpTokenLists: 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..c8ff4c2091f
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/state/tokenListViewAtom.ts
@@ -0,0 +1,21 @@
+/**
+ * tokenListViewAtom - Minimal UI state for the token list
+ *
+ * Only holds local UI state (searchInput). Token data is fetched
+ * directly by components via useTokenListContext hook.
+ */
+import { atom } from 'jotai'
+
+import { atomWithPartialUpdate } from '@cowprotocol/common-utils'
+
+export interface TokenListViewState {
+ searchInput: string
+}
+
+export const DEFAULT_TOKEN_LIST_VIEW_STATE: TokenListViewState = {
+ searchInput: '',
+}
+
+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 ce9b1dc8fe5..d6203b25ef6 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/types.ts
@@ -1,11 +1,15 @@
import { BalancesState } from '@cowprotocol/balances-and-allowances'
+import { TokenWithLogo } from '@cowprotocol/common-const'
import { ChainInfo } from '@cowprotocol/cow-sdk'
import type { TokenListTags } from '@cowprotocol/tokens'
import { PermitCompatibleTokens } from 'modules/permit'
+export type TokenSelectionHandler = (token: TokenWithLogo) => Promise | void
+
export interface SelectTokenContext {
balancesState: BalancesState
+ onTokenListItemClick?(token: TokenWithLogo): void
unsupportedTokens: { [tokenAddress: string]: { dateAdded: number } }
permitCompatibleTokens: PermitCompatibleTokens
tokenListTags: TokenListTags
@@ -16,4 +20,6 @@ export interface ChainsToSelectState {
chains: ChainInfo[] | undefined
defaultChainId?: number
isLoading?: boolean
+ disabledChainIds?: Set
+ loadingChainIds?: Set
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/updaters/RecentTokensStorageUpdater.tsx b/apps/cowswap-frontend/src/modules/tokensList/updaters/RecentTokensStorageUpdater.tsx
new file mode 100644
index 00000000000..4778eaf3425
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/updaters/RecentTokensStorageUpdater.tsx
@@ -0,0 +1,56 @@
+import { useAtom } from 'jotai'
+import { useEffect, useRef } from 'react'
+
+import { getTokenId } from '@cowprotocol/common-utils'
+import { useFavoriteTokens } from '@cowprotocol/tokens'
+
+import { recentTokensStorageAtom } from '../state/recentTokensStorageAtom'
+import { persistStoredTokens, StoredRecentTokensByChain } from '../utils/recentTokensStorage'
+
+/**
+ * Updater that handles side-effects for recent tokens storage:
+ * - Persists to localStorage when state changes
+ * - Removes tokens that became favorites
+ */
+export function RecentTokensStorageUpdater(): null {
+ const [storedTokensByChain, setStoredTokensByChain] = useAtom(recentTokensStorageAtom)
+ const favoriteTokens = useFavoriteTokens()
+
+ // Track if this is the initial render to skip persistence on mount
+ const isInitialRender = useRef(true)
+
+ // Persist to localStorage when state changes (skip initial render)
+ useEffect(() => {
+ if (isInitialRender.current) {
+ isInitialRender.current = false
+ return
+ }
+
+ persistStoredTokens(storedTokensByChain)
+ }, [storedTokensByChain])
+
+ // Remove tokens that became favorites
+ useEffect(() => {
+ const favoriteKeys = new Set(favoriteTokens.map((token) => getTokenId(token)))
+
+ 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(getTokenId(token)))
+
+ if (filtered.length) {
+ nextEntries[chainId] = filtered
+ }
+
+ didChange = didChange || filtered.length !== tokens.length
+ }
+
+ return didChange ? nextEntries : prev
+ })
+ }, [favoriteTokens, setStoredTokensByChain])
+
+ return null
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/chainsState.test.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/chainsState.test.ts
new file mode 100644
index 00000000000..5e91e83c2a7
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/utils/chainsState.test.ts
@@ -0,0 +1,343 @@
+import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import {
+ filterDestinationChains,
+ createInputChainsState,
+ computeDisabledChainIds,
+ resolveDefaultChainId,
+ createOutputChainsState,
+ CreateOutputChainsOptions,
+} from './chainsState'
+import { sortChainsByDisplayOrder } from './sortChainsByDisplayOrder'
+
+jest.mock('./sortChainsByDisplayOrder', () => ({
+ sortChainsByDisplayOrder: jest.fn((chains: ChainInfo[]) => chains),
+}))
+
+const mockSortChainsByDisplayOrder = sortChainsByDisplayOrder as jest.MockedFunction
+
+const createChainInfo = (id: number, name = `Chain ${id}`): ChainInfo =>
+ ({
+ id,
+ name,
+ label: name,
+ logo: '',
+ }) as unknown as ChainInfo
+
+const MAINNET = createChainInfo(SupportedChainId.MAINNET, 'Ethereum')
+const GNOSIS = createChainInfo(SupportedChainId.GNOSIS_CHAIN, 'Gnosis')
+const ARBITRUM = createChainInfo(SupportedChainId.ARBITRUM_ONE, 'Arbitrum')
+const BASE = createChainInfo(SupportedChainId.BASE, 'Base')
+const UNSUPPORTED = createChainInfo(999999, 'Unsupported')
+
+describe('chainsState', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockSortChainsByDisplayOrder.mockImplementation((chains) => chains)
+ })
+
+ describe('filterDestinationChains', () => {
+ it('returns undefined when input is undefined', () => {
+ expect(filterDestinationChains(undefined)).toBeUndefined()
+ })
+
+ it('returns empty array when input is empty', () => {
+ expect(filterDestinationChains([])).toEqual([])
+ })
+
+ it('filters out unsupported chains', () => {
+ const chains = [MAINNET, GNOSIS, UNSUPPORTED]
+ const result = filterDestinationChains(chains)
+
+ expect(result).toHaveLength(2)
+ expect(result).toContainEqual(MAINNET)
+ expect(result).toContainEqual(GNOSIS)
+ expect(result).not.toContainEqual(UNSUPPORTED)
+ })
+
+ it('keeps all supported chains', () => {
+ const chains = [MAINNET, GNOSIS, ARBITRUM, BASE]
+ const result = filterDestinationChains(chains)
+
+ expect(result).toHaveLength(4)
+ expect(result).toEqual(chains)
+ })
+ })
+
+ describe('createInputChainsState', () => {
+ it('creates state with sorted chains and default chain id', () => {
+ const chains = [MAINNET, GNOSIS]
+ const result = createInputChainsState(SupportedChainId.MAINNET, chains)
+
+ expect(result).toEqual({
+ defaultChainId: SupportedChainId.MAINNET,
+ chains,
+ isLoading: false,
+ })
+ expect(mockSortChainsByDisplayOrder).toHaveBeenCalledWith(chains)
+ })
+
+ it('uses custom selectedTargetChainId as defaultChainId', () => {
+ const chains = [MAINNET, GNOSIS]
+ const result = createInputChainsState(SupportedChainId.GNOSIS_CHAIN, chains)
+
+ expect(result.defaultChainId).toBe(SupportedChainId.GNOSIS_CHAIN)
+ })
+ })
+
+ describe('computeDisabledChainIds', () => {
+ it('returns empty set when isLoading is true', () => {
+ const result = computeDisabledChainIds(
+ [MAINNET, GNOSIS, ARBITRUM],
+ SupportedChainId.MAINNET,
+ new Set([SupportedChainId.GNOSIS_CHAIN]),
+ true,
+ true, // isLoading
+ )
+
+ expect(result.size).toBe(0)
+ })
+
+ it('excludes the current chainId from disabled set', () => {
+ const result = computeDisabledChainIds(
+ [MAINNET, GNOSIS, ARBITRUM],
+ SupportedChainId.MAINNET,
+ new Set([SupportedChainId.GNOSIS_CHAIN]),
+ true,
+ false,
+ )
+
+ expect(result.has(SupportedChainId.MAINNET)).toBe(false)
+ })
+
+ it('disables all chains except current when source is not supported', () => {
+ const result = computeDisabledChainIds(
+ [MAINNET, GNOSIS, ARBITRUM],
+ SupportedChainId.MAINNET,
+ new Set([SupportedChainId.GNOSIS_CHAIN]),
+ false, // sourceSupported = false
+ false,
+ )
+
+ expect(result.has(SupportedChainId.GNOSIS_CHAIN)).toBe(true)
+ expect(result.has(SupportedChainId.ARBITRUM_ONE)).toBe(true)
+ expect(result.has(SupportedChainId.MAINNET)).toBe(false)
+ })
+
+ it('disables chains not in destination set when source is supported', () => {
+ const destinationIds = new Set([SupportedChainId.MAINNET, SupportedChainId.GNOSIS_CHAIN])
+ const result = computeDisabledChainIds(
+ [MAINNET, GNOSIS, ARBITRUM],
+ SupportedChainId.MAINNET,
+ destinationIds,
+ true, // sourceSupported = true
+ false,
+ )
+
+ expect(result.has(SupportedChainId.ARBITRUM_ONE)).toBe(true)
+ expect(result.has(SupportedChainId.GNOSIS_CHAIN)).toBe(false)
+ expect(result.has(SupportedChainId.MAINNET)).toBe(false)
+ })
+
+ it('returns empty set when all chains are valid destinations', () => {
+ const destinationIds = new Set([
+ SupportedChainId.MAINNET,
+ SupportedChainId.GNOSIS_CHAIN,
+ SupportedChainId.ARBITRUM_ONE,
+ ])
+ const result = computeDisabledChainIds(
+ [MAINNET, GNOSIS, ARBITRUM],
+ SupportedChainId.MAINNET,
+ destinationIds,
+ true,
+ false,
+ )
+
+ expect(result.size).toBe(0)
+ })
+ })
+
+ describe('resolveDefaultChainId', () => {
+ it('returns selectedTargetChainId when valid and not disabled', () => {
+ const result = resolveDefaultChainId(
+ [MAINNET, GNOSIS, ARBITRUM],
+ SupportedChainId.GNOSIS_CHAIN,
+ SupportedChainId.MAINNET,
+ new Set(),
+ )
+
+ expect(result).toBe(SupportedChainId.GNOSIS_CHAIN)
+ })
+
+ it('falls back to chainId when selectedTargetChainId is disabled', () => {
+ const result = resolveDefaultChainId(
+ [MAINNET, GNOSIS, ARBITRUM],
+ SupportedChainId.GNOSIS_CHAIN,
+ SupportedChainId.MAINNET,
+ new Set([SupportedChainId.GNOSIS_CHAIN]),
+ )
+
+ expect(result).toBe(SupportedChainId.MAINNET)
+ })
+
+ it('falls back to chainId when selectedTargetChainId is not in chains list', () => {
+ const result = resolveDefaultChainId(
+ [MAINNET, ARBITRUM],
+ SupportedChainId.GNOSIS_CHAIN,
+ SupportedChainId.MAINNET,
+ new Set(),
+ )
+
+ expect(result).toBe(SupportedChainId.MAINNET)
+ })
+
+ it('returns first enabled chain when chainId is not in list', () => {
+ const result = resolveDefaultChainId(
+ [GNOSIS, ARBITRUM],
+ SupportedChainId.BASE,
+ SupportedChainId.MAINNET,
+ new Set([SupportedChainId.GNOSIS_CHAIN]),
+ )
+
+ expect(result).toBe(SupportedChainId.ARBITRUM_ONE)
+ })
+
+ it('returns first chain when all are disabled except none exist', () => {
+ const result = resolveDefaultChainId(
+ [GNOSIS, ARBITRUM],
+ SupportedChainId.BASE,
+ SupportedChainId.MAINNET,
+ new Set([SupportedChainId.GNOSIS_CHAIN, SupportedChainId.ARBITRUM_ONE]),
+ )
+
+ expect(result).toBe(SupportedChainId.GNOSIS_CHAIN)
+ })
+
+ it('returns chainId when chains list is empty', () => {
+ const result = resolveDefaultChainId([], SupportedChainId.BASE, SupportedChainId.MAINNET, new Set())
+
+ expect(result).toBe(SupportedChainId.MAINNET)
+ })
+ })
+
+ describe('createOutputChainsState', () => {
+ const createOptions = (overrides: Partial = {}): CreateOutputChainsOptions => ({
+ selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN,
+ chainId: SupportedChainId.MAINNET,
+ currentChainInfo: MAINNET,
+ bridgeSupportedNetworks: [MAINNET, GNOSIS, ARBITRUM],
+ supportedChains: [MAINNET, GNOSIS, ARBITRUM],
+ isLoading: false,
+ routesAvailability: {
+ unavailableChainIds: new Set(),
+ loadingChainIds: new Set(),
+ isLoading: false,
+ },
+ ...overrides,
+ })
+
+ it('returns state with ordered chains', () => {
+ const result = createOutputChainsState(createOptions())
+
+ expect(result.chains).toBeDefined()
+ expect(mockSortChainsByDisplayOrder).toHaveBeenCalled()
+ })
+
+ it('adds current chain to list if not present', () => {
+ const options = createOptions({
+ chainId: SupportedChainId.BASE,
+ currentChainInfo: BASE,
+ supportedChains: [MAINNET, GNOSIS],
+ })
+
+ createOutputChainsState(options)
+
+ expect(mockSortChainsByDisplayOrder).toHaveBeenCalledWith(expect.arrayContaining([MAINNET, GNOSIS, BASE]))
+ })
+
+ it('does not duplicate current chain if already present', () => {
+ const options = createOptions({
+ chainId: SupportedChainId.MAINNET,
+ currentChainInfo: MAINNET,
+ supportedChains: [MAINNET, GNOSIS],
+ })
+
+ createOutputChainsState(options)
+
+ expect(mockSortChainsByDisplayOrder).toHaveBeenCalledWith([MAINNET, GNOSIS])
+ })
+
+ it('passes isLoading through to result', () => {
+ const result = createOutputChainsState(createOptions({ isLoading: true }))
+
+ expect(result.isLoading).toBe(true)
+ })
+
+ it('includes unavailable chain ids in disabled set', () => {
+ const unavailableChainIds = new Set([SupportedChainId.ARBITRUM_ONE])
+ const result = createOutputChainsState(
+ createOptions({
+ routesAvailability: {
+ unavailableChainIds,
+ loadingChainIds: new Set(),
+ isLoading: false,
+ },
+ }),
+ )
+
+ expect(result.disabledChainIds?.has(SupportedChainId.ARBITRUM_ONE)).toBe(true)
+ })
+
+ it('includes loading chain ids when present', () => {
+ const loadingChainIds = new Set([SupportedChainId.GNOSIS_CHAIN])
+ const result = createOutputChainsState(
+ createOptions({
+ routesAvailability: {
+ unavailableChainIds: new Set(),
+ loadingChainIds,
+ isLoading: false,
+ },
+ }),
+ )
+
+ expect(result.loadingChainIds).toEqual(loadingChainIds)
+ })
+
+ it('returns undefined for disabledChainIds when empty', () => {
+ const result = createOutputChainsState(
+ createOptions({
+ bridgeSupportedNetworks: [MAINNET, GNOSIS, ARBITRUM],
+ supportedChains: [MAINNET, GNOSIS, ARBITRUM],
+ }),
+ )
+
+ expect(result.disabledChainIds).toBeUndefined()
+ })
+
+ it('returns undefined for loadingChainIds when empty', () => {
+ const result = createOutputChainsState(createOptions())
+
+ expect(result.loadingChainIds).toBeUndefined()
+ })
+
+ it('resolves default chain id correctly', () => {
+ const result = createOutputChainsState(
+ createOptions({
+ selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN,
+ }),
+ )
+
+ expect(result.defaultChainId).toBe(SupportedChainId.GNOSIS_CHAIN)
+ })
+
+ it('handles undefined bridgeSupportedNetworks', () => {
+ const result = createOutputChainsState(
+ createOptions({
+ bridgeSupportedNetworks: undefined,
+ }),
+ )
+
+ expect(result.chains).toBeDefined()
+ })
+ })
+})
diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/chainsState.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/chainsState.ts
new file mode 100644
index 00000000000..573325f7d2d
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/utils/chainsState.ts
@@ -0,0 +1,108 @@
+import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { sortChainsByDisplayOrder } from './sortChainsByDisplayOrder'
+
+import { ChainsToSelectState } from '../types'
+
+export interface CreateOutputChainsOptions {
+ selectedTargetChainId: SupportedChainId | number
+ chainId: SupportedChainId
+ currentChainInfo: ChainInfo
+ bridgeSupportedNetworks: ChainInfo[] | undefined
+ supportedChains: ChainInfo[]
+ isLoading: boolean
+ routesAvailability: {
+ unavailableChainIds: Set
+ loadingChainIds: Set
+ isLoading: boolean
+ }
+}
+
+export function filterDestinationChains(bridgeSupportedNetworks: ChainInfo[] | undefined): ChainInfo[] | undefined {
+ return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId)
+}
+
+export function createInputChainsState(
+ selectedTargetChainId: SupportedChainId | number,
+ supportedChains: ChainInfo[],
+): ChainsToSelectState {
+ return {
+ defaultChainId: selectedTargetChainId,
+ chains: sortChainsByDisplayOrder(supportedChains),
+ isLoading: false,
+ }
+}
+
+export function computeDisabledChainIds(
+ orderedChains: ChainInfo[],
+ chainId: SupportedChainId,
+ destinationIds: Set,
+ sourceSupported: boolean,
+ isLoading: boolean,
+): Set {
+ if (isLoading) return new Set()
+
+ return new Set(
+ orderedChains
+ .filter((chain) => {
+ if (chain.id === chainId) return false
+ if (!sourceSupported) return true
+ return !destinationIds.has(chain.id)
+ })
+ .map((c) => c.id),
+ )
+}
+
+export 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 ?? chainId
+}
+
+export function createOutputChainsState({
+ selectedTargetChainId,
+ chainId,
+ currentChainInfo,
+ bridgeSupportedNetworks,
+ supportedChains,
+ isLoading,
+ routesAvailability,
+}: CreateOutputChainsOptions): ChainsToSelectState {
+ 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)
+
+ const baseDisabledChainIds = computeDisabledChainIds(
+ orderedChains,
+ chainId,
+ destinationIds,
+ sourceSupported,
+ isLoading,
+ )
+
+ 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/utils/recentTokensStorage.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/recentTokensStorage.ts
new file mode 100644
index 00000000000..6c679597093
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/utils/recentTokensStorage.ts
@@ -0,0 +1,219 @@
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { getTokenId } from '@cowprotocol/common-utils'
+
+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(getTokenId(token), token)
+ }
+
+ return map
+}
+
+export function buildFavoriteTokenKeys(tokens: TokenWithLogo[]): Set {
+ const set = new Set()
+
+ for (const token of tokens) {
+ set.add(getTokenId(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 getTokenId(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(getTokenId(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 = getTokenId(token)
+ const withoutToken = tokens.filter((entry) => getTokenId(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/utils/sortChainsByDisplayOrder.test.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.test.ts
new file mode 100644
index 00000000000..a2ff344f88e
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.test.ts
@@ -0,0 +1,159 @@
+import { SupportedChainId, ChainInfo } from '@cowprotocol/cow-sdk'
+
+import { sortChainsByDisplayOrder } from './sortChainsByDisplayOrder'
+
+const createChainInfo = (id: number, name = `Chain ${id}`): ChainInfo =>
+ ({
+ id,
+ name,
+ label: name,
+ logo: '',
+ }) as unknown as ChainInfo
+
+const MAINNET = createChainInfo(SupportedChainId.MAINNET, 'Ethereum')
+const GNOSIS = createChainInfo(SupportedChainId.GNOSIS_CHAIN, 'Gnosis')
+const ARBITRUM = createChainInfo(SupportedChainId.ARBITRUM_ONE, 'Arbitrum')
+const BASE = createChainInfo(SupportedChainId.BASE, 'Base')
+const POLYGON = createChainInfo(SupportedChainId.POLYGON, 'Polygon')
+const UNSUPPORTED_1 = createChainInfo(999991, 'Unsupported 1')
+const UNSUPPORTED_2 = createChainInfo(999992, 'Unsupported 2')
+
+describe('sortChainsByDisplayOrder', () => {
+ describe('basic sorting', () => {
+ it('returns empty array for empty input', () => {
+ const result = sortChainsByDisplayOrder([])
+
+ expect(result).toEqual([])
+ })
+
+ it('returns a copy for single chain', () => {
+ const input = [MAINNET]
+ const result = sortChainsByDisplayOrder(input)
+
+ expect(result).toEqual([MAINNET])
+ expect(result).not.toBe(input)
+ })
+
+ it('does not mutate the original array', () => {
+ const input = [GNOSIS, MAINNET, ARBITRUM]
+ const inputCopy = [...input]
+
+ sortChainsByDisplayOrder(input)
+
+ expect(input).toEqual(inputCopy)
+ })
+
+ it('sorts chains by canonical order', () => {
+ // Input in wrong order: Gnosis, Arbitrum, Base, Mainnet
+ const input = [GNOSIS, ARBITRUM, BASE, MAINNET]
+ const result = sortChainsByDisplayOrder(input)
+
+ // Expected order: Mainnet, Base, Arbitrum, Gnosis
+ expect(result.map((c) => c.id)).toEqual([
+ SupportedChainId.MAINNET,
+ SupportedChainId.BASE,
+ SupportedChainId.ARBITRUM_ONE,
+ SupportedChainId.GNOSIS_CHAIN,
+ ])
+ })
+
+ it('places unsupported chains at the end', () => {
+ const input = [UNSUPPORTED_1, MAINNET, GNOSIS]
+ const result = sortChainsByDisplayOrder(input)
+
+ expect(result.map((c) => c.id)).toEqual([
+ SupportedChainId.MAINNET,
+ SupportedChainId.GNOSIS_CHAIN,
+ UNSUPPORTED_1.id,
+ ])
+ })
+
+ it('maintains relative order of unsupported chains', () => {
+ const input = [UNSUPPORTED_2, MAINNET, UNSUPPORTED_1]
+ const result = sortChainsByDisplayOrder(input)
+
+ // Unsupported chains should maintain their original relative order
+ expect(result.map((c) => c.id)).toEqual([SupportedChainId.MAINNET, UNSUPPORTED_2.id, UNSUPPORTED_1.id])
+ })
+
+ it('handles all unsupported chains', () => {
+ const input = [UNSUPPORTED_2, UNSUPPORTED_1]
+ const result = sortChainsByDisplayOrder(input)
+
+ // Should maintain original order when all have same weight
+ expect(result.map((c) => c.id)).toEqual([UNSUPPORTED_2.id, UNSUPPORTED_1.id])
+ })
+ })
+
+ describe('pinChainId option', () => {
+ it('pins specified chain to first position', () => {
+ const input = [MAINNET, BASE, ARBITRUM, GNOSIS]
+ const result = sortChainsByDisplayOrder(input, { pinChainId: GNOSIS.id })
+
+ expect(result[0].id).toBe(SupportedChainId.GNOSIS_CHAIN)
+ // Rest should maintain sorted order
+ expect(result.map((c) => c.id)).toEqual([
+ SupportedChainId.GNOSIS_CHAIN,
+ SupportedChainId.MAINNET,
+ SupportedChainId.BASE,
+ SupportedChainId.ARBITRUM_ONE,
+ ])
+ })
+
+ it('does nothing when pinChainId is already first', () => {
+ const input = [GNOSIS, ARBITRUM, MAINNET]
+ const result = sortChainsByDisplayOrder(input, { pinChainId: MAINNET.id })
+
+ // Mainnet is already first after sorting
+ expect(result[0].id).toBe(SupportedChainId.MAINNET)
+ })
+
+ it('does nothing when pinChainId is not in the list', () => {
+ const input = [MAINNET, ARBITRUM, GNOSIS]
+ const result = sortChainsByDisplayOrder(input, { pinChainId: POLYGON.id })
+
+ // Should just return sorted order
+ expect(result.map((c) => c.id)).toEqual([
+ SupportedChainId.MAINNET,
+ SupportedChainId.ARBITRUM_ONE,
+ SupportedChainId.GNOSIS_CHAIN,
+ ])
+ })
+
+ it('works with undefined options', () => {
+ const input = [GNOSIS, MAINNET]
+ const result = sortChainsByDisplayOrder(input, undefined)
+
+ expect(result.map((c) => c.id)).toEqual([SupportedChainId.MAINNET, SupportedChainId.GNOSIS_CHAIN])
+ })
+
+ it('works with empty options object', () => {
+ const input = [GNOSIS, MAINNET]
+ const result = sortChainsByDisplayOrder(input, {})
+
+ expect(result.map((c) => c.id)).toEqual([SupportedChainId.MAINNET, SupportedChainId.GNOSIS_CHAIN])
+ })
+
+ it('can pin an unsupported chain to first position', () => {
+ const input = [MAINNET, UNSUPPORTED_1, GNOSIS]
+ const result = sortChainsByDisplayOrder(input, { pinChainId: UNSUPPORTED_1.id })
+
+ expect(result[0].id).toBe(UNSUPPORTED_1.id)
+ })
+ })
+
+ describe('stability', () => {
+ it('maintains stable sort for chains with same canonical position', () => {
+ // Create multiple chains that would have the same weight (unsupported)
+ const chainA = createChainInfo(888881, 'Chain A')
+ const chainB = createChainInfo(888882, 'Chain B')
+ const chainC = createChainInfo(888883, 'Chain C')
+
+ const input = [chainA, chainB, chainC]
+ const result = sortChainsByDisplayOrder(input)
+
+ // Original order should be preserved for same-weight chains
+ expect(result.map((c) => c.id)).toEqual([chainA.id, chainB.id, chainC.id])
+ })
+ })
+})
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..4a89435c556
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts
@@ -0,0 +1,55 @@
+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/trade/containers/TradeWidget/TradeWidgetModals.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx
index 134e1a712f0..b13be0505f2 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'
@@ -16,11 +17,9 @@ import { useTradeApproveState } from 'modules/erc20Approve/state/useTradeApprove
import { RwaConsentModalContainer, useRwaConsentModalState } from 'modules/rwa'
import {
ImportTokenModal,
- SelectTokenWidget,
+ useCloseTokenSelectWidget,
useSelectTokenWidgetState,
useTokenListAddingError,
- useUpdateSelectTokenWidgetState,
- useRestrictedTokensImportStatus,
} from 'modules/tokensList'
import { useZeroApproveModalState, ZeroApprovalModal } from 'modules/zeroApproval'
@@ -36,22 +35,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,
@@ -68,22 +62,19 @@ export function TradeWidgetModals({
tokensToImport,
modalState: { isModalOpen: isAutoImportModalOpen, closeModal: closeAutoImportModal },
} = useAutoImportTokensState(rawState?.inputCurrencyId, rawState?.outputCurrencyId)
- const { isImportDisabled, blockReason, requiresConsent, restrictedTokenInfo, tokenNeedingConsent } =
- useRestrictedTokensImportStatus(tokensToImport)
- const { openModal: openRwaConsentModal } = useRwaConsentModalState()
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()
closeRwaConsentModal()
if (shouldCloseAutoImportModal) closeAutoImportModal()
- if (closeTokenSelectWidget) updateSelectTokenWidgetState({ open: false })
+ if (shouldCloseTokenSelectWidget) closeTokenSelectWidget()
setWrapNativeScreenState({ isOpen: false })
resetApproveModalState()
setTokenListAddingError(null)
@@ -94,7 +85,7 @@ export function TradeWidgetModals({
closeZeroApprovalModal,
closeRwaConsentModal,
closeAutoImportModal,
- updateSelectTokenWidgetState,
+ closeTokenSelectWidget,
setWrapNativeScreenState,
resetApproveModalState,
updateApproveAmountState,
@@ -103,8 +94,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
@@ -120,40 +112,20 @@ export function TradeWidgetModals({
* Because network might be changed from the widget inside
*/
useEffect(() => {
- resetAllScreens(isOutputTokenSelectorRef.current)
- }, [chainId, resetAllScreens])
+ const isActualChainChange = previousChainId !== null && previousChainId !== chainId
- /**
- * If auto-import modal is open and consent is required,
- * open the RWA consent modal instead
- */
- useEffect(() => {
- if (isAutoImportModalOpen && requiresConsent && restrictedTokenInfo && tokenNeedingConsent) {
- openRwaConsentModal({
- consentHash: restrictedTokenInfo.consentHash,
- token: tokenNeedingConsent,
- pendingImportTokens: tokensToImport,
- onImportSuccess: () => {
- // After consent, import the tokens
- importTokenCallback(tokensToImport)
- closeAutoImportModal()
- },
- onDismiss: () => {
- // If consent is rejected, close the auto-import modal too
- closeAutoImportModal()
- },
- })
+ if (!isActualChainChange && !isInitialRenderRef.current) {
+ return
}
- }, [
- isAutoImportModalOpen,
- requiresConsent,
- restrictedTokenInfo,
- tokenNeedingConsent,
- tokensToImport,
- openRwaConsentModal,
- importTokenCallback,
- closeAutoImportModal,
- ])
+
+ isInitialRenderRef.current = false
+
+ const shouldCloseTokenSelectWidget = isActualChainChange
+ ? isOutputTokenSelector
+ : (previousIsOutputTokenSelector ?? isOutputTokenSelector)
+
+ resetAllScreens(shouldCloseTokenSelectWidget, isActualChainChange)
+ }, [chainId, isOutputTokenSelector, previousChainId, previousIsOutputTokenSelector, resetAllScreens])
if (genericModal) {
return genericModal
@@ -171,22 +143,8 @@ export function TradeWidgetModals({
return
}
- if (isTokenSelectOpen) {
- return selectTokenWidget
- }
-
- // Show import modal only if consent is not required
- // (if consent is required, the useEffect above will open the consent modal)
- if (isAutoImportModalOpen && !requiresConsent) {
- return (
-
- )
+ if (isAutoImportModalOpen) {
+ return
}
if (isWrapNativeOpen) {
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 cf1090c1f6f..a3df04adbd5 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,7 @@
import { JSX, useEffect } from 'react'
-import { useSelectTokenWidgetState } from 'modules/tokensList'
+import { useTokenSelectorConsentFlow } from 'modules/rwa'
+import { SelectTokenWidget, useSelectTokenWidgetState } from 'modules/tokensList'
import { useSetShouldUseAutoSlippage } from 'modules/tradeSlippage'
import * as styledEl from './styled'
@@ -9,6 +10,8 @@ import { TradeWidgetModals } from './TradeWidgetModals'
import { TradeWidgetUpdaters } from './TradeWidgetUpdaters'
import { TradeWidgetProps } from './types'
+import { useIsTokenSelectWide } from '../../hooks/useIsTokenSelectWide'
+
export function TradeWidget(props: TradeWidgetProps): JSX.Element {
const { id, slots, params, confirmModal, genericModal } = props
const {
@@ -18,8 +21,12 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element {
allowSwapSameToken = false,
enableSmartSlippage,
} = params
- const modals = TradeWidgetModals({ confirmModal, genericModal, selectTokenWidget: slots.selectTokenWidget })
+ const modals = TradeWidgetModals({ confirmModal, genericModal })
const { open: isTokenSelectOpen } = useSelectTokenWidgetState()
+ const isTokenSelectWide = useIsTokenSelectWide()
+
+ const consentFlow = useTokenSelectorConsentFlow()
+ const selectTokenWidgetNode = slots.selectTokenWidget ??
const setShouldUseAutoSlippage = useSetShouldUseAutoSlippage()
@@ -28,18 +35,22 @@ 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/cowswap-frontend/src/modules/trade/hooks/useIsTokenSelectWide.ts b/apps/cowswap-frontend/src/modules/trade/hooks/useIsTokenSelectWide.ts
new file mode 100644
index 00000000000..5f1b90c213c
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/trade/hooks/useIsTokenSelectWide.ts
@@ -0,0 +1,12 @@
+import { useChainsToSelect, useSelectTokenWidgetState } from 'modules/tokensList'
+
+export function useIsTokenSelectWide(): boolean {
+ const { open: isTokenSelectOpen } = useSelectTokenWidgetState()
+ const chainsToSelect = useChainsToSelect()
+
+ const hasChainsToSelect = !!chainsToSelect
+ const isChainsLoading = chainsToSelect?.isLoading ?? false
+ const hasAvailableChains = (chainsToSelect?.chains?.length ?? 0) > 0
+
+ return isTokenSelectOpen && hasChainsToSelect && (isChainsLoading || hasAvailableChains)
+}
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.test.ts b/libs/common-utils/src/areAddressesEqual.test.ts
new file mode 100644
index 00000000000..0fcfda7cf9c
--- /dev/null
+++ b/libs/common-utils/src/areAddressesEqual.test.ts
@@ -0,0 +1,69 @@
+import { areAddressesEqual } from './areAddressesEqual'
+
+describe('areAddressesEqual', () => {
+ const ADDRESS_1 = '0xf164fc0ec4e93095b804a4795bbe1e041497b92a'
+ const ADDRESS_1_CHECKSUMMED = '0xf164fC0Ec4E93095b804a4795bBe1e041497b92a'
+ const ADDRESS_2 = '0x2E1b342132A67Ea578e4E3B814bae2107dc254CC'
+
+ describe('when both addresses are falsy', () => {
+ it('returns false when both are null', () => {
+ expect(areAddressesEqual(null, null)).toBe(false)
+ })
+
+ it('returns false when both are undefined', () => {
+ expect(areAddressesEqual(undefined, undefined)).toBe(false)
+ })
+
+ it('returns false when both are empty strings', () => {
+ expect(areAddressesEqual('', '')).toBe(false)
+ })
+
+ it('returns false when one is null and other is undefined', () => {
+ expect(areAddressesEqual(null, undefined)).toBe(false)
+ })
+ })
+
+ describe('when one address is falsy', () => {
+ it('returns false when first is null', () => {
+ expect(areAddressesEqual(null, ADDRESS_1)).toBe(false)
+ })
+
+ it('returns false when second is null', () => {
+ expect(areAddressesEqual(ADDRESS_1, null)).toBe(false)
+ })
+
+ it('returns false when first is undefined', () => {
+ expect(areAddressesEqual(undefined, ADDRESS_1)).toBe(false)
+ })
+
+ it('returns false when second is undefined', () => {
+ expect(areAddressesEqual(ADDRESS_1, undefined)).toBe(false)
+ })
+
+ it('returns false when first is empty string', () => {
+ expect(areAddressesEqual('', ADDRESS_1)).toBe(false)
+ })
+
+ it('returns false when second is empty string', () => {
+ expect(areAddressesEqual(ADDRESS_1, '')).toBe(false)
+ })
+ })
+
+ describe('when both addresses are valid', () => {
+ it('returns true for identical addresses', () => {
+ expect(areAddressesEqual(ADDRESS_1, ADDRESS_1)).toBe(true)
+ })
+
+ it('returns true for same address with different casing', () => {
+ expect(areAddressesEqual(ADDRESS_1, ADDRESS_1.toUpperCase())).toBe(true)
+ })
+
+ it('returns true for lowercase and checksummed versions', () => {
+ expect(areAddressesEqual(ADDRESS_1, ADDRESS_1_CHECKSUMMED)).toBe(true)
+ })
+
+ it('returns false for different addresses', () => {
+ expect(areAddressesEqual(ADDRESS_1, ADDRESS_2)).toBe(false)
+ })
+ })
+})
diff --git a/libs/common-utils/src/areAddressesEqual.ts b/libs/common-utils/src/areAddressesEqual.ts
index 41765bee726..d0e3e27f3f8 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 false
+ if (!a || !b) return false
- return a?.toLowerCase() === b?.toLowerCase()
+ return getTokenAddressKey(a) === getTokenAddressKey(b)
}
diff --git a/libs/common-utils/src/areTokensEqual.ts b/libs/common-utils/src/areTokensEqual.ts
index 0eb75690107..508eb7c78f4 100644
--- a/libs/common-utils/src/areTokensEqual.ts
+++ b/libs/common-utils/src/areTokensEqual.ts
@@ -1,16 +1,12 @@
+import { getTokenId } from './tokens'
+
interface TokenLike {
chainId: number
address: string
}
-export type TokenId = `${number}:${string}`
-
-export function getTokenId(chainId: number, address: string): TokenId {
- return `${chainId}:${address.toLowerCase()}`
-}
-
export function areTokensEqual(a: TokenLike | undefined | null, b: TokenLike | undefined | null): boolean {
if (!a || !b) return false
- return getTokenId(a.chainId, a.address) === getTokenId(b.chainId, b.address)
+ return getTokenId(a) === getTokenId(b)
}
diff --git a/libs/common-utils/src/index.ts b/libs/common-utils/src/index.ts
index 9bfe697e8af..e3215aee0c6 100644
--- a/libs/common-utils/src/index.ts
+++ b/libs/common-utils/src/index.ts
@@ -34,6 +34,7 @@ export * from './isFractionFalsy'
export * from './isInjectedWidget'
export * from './isIframe'
export * from './isSellOrder'
+export * from './isSupportedChainId'
export * from './isZero'
export * from './jotai/atomWithPartialUpdate'
export * from './legacyAddressUtils'
diff --git a/libs/common-utils/src/isSupportedChainId.ts b/libs/common-utils/src/isSupportedChainId.ts
new file mode 100644
index 00000000000..6be1a5dcf78
--- /dev/null
+++ b/libs/common-utils/src/isSupportedChainId.ts
@@ -0,0 +1,5 @@
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+export function isSupportedChainId(chainId: number | undefined): chainId is SupportedChainId {
+ return typeof chainId === 'number' && chainId in SupportedChainId
+}
diff --git a/libs/common-utils/src/tokens.ts b/libs/common-utils/src/tokens.ts
index a3a5287030d..c60b9603690 100644
--- a/libs/common-utils/src/tokens.ts
+++ b/libs/common-utils/src/tokens.ts
@@ -14,3 +14,23 @@ export function isNativeAddress(tokenAddress: string, chainId: ChainId): boolean
return native && doesTokenMatchSymbolOrAddress(native, tokenAddressLower)
}
+
+// further it will be a good point to have EvmAddressKey type and Solana/Bitcoin etc
+// so this type it's kind of part of unition
+export type AddressKey = `0x${string}`
+
+export function getTokenAddressKey(address: string): AddressKey {
+ return `${address.toLowerCase()}` as AddressKey
+}
+
+export interface TokenIdentifier {
+ address: string
+ chainId: number
+}
+
+// the same as for AddressKey - will be more complex when integrate solana/bitcoin
+export type TokenId = `${number}:${AddressKey}`
+
+export function getTokenId(token: TokenIdentifier): TokenId {
+ return `${token.chainId}:${getTokenAddressKey(token.address)}`
+}
diff --git a/libs/tokens/src/hooks/tokens/useRestrictedToken.ts b/libs/tokens/src/hooks/tokens/useRestrictedToken.ts
index 400468a61be..73914b6c0df 100644
--- a/libs/tokens/src/hooks/tokens/useRestrictedToken.ts
+++ b/libs/tokens/src/hooks/tokens/useRestrictedToken.ts
@@ -22,7 +22,7 @@ export function findRestrictedToken(
): RestrictedTokenInfo | undefined {
if (!token) return undefined
- const tokenId = getTokenId(token.chainId, token.address)
+ const tokenId = getTokenId({ chainId: token.chainId, address: token.address })
const foundToken = restrictedList.tokensMap[tokenId]
if (!foundToken) return undefined
diff --git a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx
index 570a3aefb05..42834e65bd8 100644
--- a/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx
+++ b/libs/tokens/src/updaters/RestrictedTokensListUpdater/index.tsx
@@ -117,7 +117,7 @@ export function RestrictedTokensListUpdater({ isRwaGeoblockEnabled }: Restricted
const tokens = await fetchTokenList(list.tokenListUrl)
for (const token of tokens) {
- const tokenId = getTokenId(token.chainId, token.address)
+ const tokenId = getTokenId({ chainId: token.chainId, address: token.address })
tokensMap[tokenId] = token
countriesPerToken[tokenId] = list.restrictedCountries
consentHashPerToken[tokenId] = RWA_CONSENT_HASH