diff --git a/apps/cow-fi/data/cow-swap/const.tsx b/apps/cow-fi/data/cow-swap/const.tsx index dabca1fc406..174f1f73bdd 100644 --- a/apps/cow-fi/data/cow-swap/const.tsx +++ b/apps/cow-fi/data/cow-swap/const.tsx @@ -8,8 +8,9 @@ import IMG_COWSWAP_NOFEES from '@cowprotocol/assets/images/image-cowswap-nofees. import IMG_COWSWAP_SWAPS from '@cowprotocol/assets/images/image-cowswap-swaps.svg' import IMG_COWSWAP_TWAP from '@cowprotocol/assets/images/image-cowswap-twap.svg' import IMG_COWSWAP_UX from '@cowprotocol/assets/images/image-cowswap-ux.svg' -import { Color, UI } from '@cowprotocol/ui' import { getAvailableChainsText } from '@cowprotocol/common-const' +import { Color, UI } from '@cowprotocol/ui' + import { CowFiCategory } from 'src/common/analytics/types' import { Link } from '@/components/Link' diff --git a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx new file mode 100644 index 00000000000..a5fce5d4be9 --- /dev/null +++ b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx @@ -0,0 +1,58 @@ +import { AccountType } from '@cowprotocol/types' + +import { render } from '@testing-library/react' + +import { BridgingEnabledUpdater } from './BridgingEnabledUpdater' + +import { Routes } from '../constants/routes' + +jest.mock('@cowprotocol/common-hooks', () => ({ + ...jest.requireActual('@cowprotocol/common-hooks'), + useSetIsBridgingEnabled: jest.fn(), +})) + +jest.mock('@cowprotocol/wallet', () => ({ + ...jest.requireActual('@cowprotocol/wallet'), + useWalletInfo: jest.fn(), + useAccountType: jest.fn(), +})) + +jest.mock('modules/trade', () => ({ + ...jest.requireActual('modules/trade'), + useTradeTypeInfo: jest.fn(), +})) + +const { useSetIsBridgingEnabled } = require('@cowprotocol/common-hooks') +const mockUseSetIsBridgingEnabled = useSetIsBridgingEnabled as jest.MockedFunction +const { useWalletInfo, useAccountType } = require('@cowprotocol/wallet') + +const mockUseWalletInfo = useWalletInfo as jest.MockedFunction +const mockUseAccountType = useAccountType as jest.MockedFunction +const { useTradeTypeInfo } = require('modules/trade') +const mockUseTradeTypeInfo = useTradeTypeInfo as jest.MockedFunction + +describe('BridgingEnabledUpdater', () => { + const setIsBridgingEnabled = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + mockUseSetIsBridgingEnabled.mockReturnValue(setIsBridgingEnabled) + mockUseWalletInfo.mockReturnValue({ account: '0x123' }) + mockUseAccountType.mockReturnValue(AccountType.EOA) + mockUseTradeTypeInfo.mockReturnValue({ route: Routes.SWAP }) + }) + + it('disables bridging for smart contract wallets', () => { + mockUseAccountType.mockReturnValue(AccountType.SMART_CONTRACT) + + render() + + expect(setIsBridgingEnabled).toHaveBeenCalledWith(false) + }) + + it('enables bridging on swap route when the wallet is compatible', () => { + render() + + expect(setIsBridgingEnabled).toHaveBeenCalledWith(true) + }) +}) diff --git a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts index e314d894610..14b975bdd3d 100644 --- a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts +++ b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts @@ -16,7 +16,6 @@ export function BridgingEnabledUpdater(): null { const setIsBridgingEnabled = useSetIsBridgingEnabled() const isSwapRoute = tradeTypeInfo?.route === Routes.SWAP - const isWalletCompatible = Boolean(account ? accountType !== AccountType.SMART_CONTRACT : true) const shouldEnableBridging = isWalletCompatible && isSwapRoute diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po index 70bc27d794e..26011950a53 100644 --- a/apps/cowswap-frontend/src/locales/es-ES.po +++ b/apps/cowswap-frontend/src/locales/es-ES.po @@ -4212,6 +4212,14 @@ msgstr "Habilitar aprobación parcial" msgid "Version" msgstr "Versión" +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Recent" +msgstr "Recientes" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Clear" +msgstr "Borrar" + #: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx msgid "All tokens" msgstr "Todos los tokens" @@ -5211,7 +5219,7 @@ msgstr "parte" #: apps/cowswap-frontend/src/modules/swap/pure/CrossChainUnlockScreen/index.tsx msgid "Cross-chain swaps are here" -msgstr "Los swaps de cadena media están aquí" +msgstr "Los swaps entre cadenas están aquí" #: apps/cowswap-frontend/src/modules/erc20Approve/containers/ApprovalAmountInput/ApprovalAmountInput.tsx #~ msgid "Approval amount:" @@ -6294,3 +6302,80 @@ 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}" diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po index 167fbfb56e5..48f5a41d4a9 100644 --- a/apps/cowswap-frontend/src/locales/ru-RU.po +++ b/apps/cowswap-frontend/src/locales/ru-RU.po @@ -4212,6 +4212,14 @@ msgstr "Включить частичные утверждения" msgid "Version" msgstr "Версии" +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Recent" +msgstr "Недавние" + +#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +msgid "Clear" +msgstr "Очистить" + #: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx msgid "All tokens" msgstr "Все токены" @@ -5211,7 +5219,7 @@ msgstr "часть" #: apps/cowswap-frontend/src/modules/swap/pure/CrossChainUnlockScreen/index.tsx msgid "Cross-chain swaps are here" -msgstr "Перекрестные цепочки здесь" +msgstr "Межсетевые обмены здесь" #: apps/cowswap-frontend/src/modules/erc20Approve/containers/ApprovalAmountInput/ApprovalAmountInput.tsx #~ msgid "Approval amount:" @@ -6294,3 +6302,80 @@ 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}" diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx index 46aff689b97..833b0ba034a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx @@ -17,7 +17,7 @@ export interface ManageListsAndTokensProps { lists: ListState[] customTokens: TokenWithLogo[] onBack(): void - onDismiss(): void + onDismiss?(): void } const tokensInputPlaceholder = '0x0000' @@ -50,20 +50,15 @@ export function ManageListsAndTokens(props: ManageListsAndTokensProps): ReactNod const tokenSearchResponse = useSearchToken(isTokenAddressValid ? tokenInput : null) const listSearchResponse = useSearchList(isListUrlValid ? listInput : null) - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const setListsTab = () => { + const setListsTab = (): void => { setCurrentTab('lists') setInputValue('') } - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const setTokensTab = () => { + const setTokensTab = (): void => { setCurrentTab('tokens') setInputValue('') } - return ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx new file mode 100644 index 00000000000..c85b9f888ed --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx @@ -0,0 +1,45 @@ +import { MouseEvent, ReactNode } from 'react' + +import { createPortal } from 'react-dom' + +import { MobileChainPanelCard, MobileChainPanelOverlay } from './styled' + +import { ChainPanel } from '../../pure/ChainPanel' + +import type { SelectTokenWidgetViewProps } from './controllerProps' + +interface MobileChainPanelPortalProps { + chainsPanelTitle: string + chainsToSelect: SelectTokenWidgetViewProps['chainsToSelect'] + onSelectChain: SelectTokenWidgetViewProps['onSelectChain'] + onClose(): void +} + +export function MobileChainPanelPortal({ + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onClose, +}: MobileChainPanelPortalProps): ReactNode { + if (typeof document === 'undefined') { + return null + } + + return createPortal( + + ) => event.stopPropagation()}> + { + onSelectChain(chain) + onClose() + }} + variant="fullscreen" + onClose={onClose} + /> + + , + document.body, + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts index f6c0e621790..ea0c2771fc3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts @@ -73,9 +73,9 @@ interface BuildModalPropsArgs { account: string | undefined hasChainPanel: boolean chainsState?: ChainsToSelectState + chainsPanelTitle: string onSelectChain?(chain: ChainInfo): void isInjectedWidgetMode: boolean - chainsPanelTitle: string modalTitle: string } @@ -136,9 +136,9 @@ export function buildSelectTokenModalPropsInput({ onDismiss, onOpenManageWidget, openPoolPage, - tokenListCategoryState, - disableErc20, - account, + tokenListCategoryState, + disableErc20, + account, hasChainPanel, chainsState, onSelectChain, @@ -175,6 +175,8 @@ export function buildSelectTokenModalPropsInput({ hasChainPanel, chainsToSelect: chainsState, chainsPanelTitle, + mobileChainsState: chainsState, + mobileChainsLabel: chainsPanelTitle, hideFavoriteTokensTooltip: isInjectedWidgetMode, selectedTargetChainId: widgetState.selectedTargetChainId, onSelectChain: selectChainHandler, @@ -209,10 +211,14 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele isRouteAvailable: props.isRouteAvailable, modalTitle: props.modalTitle, hasChainPanel: props.hasChainPanel, - hideFavoriteTokensTooltip: props.hideFavoriteTokensTooltip, chainsPanelTitle: props.chainsPanelTitle, + hideFavoriteTokensTooltip: props.hideFavoriteTokensTooltip, selectedTargetChainId: props.selectedTargetChainId, + chainsToSelect: props.chainsToSelect, + mobileChainsState: props.mobileChainsState, + mobileChainsLabel: props.mobileChainsLabel, onSelectChain: props.onSelectChain, + onOpenMobileChainPanel: props.onOpenMobileChainPanel, onClearRecentTokens: props.onClearRecentTokens, }), [ @@ -243,7 +249,11 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele props.chainsPanelTitle, props.hideFavoriteTokensTooltip, props.selectedTargetChainId, + props.chainsToSelect, + props.mobileChainsState, + props.mobileChainsLabel, props.onSelectChain, + props.onOpenMobileChainPanel, props.onClearRecentTokens, ], ) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts index 4a600002a5f..fd62f75f7b5 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts @@ -159,14 +159,14 @@ function resolveModalTitle(field: Field, tradeType: TradeType | undefined): stri const isSwapTrade = !tradeType || tradeType === TradeType.SWAP if (field === Field.INPUT) { - return isSwapTrade ? 'Swap from' : 'Sell token' + return isSwapTrade ? t`Swap from` : t`Sell token` } if (field === Field.OUTPUT) { - return isSwapTrade ? 'Swap to' : 'Buy token' + return isSwapTrade ? t`Swap to` : t`Buy token` } - return 'Select token' + return t`Select token` } export function useDismissHandler( 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 d3c5aaf7164..662d118483a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -1,11 +1,18 @@ -import { ReactNode } from 'react' +import { MouseEvent, ReactNode, useEffect, useState } from 'react' + +import { useMediaQuery } from '@cowprotocol/common-hooks' +import { addBodyClass, removeBodyClass } from '@cowprotocol/common-utils' +import { Media } from '@cowprotocol/ui' + +import { createPortal } from 'react-dom' import { useSelectTokenWidgetController, type SelectTokenWidgetProps, type SelectTokenWidgetViewProps, } from './controller' -import { InnerWrapper, ModalContainer, Wrapper } from './styled' +import { MobileChainPanelPortal } from './MobileChainPanelPortal' +import { InnerWrapper, ModalContainer, WidgetCard, WidgetOverlay, Wrapper } from './styled' import { ChainPanel } from '../../pure/ChainPanel' import { ImportListModal } from '../../pure/ImportListModal' @@ -16,27 +23,84 @@ import { ManageListsAndTokens } from '../ManageListsAndTokens' export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode { const { shouldRender, hasChainPanel, viewProps } = useSelectTokenWidgetController(props) + const isCompactLayout = useMediaQuery(Media.upToMedium(false)) + const [isMobileChainPanelOpen, setIsMobileChainPanelOpen] = useState(false) + const isChainPanelVisible = hasChainPanel && !isCompactLayout + + useEffect(() => { + if (!shouldRender) { + return + } + + if (isChainPanelVisible) { + setIsMobileChainPanelOpen(false) + } + }, [isChainPanelVisible, shouldRender]) + + useEffect(() => { + if (!shouldRender) { + removeBodyClass('noScroll') + return undefined + } + + addBodyClass('noScroll') + return () => removeBodyClass('noScroll') + }, [shouldRender]) if (!shouldRender) { return null } - return ( + const widgetContent = ( - - + + ) + + const handleOverlayClick = (event: MouseEvent): void => { + if (event.target !== event.currentTarget) { + return + } + + viewProps.onDismiss() + } + + const overlay = ( + + + {widgetContent} + + + ) + + if (typeof document === 'undefined') { + return overlay + } + + return createPortal(overlay, document.body) } function SelectTokenWidgetView( props: SelectTokenWidgetViewProps & { isChainPanelVisible: boolean + isCompactLayout: boolean + isMobileChainPanelOpen: boolean + setIsMobileChainPanelOpen(value: boolean): void }, ): ReactNode { const { isChainPanelVisible, + isCompactLayout, + isMobileChainPanelOpen, + setIsMobileChainPanelOpen, isChainPanelEnabled, chainsPanelTitle, chainsToSelect, @@ -50,21 +114,49 @@ function SelectTokenWidgetView( return blockingView } + const closeMobileChainPanel = (): void => setIsMobileChainPanelOpen(false) + const mobileChainsState = isChainPanelEnabled && !isChainPanelVisible ? chainsToSelect : undefined + const handleOpenMobileChainPanel = mobileChainsState ? () => setIsMobileChainPanelOpen(true) : undefined const showDesktopChainPanel = isChainPanelVisible && isChainPanelEnabled && chainsToSelect + const showMobileChainPanel = !isChainPanelVisible && isChainPanelEnabled && chainsToSelect && isMobileChainPanelOpen + const modalChainsToSelect = isChainPanelVisible ? undefined : chainsToSelect return ( <> - + {showDesktopChainPanel && ( )} + {showMobileChainPanel && ( + + )} ) } -function getBlockingView(props: SelectTokenWidgetViewProps): ReactNode | null { +function getBlockingView( + props: SelectTokenWidgetViewProps & { + isChainPanelVisible: boolean + isCompactLayout: boolean + isMobileChainPanelOpen: boolean + setIsMobileChainPanelOpen(value: boolean): void + }, +): ReactNode | null { const { standalone, tokenToImport, diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts index 5af04231e12..04eddcd7d8d 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts @@ -1,17 +1,43 @@ -import styled from 'styled-components/macro' +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 }>` - min-height: min(600px, 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; - flex-direction: ${({ $hasSidebar }) => ($hasSidebar ? 'row' : 'column')}; + + ${({ $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` @@ -20,3 +46,47 @@ export const ModalContainer = styled.div` 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/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts index 5bf6a56e458..ca2b35606c3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts @@ -1,9 +1,61 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { useWalletInfo, WalletInfo } from '@cowprotocol/wallet' -import { createInputChainsState, createOutputChainsState } from './useChainsToSelect' +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' +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(), + useFeatureFlags: jest.fn(), +})) + +jest.mock('entities/bridgeProvider', () => ({ + ...jest.requireActual('entities/bridgeProvider'), + useBridgeSupportedNetworks: jest.fn(), +})) + +jest.mock('./useSelectTokenWidgetState', () => ({ + ...jest.requireActual('./useSelectTokenWidgetState'), + useSelectTokenWidgetState: jest.fn(), +})) + +const mockUseWalletInfo = useWalletInfo as jest.MockedFunction +const mockUseSelectTokenWidgetState = useSelectTokenWidgetState as jest.MockedFunction + +const { useIsBridgingEnabled, useAvailableChains, useFeatureFlags } = require('@cowprotocol/common-hooks') +const mockUseIsBridgingEnabled = useIsBridgingEnabled as jest.MockedFunction +const mockUseAvailableChains = useAvailableChains as jest.MockedFunction +const mockUseFeatureFlags = useFeatureFlags as jest.MockedFunction + +const { useBridgeSupportedNetworks } = require('entities/bridgeProvider') +const mockUseBridgeSupportedNetworks = useBridgeSupportedNetworks as jest.MockedFunction< + typeof useBridgeSupportedNetworks +> + +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 = [ @@ -45,4 +97,139 @@ describe('useChainsToSelect state builders', () => { SupportedChainId.AVALANCHE, ]) }) + + it('falls back to wallet chain when bridge does not support the source chain', () => { + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.BASE, + chainId: SupportedChainId.SEPOLIA, + currentChainInfo: createChainInfoForTests(SupportedChainId.SEPOLIA), + bridgeSupportedNetworks: [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.ARBITRUM_ONE), + ], + areUnsupportedChainsEnabled: true, + isLoading: false, + }) + + expect(state.defaultChainId).toBe(SupportedChainId.SEPOLIA) + expect(state.chains?.map((chain) => chain.id)).toEqual([SupportedChainId.SEPOLIA]) + }) +}) + +describe('useChainsToSelect hook', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseWalletInfo.mockReturnValue({ chainId: SupportedChainId.MAINNET } as WalletInfo) + mockUseIsBridgingEnabled.mockReturnValue(true) + mockUseAvailableChains.mockReturnValue([SupportedChainId.MAINNET, SupportedChainId.GNOSIS_CHAIN]) + mockUseFeatureFlags.mockReturnValue({ areUnsupportedChainsEnabled: false }) + mockUseBridgeSupportedNetworks.mockReturnValue({ + data: [createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN)], + isLoading: 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 8ef9426e796..577ebbcbd4a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts @@ -50,16 +50,15 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { const chainInfo = CHAIN_INFO[chainId] if (!chainInfo) return undefined - const currentChainInfo = mapChainInfo(chainId, chainInfo) - // Limit/TWAP buys must stay on the wallet chain, so skip bridge wiring entirely. + // Limit/TWAP orders don't support chain selection - return undefined for both SELL and BUY + // These trade types rely on wallet/header network switcher instead // TODO: Revisit when SC wallet bridging supports advanced trades, so TWAPs can bridge. - const shouldForceSingleChain = isAdvancedTradeType && field === Field.OUTPUT - - if (!isBridgingEnabled && !shouldForceSingleChain) return undefined - - if (shouldForceSingleChain) { - return createSingleChainState(chainId, currentChainInfo) + if (isAdvancedTradeType) { + return undefined } + const currentChainInfo = mapChainInfo(chainId, chainInfo) + + if (!isBridgingEnabled) return undefined if (field === Field.INPUT) { return createInputChainsState(selectedTargetChainId, supportedChains) @@ -149,7 +148,7 @@ export function createOutputChainsState({ if (!isSourceChainSupportedByBridge) { // Source chain is unsupported by the bridge provider; fall back to non-bridge behavior. - return createSingleChainState(selectedTargetChainId, currentChainInfo) + return createSingleChainState(chainId, currentChainInfo) } return { diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx index bc4ff079993..cfb2395429a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx @@ -4,6 +4,7 @@ import { ChainInfo } from '@cowprotocol/cow-sdk' import { BackButton } from '@cowprotocol/ui' import { t } from '@lingui/core/macro' +import { Trans } from '@lingui/react/macro' import * as styledEl from './styled' @@ -62,7 +63,11 @@ export function ChainPanel({ onSelectChain={onSelectChain} /> {showUnavailableState && {t`No networks available for this trade.`}} - {showSearchEmptyState && {t`No networks match "${chainQuery}".`}} + {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 index 6cbbb54eb75..c52a99f92c5 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx @@ -2,10 +2,10 @@ 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%' : '240px')}; - min-width: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '220px')}; - max-width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '280px')}; + width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '200px')}; height: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : 'auto')}; flex-shrink: 0; background: var(${UI.COLOR_PAPER_DARKER}); @@ -20,7 +20,6 @@ export const Panel = styled.div<{ $variant: 'default' | 'fullscreen' }>` ${Media.upToMedium()} { width: 100%; - min-width: 0; border-left: none; border-top: 1px solid var(${UI.COLOR_BORDER}); border-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '0 0 20px 20px')}; @@ -49,6 +48,14 @@ export const PanelTitle = styled.h4<{ $isFullscreen?: boolean }>` 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); 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 a446b5c62c9..1f545df0895 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx @@ -72,9 +72,8 @@ export const ChainLogo = styled.div` --size: 28px; width: var(--size); height: var(--size); - border-radius: var(--size); overflow: hidden; - background: var(${UI.COLOR_PAPER}); + background: transparent; display: flex; align-items: center; justify-content: center; @@ -82,7 +81,7 @@ export const ChainLogo = styled.div` > img { width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; } ` 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..31d8a2f9138 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx @@ -0,0 +1,113 @@ +import { ReactNode, useEffect, useMemo, useRef } from 'react' + +import { useTheme } from '@cowprotocol/common-hooks' +import { ChainInfo } from '@cowprotocol/cow-sdk' + +import { msg } from '@lingui/core/macro' +import { Trans, useLingui } from '@lingui/react/macro' +import { ChevronDown } from 'react-feather' + +import * as styledEl from './mobileChainSelector.styled' + +import { ChainsToSelectState } from '../../types' +import { sortChainsByDisplayOrder } from '../../utils/sortChainsByDisplayOrder' +import { getChainAccent } from '../ChainsSelector' + +interface MobileChainSelectorProps { + chainsState: ChainsToSelectState + label?: string + onSelectChain(chain: ChainInfo): void + onOpenPanel(): void +} + +export function MobileChainSelector({ + chainsState, + label, + onSelectChain, + onOpenPanel, +}: MobileChainSelectorProps): ReactNode { + const { i18n } = useLingui() + const scrollRef = useRef(null) + const orderedChains = useMemo( + () => + sortChainsByDisplayOrder(chainsState.chains ?? [], { + pinChainId: chainsState.defaultChainId, + }), + [chainsState.chains, chainsState.defaultChainId], + ) + + const totalChains = chainsState.chains?.length ?? 0 + const canRenderChains = orderedChains.length > 0 + const activeChainLabel = orderedChains.find((chain) => chain.id === chainsState.defaultChainId)?.label + + useEffect(() => { + if (!scrollRef.current) { + return + } + + scrollRef.current.scrollTo({ left: 0, behavior: 'auto' }) + }, [chainsState.defaultChainId]) + + return ( + + {label ? ( + + {label} + {activeChainLabel ? ( + + {activeChainLabel} + + ) : null} + + ) : null} + + {canRenderChains ? ( + + {orderedChains.map((chain) => ( + + ))} + + ) : null} + {totalChains > 0 ? ( + + + + View all ({totalChains}) + + + + + ) : null} + + + ) +} + +interface ChainChipProps { + chain: ChainInfo + isActive: boolean + onSelectChain(chain: ChainInfo): void +} + +function ChainChip({ chain, isActive, onSelectChain }: ChainChipProps): ReactNode { + const { darkMode } = useTheme() + const accent = getChainAccent(chain.id) + const logoSrc = darkMode ? chain.logo.dark : chain.logo.light + + return ( + onSelectChain(chain)} + $active={isActive} + $accent={accent} + aria-pressed={isActive} + > + {chain.label} + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx index 5949943c12a..f25c62541f5 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx @@ -4,7 +4,6 @@ import { ChainInfo } from '@cowprotocol/cow-sdk' import { TokenListCategory } from '@cowprotocol/tokens' import { SelectTokenModalContent } from './SelectTokenModalContent' -import * as styledEl from './styled' import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' import { ChainsToSelectState, TokenSelectionHandler } from '../../types' @@ -73,13 +72,11 @@ function LegacyChainSelector({ chainsToSelect, onSelectChain }: LegacyChainSelec } return ( - - - + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx index 462ed09a45e..cfa494aae59 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx @@ -2,6 +2,8 @@ import { ReactNode, useMemo, useState } from 'react' import { BackButton } from '@cowprotocol/ui' +import { t } from '@lingui/core/macro' + import { SettingsIcon } from 'modules/trade/pure/Settings' import * as styledEl from './styled' @@ -74,10 +76,10 @@ export function TitleBarActions({ {showManageButton && ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx index 67199062de7..7a715d72407 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx @@ -8,6 +8,7 @@ import styled from 'styled-components/macro' import { allTokensMock, favoriteTokensMock } from '../../mocks' import { mapChainInfo } from '../../utils/mapChainInfo' +import { ChainPanel } from '../ChainPanel' import { SelectTokenModal, SelectTokenModalProps } from './index' @@ -15,7 +16,11 @@ const Wrapper = styled.div` max-height: 90vh; margin: 20px auto; display: flex; - width: 900px; + gap: 0; + width: 960px; + border-radius: 20px; + overflow: hidden; + border: 1px solid rgba(0, 0, 0, 0.05); ` const unsupportedTokens = {} @@ -32,6 +37,9 @@ const chainsMock: ChainInfo[] = [ SupportedChainId.MAINNET, SupportedChainId.BASE, SupportedChainId.ARBITRUM_ONE, + SupportedChainId.POLYGON, + SupportedChainId.AVALANCHE, + SupportedChainId.GNOSIS_CHAIN, ].reduce((acc, id) => { const info = CHAIN_INFO[id] @@ -42,25 +50,19 @@ const chainsMock: ChainInfo[] = [ return acc }, []) -const defaultProps: SelectTokenModalProps = { +const favoriteTokenAddresses = new Set(favoriteTokensMock.map((token) => token.address.toLowerCase())) +const recentTokensMock = allTokensMock.filter((token) => !favoriteTokenAddresses.has(token.address.toLowerCase())).slice(0, 3) + +const defaultModalProps: SelectTokenModalProps = { tokenListTags: {}, account: undefined, permitCompatibleTokens: {}, unsupportedTokens, allTokens: allTokensMock, favoriteTokens: favoriteTokensMock, + recentTokens: recentTokensMock, areTokensLoading: false, areTokensFromBridge: false, - chainsToSelect: { - chains: chainsMock, - isLoading: false, - defaultChainId: SupportedChainId.MAINNET, - }, - hasChainPanel: true, - chainsPanelTitle: 'Cross chain swap', - onSelectChain(chain: ChainInfo) { - console.log('onSelectChain', chain) - }, tokenListCategoryState: [null, () => void 0], balancesState: { values: balances, @@ -71,17 +73,13 @@ const defaultProps: SelectTokenModalProps = { selectedToken, isRouteAvailable: true, modalTitle: 'Swap from', - recentTokens: favoriteTokensMock.slice(0, 2), - selectedTargetChainId: SupportedChainId.SEPOLIA, + onSelectChain: () => undefined, onSelectToken() { console.log('onSelectToken') }, onTokenListItemClick(token) { console.log('onTokenListItemClick', token.symbol) }, - onClearRecentTokens() { - console.log('onClearRecentTokens') - }, onOpenManageWidget() { console.log('onOpenManageWidget') }, @@ -93,35 +91,57 @@ const defaultProps: SelectTokenModalProps = { }, } +const defaultChainPanelProps = { + title: 'Cross chain swap', + chainsState: { + defaultChainId: SupportedChainId.MAINNET, + chains: chainsMock, + isLoading: false, + }, + onSelectChain(chain: ChainInfo) { + console.log('onSelectChain', chain) + }, +} + const Fixtures = { default: () => ( - + + + + ), + loadingSidebar: () => ( + + + ), - noChainPanel: () => ( + noSidebar: () => ( - + ), importByAddress: () => ( - + ), NoTokenFound: () => ( - + ), searchFromInactiveLists: () => ( - + ), searchFromExternalSources: () => ( - + ), } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index 4f1e71d23a5..6dd26daf27b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -1,10 +1,11 @@ -import { ReactNode, useMemo } from 'react' +import { ComponentProps, ReactNode, useMemo } from 'react' import { SearchInput } from '@cowprotocol/ui' import { t } from '@lingui/core/macro' import { TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers' +import { MobileChainSelector } from './MobileChainSelector' import * as styledEl from './styled' import { TokenColumnContent } from './TokenColumnContent' @@ -41,6 +42,9 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { selectedTargetChainId, hasChainPanel = false, chainsPanelTitle, + mobileChainsState, + mobileChainsLabel, + onOpenMobileChainPanel, isFullScreenMobile, } = props @@ -60,6 +64,15 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { chainsPanelTitle, }) + const mobileChainSelector = getMobileChainSelectorConfig({ + showChainPanel, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, + }) + const chainsForTokenColumn = mobileChainSelector ? undefined : legacyChainsState + return ( @@ -116,6 +128,7 @@ interface SelectTokenModalShellProps { searchValue: string onSearchChange(value: string): void onSearchEnter?: () => void + mobileChainSelector?: ComponentProps sideContent?: ReactNode } @@ -130,6 +143,7 @@ function SelectTokenModalShell({ searchValue, onSearchChange, onSearchEnter, + mobileChainSelector, sideContent, }: SelectTokenModalShellProps): ReactNode { return ( @@ -155,6 +169,7 @@ function SelectTokenModalShell({ /> + {mobileChainSelector ? : null} {children} {sideContent} @@ -185,7 +200,7 @@ function useSelectTokenModalLayout(props: SelectTokenModalProps): { const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) const selectTokenContext = useSelectTokenContext(props) const resolvedModalTitle = modalTitle ?? t`Select token` - const showChainPanel = hasChainPanel && Boolean(chainsToSelect?.chains?.length) + const showChainPanel = hasChainPanel const legacyChainsState = !showChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap` @@ -208,3 +223,35 @@ function useSelectTokenModalLayout(props: SelectTokenModalProps): { resolvedModalTitle, } } + +function getMobileChainSelectorConfig({ + showChainPanel, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, +}: { + showChainPanel: boolean + mobileChainsState: SelectTokenModalProps['mobileChainsState'] + mobileChainsLabel: SelectTokenModalProps['mobileChainsLabel'] + onSelectChain?: SelectTokenModalProps['onSelectChain'] + onOpenMobileChainPanel?: SelectTokenModalProps['onOpenMobileChainPanel'] +}): ComponentProps | undefined { + const canRender = + !showChainPanel && + mobileChainsState && + onSelectChain && + onOpenMobileChainPanel && + (mobileChainsState.chains?.length ?? 0) > 0 + + if (!canRender) { + return undefined + } + + return { + chainsState: mobileChainsState, + label: mobileChainsLabel, + onSelectChain, + onOpenPanel: onOpenMobileChainPanel, + } +} 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..2f3fd129525 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts @@ -0,0 +1,147 @@ +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 }>` + --size: 44px; + 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: pointer; + transition: + border 0.2s ease, + background 0.2s ease; + flex-shrink: 0; + scroll-snap-align: start; + + > img { + --size: 100%; + width: var(--size); + height: var(--size); + object-fit: contain; + } +` + +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 98b614e413a..4dae09d3ba6 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts @@ -103,8 +103,6 @@ export const Body = styled.div` display: flex; flex: 1; min-height: 0; - gap: 16px; - align-items: stretch; ${Media.upToMedium()} { flex-direction: column; @@ -120,17 +118,6 @@ export const TokenColumn = styled.div` padding: 0; ` -export const LegacyChainsWrapper = styled.div` - border-bottom: 1px solid var(${UI.COLOR_BORDER}); - padding: 2px 10px 10px 14px; - margin: 0 14px 16px; - - ${Media.upToSmall()} { - margin: 0 10px 16px; - padding: 2px 4px 10px 8px; - } -` - export const Row = styled.div` padding: 0 24px; margin-bottom: 16px; @@ -186,26 +173,3 @@ export const RouteNotAvailable = styled.div` padding: 40px 0; text-align: center; ` - -export const ActionButton = styled.button` - ${blankButtonMixin}; - - display: flex; - width: 100%; - align-items: center; - flex-direction: row; - justify-content: center; - gap: 10px; - 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; - - &:hover { - opacity: 1; - } -` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts index e8930d1c354..887d3b0696b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -35,6 +35,9 @@ export interface TokenListContentProps { chainsPanelTitle?: string isFullScreenMobile?: boolean selectedTargetChainId?: number + mobileChainsState?: ChainsToSelectState + mobileChainsLabel?: string + onOpenMobileChainPanel?(): void } export interface ChainSelectionProps { 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 633e78de5c3..96b472d31aa 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx @@ -3,9 +3,6 @@ import React, { ReactNode, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { Loader } from '@cowprotocol/ui' -import { Trans } from '@lingui/react/macro' -import { Edit } from 'react-feather' - import { TokenSearchResults } from '../../containers/TokenSearchResults' import { SelectTokenContext } from '../../types' import { getTokenUniqueKey } from '../../utils/tokenKey' @@ -17,14 +14,12 @@ export interface TokensContentProps { selectTokenContext: SelectTokenContext favoriteTokens: TokenWithLogo[] recentTokens?: TokenWithLogo[] - hideFavoriteTokensTooltip?: boolean areTokensLoading: boolean allTokens: TokenWithLogo[] searchInput: string - standalone?: boolean areTokensFromBridge: boolean + hideFavoriteTokensTooltip?: boolean selectedTargetChainId?: number - onOpenManageWidget(): void onClearRecentTokens?: () => void } @@ -32,37 +27,34 @@ export function TokensContent({ selectTokenContext, favoriteTokens, recentTokens, - hideFavoriteTokensTooltip, areTokensLoading, allTokens, displayLpTokenLists, searchInput, - standalone, areTokensFromBridge, + hideFavoriteTokensTooltip, selectedTargetChainId, - onOpenManageWidget, onClearRecentTokens, }: TokensContentProps): ReactNode { const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0 const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0 const pinnedTokenKeys = useMemo(() => { - if (!shouldShowFavoritesInline && !shouldShowRecentsInline) { + // 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 (shouldShowFavoritesInline) { - favoriteTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) - } - if (shouldShowRecentsInline && recentTokens) { recentTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) } return pinned - }, [favoriteTokens, recentTokens, shouldShowFavoritesInline, shouldShowRecentsInline]) + }, [recentTokens, shouldShowRecentsInline]) const tokensWithoutPinned = useMemo(() => { if (!pinnedTokenKeys) { @@ -76,35 +68,20 @@ export function TokensContent({ const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined return ( - <> - - {!standalone && ( - <> - -
- - {' '} - - Manage Token Lists - - -
- - )} - + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx index 98b09635f8b..b34320b9dd6 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx @@ -4,6 +4,7 @@ import { TokenWithLogo } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' import { getIsNativeToken } from '@cowprotocol/common-utils' +import { t } from '@lingui/core/macro' import { VirtualItem } from '@tanstack/react-virtual' import { CoWAmmBanner } from 'common/containers/CoWAmmBanner' @@ -83,15 +84,15 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { if (recentTokens?.length) { composedRows.push({ type: 'title', - label: 'Recent', - actionLabel: onClearRecentTokens ? 'Clear' : undefined, + label: t`Recent`, + actionLabel: onClearRecentTokens ? t`Clear` : undefined, onAction: onClearRecentTokens, }) recentTokens.forEach((token) => composedRows.push({ type: 'token', token })) } if (favoriteTokens?.length || recentTokens?.length) { - composedRows.push({ type: 'title', label: 'All tokens' }) + composedRows.push({ type: 'title', label: t`All tokens` }) } return [...composedRows, ...tokenRows]