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 page." msgstr "Your favorite saved tokens. Edit this list in the <0>Tokens page." @@ -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}" #~ msgstr "Cancelling order with id {shortId}:<0/><1>{summary}" @@ -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>before and <1>after your swap." msgstr "With hooks you can add specific actions <0>before and <1>after 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}) 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} partsplaced every<3>{partDurationDisplay}, the total time to complete the order is<4>{totalDurationDisplay}. Each limit order remains open for<5>{partDurationDisplay}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} partsplaced every<3>{partDurationDisplay}, the total time to complete the order is<4>{totalDurationDisplay}. Each limit order remains open for<5>{partDurationDisplay}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} + + + {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> - {chain.label} - - - ))} - {shouldDisplayMore && ( - - {({ isOpen }) => ( - <> - - {selectedMenuChain ? ( - {selectedMenuChain.label} - ) : isOpen ? ( - - Less - - ) : ( - - More - - )} - {isOpen ? : } - - - {chains.map((chain) => ( - onSelectChain(chain)} - active$={defaultChainId === chain.id} - iconSize={21} - tabIndex={0} - borderless - > - {chain.label} - {chain.label} - {chain.id === defaultChainId && } - - ))} - - - )} - - )} - - ) -} +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 = ( + + {chain.label} + + ) + + 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