diff --git a/apps/cowswap-frontend/src/common/hooks/useConvertUsdToTokenValue.ts b/apps/cowswap-frontend/src/common/hooks/useConvertUsdToTokenValue.ts new file mode 100644 index 0000000000..aa5d4c7cc6 --- /dev/null +++ b/apps/cowswap-frontend/src/common/hooks/useConvertUsdToTokenValue.ts @@ -0,0 +1,33 @@ +import { TokenWithLogo, USDC } from '@cowprotocol/common-const' +import { getWrappedToken, tryParseCurrencyAmount } from '@cowprotocol/common-utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' + +import { useUsdPrice } from 'modules/usdAmount' + +export function useConvertUsdToTokenValue( + currency: Currency | null, +): (typedValue: string, isUsdMode: boolean) => string { + const currencyUsdcPrice = useUsdPrice(currency ? getWrappedToken(currency) : null) + + return (typedValue: string, isUsdMode: boolean) => { + if (isUsdMode && currencyUsdcPrice?.price) { + const usdcToken = USDC[currencyUsdcPrice.currency.chainId as SupportedChainId] + const usdAmount = tryParseCurrencyAmount(typedValue, usdcToken) + + const tokenAmount = currencyUsdcPrice.price.invert().quote(hackyAdjustAmountDust(usdAmount)) + + return tokenAmount.toExact() + } + + return typedValue + } +} + +/** + * TODO: this is a hacky way to adjust the amount to avoid dust + * For some reason, when you enter for example $366, price.quote() returns 365,9999999999 + */ +function hackyAdjustAmountDust(amount: CurrencyAmount): typeof amount { + return amount.add(tryParseCurrencyAmount('0.000001', amount.currency)) +} diff --git a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx index 9020f7c004..5074642baa 100644 --- a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx +++ b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx @@ -6,14 +6,13 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk' import { TokenAmount, HoverTooltip } from '@cowprotocol/ui' import { Currency, CurrencyAmount } from '@uniswap/sdk-core' -import { Trans } from '@lingui/macro' - import { BalanceAndSubsidy } from 'legacy/hooks/useCowBalanceAndSubsidy' import { PriceImpact } from 'legacy/hooks/usePriceImpact' import { Field } from 'legacy/state/types' import { setMaxSellTokensAnalytics } from 'modules/analytics' import { ReceiveAmount } from 'modules/swap/pure/ReceiveAmount' +import { useUsdAmount } from 'modules/usdAmount' import { CurrencyInfo } from 'common/pure/CurrencyInputPanel/types' import { CurrencySelectButton } from 'common/pure/CurrencySelectButton' @@ -21,6 +20,8 @@ import { FiatValue } from 'common/pure/FiatValue' import * as styledEl from './styled' +import { useConvertUsdToTokenValue } from '../../hooks/useConvertUsdToTokenValue' + interface BuiltItProps { className: string } @@ -84,26 +85,47 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) { customSelectTokenButton, } = props - const { field, currency, balance, fiatAmount, amount, isIndependent, receiveAmountInfo } = currencyInfo + const { + field, + currency, + balance, + fiatAmount, + amount, + isIndependent, + receiveAmountInfo, + isUsdValuesMode = false, + } = currencyInfo const disabled = !!props.disabled || isChainIdUnsupported - const viewAmount = formatInputAmount(amount, balance, isIndependent) + + const { value: usdAmount } = useUsdAmount(amount) + const { value: maxBalanceUsdAmount } = useUsdAmount(maxBalance) + const { value: balanceUsdAmount } = useUsdAmount(balance) + const viewAmount = isUsdValuesMode ? formatInputAmount(usdAmount) : formatInputAmount(amount, balance, isIndependent) const [typedValue, setTypedValue] = useState(viewAmount) + const convertUsdToTokenValue = useConvertUsdToTokenValue(currency) + const onUserInputDispatch = useCallback( (typedValue: string) => { - setTypedValue(typedValue) - onUserInput(field, typedValue) + const value = convertUsdToTokenValue(typedValue, isUsdValuesMode) + + setTypedValue(value) + onUserInput(field, value) }, - [onUserInput, field], + [onUserInput, field, viewAmount, convertUsdToTokenValue, isUsdValuesMode], ) const handleMaxInput = useCallback(() => { if (!maxBalance) { return } - onUserInputDispatch(maxBalance.toExact()) - setMaxSellTokensAnalytics() - }, [maxBalance, onUserInputDispatch]) + const value = isUsdValuesMode ? maxBalanceUsdAmount : maxBalance + + if (value) { + onUserInputDispatch(value.toExact()) + setMaxSellTokensAnalytics() + } + }, [maxBalance, onUserInputDispatch, convertUsdToTokenValue, isUsdValuesMode, maxBalanceUsdAmount]) useEffect(() => { const areValuesSame = parseFloat(viewAmount) === parseFloat(typedValue) @@ -128,13 +150,31 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) { const numericalInput = ( ) + const balanceView = ( +
+ {balance && !disabled && ( + + {isUsdValuesMode ? ( + + ) : ( + + )} + {showSetMax && balance.greaterThan(0) && ( + Max + )} + + )} +
+ ) + const priceImpactParams: typeof _priceImpactParams = useMemo(() => { if (!_priceImpactParams) return undefined @@ -159,10 +199,28 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) { pointerDisabled={disabled} readOnly={inputDisabled} > - {topLabel && {topLabel}} + + {topLabel && ( + + {topLabel}{' '} + {isUsdValuesMode ? : ''} + + )} + + {isUsdValuesMode && balanceView} + {topContent} +
+ {inputTooltip ? ( + + {numericalInput} + + ) : ( + numericalInput + )} +
-
- {inputTooltip ? ( - - {numericalInput} - - ) : ( - numericalInput - )} -
- {balance && !disabled && ( - - Balance: - {showSetMax && balance.greaterThan(0) && ( - Max - )} - - )} -
-
- {amount && ( + {amount && !isUsdValuesMode && ( )}
+ {!isUsdValuesMode && balanceView}
diff --git a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/styled.tsx b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/styled.tsx index 81d5492220..2df15709f0 100644 --- a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/styled.tsx +++ b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/styled.tsx @@ -72,6 +72,15 @@ export const CurrencyTopLabel = styled.div` } ` +export const TopRow = styled.div` + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; +` + export const NumericalInput = styled(Input)<{ $loading: boolean }>` width: 100%; height: 100%; @@ -81,6 +90,7 @@ export const NumericalInput = styled(Input)<{ $loading: boolean }>` font-size: 28px; font-weight: 500; color: inherit; + text-align: left; &::placeholder { opacity: 0.7; @@ -106,7 +116,7 @@ export const TokenAmountStyled = styled(TokenAmount)` export const BalanceText = styled.span` font-weight: inherit; - font-size: inherit; + font-size: 13px; gap: 5px; display: flex; align-items: center; @@ -150,7 +160,9 @@ export const SetMaxBtn = styled.button` border-radius: 6px; padding: 3px 4px; text-transform: uppercase; - transition: background var(${UI.ANIMATION_DURATION}) ease-in-out, color var(${UI.ANIMATION_DURATION}) ease-in-out; + transition: + background var(${UI.ANIMATION_DURATION}) ease-in-out, + color var(${UI.ANIMATION_DURATION}) ease-in-out; &:hover { background: var(${UI.COLOR_PRIMARY}); diff --git a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/types.ts b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/types.ts index 076ecfb355..079c5e532a 100644 --- a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/types.ts +++ b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/types.ts @@ -16,4 +16,5 @@ export interface CurrencyInfo { balance: CurrencyAmount | null fiatAmount: CurrencyAmount | null topContent?: ReactNode + isUsdValuesMode?: boolean } diff --git a/apps/cowswap-frontend/src/common/pure/OrderExecutionStatusList/RateTooltipHeader.tsx b/apps/cowswap-frontend/src/common/pure/OrderExecutionStatusList/RateTooltipHeader.tsx deleted file mode 100644 index b179c8bad8..0000000000 --- a/apps/cowswap-frontend/src/common/pure/OrderExecutionStatusList/RateTooltipHeader.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import styled from 'styled-components/macro' - -import { OrderExecutionStatusList } from './index' - -const Content = styled.div` - display: flex; - flex-flow: column wrap; - align-items: center; - justify-content: center; - width: 100%; - padding: 5px 10px; - color: inherit; - - > p { - font-size: 13px; - font-weight: 400; - line-height: 1.5; - padding: 0; - margin: 0; - color: inherit; - } - - > h3 { - font-size: 14px; - font-weight: 600; - margin: 21px 0 0; - padding: 0; - text-align: left; - width: 100%; - color: inherit; - } -` - -interface RateTooltipHeaderProps { - isOpenOrdersTab?: boolean -} - -export function RateTooltipHeader({ isOpenOrdersTab }: RateTooltipHeaderProps) { - return ( - -

- Network costs (incl. gas) are covered by filling your order when the market price is better than your limit - price. -

- - {isOpenOrdersTab && ( - <> -

How close is my order to executing?

- - - )} -
- ) -} diff --git a/apps/cowswap-frontend/src/common/pure/OrderExecutionStatusList/calculateOrderExecutionStatus.test.ts b/apps/cowswap-frontend/src/common/pure/OrderExecutionStatusList/calculateOrderExecutionStatus.test.ts deleted file mode 100644 index 8927078786..0000000000 --- a/apps/cowswap-frontend/src/common/pure/OrderExecutionStatusList/calculateOrderExecutionStatus.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Percent } from '@uniswap/sdk-core' - -import { calculateOrderExecutionStatus } from './index' - -describe('calculateOrderExecutionStatus', () => { - it('returns `undefined` when any parameter is missing', () => { - expect(calculateOrderExecutionStatus(undefined)).toBe(undefined) - }) - - describe('veryClose', () => { - test('-1%', () => { - expect(calculateOrderExecutionStatus(new Percent(-1, 100))).toBe('veryClose') - }) - test('0.1%', () => { - expect(calculateOrderExecutionStatus(new Percent(1, 1_000))).toBe('veryClose') - }) - test('0.49%', () => { - expect(calculateOrderExecutionStatus(new Percent(49, 100_000))).toBe('veryClose') - }) - }) - - describe('close', () => { - test('0.5%', () => { - expect(calculateOrderExecutionStatus(new Percent(5, 1_000))).toBe('close') - }) - test('1%', () => { - expect(calculateOrderExecutionStatus(new Percent(1, 100))).toBe('close') - }) - test('5%', () => { - expect(calculateOrderExecutionStatus(new Percent(5, 100))).toBe('close') - }) - }) - - describe('notClose', () => { - test('5.01%', () => { - expect(calculateOrderExecutionStatus(new Percent(501, 10_000))).toBe('notClose') - }) - test('10%', () => { - expect(calculateOrderExecutionStatus(new Percent(1, 10))).toBe('notClose') - }) - }) -}) diff --git a/apps/cowswap-frontend/src/common/pure/OrderExecutionStatusList/index.tsx b/apps/cowswap-frontend/src/common/pure/OrderExecutionStatusList/index.tsx deleted file mode 100644 index bae8e6ac94..0000000000 --- a/apps/cowswap-frontend/src/common/pure/OrderExecutionStatusList/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { UI } from '@cowprotocol/ui' -import { Percent } from '@uniswap/sdk-core' - -import styled from 'styled-components/macro' - -const StatusList = styled.ol` - display: flex; - flex-flow: row wrap; - list-style: none; - font-size: 12px; - font-weight: 400; - gap: 5px; - padding: 0; - - > li { - display: flex; - gap: 5px; - align-items: center; - width: 100%; - } -` - -const LOWER_PERCENTAGE_DIFFERENCE = new Percent(5, 1000) // 0.5% -const UPPER_PERCENTAGE_DIFFERENCE = new Percent(5, 100) // 5% -export type OrderExecutionStatus = 'notClose' | 'close' | 'veryClose' - -export function calculateOrderExecutionStatus(difference: Percent | undefined): OrderExecutionStatus | undefined { - if (!difference) { - return undefined - } - - if (difference.lessThan(LOWER_PERCENTAGE_DIFFERENCE)) { - return 'veryClose' - } else if (difference.greaterThan(UPPER_PERCENTAGE_DIFFERENCE)) { - return 'notClose' - } else { - return 'close' - } -} - -export const ExecuteIndicator = styled.div<{ status?: OrderExecutionStatus }>` - --size: 6px; - width: var(--size); - height: var(--size); - min-width: var(--size); - min-height: var(--size); - border-radius: var(--size); - display: block; - margin: 0 3px 0 0; - background: ${({ status, theme }) => { - switch (status) { - case 'veryClose': - return theme.success - case 'close': - return theme.info - case 'notClose': - default: - return `var(${UI.COLOR_TEXT})` - } - }}; -` - -export function OrderExecutionStatusList() { - return ( - -
  • - Very close (<0.5% from market price) -
  • -
  • - Close (0.5% - 5% from market price) -
  • -
  • - Not yet close (>5% from market price) -
  • -
    - ) -} -export { RateTooltipHeader } from './RateTooltipHeader' diff --git a/apps/cowswap-frontend/src/modules/advancedOrders/containers/AdvancedOrdersSettings/index.tsx b/apps/cowswap-frontend/src/modules/advancedOrders/containers/AdvancedOrdersSettings/index.tsx index 96f9769fc1..62d6a56521 100644 --- a/apps/cowswap-frontend/src/modules/advancedOrders/containers/AdvancedOrdersSettings/index.tsx +++ b/apps/cowswap-frontend/src/modules/advancedOrders/containers/AdvancedOrdersSettings/index.tsx @@ -1,10 +1,11 @@ import { useAtomValue, useSetAtom } from 'jotai' import { useCallback } from 'react' -import { Menu, MenuItem } from '@reach/menu-button' +import { Menu, MenuItem, MenuPopover, MenuItems } from '@reach/menu-button' +import styled from 'styled-components/macro' import { Settings } from 'modules/advancedOrders/pure/Settings' -import { MenuContent, SettingsButton, SettingsIcon } from 'modules/trade/pure/Settings' +import { SettingsButton, SettingsIcon } from 'modules/trade/pure/Settings' import { advancedOrdersSettingsAtom, @@ -12,6 +13,15 @@ import { updateAdvancedOrdersSettingsAtom, } from '../../state/advancedOrdersSettingsAtom' +const MenuWrapper = styled.div` + [data-reach-menu-popover] { + position: absolute; + width: 100%; + left: 0; + top: 0; + } +` + export function AdvancedOrdersSettings() { const settingsState = useAtomValue(advancedOrdersSettingsAtom) const updateSettingsState = useSetAtom(updateAdvancedOrdersSettingsAtom) @@ -20,19 +30,23 @@ export function AdvancedOrdersSettings() { (state: Partial) => { updateSettingsState(state) }, - [updateSettingsState] + [updateSettingsState], ) return ( - - - - - - void 0}> - - - - + + + + + + + + void 0}> + + + + + + ) } diff --git a/apps/cowswap-frontend/src/modules/advancedOrders/pure/Settings/index.tsx b/apps/cowswap-frontend/src/modules/advancedOrders/pure/Settings/index.tsx index 51ec93bc1f..77f295a4b1 100644 --- a/apps/cowswap-frontend/src/modules/advancedOrders/pure/Settings/index.tsx +++ b/apps/cowswap-frontend/src/modules/advancedOrders/pure/Settings/index.tsx @@ -1,6 +1,14 @@ -import { AdvancedOrdersSettingsState } from 'modules/advancedOrders/state/advancedOrdersSettingsAtom' +import { useAtomValue, useSetAtom } from 'jotai' + +import { + limitOrdersSettingsAtom, + updateLimitOrdersSettingsAtom, +} from 'modules/limitOrders/state/limitOrdersSettingsAtom' +import { ORDERS_TABLE_SETTINGS } from 'modules/trade/const/common' import { SettingsBox, SettingsContainer, SettingsTitle } from 'modules/trade/pure/Settings' +import { AdvancedOrdersSettingsState } from '../../state/advancedOrdersSettingsAtom' + export interface SettingsProps { state: AdvancedOrdersSettingsState onStateChanged: (state: Partial) => void @@ -8,10 +16,12 @@ export interface SettingsProps { export function Settings({ state, onStateChanged }: SettingsProps) { const { showRecipient } = state + const limitOrdersSettings = useAtomValue(limitOrdersSettingsAtom) + const updateLimitOrdersSettings = useSetAtom(updateLimitOrdersSettingsAtom) return ( - Interface Settings + Advanced Order Settings onStateChanged({ showRecipient: !showRecipient })} /> + + updateLimitOrdersSettings({ ordersTableOnLeft: !limitOrdersSettings.ordersTableOnLeft })} + /> ) } diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/index.tsx index a2420948e3..3a8970856b 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/index.tsx @@ -78,7 +78,7 @@ export function LimitOrdersWidget() { const widgetActions = useLimitOrdersWidgetActions() const isWrapOrUnwrap = useIsWrapOrUnwrap() - const { showRecipient: showRecipientSetting } = settingsState + const { showRecipient: showRecipientSetting, isUsdValuesMode } = settingsState const showRecipient = showRecipientSetting || !!recipient const priceImpact = useTradePriceImpact() @@ -91,13 +91,14 @@ export function LimitOrdersWidget() { const inputCurrencyInfo: CurrencyInfo = { field: Field.INPUT, - label: isSell ? 'Sell amount' : 'You sell at most', + label: isSell ? 'Sell' : 'You sell at most', currency: inputCurrency, amount: inputCurrencyAmount, isIndependent: isSell, balance: inputCurrencyBalance, fiatAmount: inputCurrencyFiatAmount, receiveAmountInfo: null, + isUsdValuesMode, } const outputCurrencyInfo: CurrencyInfo = { field: Field.OUTPUT, @@ -108,6 +109,7 @@ export function LimitOrdersWidget() { balance: outputCurrencyBalance, fiatAmount: outputCurrencyFiatAmount, receiveAmountInfo: null, + isUsdValuesMode, } const props: LimitOrdersProps = { @@ -176,7 +178,7 @@ const LimitOrders = React.memo((props: LimitOrdersProps) => { handleUnlock={() => updateLimitOrdersState({ isUnlocked: true })} /> ), - middleContent: ( + topContent: ( <> {!isWrapOrUnwrap && ClosableBanner(ZERO_BANNER_STORAGE_KEY, (onClose) => ( @@ -194,17 +196,33 @@ const LimitOrders = React.memo((props: LimitOrdersProps) => {

    ))} - - - - + {props.settingsState.limitPricePosition === 'top' && ( + + + + )} + + ), + middleContent: ( + <> + {props.settingsState.limitPricePosition === 'between' && ( + + + + )} ), bottomContent(warnings) { return ( <> + {props.settingsState.limitPricePosition === 'bottom' && ( + + + + )} - + + @@ -220,7 +238,7 @@ const LimitOrders = React.memo((props: LimitOrdersProps) => { } const params = { - compactView: false, + compactView: true, recipient, showRecipient, isTradePriceUpdating: isRateLoading, diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/styled.tsx b/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/styled.tsx index c23762aa09..1c0c34d7df 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/styled.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/styled.tsx @@ -1,9 +1,7 @@ -import { Media } from '@cowprotocol/ui' +import { Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' -import { NumericalInput } from 'modules/limitOrders/containers/RateInput/styled' - export const TradeButtonBox = styled.div` margin: 10px 0 0; display: flex; @@ -20,20 +18,18 @@ export const FooterBox = styled.div` ` export const RateWrapper = styled.div` - display: grid; - grid-template-columns: auto 151px; - grid-template-rows: max-content; + display: flex; + flex-flow: column wrap; + width: 100%; max-width: 100%; gap: 6px; text-align: right; color: inherit; + background: var(${UI.COLOR_PAPER_DARKER}); + border-radius: 16px; ${Media.upToSmall()} { display: flex; flex-flow: column wrap; } - - ${NumericalInput} { - font-size: 21px; - } ` diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/hooks/useExecutionPriceUsdValue.ts b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/hooks/useExecutionPriceUsdValue.ts new file mode 100644 index 0000000000..53d9e68a2a --- /dev/null +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/hooks/useExecutionPriceUsdValue.ts @@ -0,0 +1,23 @@ +import { useAtomValue } from 'jotai/index' + +import { tryParseCurrencyAmount } from '@cowprotocol/common-utils' +import { Currency, Price } from '@uniswap/sdk-core' + +import { useUsdAmount } from 'modules/usdAmount' + +import { limitRateAtom } from '../../../state/limitRateAtom' + +export function useExecutionPriceUsdValue(executionPrice: Price | null) { + const { isInverted } = useAtomValue(limitRateAtom) + const executionPriceToDisplay = isInverted ? executionPrice?.invert() : executionPrice + const executionPriceBaseAmount = tryParseCurrencyAmount( + '1', + isInverted ? executionPrice?.quoteCurrency : executionPrice?.baseCurrency, + ) + + const { value: rateAsUsdAmount } = useUsdAmount( + executionPriceBaseAmount ? executionPriceToDisplay?.quote(executionPriceBaseAmount) : null, + ) + + return rateAsUsdAmount +} diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/hooks/useRateDisplayedValue.ts b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/hooks/useRateDisplayedValue.ts new file mode 100644 index 0000000000..63cbe2d99a --- /dev/null +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/hooks/useRateDisplayedValue.ts @@ -0,0 +1,36 @@ +import { useAtomValue } from 'jotai/index' +import { useMemo } from 'react' + +import { formatInputAmount, tryParseCurrencyAmount } from '@cowprotocol/common-utils' +import { Currency } from '@uniswap/sdk-core' + +import { useUsdAmount } from 'modules/usdAmount' + +import { useLimitOrdersDerivedState } from '../../../hooks/useLimitOrdersDerivedState' +import { limitRateAtom } from '../../../state/limitRateAtom' + +export function useRateDisplayedValue(currency: Currency | null, isUsdMode: boolean): string { + const { isInverted, activeRate, typedValue, isTypedValue } = useAtomValue(limitRateAtom) + const { inputCurrency, outputCurrency } = useLimitOrdersDerivedState() + const areBothCurrencies = !!inputCurrency && !!outputCurrency + + const rawRate = useMemo(() => { + if (isTypedValue) return typedValue || '' + + if (!activeRate || !areBothCurrencies || activeRate.equalTo(0)) return '' + + const rate = isInverted ? activeRate.invert() : activeRate + + return formatInputAmount(rate) + }, [activeRate, areBothCurrencies, isInverted, isTypedValue, typedValue]) + + const rateAsCurrencyAmount = !currency || !rawRate ? null : tryParseCurrencyAmount(rawRate, currency) + + const { value: rateAsUsdAmount } = useUsdAmount(rateAsCurrencyAmount) + + if (isUsdMode) { + return formatInputAmount(rateAsUsdAmount) + } + + return rawRate +} diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx index e1d21cab06..e68bdaab89 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx @@ -1,46 +1,53 @@ import { useAtomValue, useSetAtom } from 'jotai' import { useCallback, useEffect, useMemo, useState } from 'react' -import { formatInputAmount, getAddress, isFractionFalsy } from '@cowprotocol/common-utils' -import { HelpTooltip, Loader, TokenSymbol } from '@cowprotocol/ui' +import LockedIcon from '@cowprotocol/assets/images/icon-locked.svg' +import UnlockedIcon from '@cowprotocol/assets/images/icon-unlocked.svg' +import UsdIcon from '@cowprotocol/assets/images/icon-USD.svg' +import { formatInputAmount, getAddress, isFractionFalsy, tryParseCurrencyAmount } from '@cowprotocol/common-utils' +import { TokenLogo } from '@cowprotocol/tokens' +import { FiatAmount, HelpTooltip, HoverTooltip, TokenSymbol } from '@cowprotocol/ui' import { useWalletInfo } from '@cowprotocol/wallet' -import { RefreshCw } from 'react-feather' - +import SVG from 'react-inlinesvg' import { useLimitOrdersDerivedState } from 'modules/limitOrders/hooks/useLimitOrdersDerivedState' import { useRateImpact } from 'modules/limitOrders/hooks/useRateImpact' import { useUpdateActiveRate } from 'modules/limitOrders/hooks/useUpdateActiveRate' -import { ExecutionPriceTooltip } from 'modules/limitOrders/pure/ExecutionPriceTooltip' import { HeadingText } from 'modules/limitOrders/pure/RateInput/HeadingText' import { executionPriceAtom } from 'modules/limitOrders/state/executionPriceAtom' +import { + limitOrdersSettingsAtom, + updateLimitOrdersSettingsAtom, +} from 'modules/limitOrders/state/limitOrdersSettingsAtom' import { limitRateAtom, updateLimitRateAtom } from 'modules/limitOrders/state/limitRateAtom' import { toFraction } from 'modules/limitOrders/utils/toFraction' +import { useUsdAmount } from 'modules/usdAmount' -import { ordersTableFeatures } from 'common/constants/featureFlags' +import { useConvertUsdToTokenValue } from 'common/hooks/useConvertUsdToTokenValue' import { ExecutionPrice } from 'common/pure/ExecutionPrice' import { getQuoteCurrency, getQuoteCurrencyByStableCoin } from 'common/services/getQuoteCurrency' +import { useExecutionPriceUsdValue } from './hooks/useExecutionPriceUsdValue' +import { useRateDisplayedValue } from './hooks/useRateDisplayedValue' import * as styledEl from './styled' export function RateInput() { const { chainId } = useWalletInfo() // Rate state - const { - isInverted, - activeRate, - isLoading, - marketRate, - feeAmount, - isLoadingMarketRate, - typedValue, - isTypedValue, - initialRate, - } = useAtomValue(limitRateAtom) + const { isInverted, activeRate, isLoading, marketRate, isLoadingMarketRate, initialRate } = + useAtomValue(limitRateAtom) const updateRate = useUpdateActiveRate() const updateLimitRateState = useSetAtom(updateLimitRateAtom) const executionPrice = useAtomValue(executionPriceAtom) + const { limitPriceLocked, isUsdValuesMode } = useAtomValue(limitOrdersSettingsAtom) + const updateLimitOrdersSettings = useSetAtom(updateLimitOrdersSettingsAtom) + + const executionPriceUsdValue = useExecutionPriceUsdValue(executionPrice) + const [isQuoteCurrencySet, setIsQuoteCurrencySet] = useState(false) + const [typedTrailingZeros, setTypedTrailingZeros] = useState('') + const [isUsdRateMode, setIsUsdRateMode] = useState(isUsdValuesMode) // Limit order state const { inputCurrency, outputCurrency, inputCurrencyAmount, outputCurrencyAmount } = useLimitOrdersDerivedState() @@ -52,45 +59,76 @@ export function RateInput() { const primaryCurrency = isInverted ? outputCurrency : inputCurrency const secondaryCurrency = isInverted ? inputCurrency : outputCurrency - // Handle rate display - const displayedRate = useMemo(() => { - if (isTypedValue) return typedValue || '' + const marketRateRaw = + marketRate && !marketRate.equalTo(0) ? formatInputAmount(isInverted ? marketRate.invert() : marketRate) : null - if (!activeRate || !areBothCurrencies || activeRate.equalTo(0)) return '' + const { value: marketRateUsd } = useUsdAmount( + marketRateRaw && secondaryCurrency ? tryParseCurrencyAmount(marketRateRaw, secondaryCurrency) : null, + ) - const rate = isInverted ? activeRate.invert() : activeRate + const marketRateDisplay = isUsdRateMode ? '$' + formatInputAmount(marketRateUsd) : marketRateRaw - return formatInputAmount(rate) - }, [activeRate, areBothCurrencies, isInverted, isTypedValue, typedValue]) - - // Handle set market price - const handleSetMarketPrice = useCallback(() => { - updateRate({ - activeRate: isFractionFalsy(marketRate) ? initialRate : marketRate, - isTypedValue: false, - isRateFromUrl: false, - isAlternativeOrderRate: false, - }) - }, [marketRate, initialRate, updateRate]) + const displayedRate = useRateDisplayedValue(secondaryCurrency, isUsdRateMode) + const convertUsdToTokenValue = useConvertUsdToTokenValue(secondaryCurrency) // Handle rate input const handleUserInput = useCallback( (typedValue: string) => { - updateLimitRateState({ typedValue }) + const trailing = typedValue.slice(displayedRate.length) + const onlyTrailingZeroAdded = typedValue.includes('.') && /^0+$/.test(trailing) + + /** + * Since we convert USD to token value, we need to handle trailing zeros separately, otherwise we will lose them + */ + if (onlyTrailingZeroAdded) { + setTypedTrailingZeros(trailing) + return + } + + setTypedTrailingZeros('') + + const value = convertUsdToTokenValue(typedValue, isUsdRateMode) + + updateLimitRateState({ typedValue: value }) updateRate({ - activeRate: toFraction(typedValue, isInverted), + activeRate: toFraction(value, isInverted), isTypedValue: true, isRateFromUrl: false, isAlternativeOrderRate: false, }) }, - [isInverted, updateRate, updateLimitRateState] + [isUsdRateMode, isInverted, updateRate, updateLimitRateState, displayedRate, convertUsdToTokenValue], ) + // Handle set market price + const handleSetMarketPrice = useCallback(() => { + updateRate({ + activeRate: isFractionFalsy(marketRate) ? initialRate : marketRate, + isTypedValue: false, + isRateFromUrl: false, + isAlternativeOrderRate: false, + }) + }, [marketRate, initialRate, updateRate]) + // Handle toggle primary field const handleToggle = useCallback(() => { - updateLimitRateState({ isInverted: !isInverted, isTypedValue: false }) - }, [isInverted, updateLimitRateState]) + if (isUsdRateMode) { + // When in USD mode, just switch to token mode without toggling tokens + setIsUsdRateMode(false) + } else { + // When already in token mode, toggle between tokens + updateLimitRateState({ isInverted: !isInverted, isTypedValue: false }) + } + }, [isInverted, updateLimitRateState, isUsdRateMode]) + + // Handle toggle price lock + const handleTogglePriceLock = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + updateLimitOrdersSettings({ limitPriceLocked: !limitPriceLocked }) + }, + [limitPriceLocked, updateLimitOrdersSettings], + ) const isDisabledMPrice = useMemo(() => { if (isLoadingMarketRate) return true @@ -140,17 +178,41 @@ export function RateInput() { setIsQuoteCurrencySet(false) }, [inputCurrency, outputCurrency]) + // Depend rate USD mode on settings + useEffect(() => { + setIsUsdRateMode(isUsdValuesMode) + }, [isUsdValuesMode]) + return ( <> - - - - Set to market - + + + + + + } + onToggle={handleToggle} + /> + {areBothCurrencies && (isLoadingMarketRate || marketRateDisplay) && ( + + Market:{' '} + + {isLoadingMarketRate ? : marketRateDisplay} + + + )} - {isLoading && areBothCurrencies ? ( @@ -158,47 +220,48 @@ export function RateInput() { )} - - - - - - - - + {secondaryCurrency && ( + + + + + + + + + setIsUsdRateMode((state) => !state)} $active={isUsdRateMode}> + + + + )} - {ordersTableFeatures.DISPLAY_EST_EXECUTION_PRICE && ( - - - Order executes at{' '} - {isLoadingMarketRate ? ( - - ) : executionPrice ? ( - - } - /> - ) : null} - - {!isLoadingMarketRate && executionPrice && ( - + + + {isLoadingMarketRate ? ( + + ) : executionPrice ? ( + isUsdRateMode ? ( + + ) : ( + + ) + ) : ( + '-' )} - - )} + + + Estimated fill price + + + ) } diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/styled.ts b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/styled.ts index 08d19b62ea..c56abdaaa8 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/styled.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/styled.ts @@ -1,4 +1,4 @@ -import { Loader, Media } from '@cowprotocol/ui' +import { Loader, Media, TokenSymbol } from '@cowprotocol/ui' import { UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' @@ -6,12 +6,9 @@ import styled from 'styled-components/macro' import Input from 'legacy/components/NumericalInput' export const Wrapper = styled.div` - background: var(${UI.COLOR_PAPER_DARKER}); - border-radius: 16px; - padding: 10px 16px; - flex: 1 1 70%; - min-height: 80px; - justify-content: space-between; + padding: 16px 16px 0; + width: 100%; + max-width: 100%; display: flex; flex-flow: row wrap; color: inherit; @@ -29,28 +26,53 @@ export const Header = styled.div` font-weight: 500; width: 100%; color: inherit; + + > span > i { + font-style: normal; + color: var(${UI.COLOR_TEXT}); + } +` + +export const MarketRateWrapper = styled.div` + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 400; + + > i { + font-style: normal; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + } ` export const MarketPriceButton = styled.button` - background: var(${UI.COLOR_PAPER}); color: inherit; white-space: nowrap; border: none; font-weight: 500; cursor: pointer; - border-radius: 9px; - padding: 5px 8px; - font-size: 11px; - transition: background var(${UI.ANIMATION_DURATION}) ease-in-out, color var(${UI.ANIMATION_DURATION}) ease-in-out; + font-size: inherit; + background: transparent; + padding: 0; + color: var(${UI.COLOR_TEXT}); + transition: + background var(${UI.ANIMATION_DURATION}) ease-in-out, + color var(${UI.ANIMATION_DURATION}) ease-in-out; + text-decoration: underline; + text-decoration-style: dashed; + text-decoration-thickness: 1px; + text-underline-offset: 2px; + text-decoration-color: var(${UI.COLOR_TEXT_OPACITY_70}); - &:disabled { - cursor: default; - opacity: 0.6; + > svg { + margin: 0 0 -2px 7px; } - &:not(:disabled):hover { - background: var(${UI.COLOR_PRIMARY}); - color: var(${UI.COLOR_BUTTON_TEXT}); + &:disabled { + cursor: default; + opacity: 0.7; + text-decoration: none; } ` @@ -59,7 +81,9 @@ export const Body = styled.div` align-items: center; justify-content: space-between; width: 100%; + max-width: 100%; gap: 8px; + padding: 12px 0 4px; color: inherit; ` @@ -68,9 +92,11 @@ export const NumericalInput = styled(Input)<{ $loading: boolean }>` align-items: center; background: none; border: none; - width: 100%; text-align: left; color: inherit; + font-size: 32px; + letter-spacing: -1.5px; + flex: 1 1 auto; &::placeholder { opacity: 0.7; @@ -78,32 +104,126 @@ export const NumericalInput = styled(Input)<{ $loading: boolean }>` } ` -export const ActiveCurrency = styled.button` +export const CurrencyToggleGroup = styled.div` + display: flex; + align-items: center; + background: transparent; + overflow: hidden; +` + +export const ActiveCurrency = styled.button<{ $active?: boolean }>` + --height: 25px; + --skew-width: 6px; + --skew-offset: -3px; + --skew-angle: -10deg; + --padding: 10px; + --gap: 6px; + --font-size: 13px; + --border-radius: 8px; + display: flex; align-items: center; - justify-content: flex-end; + gap: var(--gap); + font-size: var(--font-size); + font-weight: var(${UI.FONT_WEIGHT_MEDIUM}); border: none; - background: none; - padding: 0; - margin: 0 0 0 auto; - gap: 8px; - max-width: 130px; - width: auto; - color: inherit; cursor: pointer; + position: relative; + height: var(--height); + border-radius: var(--border-radius); + transition: all 0.2s ease-in-out; + background: ${({ $active }) => ($active ? 'var(' + UI.COLOR_PAPER + ')' : 'var(' + UI.COLOR_PAPER_DARKEST + ')')}; + color: ${({ $active }) => ($active ? 'var(' + UI.COLOR_TEXT + ')' : 'var(' + UI.COLOR_TEXT_OPACITY_50 + ')')}; + padding: 0 10px; + + &:first-child { + padding-right: var(--padding); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + ${({ $active }) => + $active && + ` + &::after { + content: ''; + position: absolute; + right: var(--skew-offset); + top: 0; + bottom: 0; + width: var(--skew-width); + background: var(${UI.COLOR_PAPER_DARKER}); + transform: skew(var(--skew-angle)); + z-index: 5; + } + `} + } + + &:last-child { + padding-left: var(--padding); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + ${({ $active }) => + $active && + ` + &::before { + content: ''; + position: absolute; + left: var(--skew-offset); + top: 0; + bottom: 0; + width: var(--skew-width); + background: var(${UI.COLOR_PAPER_DARKER}); + transform: skew(var(--skew-angle)); + z-index: 5; + } + `} + } + + &:hover { + color: var(${UI.COLOR_TEXT}); + } ` -export const ActiveSymbol = styled.span` +export const UsdButton = styled(ActiveCurrency)` + font-weight: var(${UI.FONT_WEIGHT_BOLD}); + min-width: 40px; + justify-content: center; + + > svg { + width: 10px; + height: 16px; + color: inherit; + } +` + +export const ActiveSymbol = styled.span<{ $active?: boolean }>` color: inherit; font-size: 13px; font-weight: 500; text-align: right; padding: 10px 0; + display: flex; + gap: 4px; + + ${({ $active }) => + !$active && + ` + > div { + background: transparent; + } + > div > img { + opacity: 0.5; + } + + > ${TokenSymbol} { + color: var(${UI.COLOR_TEXT_OPACITY_50}); + } + `} ` export const ActiveIcon = styled.div` - --size: 20px; - background-color: var(${UI.COLOR_PAPER}); + --size: 19px; color: inherit; width: var(--size); min-width: var(--size); @@ -113,6 +233,19 @@ export const ActiveIcon = styled.div` display: flex; align-items: center; justify-content: center; + cursor: pointer; + margin: 0 2px 0 0; + transition: + color var(${UI.ANIMATION_DURATION}) ease-in-out, + background var(${UI.ANIMATION_DURATION}) ease-in-out; + background: transparent; + border: 1px solid var(${UI.COLOR_PAPER_DARKEST}); + + &:hover { + color: var(${UI.COLOR_TEXT}); + background: var(${UI.COLOR_PAPER}); + border-color: var(${UI.COLOR_PAPER}); + } ` export const RateLoader = styled(Loader)` @@ -123,33 +256,29 @@ export const EstimatedRate = styled.div` display: flex; width: 100%; justify-content: space-between; - min-height: 42px; + min-height: 36px; margin: 0; padding: 12px 10px 14px; - font-size: 13px; + font-size: 16px; border-radius: 0 0 16px 16px; - font-weight: 400; + font-weight: 500; + color: var(${UI.COLOR_TEXT}); background: var(${UI.COLOR_PAPER}); border: 2px solid var(${UI.COLOR_PAPER_DARKER}); - background: red; > b { display: flex; flex-flow: row nowrap; - font-weight: normal; + font-weight: inherit; text-align: left; - opacity: 0.7; transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; - - &:hover { - opacity: 1; - } + color: inherit; } - // TODO: Make the question helper icon transparent through a prop instead > b svg { opacity: 0.7; transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; + color: inherit; &:hover { opacity: 1; @@ -161,4 +290,36 @@ export const EstimatedRate = styled.div` color: inherit; opacity: 0.7; } + + > span { + display: flex; + align-items: center; + font-size: 13px; + font-weight: 400; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + } +` + +export const LockIcon = styled.span` + --size: 19px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + color: inherit; + border-radius: var(--size); + width: var(--size); + min-width: var(--size); + height: var(--size); + border: 1px solid var(${UI.COLOR_PAPER_DARKEST}); + cursor: pointer; + transition: + border-color var(${UI.ANIMATION_DURATION}) ease-in-out, + background var(${UI.ANIMATION_DURATION}) ease-in-out; + + &:hover { + border-color: var(${UI.COLOR_PAPER}); + background: var(${UI.COLOR_PAPER}); + } ` diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/SettingsWidget/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/containers/SettingsWidget/index.tsx index 00267f9fb2..f4222a4efc 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/SettingsWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/SettingsWidget/index.tsx @@ -1,9 +1,11 @@ import { useAtomValue, useSetAtom } from 'jotai' -import React from 'react' -import { Menu, MenuItem } from '@reach/menu-button' +import UsdIcon from '@cowprotocol/assets/images/icon-USD.svg' -import { MenuContent, SettingsButton, SettingsIcon } from 'modules/trade/pure/Settings' +import { Menu, MenuItem, MenuPopover, MenuItems } from '@reach/menu-button' +import SVG from 'react-inlinesvg' + +import { ButtonsContainer, SettingsButton, SettingsIcon, UsdButton } from 'modules/trade/pure/Settings' import { Settings } from '../../pure/Settings' import { limitOrdersSettingsAtom, updateLimitOrdersSettingsAtom } from '../../state/limitOrdersSettingsAtom' @@ -11,19 +13,25 @@ import { limitOrdersSettingsAtom, updateLimitOrdersSettingsAtom } from '../../st export function SettingsWidget() { const settingsState = useAtomValue(limitOrdersSettingsAtom) const updateSettingsState = useSetAtom(updateLimitOrdersSettingsAtom) + const isUsdValuesMode = settingsState.isUsdValuesMode return ( - <> + + updateSettingsState({ isUsdValuesMode: !isUsdValuesMode })} active={isUsdValuesMode}> + + - - void 0}> - - - + + + void 0}> + + + + - + ) } diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeRateDetails/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeRateDetails/index.tsx index 8817f5a27a..0647846b5f 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeRateDetails/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeRateDetails/index.tsx @@ -1,6 +1,8 @@ import React, { useState, useCallback } from 'react' import { TradeTotalCostsDetails, PartnerFeeRow } from 'modules/trade' +import { StyledRateInfo } from 'modules/trade/containers/TradeTotalCostsDetails/styled' +import { Box } from 'modules/trade/containers/TradeTotalCostsDetails/styled' import { useUsdAmount } from 'modules/usdAmount' import { useVolumeFee, useVolumeFeeTooltip } from 'modules/volumeFee' @@ -10,10 +12,11 @@ import { useLimitOrderPartnerFeeAmount } from '../../hooks/useLimitOrderPartnerF interface TradeRateDetailsProps { rateInfoParams?: RateInfoParams + alwaysExpanded?: boolean } -export function TradeRateDetails({ rateInfoParams }: TradeRateDetailsProps) { - const [isFeeDetailsOpen, setFeeDetailsOpen] = useState(false) +export function TradeRateDetails({ rateInfoParams, alwaysExpanded = false }: TradeRateDetailsProps) { + const [isFeeDetailsOpen, setFeeDetailsOpen] = useState(alwaysExpanded) const volumeFee = useVolumeFee() const partnerFeeAmount = useLimitOrderPartnerFeeAmount() const volumeFeeTooltip = useVolumeFeeTooltip() @@ -21,8 +24,9 @@ export function TradeRateDetails({ rateInfoParams }: TradeRateDetailsProps) { const partnerFeeBps = volumeFee?.bps const toggleAccordion = useCallback(() => { + if (alwaysExpanded) return setFeeDetailsOpen((prev) => !prev) - }, []) + }, [alwaysExpanded]) const partnerFeeRow = ( + + {partnerFeeRow} + + ) + } + return ( { const { activeRate, amount, orderKind } = params const field = isSellOrder(orderKind) ? Field.INPUT : Field.OUTPUT + const isBuyAmountChange = field === Field.OUTPUT + if (isBuyAmountChange) { + const update: Partial> = { + orderKind, + outputCurrencyAmount: FractionUtils.serializeFractionToJSON(amount), + } + + updateLimitOrdersState(update) + + // If price is unlocked, update the rate based on the new amounts + if (!limitPriceLocked) { + // Calculate and update the new rate + if (amount && currentInputAmount) { + const newRate = new Price( + currentInputAmount.currency, + amount.currency, + currentInputAmount.quotient, + amount.quotient, + ) + updateLimitRateState({ + activeRate: FractionUtils.fractionLikeToFraction(newRate), + isTypedValue: false, + isRateFromUrl: false, + isAlternativeOrderRate: false, + }) + } + } + return + } + + // Normal flow for SELL amount changes const calculatedAmount = calculateAmountForRate({ activeRate, amount, @@ -37,21 +73,21 @@ export function useUpdateCurrencyAmount() { outputCurrency, }) - const inputCurrencyAmount = FractionUtils.serializeFractionToJSON( - field === Field.INPUT ? amount : calculatedAmount - ) - const outputCurrencyAmount = FractionUtils.serializeFractionToJSON( - field === Field.OUTPUT ? amount : calculatedAmount - ) + const newInputAmount = (field as Field) === Field.INPUT ? amount : calculatedAmount + const newOutputAmount = (field as Field) === Field.OUTPUT ? amount : calculatedAmount const update: Partial> = { orderKind, - ...(inputCurrencyAmount ? { inputCurrencyAmount } : undefined), - ...(outputCurrencyAmount ? { outputCurrencyAmount } : undefined), + ...(newInputAmount + ? { inputCurrencyAmount: FractionUtils.serializeFractionToJSON(newInputAmount) } + : undefined), + ...(newOutputAmount + ? { outputCurrencyAmount: FractionUtils.serializeFractionToJSON(newOutputAmount) } + : undefined), } updateLimitOrdersState(update) }, - [inputCurrency, outputCurrency, updateLimitOrdersState] + [inputCurrency, outputCurrency, updateLimitOrdersState, limitPriceLocked, updateLimitRateState, currentInputAmount], ) } diff --git a/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/deadlines.ts b/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/deadlines.ts index b2fd186d29..7364eae9f3 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/deadlines.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/deadlines.ts @@ -11,18 +11,35 @@ export interface LimitOrderDeadline { export const MIN_CUSTOM_DEADLINE = ms`30min` export const MAX_CUSTOM_DEADLINE = MAX_ORDER_DEADLINE -export const defaultLimitOrderDeadline: LimitOrderDeadline = { title: '7 Days', value: ms`7d` } - -export const LIMIT_ORDERS_DEADLINES: LimitOrderDeadline[] = [ - { title: '5 Minutes', value: ms`5m` }, - { title: '30 Minutes', value: ms`30m` }, - { title: '1 Hour', value: ms`1 hour` }, - { title: '1 Day', value: ms`1d` }, - { title: '3 Days', value: ms`3d` }, - defaultLimitOrderDeadline, - { title: '1 Month', value: ms`30d` }, - { title: '1 Year (max)', value: MAX_CUSTOM_DEADLINE }, -] +export enum LimitOrderDeadlinePreset { + FIVE_MINUTES = '5 Minutes', + THIRTY_MINUTES = '30 Minutes', + ONE_HOUR = '1 Hour', + ONE_DAY = '1 Day', + THREE_DAYS = '3 Days', + ONE_MONTH = '1 Month', + SIX_MONTHS = '6 Months (max)', +} + +const DEADLINE_VALUES: Record = { + [LimitOrderDeadlinePreset.FIVE_MINUTES]: ms`5m`, + [LimitOrderDeadlinePreset.THIRTY_MINUTES]: ms`30m`, + [LimitOrderDeadlinePreset.ONE_HOUR]: ms`1 hour`, + [LimitOrderDeadlinePreset.ONE_DAY]: ms`1d`, + [LimitOrderDeadlinePreset.THREE_DAYS]: ms`3d`, + [LimitOrderDeadlinePreset.ONE_MONTH]: ms`30d`, + [LimitOrderDeadlinePreset.SIX_MONTHS]: MAX_CUSTOM_DEADLINE, +} + +export const defaultLimitOrderDeadline: LimitOrderDeadline = { + title: LimitOrderDeadlinePreset.SIX_MONTHS, + value: DEADLINE_VALUES[LimitOrderDeadlinePreset.SIX_MONTHS], +} + +export const LIMIT_ORDERS_DEADLINES: LimitOrderDeadline[] = Object.entries(DEADLINE_VALUES).map(([title, value]) => ({ + title, + value, +})) /** * Get limit order deadlines and optionally adds diff --git a/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/index.tsx index 247d86c01f..32d1fdb777 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/index.tsx @@ -126,7 +126,7 @@ export function DeadlineSelector(props: DeadlineSelectorProps) { return ( - Expiry + Order expires in {isDeadlineDisabled ? ( diff --git a/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/styled.tsx b/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/styled.tsx index 8f07cfb6cc..73272e0ef7 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/styled.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/styled.tsx @@ -5,52 +5,32 @@ import { transparentize } from 'color2k' import { X } from 'react-feather' import styled from 'styled-components/macro' -export const Wrapper = styled.div<{ inline?: boolean; minHeight?: string }>` - background: var(${UI.COLOR_PAPER_DARKER}); +export const Wrapper = styled.div` color: inherit; - border-radius: 16px; - padding: 10px 16px; - min-height: ${({ minHeight }) => minHeight || '80px'}; + padding: 0; justify-content: space-between; display: flex; flex-flow: row wrap; - - ${({ inline }) => - inline && - ` - justify-content: space-between; - - > span { - flex: 1; - } - - > button { - width: auto; - } - `} + width: 100%; + font-size: 13px; + font-weight: inherit; + min-height: 24px; ` export const Label = styled.span` display: flex; - align-items: center; - justify-content: space-between; - font-size: 13px; - font-weight: 500; - width: 100%; + font-size: inherit; + font-weight: inherit; color: inherit; - opacity: 0.7; - transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; - - &:hover { - opacity: 1; - } + align-self: center; + justify-self: center; ` export const Current = styled(MenuButton)<{ $custom?: boolean }>` color: inherit; font-size: ${({ $custom }) => ($custom ? '12px' : '100%')}; letter-spacing: ${({ $custom }) => ($custom ? '-0.3px' : '0')}; - font-weight: 500; + font-weight: inherit; display: flex; align-items: center; justify-content: space-between; @@ -61,7 +41,6 @@ export const Current = styled(MenuButton)<{ $custom?: boolean }>` padding: 0; white-space: nowrap; cursor: pointer; - width: 100%; text-overflow: ellipsis; overflow: hidden; diff --git a/apps/cowswap-frontend/src/modules/limitOrders/pure/EstimatedFillPrice/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/pure/EstimatedFillPrice/index.tsx new file mode 100644 index 0000000000..5c9b4e47c9 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/EstimatedFillPrice/index.tsx @@ -0,0 +1,82 @@ +import { TokenAmount, HoverTooltip } from '@cowprotocol/ui' +import { UI } from '@cowprotocol/ui' +import { FractionLike, Nullish } from '@cowprotocol/ui' +import { Currency, Price, CurrencyAmount, Fraction } from '@uniswap/sdk-core' + +import styled from 'styled-components/macro' + +import { ExecutionPriceTooltip } from '../ExecutionPriceTooltip' + +const EstimatedFillPriceBox = styled.div` + display: flex; + justify-content: space-between; + padding: 10px; + border-radius: 0 0 16px 16px; + font-size: 14px; + font-weight: 600; + background: var(${UI.COLOR_PAPER}); + border: 1px solid var(${UI.COLOR_PAPER_DARKER}); +` + +const Label = styled.div` + display: flex; + align-items: center; + gap: 5px; + font-weight: 500; +` + +const Value = styled.div` + font-size: 16px; + display: flex; + align-items: center; + gap: 4px; +` + +const QuestionWrapper = styled.div` + display: flex; + align-items: center; +` + +export interface EstimatedFillPriceProps { + currency: Currency + estimatedFillPrice: Nullish + executionPrice: Price + isInverted: boolean + feeAmount: CurrencyAmount | null + marketRate: Fraction | null +} + +export function EstimatedFillPrice({ + currency, + estimatedFillPrice, + executionPrice, + isInverted, + feeAmount, + marketRate, +}: EstimatedFillPriceProps) { + return ( + + + + ≈ + + + ) +} diff --git a/apps/cowswap-frontend/src/modules/limitOrders/pure/ExecutionPriceTooltip/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/pure/ExecutionPriceTooltip/index.tsx index f31603ba88..1e06c44654 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/ExecutionPriceTooltip/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/ExecutionPriceTooltip/index.tsx @@ -4,7 +4,6 @@ import { Currency, CurrencyAmount, Fraction, Price } from '@uniswap/sdk-core' import { useUsdAmount } from 'modules/usdAmount' import { ExecutionPrice } from 'common/pure/ExecutionPrice' -import { RateTooltipHeader } from 'common/pure/OrderExecutionStatusList' import { convertAmountToCurrency } from 'utils/orderUtils/calculateExecutionPrice' import * as styledEl from './styled' @@ -30,13 +29,13 @@ function formatFeeAmount({ return !isInverted && invertedFee && currency && feeAmount ? convertAmountToCurrency( CurrencyAmount.fromFractionalAmount(feeAmount.currency, invertedFee.numerator, invertedFee.denominator), - currency + currency, ) : feeAmount } export function ExecutionPriceTooltip(props: ExecutionPriceTooltipProps) { - const { isInverted, displayedRate, executionPrice, isOpenOrdersTab } = props + const { isInverted, displayedRate, executionPrice } = props const currentCurrency = isInverted ? executionPrice?.baseCurrency : executionPrice?.quoteCurrency const formattedFeeAmount = formatFeeAmount(props) @@ -45,8 +44,6 @@ export function ExecutionPriceTooltip(props: ExecutionPriceTooltipProps) { return ( - -

    Limit price

    diff --git a/apps/cowswap-frontend/src/modules/limitOrders/pure/RateInput/HeadingText.tsx b/apps/cowswap-frontend/src/modules/limitOrders/pure/RateInput/HeadingText.tsx index 822e847b32..88ed3dee08 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/RateInput/HeadingText.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/RateInput/HeadingText.tsx @@ -1,4 +1,5 @@ -import { UI } from '@cowprotocol/ui' +import { TokenLogo } from '@cowprotocol/tokens' +import { TokenSymbol, UI } from '@cowprotocol/ui' import { Currency } from '@uniswap/sdk-core' import styled from 'styled-components/macro' @@ -9,33 +10,65 @@ type Props = { currency: Currency | null inputCurrency: Currency | null rateImpact: number + toggleIcon?: React.ReactNode + onToggle?: () => void } const Wrapper = styled.span` display: flex; flex-flow: row wrap; - align-items: flex-start; + align-items: center; justify-content: flex-start; text-align: left; gap: 0 3px; - opacity: 0.7; + font-size: 13px; + font-weight: 400; + margin: auto 0; + color: var(${UI.COLOR_TEXT_OPACITY_70}); +` + +const TokenWrapper = styled.div` + display: flex; + align-items: center; + gap: 4px; + font-weight: 600; + color: var(${UI.COLOR_TEXT}); +` + +const TextWrapper = styled.span<{ clickable: boolean }>` + display: flex; + align-items: center; + gap: 4px; + cursor: ${({ clickable }) => (clickable ? 'pointer' : 'default')}; transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; &:hover { - opacity: 1; + text-decoration: underline; + text-decoration-style: dashed; + text-decoration-thickness: 1px; + text-underline-offset: 2px; + text-decoration-color: var(${UI.COLOR_TEXT_OPACITY_70}); } ` -export function HeadingText({ inputCurrency, currency, rateImpact }: Props) { +export function HeadingText({ inputCurrency, currency, rateImpact, toggleIcon, onToggle }: Props) { if (!currency) { return Select input and output } return ( - {/* Price of */} - Limit price - {} + {toggleIcon} + + When + + 1 + + + + is worth + {} + ) } diff --git a/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.cosmos.tsx index f1d4599a3c..b03535f199 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.cosmos.tsx @@ -6,6 +6,11 @@ const defaultProps: SettingsProps = { partialFillsEnabled: true, deadlineMilliseconds: 200_000, customDeadlineTimestamp: null, + limitPricePosition: 'between', + limitPriceLocked: false, + columnLayout: 'DEFAULT', + ordersTableOnLeft: false, + isUsdValuesMode: false, }, onStateChanged(state) { console.log('Settings state changed: ', state) diff --git a/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.tsx index a12ec03174..4b756b6253 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.tsx @@ -1,18 +1,155 @@ +import { useCallback, useState } from 'react' + +import { UI } from '@cowprotocol/ui' +import { HelpTooltip } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +import { ORDERS_TABLE_SETTINGS } from 'modules/trade/const/common' import { SettingsBox, SettingsContainer, SettingsTitle } from 'modules/trade/pure/Settings' import { LimitOrdersSettingsState } from '../../state/limitOrdersSettingsAtom' +const DropdownButton = styled.button` + background: var(${UI.COLOR_PAPER_DARKER}); + color: inherit; + border: 1px solid var(${UI.COLOR_BORDER}); + border-radius: 12px; + padding: 10px 34px 10px 12px; + min-width: 140px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + transition: all 0.2s ease-in-out; + + &::after { + content: '▼'; + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + transition: transform 0.2s ease-in-out; + color: var(${UI.COLOR_PRIMARY_OPACITY_50}); + } + + &:hover { + border-color: var(${UI.COLOR_PRIMARY_OPACITY_25}); + background: var(${UI.COLOR_PRIMARY_OPACITY_10}); + } + + &:focus { + outline: none; + } +` + +const DropdownList = styled.div<{ isOpen: boolean }>` + display: ${({ isOpen }) => (isOpen ? 'block' : 'none')}; + position: absolute; + top: calc(100% + 8px); + right: 0; + background: var(${UI.COLOR_PAPER}); + border: 1px solid var(${UI.COLOR_BORDER}); + border-radius: 12px; + padding: 6px; + min-width: 140px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +` + +const DropdownItem = styled.div` + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + transition: all 0.15s ease-in-out; + color: inherit; + + &:hover { + background: var(${UI.COLOR_PRIMARY_OPACITY_10}); + transform: translateX(2px); + } + + &:active { + transform: translateX(2px) scale(0.98); + } +` + +const DropdownContainer = styled.div` + position: relative; + user-select: none; +` + +const SettingsRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 15px; + font-weight: 400; + color: inherit; + font-size: 14px; +` + +const SettingsLabel = styled.div` + display: flex; + align-items: center; + gap: 5px; +` + export interface SettingsProps { state: LimitOrdersSettingsState onStateChanged: (state: Partial) => void } +const POSITION_LABELS = { + top: 'Top', + between: 'Between currencies', + bottom: 'Bottom', +} + +const COLUMN_LAYOUT_LABELS = { + DEFAULT: 'Default view', + VIEW_2: 'Limit price / Fills at / Distance', + VIEW_3: 'Limit price / Fills at + Distance / Market', +} + export function Settings({ state, onStateChanged }: SettingsProps) { - const { showRecipient, partialFillsEnabled } = state + const { showRecipient, partialFillsEnabled, limitPricePosition, limitPriceLocked, columnLayout, ordersTableOnLeft } = + state + const [isOpen, setIsOpen] = useState(false) + const [isColumnLayoutOpen, setIsColumnLayoutOpen] = useState(false) + + const handleSelect = useCallback( + (value: LimitOrdersSettingsState['limitPricePosition']) => (e: React.MouseEvent) => { + e.stopPropagation() + onStateChanged({ limitPricePosition: value }) + setIsOpen(false) + }, + [onStateChanged], + ) + + const handleColumnLayoutSelect = (value: LimitOrdersSettingsState['columnLayout']) => (e: React.MouseEvent) => { + e.stopPropagation() + onStateChanged({ columnLayout: value }) + setIsColumnLayoutOpen(false) + } + + const toggleDropdown = (e: React.MouseEvent) => { + e.stopPropagation() + setIsOpen(!isOpen) + } + + const toggleColumnLayoutDropdown = (e: React.MouseEvent) => { + e.stopPropagation() + setIsColumnLayoutOpen(!isColumnLayoutOpen) + } return ( - Interface Settings + Limit Order Settings onStateChanged({ partialFillsEnabled: !partialFillsEnabled })} /> + + onStateChanged({ limitPriceLocked: !limitPriceLocked })} + /> + + onStateChanged({ ordersTableOnLeft: !ordersTableOnLeft })} + /> + + + + Limit Price Position + + + + {POSITION_LABELS[limitPricePosition]} + + {Object.entries(POSITION_LABELS).map(([value, label]) => ( + + {label} + + ))} + + + + + + + Column Layout + + + + {COLUMN_LAYOUT_LABELS[columnLayout]} + + {Object.entries(COLUMN_LAYOUT_LABELS).map(([value, label]) => ( + + {label} + + ))} + + + ) } diff --git a/apps/cowswap-frontend/src/modules/limitOrders/state/limitOrdersSettingsAtom.ts b/apps/cowswap-frontend/src/modules/limitOrders/state/limitOrdersSettingsAtom.ts index b4df5c2ef4..7f1d8c4b11 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/state/limitOrdersSettingsAtom.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/state/limitOrdersSettingsAtom.ts @@ -12,11 +12,18 @@ import { alternativeOrderReadWriteAtomFactory, } from 'modules/trade/state/alternativeOrder' +export type ColumnLayoutType = 'DEFAULT' | 'VIEW_2' | 'VIEW_3' + export interface LimitOrdersSettingsState { readonly showRecipient: boolean readonly partialFillsEnabled: boolean readonly deadlineMilliseconds: Milliseconds readonly customDeadlineTimestamp: Timestamp | null + readonly limitPricePosition: 'top' | 'between' | 'bottom' + readonly limitPriceLocked: boolean + readonly columnLayout: ColumnLayoutType + readonly ordersTableOnLeft: boolean + readonly isUsdValuesMode: boolean } export const defaultLimitOrdersSettings: LimitOrdersSettingsState = { @@ -24,40 +31,45 @@ export const defaultLimitOrdersSettings: LimitOrdersSettingsState = { partialFillsEnabled: true, deadlineMilliseconds: defaultLimitOrderDeadline.value, customDeadlineTimestamp: null, + limitPricePosition: 'top', + limitPriceLocked: true, + columnLayout: 'DEFAULT', + ordersTableOnLeft: false, + isUsdValuesMode: false, } // regular const regularLimitOrdersSettingsAtom = atomWithStorage( - 'limit-orders-settings-atom:v2', + 'limit-orders-settings-atom:v3', defaultLimitOrdersSettings, - getJotaiIsolatedStorage() + getJotaiIsolatedStorage(), ) const regularUpdateLimitOrdersSettingsAtom = atom( null, - partialFillsOverrideSetterFactory(regularLimitOrdersSettingsAtom) + partialFillsOverrideSetterFactory(regularLimitOrdersSettingsAtom), ) // alternative const alternativeLimitOrdersSettingsAtom = atom(defaultLimitOrdersSettings) const alternativeUpdateLimitOrdersSettingsAtom = atom( null, - partialFillsOverrideSetterFactory(alternativeLimitOrdersSettingsAtom) + partialFillsOverrideSetterFactory(alternativeLimitOrdersSettingsAtom), ) // export export const limitOrdersSettingsAtom = alternativeOrderReadWriteAtomFactory( regularLimitOrdersSettingsAtom, - alternativeLimitOrdersSettingsAtom + alternativeLimitOrdersSettingsAtom, ) export const updateLimitOrdersSettingsAtom = atom( null, - alternativeOrderAtomSetterFactory(regularUpdateLimitOrdersSettingsAtom, alternativeUpdateLimitOrdersSettingsAtom) + alternativeOrderAtomSetterFactory(regularUpdateLimitOrdersSettingsAtom, alternativeUpdateLimitOrdersSettingsAtom), ) // utils function partialFillsOverrideSetterFactory( - atomToUpdate: typeof regularLimitOrdersSettingsAtom | typeof alternativeLimitOrdersSettingsAtom + atomToUpdate: typeof regularLimitOrdersSettingsAtom | typeof alternativeLimitOrdersSettingsAtom, ) { return (get: Getter, set: Setter, nextState: Partial) => { set(atomToUpdate, () => { diff --git a/apps/cowswap-frontend/src/modules/ordersTable/const/tabs.ts b/apps/cowswap-frontend/src/modules/ordersTable/const/tabs.ts index 72e324a5be..fe4fdacbf4 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/const/tabs.ts +++ b/apps/cowswap-frontend/src/modules/ordersTable/const/tabs.ts @@ -5,9 +5,21 @@ export interface OrderTab { isActive?: boolean } +export const ALL_ORDERS_TAB: OrderTab = { + id: 'all', + title: 'All orders', + count: 0, +} + +export const UNFILLABLE_TAB: OrderTab = { + id: 'unfillable', + title: 'Unfillable', + count: 0, +} + export const OPEN_TAB: OrderTab = { id: 'open', - title: 'Open orders', + title: 'Open', count: 0, } @@ -17,7 +29,7 @@ export const HISTORY_TAB: OrderTab = { count: 0, } -export const ORDERS_TABLE_TABS: OrderTab[] = [OPEN_TAB, HISTORY_TAB] +export const ORDERS_TABLE_TABS: OrderTab[] = [ALL_ORDERS_TAB, UNFILLABLE_TAB, OPEN_TAB, HISTORY_TAB] export const ORDERS_TABLE_PAGE_SIZE = 10 diff --git a/apps/cowswap-frontend/src/modules/ordersTable/containers/MultipleCancellationMenu/index.tsx b/apps/cowswap-frontend/src/modules/ordersTable/containers/MultipleCancellationMenu/index.tsx index befa8c25a9..ea38bb0bb6 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/containers/MultipleCancellationMenu/index.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/containers/MultipleCancellationMenu/index.tsx @@ -22,8 +22,7 @@ const Wrapper = styled.div<{ hasSelectedItems: boolean }>` align-items: center; justify-content: space-between; gap: 6px; - margin-left: ${({ hasSelectedItems }) => (hasSelectedItems ? '' : 'auto')}; - margin: 0 10px 0 0; + margin: 0 0 0 ${({ hasSelectedItems }) => (hasSelectedItems ? '' : 'auto')}; ${Media.upToSmall()} { width: 100%; @@ -39,13 +38,15 @@ const ActionButton = styled.button` font-weight: 600; text-decoration: none; font-size: 13px; - padding: 10px 15px; + padding: 7px 12px; margin: 0; gap: 5px; border: 0; outline: none; cursor: pointer; - transition: background var(${UI.ANIMATION_DURATION}) ease-in-out, color var(${UI.ANIMATION_DURATION}) ease-in-out; + transition: + background var(${UI.ANIMATION_DURATION}) ease-in-out, + color var(${UI.ANIMATION_DURATION}) ease-in-out; border-radius: 24px; vertical-align: center; @@ -61,9 +62,10 @@ const ActionButton = styled.button` const TextButton = styled.button` display: inline-block; - color: inherit; - font-size: 13px; - padding: 5px 10px; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + font-size: 12px; + font-weight: 500; + padding: 5px; cursor: pointer; background: none; outline: none; diff --git a/apps/cowswap-frontend/src/modules/ordersTable/containers/OrdersTableWidget/hooks/useOrdersTableList.ts b/apps/cowswap-frontend/src/modules/ordersTable/containers/OrdersTableWidget/hooks/useOrdersTableList.ts index 6f9e37bee4..853aab2b91 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/containers/OrdersTableWidget/hooks/useOrdersTableList.ts +++ b/apps/cowswap-frontend/src/modules/ordersTable/containers/OrdersTableWidget/hooks/useOrdersTableList.ts @@ -6,12 +6,15 @@ import { getIsComposableCowOrder } from 'utils/orderUtils/getIsComposableCowOrde import { getIsNotComposableCowOrder } from 'utils/orderUtils/getIsNotComposableCowOrder' import { TabOrderTypes } from '../../../types' +import { getOrderParams } from '../../../utils/getOrderParams' import { groupOrdersTable } from '../../../utils/groupOrdersTable' import { getParsedOrderFromTableItem, isParsedOrder, OrderTableItem } from '../../../utils/orderTableGroupUtils' export interface OrdersTableList { pending: OrderTableItem[] history: OrderTableItem[] + unfillable: OrderTableItem[] + all: OrderTableItem[] } const ordersSorter = (a: OrderTableItem, b: OrderTableItem) => { @@ -23,13 +26,18 @@ const ordersSorter = (a: OrderTableItem, b: OrderTableItem) => { const ORDERS_LIMIT = 100 -export function useOrdersTableList(allOrders: Order[], orderType: TabOrderTypes): OrdersTableList { +export function useOrdersTableList( + allOrders: Order[], + orderType: TabOrderTypes, + chainId: number, + balancesAndAllowances: any, +): OrdersTableList { const allSortedOrders = useMemo(() => { return groupOrdersTable(allOrders).sort(ordersSorter) }, [allOrders]) return useMemo(() => { - const { pending, history } = allSortedOrders.reduce( + const { pending, history, unfillable, all } = allSortedOrders.reduce( (acc, item) => { const order = isParsedOrder(item) ? item : item.parent @@ -41,7 +49,22 @@ export function useOrdersTableList(allOrders: Order[], orderType: TabOrderTypes) return acc } - if (PENDING_STATES.includes(order.status)) { + // Add to 'all' list regardless of status + acc.all.push(item) + + const isPending = PENDING_STATES.includes(order.status) + + // Check if order is unfillable (insufficient balance or allowance) + const params = getOrderParams(chainId, balancesAndAllowances, order) + const isUnfillable = params.hasEnoughBalance === false || params.hasEnoughAllowance === false + + // Only add to unfillable if the order is both pending and unfillable + if (isPending && isUnfillable) { + acc.unfillable.push(item) + } + + // Add to pending or history based on status + if (isPending) { acc.pending.push(item) } else { acc.history.push(item) @@ -49,9 +72,14 @@ export function useOrdersTableList(allOrders: Order[], orderType: TabOrderTypes) return acc }, - { pending: [], history: [] } as OrdersTableList + { pending: [], history: [], unfillable: [], all: [] } as OrdersTableList, ) - return { pending: pending.slice(0, ORDERS_LIMIT), history: history.slice(0, ORDERS_LIMIT) } - }, [allSortedOrders, orderType]) + return { + pending: pending.slice(0, ORDERS_LIMIT), + history: history.slice(0, ORDERS_LIMIT), + unfillable: unfillable.slice(0, ORDERS_LIMIT), + all: all.slice(0, ORDERS_LIMIT), + } + }, [allSortedOrders, orderType, chainId, balancesAndAllowances]) } diff --git a/apps/cowswap-frontend/src/modules/ordersTable/containers/OrdersTableWidget/index.tsx b/apps/cowswap-frontend/src/modules/ordersTable/containers/OrdersTableWidget/index.tsx index 509dd0ff75..2f727fdaf0 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/containers/OrdersTableWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/containers/OrdersTableWidget/index.tsx @@ -1,15 +1,18 @@ import { useAtomValue, useSetAtom } from 'jotai' -import { ReactNode, useCallback, useEffect, useMemo } from 'react' +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import { useTokensAllowances, useTokensBalances } from '@cowprotocol/balances-and-allowances' +import { UI } from '@cowprotocol/ui' import { useIsSafeViaWc, useWalletDetails, useWalletInfo } from '@cowprotocol/wallet' +import { Search } from 'react-feather' import { useLocation } from 'react-router-dom' import styled from 'styled-components/macro' import { Order } from 'legacy/state/orders/actions' import { useInjectedWidgetParams } from 'modules/injectedWidget' +import { limitOrdersSettingsAtom } from 'modules/limitOrders/state/limitOrdersSettingsAtom' import { pendingOrdersPricesAtom } from 'modules/orders/state/pendingOrdersPricesAtom' import { useGetSpotPrice } from 'modules/orders/state/spotPricesAtom' import { BalancesAndAllowances } from 'modules/tokens' @@ -25,19 +28,67 @@ import { OrdersTableList, useOrdersTableList } from './hooks/useOrdersTableList' import { useOrdersTableTokenApprove } from './hooks/useOrdersTableTokenApprove' import { useValidatePageUrlParams } from './hooks/useValidatePageUrlParams' -import { OPEN_TAB, ORDERS_TABLE_TABS } from '../../const/tabs' +import { OPEN_TAB, ORDERS_TABLE_TABS, ALL_ORDERS_TAB } from '../../const/tabs' import { OrdersTableContainer } from '../../pure/OrdersTableContainer' +import { ColumnLayout, LAYOUT_MAP } from '../../pure/OrdersTableContainer/tableHeaders' import { OrderActions } from '../../pure/OrdersTableContainer/types' import { TabOrderTypes } from '../../types' import { buildOrdersTableUrl } from '../../utils/buildOrdersTableUrl' -import { OrderTableItem, tableItemsToOrders } from '../../utils/orderTableGroupUtils' +import { OrderTableItem, tableItemsToOrders, getParsedOrderFromTableItem } from '../../utils/orderTableGroupUtils' import { parseOrdersTableUrl } from '../../utils/parseOrdersTableUrl' import { MultipleCancellationMenu } from '../MultipleCancellationMenu' import { OrdersReceiptModal } from '../OrdersReceiptModal' import { useGetAlternativeOrderModalContextCallback, useSelectReceiptOrder } from '../OrdersReceiptModal/hooks' +const SearchInputContainer = styled.div` + margin: 0; + padding: 0 0 0 16px; + position: relative; +` + +const SearchIcon = styled(Search)` + position: absolute; + left: 28px; + top: 50%; + transform: translateY(-50%); + color: var(${UI.COLOR_TEXT_OPACITY_50}); + width: 16px; + height: 16px; +` + +const SearchInput = styled.input` + width: 100%; + padding: 8px 12px 8px 36px; + border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); + background: var(${UI.COLOR_PAPER}); + color: var(${UI.COLOR_TEXT}); + border-radius: 8px; + font-size: 13px; + font-weight: 500; + + &::placeholder { + color: var(${UI.COLOR_TEXT_OPACITY_50}); + } + + &:focus { + outline: none; + border-color: var(${UI.COLOR_TEXT_OPACITY_50}); + } +` + function getOrdersListByIndex(ordersList: OrdersTableList, id: string): OrderTableItem[] { - return id === OPEN_TAB.id ? ordersList.pending : ordersList.history + switch (id) { + case 'all': + return ordersList.all + case 'unfillable': + return ordersList.unfillable + case 'open': + return ordersList.pending + case 'history': + return ordersList.history + default: + return ordersList.pending + } } function toggleOrderInCancellationList(state: CancellableOrder[], order: CancellableOrder): CancellableOrder[] { @@ -50,10 +101,6 @@ function toggleOrderInCancellationList(state: CancellableOrder[], order: Cancell return [...state, order] } -const ContentWrapper = styled.div` - width: 100%; -` - interface OrdersTableWidgetProps { displayOrdersOnlyForSafeApp: boolean orders: Order[] @@ -71,7 +118,6 @@ export function OrdersTableWidget({ const location = useLocation() const navigate = useNavigate() const cancelOrder = useCancelOrder() - const ordersList = useOrdersTableList(allOrders, orderType) const { allowsOffchainSigning } = useWalletDetails() const pendingOrdersPrices = useAtomValue(pendingOrdersPricesAtom) const ordersToCancel = useAtomValue(ordersToCancelAtom) @@ -80,12 +126,33 @@ export function OrdersTableWidget({ const selectReceiptOrder = useSelectReceiptOrder() const isSafeViaWc = useIsSafeViaWc() const injectedWidgetParams = useInjectedWidgetParams() + const [searchTerm, setSearchTerm] = useState('') + const limitOrdersSettings = useAtomValue(limitOrdersSettingsAtom) + const columnLayout = useMemo( + () => LAYOUT_MAP[limitOrdersSettings.columnLayout] || ColumnLayout.DEFAULT, + [limitOrdersSettings.columnLayout], + ) + + const balancesState = useTokensBalances() + const allowancesState = useTokensAllowances() + + const balancesAndAllowances: BalancesAndAllowances = useMemo(() => { + const { isLoading: balancesLoading, values: balances } = balancesState + const { isLoading: allowancesLoading, values: allowances } = allowancesState + return { + isLoading: balancesLoading || allowancesLoading, + balances, + allowances, + } + }, [balancesState, allowancesState]) + + const ordersList = useOrdersTableList(allOrders, orderType, chainId, balancesAndAllowances) const { currentTabId, currentPageNumber } = useMemo(() => { const params = parseOrdersTableUrl(location.search) return { - currentTabId: params.tabId || OPEN_TAB.id, + currentTabId: params.tabId || ALL_ORDERS_TAB.id, currentPageNumber: params.pageNumber || 1, } }, [location.search]) @@ -100,21 +167,6 @@ export function OrdersTableWidget({ }) }, [currentTabId, ordersList]) - const isOpenOrdersTab = useMemo(() => OPEN_TAB.id === currentTabId, [currentTabId]) - - const balancesState = useTokensBalances() - const allowancesState = useTokensAllowances() - - const balancesAndAllowances: BalancesAndAllowances = useMemo(() => { - const { isLoading: balancesLoading, values: balances } = balancesState - const { isLoading: allowancesLoading, values: allowances } = allowancesState - return { - isLoading: balancesLoading || allowancesLoading, - balances, - allowances, - } - }, [balancesState, allowancesState]) - const { pendingActivity } = useCategorizeRecentActivity() const toggleOrdersForCancellation = useCallback( @@ -160,32 +212,104 @@ export function OrdersTableWidget({ useValidatePageUrlParams(orders.length, currentTabId, currentPageNumber) + const filteredOrders = useMemo(() => { + if (!searchTerm) return orders + + const searchTermLower = searchTerm.toLowerCase().trim() + + // First try exact symbol matches (case-insensitive) + const exactMatches = orders.filter((order) => { + const parsedOrder = getParsedOrderFromTableItem(order) + const inputToken = parsedOrder.inputToken + const outputToken = parsedOrder.outputToken + + return [inputToken.symbol, outputToken.symbol].some((symbol) => { + return symbol?.toLowerCase() === searchTermLower + }) + }) + + // If we have exact matches, return those + if (exactMatches.length > 0) { + return exactMatches + } + + // Otherwise, fall back to partial matches and address search + return orders.filter((order) => { + const parsedOrder = getParsedOrderFromTableItem(order) + const inputToken = parsedOrder.inputToken + const outputToken = parsedOrder.outputToken + + // Check for partial symbol matches (case-insensitive) + const symbolMatch = [inputToken.symbol, outputToken.symbol].some((symbol) => { + return symbol?.toLowerCase().includes(searchTermLower) + }) + + if (symbolMatch) return true + + // If not a symbol match, check for address matches + // Clean up the search term but preserve '0x' prefix if present + const hasPrefix = searchTermLower.startsWith('0x') + const cleanedSearch = searchTermLower.replace(/[^0-9a-fx]/g, '') + + // For exact address matches (40 or 42 chars), do strict comparison + if (cleanedSearch.length === 40 || cleanedSearch.length === 42) { + const searchTermNormalized = hasPrefix ? cleanedSearch : `0x${cleanedSearch}` + return [inputToken.address, outputToken.address].some( + (address) => address.toLowerCase() === searchTermNormalized.toLowerCase(), + ) + } + + // For partial address matches + const searchWithoutPrefix = hasPrefix ? cleanedSearch.slice(2) : cleanedSearch + if (searchWithoutPrefix.length >= 2) { + // Only search if we have at least 2 characters + return [inputToken.address, outputToken.address].some((address) => { + const addressWithoutPrefix = address.slice(2).toLowerCase() + return addressWithoutPrefix.includes(searchWithoutPrefix.toLowerCase()) + }) + } + + return false + }) + }, [orders, searchTerm]) + return ( <> - - {children} - - {isOpenOrdersTab && orders.length && } - - + {children} + + {(currentTabId === OPEN_TAB.id || currentTabId === 'all' || currentTabId === 'unfillable') && + orders.length > 0 && } + + + + setSearchTerm(e.target.value)} + /> + + + ) diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/index.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/index.tsx index f066c74d6b..5e40b59f38 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/index.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/index.tsx @@ -39,32 +39,49 @@ const Wrapper = styled.div<{ left: 0; top: 0; background: var(--statusBackground); - /* opacity: 0.14; */ z-index: 1; border-radius: ${({ withWarning }) => (withWarning ? '9px 0 0 9px' : '9px')}; } ` +const StatusContent = styled.div` + display: flex; + align-items: center; + gap: 4px; + position: relative; + z-index: 2; +` + type OrderStatusBoxProps = { order: ParsedOrder widthAuto?: boolean withWarning?: boolean onClick?: Command + WarningTooltip?: React.ComponentType<{ children: React.ReactNode }> } -export function OrderStatusBox({ order, widthAuto, withWarning, onClick }: OrderStatusBoxProps) { +export function OrderStatusBox({ order, widthAuto, withWarning, onClick, WarningTooltip }: OrderStatusBoxProps) { const { title, color, background } = getOrderStatusTitleAndColor(order) + + const content = {title} + return ( - - {/* Status overrides for special cases */} - {title} - + <> + + {content} + + {withWarning && WarningTooltip && ( + + <> + + )} + ) } diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/EstimatedExecutionPrice.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/EstimatedExecutionPrice.tsx index e4c71dda24..cda6d61cd5 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/EstimatedExecutionPrice.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/EstimatedExecutionPrice.tsx @@ -1,5 +1,6 @@ import AlertTriangle from '@cowprotocol/assets/cow-swap/alert.svg' import { ZERO_FRACTION } from '@cowprotocol/common-const' +import { Command } from '@cowprotocol/types' import { UI } from '@cowprotocol/ui' import { SymbolElement, TokenAmount, TokenAmountProps } from '@cowprotocol/ui' import { HoverTooltip } from '@cowprotocol/ui' @@ -10,7 +11,6 @@ import SVG from 'react-inlinesvg' import styled from 'styled-components/macro' import { HIGH_FEE_WARNING_PERCENTAGE } from 'common/constants/common' -import { calculateOrderExecutionStatus, ExecuteIndicator } from 'common/pure/OrderExecutionStatusList' import * as styledEl from './styled' @@ -28,19 +28,6 @@ export const EstimatedExecutionPriceWrapper = styled.span<{ hasWarning: boolean; color: inherit; } - // Triangle warning icon override - ${styledEl.WarningIndicator} { - padding: 0 0 0 3px; - - svg { - --size: 18px; - width: var(--size); - height: var(--size); - min-width: var(--size); - min-height: var(--size); - } - } - // Popover container override > div > div, > span { @@ -50,21 +37,36 @@ export const EstimatedExecutionPriceWrapper = styled.span<{ hasWarning: boolean; ` const UnfillableLabel = styled.span` - width: 100%; - max-width: 90px; - background: var(${UI.COLOR_DANGER_BG}); - color: var(${UI.COLOR_DANGER_TEXT}); + width: auto; + color: var(${UI.COLOR_DANGER}); position: relative; - border-radius: 9px; display: flex; align-items: center; justify-content: center; - font-size: 11px; - font-weight: 600; - padding: 6px 2px; - margin: 0 4px 0 0; - letter-spacing: 0.2px; - text-transform: uppercase; + font-size: inherit; + font-weight: 500; + line-height: 1.1; + flex-flow: row wrap; + align-items: center; + justify-content: flex-start; + gap: 3px; +` + +const ApprovalLink = styled.button` + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + font-size: inherit; + color: inherit; + text-decoration: underline; + color: var(${UI.COLOR_PRIMARY}); + font-weight: 500; + + &:hover { + opacity: 1; + } ` export type EstimatedExecutionPriceProps = TokenAmountProps & { @@ -75,10 +77,15 @@ export type EstimatedExecutionPriceProps = TokenAmountProps & { amountDifference?: CurrencyAmount percentageFee?: Percent amountFee?: CurrencyAmount + warningText?: string + WarningTooltip?: React.FC<{ children: React.ReactNode; showIcon: boolean }> + onApprove?: Command } export function EstimatedExecutionPrice(props: EstimatedExecutionPriceProps) { const { + amount, + tokenSymbol, isInverted, isUnfillable, canShowWarning, @@ -86,7 +93,9 @@ export function EstimatedExecutionPrice(props: EstimatedExecutionPriceProps) { amountDifference, percentageFee, amountFee, - amount, + warningText, + WarningTooltip, + onApprove, ...rest } = props @@ -98,22 +107,31 @@ export function EstimatedExecutionPrice(props: EstimatedExecutionPriceProps) { const absoluteDifferenceAmount = amountDifference?.lessThan(ZERO_FRACTION) ? amountDifference.multiply(MINUS_ONE_FRACTION) : amountDifference - const orderExecutionStatus = calculateOrderExecutionStatus(percentageDifferenceInverted) + const feeWarning = canShowWarning && percentageFee?.greaterThan(HIGH_FEE_WARNING_PERCENTAGE) const isNegativeDifference = percentageDifferenceInverted?.lessThan(ZERO_FRACTION) const marketPriceNeedsToGoDown = isInverted ? !isNegativeDifference : isNegativeDifference const content = ( <> - - + ) + const unfillableLabel = ( + + {warningText} + {warningText === 'Insufficient allowance' && onApprove && ( + Set approval + )} + {WarningTooltip && {null}} + + ) + return ( {isUnfillable ? ( - UNFILLABLE +
    {unfillableLabel}
    ) : !absoluteDifferenceAmount ? ( {content} ) : ( @@ -121,7 +139,9 @@ export function EstimatedExecutionPrice(props: EstimatedExecutionPriceProps) { wrapInContainer={true} content={ - {!isNegativeDifference ? ( + {isNegativeDifference && Math.abs(Number(percentageDifferenceInverted?.toFixed(4) ?? 0)) <= 0.01 ? ( + <>Will execute soon! + ) : ( <> Market price needs to go {marketPriceNeedsToGoDown ? 'down 📉' : 'up 📈'} by  @@ -133,8 +153,6 @@ export function EstimatedExecutionPrice(props: EstimatedExecutionPriceProps) {
     to execute your order. - ) : ( - <>Will execute soon! )} } diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/OrderWarning.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/OrderWarning.tsx new file mode 100644 index 0000000000..3f50ba73aa --- /dev/null +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/OrderWarning.tsx @@ -0,0 +1,143 @@ +import React from 'react' + +import AlertTriangle from '@cowprotocol/assets/cow-swap/alert.svg' +import { Command } from '@cowprotocol/types' +import { ButtonSecondary, TokenSymbol, UI, HoverTooltip } from '@cowprotocol/ui' + +import SVG from 'react-inlinesvg' + +import * as styledEl from './styled' + +interface WarningProps { + symbol: string + isScheduled: boolean +} + +interface AllowanceWarningProps extends WarningProps { + approve: Command +} + +function BalanceWarning({ symbol, isScheduled }: WarningProps) { + return ( + +

    Insufficient token balance

    +

    + Insufficient{' '} + + + {' '} + balance detected. +
    +
    + {isScheduled ? ( + <> + If the balance remains insufficient at creation time, this order portion will not be created. Add more{' '} + + + {' '} + before that time. + + ) : ( + <> + The order remains open. Execution requires sufficient{' '} + + + {' '} + balance. + + )} +

    +
    + ) +} + +function AllowanceWarning({ symbol, isScheduled, approve }: AllowanceWarningProps) { + return ( + +

    Insufficient token allowance

    +

    + {isScheduled ? ( + <> + Insufficient allowance granted for{' '} + + + + . If allowance remains insufficient at creation time, this portion will not be created. Approve the{' '} + + + {' '} + token before creation. + + ) : ( + <> + The order remains open. Execution requires adequate allowance for{' '} + + + + . Approve the token to proceed. + + )} +

    + + Set approval + +
    + ) +} + +interface WarningTooltipProps { + children: React.ReactNode + hasEnoughBalance: boolean + hasEnoughAllowance: boolean + inputTokenSymbol: string + isOrderScheduled: boolean + onApprove: Command + showIcon?: boolean +} + +export function WarningTooltip({ + children, + hasEnoughBalance, + hasEnoughAllowance, + inputTokenSymbol, + isOrderScheduled, + onApprove, + showIcon = false, +}: WarningTooltipProps) { + const withAllowanceWarning = hasEnoughAllowance === false + + const tooltipContent = ( + + {hasEnoughBalance === false && } + {withAllowanceWarning && ( + + )} + + ) + + if (showIcon) { + return ( + + } + /> + {children} + + ) + } + + return ( + + {children} + + ) +} diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/index.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/index.tsx index 8bd52a248f..cbd63f9469 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/index.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/index.tsx @@ -1,18 +1,15 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' -import AlertTriangle from '@cowprotocol/assets/cow-swap/alert.svg' import { ZERO_FRACTION } from '@cowprotocol/common-const' import { useTimeAgo } from '@cowprotocol/common-hooks' import { getAddress, getEtherscanLink } from '@cowprotocol/common-utils' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { TokenLogo } from '@cowprotocol/tokens' import { Command, UiOrderType } from '@cowprotocol/types' -import { ButtonSecondary, Loader, TokenAmount, TokenSymbol, UI } from '@cowprotocol/ui' +import { Loader, TokenAmount, UI } from '@cowprotocol/ui' import { PercentDisplay, percentIsAlmostHundred } from '@cowprotocol/ui' import { Currency, CurrencyAmount, Percent, Price } from '@uniswap/sdk-core' -import SVG from 'react-inlinesvg' - import { CREATING_STATES, OrderStatus } from 'legacy/state/orders/actions' import { PendingOrderPrices } from 'modules/orders/state/pendingOrdersPricesAtom' @@ -25,20 +22,40 @@ import { isOrderCancellable } from 'common/utils/isOrderCancellable' import { calculatePercentageInRelationToReference } from 'utils/orderUtils/calculatePercentageInRelationToReference' import { calculatePriceDifference, PriceDifference } from 'utils/orderUtils/calculatePriceDifference' import { getIsComposableCowParentOrder } from 'utils/orderUtils/getIsComposableCowParentOrder' +import { getIsFinalizedOrder } from 'utils/orderUtils/getIsFinalizedOrder' import { getSellAmountWithFee } from 'utils/orderUtils/getSellAmountWithFee' import { getUiOrderType } from 'utils/orderUtils/getUiOrderType' import { ParsedOrder } from 'utils/orderUtils/parseOrder' import { EstimatedExecutionPrice } from './EstimatedExecutionPrice' import { OrderContextMenu } from './OrderContextMenu' +import { WarningTooltip } from './OrderWarning' import * as styledEl from './styled' import { OrderParams } from '../../../utils/getOrderParams' import { OrderStatusBox } from '../../OrderStatusBox' import { CheckboxCheckmark, TableRow, TableRowCheckbox, TableRowCheckboxWrapper } from '../styled' +import { ColumnLayout } from '../tableHeaders' import { OrderActions } from '../types' +// Constants const TIME_AGO_UPDATE_INTERVAL = 3000 +const MIN_PERCENTAGE_TO_DISPLAY = 0.01 // Minimum percentage to display (show dash below this) +const GOOD_PRICE_THRESHOLD = 1.0 // 1% or less difference - good price +const FAIR_PRICE_THRESHOLD = 5.0 // 5% or less difference - fair price + +// Helper to determine the color based on percentage +function getDistanceColor(percentage: number): string { + const absPercentage = Math.abs(percentage) + + if (absPercentage <= GOOD_PRICE_THRESHOLD) { + return `var(${UI.COLOR_SUCCESS})` // Green - good price + } else if (absPercentage <= FAIR_PRICE_THRESHOLD) { + return `var(${UI.COLOR_PRIMARY})` // Blue - fair price + } + + return 'inherit' // Default text color for larger differences +} function CurrencyAmountItem({ amount }: { amount: CurrencyAmount }) { return ( @@ -52,104 +69,28 @@ function CurrencySymbolItem({ amount }: { amount: CurrencyAmount }) { return } -function BalanceWarning(params: { symbol: string; isScheduled: boolean }) { - const { symbol, isScheduled } = params - - return ( - -

    Insufficient balance

    -

    - Your wallet currently has insufficient{' '} - - - {' '} - balance to execute this order. -
    -
    - {isScheduled ? ( - <> - If there are not enough funds for this order by creation time, this part won't be created. Top up your{' '} - - - {' '} - balance before then to have it created. - - ) : ( - <> - The order is still open and will become executable when you top up your{' '} - - - {' '} - balance. - - )} -

    -
    - ) -} -function AllowanceWarning(params: { symbol: string; isScheduled: boolean; approve: Command }) { - const { symbol, isScheduled } = params - - return ( - -

    Insufficient approval for this order

    -

    - {isScheduled ? ( - <> - You haven't given CoW Swap sufficient allowance to spend{' '} - - - - . -
    - If there's not enough allowance for this order by creation time, this part won't be created. Approve{' '} - - - {' '} - in your account token page before then to have it created. - - ) : ( - <> - This order is still open and valid, but you haven't given CoW Swap sufficient allowance to spend{' '} - - - - . -
    - The order will become executable when you approve{' '} - - - {' '} - in your account token page. - - )} -

    - - Approve - -
    - ) -} - export interface OrderRowProps { order: ParsedOrder prices: PendingOrderPrices | undefined | null spotPrice: Price | undefined | null isRateInverted: boolean - isOpenOrdersTab: boolean + showLimitPrice: boolean + isHistoryTab: boolean isRowSelectable: boolean isRowSelected: boolean isChild?: boolean orderParams: OrderParams onClick: Command orderActions: OrderActions - children?: JSX.Element + children?: React.ReactNode + columnLayout?: ColumnLayout } export function OrderRow({ order, isRateInverted: isGloballyInverted, - isOpenOrdersTab, + showLimitPrice, + isHistoryTab, isRowSelectable, isRowSelected, isChild, @@ -159,6 +100,7 @@ export function OrderRow({ prices, spotPrice, children, + columnLayout = ColumnLayout.DEFAULT, }: OrderRowProps) { const { buyAmount, rateInfoParams, hasEnoughAllowance, hasEnoughBalance, chainId } = orderParams const { creationTime, expirationTime, status } = order @@ -184,9 +126,10 @@ export function OrderRow({ const isScheduledCreating = isOrderScheduled && Date.now() > creationTime.getTime() const expirationTimeAgo = useTimeAgo(expirationTime, TIME_AGO_UPDATE_INTERVAL) const creationTimeAgo = useTimeAgo(creationTime, TIME_AGO_UPDATE_INTERVAL) - - // TODO: set the real value when API returns it - // const executedTimeAgo = useTimeAgo(expirationTime, TIME_AGO_UPDATE_INTERVAL) + const fulfillmentTimeAgo = useTimeAgo( + order.fulfillmentTime ? new Date(order.fulfillmentTime) : undefined, + TIME_AGO_UPDATE_INTERVAL, + ) const activityUrl = chainId ? getActivityUrl(chainId, order) : undefined const [isInverted, setIsInverted] = useState(() => { @@ -205,7 +148,7 @@ export function OrderRow({ const executedPriceInverted = isInverted ? executedPrice?.invert() : executedPrice const spotPriceInverted = isInverted ? spotPrice?.invert() : spotPrice - const priceDiffs = usePricesDifference(prices, spotPrice, isInverted) + const priceDiffs = usePricesDifference(prices, spotPrice) const feeDifference = useFeeAmountDifference(rateInfoParams, prices) const isExecutedPriceZero = executedPriceInverted !== undefined && executedPriceInverted?.equalTo(ZERO_FRACTION) @@ -215,15 +158,131 @@ export function OrderRow({ const inputTokenSymbol = order.inputToken.symbol || '' + const getWarningText = () => { + if (hasEnoughBalance === false) return 'Insufficient balance' + if (hasEnoughAllowance === false) return 'Insufficient allowance' + return 'Unfillable' + } + + const renderWarningTooltip = (showIcon?: boolean) => (props: { children: React.ReactNode }) => ( + orderActions.approveOrderToken(order.inputToken)} + showIcon={showIcon} + {...props} + /> + ) + + const renderLimitPrice = () => ( + + + + ) + + const renderFillsAt = () => ( + <> + {getIsFinalizedOrder(order) ? ( + '-' + ) : prices && estimatedExecutionPrice ? ( + + {!isUnfillable && + priceDiffs?.percentage && + Math.abs(Number(priceDiffs.percentage.toFixed(4))) <= MIN_PERCENTAGE_TO_DISPLAY ? ( + ⚡️ Pending execution + ) : ( + orderActions.approveOrderToken(order.inputToken)} + WarningTooltip={renderWarningTooltip()} + /> + )} + + ) : prices === null || !estimatedExecutionPrice || isOrderCreating ? ( + '-' + ) : ( + + )} + + ) + + const renderFillsAtWithDistance = () => { + const fillsAtContent = renderFillsAt() + const distance = + !isUnfillable && priceDiffs?.percentage && Number(priceDiffs?.percentage.toFixed(4)) >= MIN_PERCENTAGE_TO_DISPLAY + ? `${priceDiffs?.percentage.toFixed(2)}%` + : '-' + + return ( + + {fillsAtContent} + + {distance} + + + ) + } + + const renderDistanceToMarket = () => ( + <> + {isUnfillable ? ( + '-' + ) : priceDiffs?.percentage && Number(priceDiffs.percentage.toFixed(4)) >= MIN_PERCENTAGE_TO_DISPLAY ? ( + + {priceDiffs.percentage.toFixed(2)}% + + ) : ( + '-' + )} + + ) + + const renderMarketPrice = () => ( + <> + {spotPrice ? ( + + ) : spotPrice === null ? ( + '-' + ) : ( + + )} + + ) + return ( {/*Checkbox for multiple cancellation*/} - {isRowSelectable && isOpenOrdersTab && ( + {isRowSelectable && !isHistoryTab && ( - {/* Limit price */} - - - - - + {/* Non-history tab columns */} + {!isHistoryTab ? ( + <> + {/* Price columns based on layout */} + {columnLayout === ColumnLayout.DEFAULT && ( + <> + + {showLimitPrice ? renderLimitPrice() : renderFillsAt()} + + {renderDistanceToMarket()} + {renderMarketPrice()} + + )} - {/* Market price */} - {/* {isOpenOrdersTab && ordersTableFeatures.DISPLAY_EST_EXECUTION_PRICE && ( */} - {isOpenOrdersTab && ( - - {/*// TODO: gray out the price when it was updated too long ago*/} - {spotPrice ? ( - - ) : spotPrice === null ? ( - '-' - ) : ( - + {columnLayout === ColumnLayout.VIEW_2 && ( + <> + {renderLimitPrice()} + {renderFillsAt()} + {renderDistanceToMarket()} + )} - - )} - {/* Execution price */} - {!isOpenOrdersTab && ( - - {executedPriceInverted ? ( - - ) : ( - '-' + {columnLayout === ColumnLayout.VIEW_3 && ( + <> + {renderLimitPrice()} + {renderFillsAtWithDistance()} + {renderMarketPrice()} + )} - - )} - {/* Executes at */} - {isOpenOrdersTab && ( - - {/*// TODO: gray out the price when it was updated too long ago*/} - {prices && estimatedExecutionPrice ? ( - - + {expirationTimeAgo} + {isScheduledCreating ? 'Creating...' : creationTimeAgo} + + + ) : ( + <> + {/* History tab columns */} + {/* Limit price */} + + + + + {/* Execution price */} + + {executedPriceInverted ? ( + - - ) : prices === null || !estimatedExecutionPrice || isOrderCreating ? ( - '-' - ) : ( - - )} - - )} + ) : ( + '-' + )} + - {/* Expires */} - {/* Created */} - {isOpenOrdersTab && ( - - {expirationTimeAgo} - {isScheduledCreating ? 'Creating...' : creationTimeAgo} - - )} + + {order.status === OrderStatus.FULFILLED && fulfillmentTimeAgo ? fulfillmentTimeAgo : '-'} + - {/* TODO: Enable once there is back-end support */} - {/* {!isOpenOrdersTab && ordersTableFeatures.DISPLAY_EXECUTION_TIME && ( - - {order.status === OrderStatus.FULFILLED ? executedTimeAgo : '-'} - - )} */} + {creationTimeAgo} + + )} {/* Filled % */} - - - - + + + + + + {/* Status label */} - {children ? ( - children - ) : ( - <> - - {withWarning && ( - - } - text={ - - {hasEnoughBalance === false && ( - - )} - {withAllowanceWarning && ( - orderActions.approveOrderToken(order.inputToken)} - symbol={inputTokenSymbol} - isScheduled={isOrderScheduled} - /> - )} - - } - /> - - )} - - )} + + {/* Children (e.g. ToggleExpandButton for parent orders) */} + {children} + {/* Action content menu */} { - return calculatePriceDifference({ referencePrice: spotPrice, targetPrice: estimatedExecutionPrice, isInverted }) - }, [estimatedExecutionPrice, spotPrice, isInverted]) + if (!spotPrice || !estimatedExecutionPrice) return null + + // Calculate price difference using original (non-inverted) prices + // The percentage should stay the same regardless of display inversion + return calculatePriceDifference({ + referencePrice: spotPrice, + targetPrice: estimatedExecutionPrice, + isInverted: false, + }) + }, [estimatedExecutionPrice, spotPrice]) // Remove isInverted from dependencies since it shouldn't affect the calculation } /** diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/styled.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/styled.tsx index 3e137e4363..fde0b5b2ea 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/styled.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/styled.tsx @@ -96,23 +96,22 @@ export const AmountItem = styled.div` export const CellElement = styled.div<{ clickable?: boolean doubleRow?: boolean - hasBackground?: boolean }>` - padding: 0 ${({ hasBackground }) => (hasBackground ? '10px' : '0')}; + padding: 0; font-size: 12px; font-weight: 500; gap: 5px; height: 100%; display: flex; - flex-direction: row; + flex-flow: row nowrap; align-items: ${({ doubleRow }) => (doubleRow ? 'flex-start' : 'center')}; text-align: left; - background: ${({ hasBackground }) => - hasBackground ? `linear-gradient(90deg, var(${UI.COLOR_TEXT_OPACITY_10}) 0%, transparent 100%)` : 'transparent'}; cursor: ${({ clickable }) => (clickable ? 'pointer' : '')}; > b { font-weight: 500; + width: 100%; + text-align: left; } ${({ doubleRow }) => @@ -177,24 +176,20 @@ export const CurrencyAmountWrapper = styled.div<{ clickable?: boolean }>` ` export const ProgressBarWrapper = styled.div` - width: 100%; - max-width: 50%; + flex: 1; align-items: center; flex-flow: row nowrap; gap: 8px; - flex-direction: row-reverse; padding: 0; font-size: 12px; font-weight: 500; height: 100%; + width: 100%; + max-width: 190px; display: flex; text-align: left; background: transparent; - justify-content: center; - - > b { - line-height: 1; - } + justify-content: flex-start; ` export const ProgressBar = styled.div<{ value: string }>` @@ -215,6 +210,14 @@ export const ProgressBar = styled.div<{ value: string }>` } ` +export const FilledPercentageContainer = styled.div` + display: grid; + grid-template-columns: 50px 36px; + gap: 4px; + align-items: center; + width: 100%; +` + export const ExecuteCellWrapper = styled.div` width: 100%; display: flex; @@ -327,3 +330,7 @@ export const ToggleExpandButton = styled.div<{ isCollapsed?: boolean }>` transform: ${({ isCollapsed }) => (isCollapsed ? 'rotate(90deg)' : 'rotate(0deg)')}; } ` + +export const DistanceToMarket = styled.span<{ $color: string }>` + color: ${({ $color }: { $color: string }) => $color}; +` diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTable.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTable.tsx index d2051d80d7..a34144ee2c 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTable.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTable.tsx @@ -1,32 +1,25 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import iconOrderExecution from '@cowprotocol/assets/cow-swap/orderExecution.svg' import { SupportedChainId } from '@cowprotocol/cow-sdk' -import { Media, QuestionTooltipIconWrapper, UI } from '@cowprotocol/ui' -import { HelpTooltip } from '@cowprotocol/ui' +import { Media, UI } from '@cowprotocol/ui' import { Currency, Price } from '@uniswap/sdk-core' -import { Trans } from '@lingui/macro' -import { X } from 'react-feather' -import SVG from 'react-inlinesvg' import styled from 'styled-components/macro' import { PendingOrdersPrices } from 'modules/orders/state/pendingOrdersPricesAtom' import { SpotPricesKeyParams } from 'modules/orders/state/spotPricesAtom' import { BalancesAndAllowances } from 'modules/tokens' -import { ordersTableFeatures } from 'common/constants/featureFlags' -import { OrderExecutionStatusList, RateTooltipHeader } from 'common/pure/OrderExecutionStatusList' -import { InvertRateControl } from 'common/pure/RateInfo' import { CancellableOrder } from 'common/utils/isOrderCancellable' import { isOrderOffChainCancellable } from 'common/utils/isOrderOffChainCancellable' import { OrderRow } from './OrderRow' import { CheckboxCheckmark, TableHeader, TableRowCheckbox, TableRowCheckboxWrapper } from './styled' import { TableGroup } from './TableGroup' +import { ColumnLayout, createTableHeaders } from './tableHeaders' import { OrderActions } from './types' -import { ORDERS_TABLE_PAGE_SIZE } from '../../const/tabs' +import { HISTORY_TAB, ORDERS_TABLE_PAGE_SIZE } from '../../const/tabs' import { useGetBuildOrdersTableUrl } from '../../hooks/useGetBuildOrdersTableUrl' import { getOrderParams } from '../../utils/getOrderParams' import { @@ -41,11 +34,9 @@ import { OrdersTablePagination } from '../OrdersTablePagination' const TableBox = styled.div` display: block; - border-radius: 16px; border: none; padding: 0; position: relative; - overflow: hidden; background: var(${UI.COLOR_PAPER}); ${Media.upToLargeAlt()} { @@ -65,16 +56,14 @@ const TableInner = styled.div` ${({ theme }) => theme.colorScrollbar}; ` -const HeaderElement = styled.div<{ doubleRow?: boolean; hasBackground?: boolean }>` +const HeaderElement = styled.div<{ doubleRow?: boolean }>` height: 100%; - padding: 0 ${({ hasBackground }) => (hasBackground ? '10px' : '0')}; + padding: 0; font-size: 12px; line-height: 1.1; font-weight: 500; display: flex; align-items: ${({ doubleRow }) => (doubleRow ? 'flex-start' : 'center')}; - background: ${({ hasBackground }) => - hasBackground ? `linear-gradient(90deg, var(${UI.COLOR_TEXT_OPACITY_10}) 0%, transparent 100%)` : 'transparent'}; > span { display: flex; @@ -93,26 +82,6 @@ const HeaderElement = styled.div<{ doubleRow?: boolean; hasBackground?: boolean opacity: 0.7; } `} - - ${QuestionTooltipIconWrapper} { - opacity: 0.5; - transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; - - &:hover { - opacity: 1; - } - } - - ${({ doubleRow }) => - doubleRow && - ` - flex-flow: column wrap; - gap: 2px; - - > i { - opacity: 0.7; - } - `} ` const Rows = styled.div` @@ -125,71 +94,8 @@ const Rows = styled.div` } ` -const StyledInvertRateControl = styled(InvertRateControl)` - display: inline-flex; - margin-left: 5px; -` - -const StyledCloseIcon = styled(X)` - height: 24px; - width: 24px; - opacity: 0.6; - transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; - - &:hover { - cursor: pointer; - opacity: 1; - } - - > line { - stroke: var(${UI.COLOR_TEXT}); - } -` - -const OrdersExplainerBanner = styled.div` - display: grid; - background: ${`linear-gradient(90deg, var(${UI.COLOR_PAPER}) 0%, var(${UI.COLOR_PAPER_DARKER}) 100%)`}; - width: 100%; - gap: 16px; - grid-template-columns: 6.2fr 5.5fr 24px; - grid-template-rows: minmax(90px, 1fr); - align-items: center; - border-top: 1px solid transparent; - border-bottom: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); - padding: 0 16px; - color: inherit; - - ${Media.upToLargeAlt()} { - width: fit-content; - grid-template-columns: minmax(462px, 4fr) minmax(426px, 3.8fr) 24px; - } - - /* 1st section */ - > div { - display: flex; - align-items: center; - gap: 12px; - color: inherit; - - > svg > path { - fill: currentColor; - } - - > b { - font-size: 18px; - font-weight: 500; - } - } - - /* 2nd section */ - > span { - display: flex; - flex-flow: column wrap; - } -` - export interface OrdersTableProps { - isOpenOrdersTab: boolean + currentTab: string allowsOffchainSigning: boolean currentPageNumber: number chainId: SupportedChainId @@ -199,10 +105,11 @@ export interface OrdersTableProps { balancesAndAllowances: BalancesAndAllowances getSpotPrice: (params: SpotPricesKeyParams) => Price | null orderActions: OrderActions + columnLayout?: ColumnLayout } export function OrdersTable({ - isOpenOrdersTab, + currentTab, selectedOrders, allowsOffchainSigning, chainId, @@ -212,9 +119,10 @@ export function OrdersTable({ getSpotPrice, orderActions, currentPageNumber, + columnLayout, }: OrdersTableProps) { const buildOrdersTableUrl = useGetBuildOrdersTableUrl() - const [isRateInverted, setIsRateInverted] = useState(false) + const [showLimitPrice, setShowLimitPrice] = useState(false) const checkboxRef = useRef(null) const step = currentPageNumber * ORDERS_TABLE_PAGE_SIZE @@ -234,28 +142,12 @@ export function OrdersTable({ return selectedOrders.reduce( (acc, val) => { acc[val.id] = true - return acc }, {} as { [key: string]: true }, ) }, [selectedOrders]) - // Explainer banner for orders - const [showOrdersExplainerBanner, setShowOrdersExplainerBanner] = useState(() => { - const item = localStorage.getItem('showOrdersExplainerBanner') - return item !== null ? item === 'true' : true - }) - - const closeOrdersExplainerBanner = (): void => { - setShowOrdersExplainerBanner(false) - localStorage.setItem('showOrdersExplainerBanner', 'false') - } - - useEffect(() => { - localStorage.setItem('showOrdersExplainerBanner', showOrdersExplainerBanner.toString()) - }, [showOrdersExplainerBanner]) - const cancellableOrders = useMemo( () => ordersPage.filter((item) => isOrderOffChainCancellable(getParsedOrderFromTableItem(item))), [ordersPage], @@ -280,119 +172,63 @@ export function OrdersTable({ checkbox.checked = allOrdersSelected }, [allOrdersSelected, selectedOrders.length]) + const tableHeaders = useMemo( + () => createTableHeaders(showLimitPrice, setShowLimitPrice, columnLayout), + [showLimitPrice, columnLayout], + ) + + const visibleHeaders = useMemo(() => { + const isHistoryTab = currentTab === HISTORY_TAB.id + return tableHeaders.filter((header) => { + // If showInHistory is not defined, show the header in all tabs + if (header.showInHistory === undefined) return true + // Otherwise, show based on the showInHistory value + return header.showInHistory === isHistoryTab + }) + }, [tableHeaders, currentTab]) + return ( <> - - {isRowSelectable && isOpenOrdersTab && ( - - - - orderActions.toggleOrdersForCancellation( - event.target.checked ? tableItemsToOrders(ordersPage) : [], - ) - } - /> - - - - )} - - - Sell → Buy - - - - - Limit price - - setIsRateInverted(!isRateInverted)} /> - - - {isOpenOrdersTab && ordersTableFeatures.DISPLAY_EST_EXECUTION_PRICE && ( - - - - Order executes at } /> - - - - Market price - - - )} - - {isOpenOrdersTab && ( - - - Market price - - - )} - - {isOpenOrdersTab && ( - - - - Executes at } /> - - - - )} - - {!isOpenOrdersTab && ( - - - - Execution price } /> - - - - )} - - {isOpenOrdersTab && ( - - Expiration - - Creation - - - )} - - {/* {!isOpenOrdersTab && ordersTableFeatures.DISPLAY_EXECUTION_TIME && ( - - Execution time - - )} */} - - - Filled - - - - Status - - {/*Cancel order column*/} - + + {visibleHeaders.map((header) => { + if (header.id === 'checkbox' && (!isRowSelectable || currentTab === HISTORY_TAB.id)) { + return null + } - {/* Show explainer modal if user hasn't closed it */} - {isOpenOrdersTab && showOrdersExplainerBanner && ( - -
    - - - How close is my
    order to executing? -
    -
    - {OrderExecutionStatusList()} - -
    - )} + if (header.id === 'checkbox') { + return ( + + + + orderActions.toggleOrdersForCancellation( + event.target.checked ? tableItemsToOrders(ordersPage) : [], + ) + } + /> + + + + ) + } + + return ( + + {header.content} + {header.extraComponent} + + ) + })} +
    {ordersPage.map((item) => { @@ -406,21 +242,21 @@ export function OrdersTable({ if (isParsedOrder(item)) { const order = item - const orderParams = getOrderParams(chainId, balancesAndAllowances, order) - return ( orderActions.selectReceiptOrder(order)} + orderActions={orderActions} + columnLayout={columnLayout} /> ) } else { @@ -432,10 +268,11 @@ export function OrdersTable({ key={item.parent.id} isRowSelectable={isRowSelectable} isRowSelected={!!selectedOrdersMap[item.parent.id]} - isOpenOrdersTab={isOpenOrdersTab} + isHistoryTab={currentTab === HISTORY_TAB.id} spotPrice={spotPrice} prices={pendingOrdersPrices[item.parent.id]} - isRateInverted={isRateInverted} + isRateInverted={false} + showLimitPrice={showLimitPrice} orderActions={orderActions} /> ) diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTabs.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTabs.tsx index f746ab82e4..ce129d5d03 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTabs.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTabs.tsx @@ -8,18 +8,10 @@ import { OrderTab } from '../../const/tabs' import { useGetBuildOrdersTableUrl } from '../../hooks/useGetBuildOrdersTableUrl' const Tabs = styled.div` - display: inline-block; - border-radius: 9px; - overflow: hidden; + display: flex; + flex-flow: row wrap; + gap: 4px; margin: 0; - border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); - - ${Media.upToMedium()} { - width: 100%; - display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: max-content; - } ` const TabButton = styled(Link)<{ active: string }>` @@ -27,13 +19,17 @@ const TabButton = styled(Link)<{ active: string }>` background: ${({ active }) => (active === 'true' ? `var(${UI.COLOR_TEXT_OPACITY_10})` : 'transparent')}; color: ${({ active }) => (active === 'true' ? `var(${UI.COLOR_TEXT_PAPER})` : 'inherit')}; font-weight: ${({ active }) => (active === 'true' ? '600' : '400')}; + border-radius: 14px; + border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); text-decoration: none; font-size: 13px; - padding: 10px 24px; + padding: 10px; border: 0; outline: none; cursor: pointer; - transition: background var(${UI.ANIMATION_DURATION}) ease-in-out, color var(${UI.ANIMATION_DURATION}) ease-in-out; + transition: + background var(${UI.ANIMATION_DURATION}) ease-in-out, + color var(${UI.ANIMATION_DURATION}) ease-in-out; ${Media.upToMedium()} { text-align: center; @@ -54,7 +50,7 @@ export function OrdersTabs({ tabs }: OrdersTabsProps) { const buildOrdersTableUrl = useGetBuildOrdersTableUrl() const activeTabIndex = Math.max( tabs.findIndex((i) => i.isActive), - 0 + 0, ) return ( diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/TableGroup.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/TableGroup.tsx index 8f582d5164..d545967624 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/TableGroup.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/TableGroup.tsx @@ -33,7 +33,8 @@ export interface TableGroupProps { prices: PendingOrderPrices | undefined | null spotPrice: Price | undefined | null isRateInverted: boolean - isOpenOrdersTab: boolean + showLimitPrice: boolean + isHistoryTab: boolean isRowSelectable: boolean isRowSelected: boolean orderActions: OrderActions @@ -47,7 +48,8 @@ export function TableGroup(props: TableGroupProps) { prices, spotPrice, isRateInverted, - isOpenOrdersTab, + showLimitPrice, + isHistoryTab, isRowSelectable, isRowSelected, orderActions, @@ -68,10 +70,11 @@ export function TableGroup(props: TableGroupProps) { const commonProps = { isRowSelectable, - isOpenOrdersTab, + isHistoryTab, spotPrice, prices, isRateInverted, + showLimitPrice, orderActions, } @@ -111,7 +114,6 @@ export function TableGroup(props: TableGroupProps) { /> ))} {/* Only show pagination if more than 1 page available */} - {/*TODO: add styled to the paginator*/} {childrenLength > ORDERS_TABLE_PAGE_SIZE && ( (theme.isInjectedWidgetMode ? `var(${UI.COLOR_PAPER})` : 'transparent')}; - color: inherit; - border: none; - border-radius: var(${UI.BORDER_RADIUS_NORMAL}); - box-shadow: none; - position: relative; - padding: ${({ theme }) => (theme.isInjectedWidgetMode ? '16px' : '0')}; - padding: ${({ theme }) => (theme.isInjectedWidgetMode ? '16px' : '0')}; - min-height: 200px; +const Wrapper = styled.div` + display: flex; + flex-flow: column wrap; + gap: 16px; width: 100%; - margin: 0 0 76px; ` const Content = styled.div` @@ -36,8 +32,6 @@ const Content = styled.div` flex-flow: column wrap; align-items: center; justify-content: center; - border-radius: 16px; - border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); color: inherit; min-height: 490px; padding: 0; @@ -113,14 +107,12 @@ const MeditatingCowImg = styled.img` padding: 16px; ` -const Header = styled.div` - display: grid; - grid-template-columns: 150px 1fr; - grid-template-rows: max-content; +const TopContainer = styled.div` + display: flex; align-items: center; - gap: 3px; + justify-content: space-between; width: 100%; - margin: 0 0 24px; + gap: 3px; ${Media.upToMedium()} { display: block; @@ -140,7 +132,8 @@ const Header = styled.div` const TabsContainer = styled.div<{ withSingleChild: boolean }>` display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; + width: 100%; ${Media.upToMedium()} { ${({ withSingleChild }) => @@ -168,15 +161,32 @@ const ExternalArrow = styled.span` font-size: 11px; } ` -interface OrdersProps extends OrdersTabsProps, OrdersTableProps { + +const RightContainer = styled.div` + display: flex; + flex-flow: row wrap; +` + +interface OrdersProps { isWalletConnected: boolean - isOpenOrdersTab: boolean isSafeViaWc: boolean displayOrdersOnlyForSafeApp: boolean pendingActivities: string[] children?: ReactNode orderType: TabOrderTypes injectedWidgetParams: Partial + tabs: Array<{ id: string; title: string; count: number; isActive?: boolean }> + chainId: number + orders: any[] + selectedOrders: any[] + allowsOffchainSigning: boolean + balancesAndAllowances: any + orderActions: OrderActions + currentPageNumber: number + pendingOrdersPrices: any + getSpotPrice: any + searchTerm?: string + columnLayout?: ColumnLayout } export function OrdersTableContainer({ @@ -187,7 +197,6 @@ export function OrdersTableContainer({ isSafeViaWc, displayOrdersOnlyForSafeApp, selectedOrders, - isOpenOrdersTab, allowsOffchainSigning, balancesAndAllowances, orderActions, @@ -198,7 +207,14 @@ export function OrdersTableContainer({ orderType, pendingActivities, injectedWidgetParams, + searchTerm, + columnLayout, }: OrdersProps) { + const currentTab = useMemo(() => { + const activeTab = tabs.find((tab) => tab.isActive) + return activeTab?.id || ALL_ORDERS_TAB.id + }, [tabs]) + const content = () => { const emptyOrdersImage = injectedWidgetParams.images?.emptyOrders @@ -238,23 +254,44 @@ export function OrdersTableContainer({ )}

    - {isOpenOrdersTab ? 'No open orders' : 'No orders history'} + + {searchTerm + ? 'No matching orders found' + : currentTab === ALL_ORDERS_TAB.id + ? 'No orders' + : currentTab === UNFILLABLE_TAB.id + ? 'No unfillable orders' + : currentTab === OPEN_TAB.id + ? 'No open orders' + : 'No order history'} +

    {displayOrdersOnlyForSafeApp && isSafeViaWc ? ( - Use the to see {isOpenOrdersTab ? 'open orders' : 'orders history'} + Use the to see {currentTab === HISTORY_TAB.id ? 'orders history' : 'your orders'} + ) : searchTerm ? ( + Try adjusting your search term or clearing the filter ) : ( <> - You don't have any {isOpenOrdersTab ? 'open' : ''} orders at the moment.
    - Time to create a new one! {/* TODO: add link for Advanced orders also */} - {orderType === TabOrderTypes.LIMIT ? ( - - Learn more - - - ) : null} + + You don't have any{' '} + {currentTab === UNFILLABLE_TAB.id ? 'unfillable' : currentTab === OPEN_TAB.id ? 'open' : ''} orders at + the moment. + {' '} + {(currentTab === OPEN_TAB.id || currentTab === ALL_ORDERS_TAB.id) && ( + <> +
    + Time to create a new one!{' '} + {orderType === TabOrderTypes.LIMIT ? ( + + Learn more + + + ) : null} + + )} )}

    @@ -264,31 +301,30 @@ export function OrdersTableContainer({ return ( ) } return ( - -
    -

    Your Orders

    + + - {children ||
    } + {children && {children}}
    -
    - + {content()} -
    + ) } diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/styled.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/styled.tsx index f09d90c309..cd161df303 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/styled.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/styled.tsx @@ -1,35 +1,107 @@ import { Media, UI } from '@cowprotocol/ui' import { transparentize } from 'color2k' -import styled, { css } from 'styled-components/macro' +import styled from 'styled-components/macro' import { RateWrapper } from 'common/pure/RateInfo' -export const TableHeader = styled.div<{ isOpenOrdersTab: boolean; isRowSelectable: boolean }>` - --height: 50px; +import { ColumnLayout } from './tableHeaders' + +export const SettingsContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-right: 16px; +` + +export const SettingsSelect = styled.select` + background: var(${UI.COLOR_PAPER_DARKER}); + color: inherit; + border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); + border-radius: 8px; + padding: 8px 12px; + font-size: 13px; + cursor: pointer; + + &:focus { + outline: none; + border-color: var(${UI.COLOR_TEXT}); + } +` + +export const SettingsLabel = styled.span` + font-size: 13px; + color: inherit; + opacity: 0.7; +` + +export const LayoutSelector = styled.select` + background: var(${UI.COLOR_PAPER_DARKER}); + color: inherit; + border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); + border-radius: 8px; + padding: 4px 8px; + font-size: 12px; + cursor: pointer; + margin-left: 8px; + + &:focus { + outline: none; + border-color: var(${UI.COLOR_TEXT}); + } +` + +export const TableWrapper = styled.div` + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ + + ${Media.upToSmall()} { + margin: 0 -12px; /* Negative margin to allow full-width scrolling */ + padding: 0 12px; + width: calc(100% + 24px); + } +` + +export const TableHeader = styled.div<{ isHistoryTab: boolean; isRowSelectable: boolean; columnLayout?: ColumnLayout }>` + --header-height: 26px; + --row-height: 41px; --checkboxSize: 16px; --checkBoxBorderRadius: 3px; display: grid; gap: 14px; - grid-template-columns: ${({ isOpenOrdersTab, isRowSelectable }) => - `${isRowSelectable && isOpenOrdersTab ? 'var(--checkboxSize) 3fr' : '3.2fr'} repeat(2,2fr) ${ - isOpenOrdersTab ? '2.5fr 1.4fr' : '' - } 0.7fr 108px 24px`}; - grid-template-rows: minmax(var(--height), 1fr); + grid-template-columns: ${({ isHistoryTab, isRowSelectable, columnLayout }) => { + if (isHistoryTab) { + return `minmax(200px, 2.5fr) + repeat(4, minmax(110px, 1fr)) + minmax(80px, 0.8fr) + minmax(100px, 1fr) + 24px` + } + + const checkboxColumn = isRowSelectable ? 'var(--checkboxSize)' : '' + const baseColumns = `${checkboxColumn}` + + switch (columnLayout) { + case ColumnLayout.VIEW_2: + return `${baseColumns} minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) 60px minmax(120px,1fr) minmax(80px,90px) minmax(80px,0.8fr) 24px` + case ColumnLayout.VIEW_3: + return `${baseColumns} minmax(160px,2fr) minmax(120px,1fr) minmax(140px,1fr) minmax(120px,1fr) minmax(120px,1fr) minmax(80px,90px) minmax(80px,0.8fr) 24px` + default: + return `${baseColumns} minmax(200px, 2.5fr) minmax(140px,1fr) 60px minmax(110px,1fr) minmax(110px,1fr) minmax(80px,90px) minmax(80px,0.8fr) 24px` + } + }}; + grid-template-rows: minmax(var(--header-height), 1fr); align-items: center; border: none; - border-bottom: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); - padding: 0 12px; - - ${Media.upToLargeAlt()} { - ${({ isRowSelectable, isOpenOrdersTab }) => css` - grid-template-columns: ${`${ - isRowSelectable && isOpenOrdersTab ? 'var(--checkboxSize) minmax(200px,2fr)' : 'minmax(200px,2fr)' - } repeat(2,minmax(110px,2fr)) ${ - isOpenOrdersTab ? 'minmax(140px,2.2fr) minmax(100px,1fr)' : '' - } minmax(50px,1fr) 108px 24px`}; - `} - } + padding: 5px 12px; + background: var(${UI.COLOR_PAPER_DARKER}); + border-top: none; + border-right: none; + border-left: none; + border-image: initial; + border-bottom: 1px solid var(--cow-color-text-opacity-10); + min-width: 888px; /* Minimum width to prevent too much squeezing */ ${Media.upToSmall()} { --checkboxSize: 24px; @@ -37,7 +109,13 @@ export const TableHeader = styled.div<{ isOpenOrdersTab: boolean; isRowSelectabl } ` -export const TableRow = styled(TableHeader)<{ isChildOrder?: boolean }>` +export const TableRow = styled(TableHeader)<{ + isChildOrder?: boolean + isHistoryTab: boolean + isRowSelectable: boolean + columnLayout?: ColumnLayout +}>` + grid-template-rows: minmax(var(--row-height), 1fr); background: ${({ isChildOrder }) => (isChildOrder ? `var(${UI.COLOR_PAPER_DARKER})` : 'transparent')}; transition: background var(${UI.ANIMATION_DURATION}) ease-in-out; display: grid; @@ -46,17 +124,6 @@ export const TableRow = styled(TableHeader)<{ isChildOrder?: boolean }>` background: var(${UI.COLOR_PAPER_DARKER}); } - > div:first-child { - margin: 0; - - &::before { - display: ${({ isChildOrder }) => (isChildOrder ? 'inline-block' : 'none')}; - color: ${({ theme }) => transparentize(theme.text, 0.5)}; - content: '↳'; - text-decoration: none !important; - } - } - > div:first-child { margin-left: ${({ isChildOrder }) => (isChildOrder ? '5px' : '')}; @@ -118,26 +185,32 @@ export const TableRowCheckbox = styled.input` background: transparent; border: 2px solid var(${UI.COLOR_TEXT}); border-radius: var(--checkBoxBorderRadius); - transition: background var(${UI.ANIMATION_DURATION}) ease-in-out, opacity var(${UI.ANIMATION_DURATION}) ease-in-out, + transition: + background var(${UI.ANIMATION_DURATION}) ease-in-out, + opacity var(${UI.ANIMATION_DURATION}) ease-in-out, border-color var(${UI.ANIMATION_DURATION}) ease-in-out; appearance: none; margin: 0; outline: 0; opacity: 0.5; + z-index: 5; &:checked { border-color: var(${UI.COLOR_PRIMARY}); background: var(${UI.COLOR_PRIMARY}); opacity: 1; + z-index: 6; } &:checked + ${CheckboxCheckmark}::after { display: block; + z-index: 6; } &:indeterminate { background: var(${UI.COLOR_PRIMARY}); border-color: var(${UI.COLOR_PRIMARY}); + z-index: 6; } &:indeterminate + ${CheckboxCheckmark}::after { @@ -146,15 +219,18 @@ export const TableRowCheckbox = styled.input` border-width: 2px 0 0 0; top: calc(50% + 3px); transform: none; + z-index: 6; ${Media.upToSmall()} { top: calc(50% + 4px); } } - &[disabled], - &[disabled] + ${CheckboxCheckmark} { - cursor: default; + &:disabled { + cursor: not-allowed; + opacity: 0.1; + background: var(${UI.COLOR_TEXT}); + z-index: 6; } ` @@ -171,10 +247,12 @@ export const TableRowCheckboxWrapper = styled.label` background: var(${UI.COLOR_PRIMARY}); border-color: var(${UI.COLOR_PRIMARY}); opacity: 0.5; + z-index: 6; + ${CheckboxCheckmark}::after { display: block; border-color: var(${UI.COLOR_BUTTON_TEXT}); + z-index: 6; } } ` diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/tableHeaders.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/tableHeaders.tsx new file mode 100644 index 0000000000..11a148ce3a --- /dev/null +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/tableHeaders.tsx @@ -0,0 +1,237 @@ +import { ReactNode } from 'react' + +import { Trans } from '@lingui/macro' +import { Repeat } from 'react-feather' +import styled from 'styled-components/macro' + +export enum ColumnLayout { + DEFAULT = 'DEFAULT', + VIEW_2 = 'VIEW_2', + VIEW_3 = 'VIEW_3', +} + +export const LAYOUT_MAP: Record = { + DEFAULT: ColumnLayout.DEFAULT, + VIEW_2: ColumnLayout.VIEW_2, + VIEW_3: ColumnLayout.VIEW_3, +} as const + +const StyledArrowControl = styled.div` + display: inline-flex; + margin-left: 5px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s ease-in-out; + + &:hover { + opacity: 1; + } + + > svg { + width: 14px; + height: 14px; + } +` + +const HeaderElement = styled.div<{ doubleRow?: boolean }>` + display: flex; + flex-direction: column; + gap: 3px; + + i { + font-style: normal; + opacity: 0.7; + font-size: 0.85em; + } +` + +export interface TableHeaderConfig { + id: string + content: ReactNode + doubleRow?: boolean + showInHistory?: boolean + width?: string + extraComponent?: ReactNode + order: number +} + +// Core columns that appear in all views +const CORE_COLUMNS = { + CHECKBOX: { + id: 'checkbox', + content: null, + order: 1, + }, + TRADE: { + id: 'trade', + content: Sell → Buy, + order: 2, + }, +} + +// Price-related columns for different layouts +const PRICE_COLUMNS = { + DEFAULT: (showLimitPrice: boolean, setShowLimitPrice: (value: boolean) => void): TableHeaderConfig[] => [ + { + id: 'fillsAt', + content: showLimitPrice ? Limit price : Fills at, + showInHistory: false, + order: 3, + extraComponent: ( + setShowLimitPrice(!showLimitPrice)}> + + + ), + }, + { + id: 'distanceToMarket', + content: ( + + Distance
    + to market +
    + ), + showInHistory: false, + order: 4, + }, + { + id: 'marketPrice', + content: Market price, + showInHistory: false, + order: 5, + }, + ], + VIEW_2: (): TableHeaderConfig[] => [ + { + id: 'limitPrice', + content: Limit price, + showInHistory: false, + order: 3, + }, + { + id: 'fillsAt', + content: Fills at, + showInHistory: false, + order: 4, + }, + { + id: 'distanceToMarket', + content: ( + + Distance
    + to market +
    + ), + showInHistory: false, + order: 5, + }, + ], + VIEW_3: (): TableHeaderConfig[] => [ + { + id: 'limitPrice', + content: Limit price, + showInHistory: false, + order: 3, + }, + { + id: 'fillsAtWithDistance', + content: ( + + Fills at + + Distance to market + + + ), + showInHistory: false, + doubleRow: true, + order: 4, + }, + { + id: 'marketPrice', + content: Market price, + showInHistory: false, + order: 5, + }, + ], +} + +// Columns that appear after price columns +const DETAIL_COLUMNS: TableHeaderConfig[] = [ + { + id: 'limitPrice', + content: Limit price, + showInHistory: true, + order: 6, + }, + { + id: 'expiration', + content: ( + + Expiration + + Creation + + + ), + showInHistory: false, + doubleRow: true, + order: 7, + }, + { + id: 'executionPrice', + content: Execution price, + showInHistory: true, + order: 8, + }, + { + id: 'executionTime', + content: Execution time, + showInHistory: true, + order: 9, + }, + { + id: 'creationTime', + content: Creation time, + showInHistory: true, + order: 10, + }, + { + id: 'filled', + content: Filled, + order: 11, + }, + { + id: 'status', + content: Status, + order: 12, + }, + { + id: 'actions', + content: null, + order: 13, + }, +] + +export const createTableHeaders = ( + showLimitPrice: boolean, + setShowLimitPrice: (value: boolean) => void, + columnLayout: ColumnLayout = ColumnLayout.DEFAULT, +): TableHeaderConfig[] => { + // Get the appropriate price columns based on layout + const priceColumns = (() => { + switch (columnLayout) { + case ColumnLayout.VIEW_2: + return PRICE_COLUMNS.VIEW_2() + case ColumnLayout.VIEW_3: + return PRICE_COLUMNS.VIEW_3() + default: + return PRICE_COLUMNS.DEFAULT(showLimitPrice, setShowLimitPrice) + } + })() + + // Combine all columns and sort by order + return [CORE_COLUMNS.CHECKBOX, CORE_COLUMNS.TRADE, ...priceColumns, ...DETAIL_COLUMNS].sort( + (a, b) => a.order - b.order, + ) +} diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/FilledField.tsx b/apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/FilledField.tsx index 767dfd7d01..7addde60e4 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/FilledField.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/FilledField.tsx @@ -23,10 +23,10 @@ export function FilledField({ order }: Props) { + - diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/styled.ts b/apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/styled.ts index 392f8680b1..0051874ba3 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/styled.ts @@ -193,7 +193,8 @@ export const LightButton = styled.button` padding: 6px 14px; border-radius: var(${UI.BORDER_RADIUS_NORMAL}); background-color: var(${UI.COLOR_PAPER_DARKER}); - transition: border var(${UI.ANIMATION_DURATION}) ease-in-out, + transition: + border var(${UI.ANIMATION_DURATION}) ease-in-out, background-color var(${UI.ANIMATION_DURATION}) ease-in-out; cursor: pointer; color: inherit; diff --git a/apps/cowswap-frontend/src/modules/trade/const/common.ts b/apps/cowswap-frontend/src/modules/trade/const/common.ts index 503f40f539..851ff6d027 100644 --- a/apps/cowswap-frontend/src/modules/trade/const/common.ts +++ b/apps/cowswap-frontend/src/modules/trade/const/common.ts @@ -8,3 +8,11 @@ export const TradeTypeToUiOrderType: Record = { [TradeType.ADVANCED_ORDERS]: UiOrderType.TWAP, [TradeType.YIELD]: UiOrderType.YIELD, } + +export const ORDERS_TABLE_SETTINGS = { + LEFT_ALIGNED: { + title: 'Desktop: Left-Aligned Orders Table', + tooltip: + 'When enabled, the orders table will be displayed on the left side on desktop screens. On mobile, the orders table will always be stacked below.', + }, +} as const diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx index 6866a903ba..a2f3738b46 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx @@ -193,6 +193,7 @@ export function TradeWidgetForm(props: TradeWidgetProps) { /> {!isWrapOrUnwrap && middleContent} + + {slots.limitPriceInput} + {withRecipient && } {isWrapOrUnwrap ? ( diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/types.ts b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/types.ts index 0cf4606b84..f9fe79e55b 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/types.ts +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/types.ts @@ -38,6 +38,7 @@ export interface TradeWidgetSlots { lockScreen?: ReactNode topContent?: ReactNode middleContent?: ReactNode + limitPriceInput?: ReactNode bottomContent?(warnings: ReactNode | null): ReactNode outerContent?: ReactNode updaters?: ReactNode diff --git a/apps/cowswap-frontend/src/modules/trade/pure/Settings/SettingsIcon.tsx b/apps/cowswap-frontend/src/modules/trade/pure/Settings/SettingsIcon.tsx index a3a5694823..007cf8e99c 100644 --- a/apps/cowswap-frontend/src/modules/trade/pure/Settings/SettingsIcon.tsx +++ b/apps/cowswap-frontend/src/modules/trade/pure/Settings/SettingsIcon.tsx @@ -1,23 +1,13 @@ import IMAGE_ICON_SETTINGS_ALT from '@cowprotocol/assets/images/icon-settings-alt.svg' -import { UI } from '@cowprotocol/ui' import SVG from 'react-inlinesvg' -import styled from 'styled-components/macro' -const StyledMenuIcon = styled.span` - height: var(${UI.ICON_SIZE_NORMAL}); - width: var(${UI.ICON_SIZE_NORMAL}); - opacity: 0.6; - - &:hover { - opacity: 1; - } -` +import { SettingsButtonIcon } from './styled' export function SettingsIcon() { return ( - + - + ) } diff --git a/apps/cowswap-frontend/src/modules/trade/pure/Settings/styled.ts b/apps/cowswap-frontend/src/modules/trade/pure/Settings/styled.ts index 8ae70bc993..69c680c9c2 100644 --- a/apps/cowswap-frontend/src/modules/trade/pure/Settings/styled.ts +++ b/apps/cowswap-frontend/src/modules/trade/pure/Settings/styled.ts @@ -1,38 +1,50 @@ import { UI } from '@cowprotocol/ui' import { MenuButton, MenuList } from '@reach/menu-button' -import { transparentize } from 'color2k' -import styled from 'styled-components/macro' - -export const SettingsTitle = styled.h3` - font-weight: 600; - font-size: 14px; - color: inherit; - margin: 0 0 12px 0; -` +import styled, { css } from 'styled-components/macro' export const SettingsContainer = styled.div` - margin: 12px 0 0; - padding: 16px; + --padding: 10px; + display: flex; + flex-direction: column; + margin: 0; + padding: var(--padding); border-radius: 12px; - box-shadow: ${({ theme }) => theme.boxShadow2}; - border: 1px solid ${({ theme }) => transparentize(theme.white, 0.95)}; + box-shadow: var(${UI.BOX_SHADOW_2}); + border: 1px solid var(${UI.COLOR_PAPER_DARKER}); background: var(${UI.COLOR_PAPER}); color: inherit; + gap: 10px; + position: absolute; + right: 9px; + margin: auto; + top: 48px; + z-index: 100; + min-width: 330px; + max-width: calc(100% - var(--padding) * 2); +` + +export const SettingsTitle = styled.h3` + font-weight: 600; + font-size: 15px; + color: inherit; + width: 100%; + text-align: left; + margin: 0; ` export const SettingsBoxWrapper = styled.div<{ disabled: boolean }>` display: flex; justify-content: space-between; - margin: 0 0 10px; + margin: 0; + gap: 10px; color: inherit; + opacity: ${({ disabled }) => (disabled ? '0.7' : '1')}; + pointer-events: ${({ disabled }) => (disabled ? 'none' : '')}; :last-child { margin-bottom: 0; } - - opacity: ${({ disabled }) => (disabled ? '0.7' : '1')}; - pointer-events: ${({ disabled }) => (disabled ? 'none' : '')}; ` export const SettingsBoxTitle = styled.div` @@ -41,8 +53,6 @@ export const SettingsBoxTitle = styled.div` font-weight: 400; color: inherit; font-size: 14px; - opacity: 0.85; - margin-right: 2rem; ` export const SettingsButton = styled(MenuButton)` @@ -56,8 +66,63 @@ export const SettingsButton = styled(MenuButton)` cursor: pointer; ` +const iconButtonStyles = css<{ active?: boolean; iconSize?: string }>` + --maxSize: 28px; + --iconSize: ${({ iconSize }) => iconSize || `var(${UI.ICON_SIZE_NORMAL})`}; + background: ${({ active }) => (active ? `var(${UI.COLOR_PAPER_DARKER})` : 'none')}; + border: none; + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(${UI.COLOR_TEXT}); + opacity: ${({ active }) => (active ? '1' : '0.6')}; + transition: all var(${UI.ANIMATION_DURATION}) ease-in-out; + border-radius: 8px; + max-width: var(--maxSize); + max-height: var(--maxSize); + width: var(--maxSize); + height: var(--maxSize); + + &:hover { + opacity: 1; + background: var(${UI.COLOR_PAPER_DARKER}); + } + + > svg { + width: var(--iconSize); + height: var(--iconSize); + color: inherit; + object-fit: contain; + } +` + +export const SettingsButtonIcon = styled.span<{ active?: boolean; iconSize?: string }>` + ${iconButtonStyles} + --iconSize: 18px; + margin: auto; +` + export const MenuContent = styled(MenuList)` position: relative; z-index: 2; color: var(${UI.COLOR_TEXT_PAPER}); ` + +export const ButtonsContainer = styled.div` + display: flex; + gap: 4px; + + [data-reach-menu-popover] { + position: absolute; + width: 100%; + left: 0; + top: 0; + } +` + +export const UsdButton = styled.button<{ active?: boolean }>` + ${iconButtonStyles} + --iconSize: 20px; +` diff --git a/apps/cowswap-frontend/src/modules/trade/pure/TradePageLayout/index.tsx b/apps/cowswap-frontend/src/modules/trade/pure/TradePageLayout/index.tsx index b269c147ba..7e48603ae6 100644 --- a/apps/cowswap-frontend/src/modules/trade/pure/TradePageLayout/index.tsx +++ b/apps/cowswap-frontend/src/modules/trade/pure/TradePageLayout/index.tsx @@ -1,26 +1,36 @@ -import { Media } from '@cowprotocol/ui' +import { Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' import { WIDGET_MAX_WIDTH } from 'theme' -export const PageWrapper = styled.div<{ isUnlocked: boolean }>` +const DEFAULT_MAX_WIDTH = '1500px' + +export const PageWrapper = styled.div<{ isUnlocked: boolean; secondaryOnLeft?: boolean; maxWidth?: string }>` width: 100%; display: grid; - max-width: 1500px; + max-width: ${({ maxWidth = DEFAULT_MAX_WIDTH }) => maxWidth}; margin: 0 auto; - grid-template-columns: ${({ isUnlocked }) => (isUnlocked ? WIDGET_MAX_WIDTH.swap : '')} 1fr; - grid-template-rows: max-content; - grid-column-gap: 20px; + grid-template-columns: 1fr; + grid-template-rows: auto auto; + grid-template-areas: 'primary' 'secondary'; + gap: 20px; - ${Media.upToLarge()} { - display: flex; - flex-flow: column wrap; + ${Media.LargeAndUp()} { + grid-template-columns: ${({ isUnlocked, secondaryOnLeft }) => + isUnlocked + ? secondaryOnLeft + ? '1fr minmax(auto, ' + WIDGET_MAX_WIDTH.swap.replace('px', '') + 'px)' + : 'minmax(auto, ' + WIDGET_MAX_WIDTH.swap.replace('px', '') + 'px) 1fr' + : '1fr'}; + grid-template-rows: 1fr; + grid-template-areas: ${({ secondaryOnLeft }) => (secondaryOnLeft ? '"secondary primary"' : '"primary secondary"')}; } > div:last-child { display: ${({ isUnlocked }) => (isUnlocked ? '' : 'none')}; } ` + // Form + banner export const PrimaryWrapper = styled.div` display: flex; @@ -30,16 +40,29 @@ export const PrimaryWrapper = styled.div` max-width: ${WIDGET_MAX_WIDTH.swap}; margin: 0 auto; color: inherit; + grid-area: primary; ` // Graph + orders table export const SecondaryWrapper = styled.div` display: flex; + flex-flow: column wrap; width: 100%; overflow: hidden; + border-radius: var(${UI.BORDER_RADIUS_NORMAL}); + background: var(${UI.COLOR_PAPER}); + color: inherit; + border: none; + box-shadow: none; + position: relative; + padding: 10px; + min-height: 200px; + width: 100%; + margin: 0 0 76px; + grid-area: secondary; ${Media.upToLargeAlt()} { flex-flow: column wrap; - margin: 56px 0; + margin: 0 0 20px; } ` diff --git a/apps/cowswap-frontend/src/modules/trade/types/TradeRawState.ts b/apps/cowswap-frontend/src/modules/trade/types/TradeRawState.ts index 7e4ab675fd..8094aff7b0 100644 --- a/apps/cowswap-frontend/src/modules/trade/types/TradeRawState.ts +++ b/apps/cowswap-frontend/src/modules/trade/types/TradeRawState.ts @@ -1,4 +1,4 @@ -import { WRAPPED_NATIVE_CURRENCIES as WETH } from '@cowprotocol/common-const' +import { USDC, WRAPPED_NATIVE_CURRENCIES as WETH } from '@cowprotocol/common-const' import { OrderKind, SupportedChainId } from '@cowprotocol/cow-sdk' export interface TradeUrlParams { @@ -30,7 +30,7 @@ export function getDefaultTradeRawState(chainId: SupportedChainId | null): Trade return { chainId, inputCurrencyId: chainId ? WETH[chainId]?.symbol || null : null, - outputCurrencyId: null, + outputCurrencyId: chainId ? USDC[chainId].symbol || null : null, recipient: null, recipientAddress: null, } diff --git a/apps/cowswap-frontend/src/pages/AdvancedOrders/index.tsx b/apps/cowswap-frontend/src/pages/AdvancedOrders/index.tsx index 9443164f31..7c629bf4f4 100644 --- a/apps/cowswap-frontend/src/pages/AdvancedOrders/index.tsx +++ b/apps/cowswap-frontend/src/pages/AdvancedOrders/index.tsx @@ -9,6 +9,7 @@ import { SetupAdvancedOrderAmountsFromUrlUpdater, } from 'modules/advancedOrders' import { useInjectedWidgetParams } from 'modules/injectedWidget' +import { limitOrdersSettingsAtom } from 'modules/limitOrders/state/limitOrdersSettingsAtom' import { OrdersTableWidget, TabOrderTypes } from 'modules/ordersTable' import * as styledEl from 'modules/trade/pure/TradePageLayout' import { @@ -24,8 +25,11 @@ import { } from 'modules/twap' import { TwapFormState } from 'modules/twap/pure/PrimaryActionButton/getTwapFormState' +const ADVANCED_ORDERS_MAX_WIDTH = '1800px' + export default function AdvancedOrdersPage() { const { isUnlocked } = useAtomValue(advancedOrdersAtom) + const { ordersTableOnLeft } = useAtomValue(limitOrdersSettingsAtom) const allEmulatedOrders = useAllEmulatedOrders() const isFallbackHandlerRequired = useIsFallbackHandlerRequired() @@ -43,7 +47,11 @@ export default function AdvancedOrdersPage() { <> - + {isFallbackHandlerRequired && pendingOrders.length > 0 && } + diff --git a/apps/cowswap-frontend/src/utils/orderUtils/parseOrder.ts b/apps/cowswap-frontend/src/utils/orderUtils/parseOrder.ts index f6a3d0b4e2..d85b05113f 100644 --- a/apps/cowswap-frontend/src/utils/orderUtils/parseOrder.ts +++ b/apps/cowswap-frontend/src/utils/orderUtils/parseOrder.ts @@ -49,6 +49,7 @@ export interface ParsedOrder { partiallyFillable: boolean creationTime: Date expirationTime: Date + fulfillmentTime: string | undefined composableCowInfo?: ComposableCowInfo fullAppData: Order['fullAppData'] signingScheme: SigningScheme @@ -66,6 +67,7 @@ export const parseOrder = (order: Order): ParsedOrder => { const executedFeeToken = order.apiAdditionalInfo?.executedFeeToken || null const totalFee = order.apiAdditionalInfo?.totalFee || null const creationTime = new Date(order.creationTime) + const fulfillmentTime = order.fulfillmentTime const fullyFilled = isOrderFilled(order) const partiallyFilled = isPartiallyFilled(order) const filledPercentDisplay = filledPercentage.times(100).toString() @@ -119,6 +121,7 @@ export const parseOrder = (order: Order): ParsedOrder => { receiver: order.receiver || undefined, creationTime, expirationTime, + fulfillmentTime, fullAppData: order.fullAppData, executionData, signingScheme: order.signingScheme, diff --git a/libs/assets/src/images/icon-USD.svg b/libs/assets/src/images/icon-USD.svg new file mode 100644 index 0000000000..eaaacd1af3 --- /dev/null +++ b/libs/assets/src/images/icon-USD.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/assets/src/images/icon-locked.svg b/libs/assets/src/images/icon-locked.svg new file mode 100644 index 0000000000..debfee8ade --- /dev/null +++ b/libs/assets/src/images/icon-locked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/assets/src/images/icon-switch-arrows.svg b/libs/assets/src/images/icon-switch-arrows.svg new file mode 100644 index 0000000000..98b6786c9b --- /dev/null +++ b/libs/assets/src/images/icon-switch-arrows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/assets/src/images/icon-unlocked.svg b/libs/assets/src/images/icon-unlocked.svg new file mode 100644 index 0000000000..7ccd1d2701 --- /dev/null +++ b/libs/assets/src/images/icon-unlocked.svg @@ -0,0 +1 @@ + \ No newline at end of file