diff --git a/apps/cowswap-frontend/src/common/constants/common.ts b/apps/cowswap-frontend/src/common/constants/common.ts index 4834eb4d7f..61519cdff6 100644 --- a/apps/cowswap-frontend/src/common/constants/common.ts +++ b/apps/cowswap-frontend/src/common/constants/common.ts @@ -3,7 +3,12 @@ import { Percent } from '@uniswap/sdk-core' import ms from 'ms.macro' -export const HIGH_FEE_WARNING_PERCENTAGE = new Percent(1, 10) +export const HIGH_FEE_WARNING_PERCENTAGE = new Percent(1, 10) // 10% + +// Price difference thresholds +export const PENDING_EXECUTION_THRESHOLD_PERCENTAGE = 0.01 // 0.01% - threshold for considering an order close enough to market price for execution +export const GOOD_PRICE_THRESHOLD_PERCENTAGE = 1.0 // 1% or less difference - good price +export const FAIR_PRICE_THRESHOLD_PERCENTAGE = 5.0 // 5% or less difference - fair price export const MAX_ORDER_DEADLINE = ms`1y` // https://github.com/cowprotocol/infrastructure/blob/staging/services/Pulumi.yaml#L7 diff --git a/apps/cowswap-frontend/src/common/hooks/useConvertUsdToTokenValue.ts b/apps/cowswap-frontend/src/common/hooks/useConvertUsdToTokenValue.ts index aa5d4c7cc6..db21e8a158 100644 --- a/apps/cowswap-frontend/src/common/hooks/useConvertUsdToTokenValue.ts +++ b/apps/cowswap-frontend/src/common/hooks/useConvertUsdToTokenValue.ts @@ -1,7 +1,7 @@ -import { TokenWithLogo, USDC } from '@cowprotocol/common-const' +import { 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 { Currency } from '@uniswap/sdk-core' import { useUsdPrice } from 'modules/usdAmount' @@ -15,7 +15,7 @@ export function useConvertUsdToTokenValue( const usdcToken = USDC[currencyUsdcPrice.currency.chainId as SupportedChainId] const usdAmount = tryParseCurrencyAmount(typedValue, usdcToken) - const tokenAmount = currencyUsdcPrice.price.invert().quote(hackyAdjustAmountDust(usdAmount)) + const tokenAmount = currencyUsdcPrice.price.invert().quote(usdAmount) return tokenAmount.toExact() } @@ -23,11 +23,3 @@ export function useConvertUsdToTokenValue( 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 5074642baa..ebe1d16acb 100644 --- a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx +++ b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx @@ -106,14 +106,22 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) { const convertUsdToTokenValue = useConvertUsdToTokenValue(currency) const onUserInputDispatch = useCallback( - (typedValue: string) => { - const value = convertUsdToTokenValue(typedValue, isUsdValuesMode) + (typedValue: string, currencyValue?: string) => { + // Always pass through empty string to allow clearing + if (typedValue === '') { + setTypedValue('') + onUserInput(field, '') + return + } - setTypedValue(value) + setTypedValue(typedValue) + // Avoid converting from USD if currencyValue is already provided + const value = currencyValue || convertUsdToTokenValue(typedValue, isUsdValuesMode) onUserInput(field, value) }, - [onUserInput, field, viewAmount, convertUsdToTokenValue, isUsdValuesMode], + [onUserInput, field, convertUsdToTokenValue, isUsdValuesMode], ) + const handleMaxInput = useCallback(() => { if (!maxBalance) { return @@ -122,19 +130,23 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) { const value = isUsdValuesMode ? maxBalanceUsdAmount : maxBalance if (value) { - onUserInputDispatch(value.toExact()) + onUserInputDispatch(value.toExact(), isUsdValuesMode ? maxBalance.toExact() : undefined) setMaxSellTokensAnalytics() } - }, [maxBalance, onUserInputDispatch, convertUsdToTokenValue, isUsdValuesMode, maxBalanceUsdAmount]) + }, [maxBalance, onUserInputDispatch, isUsdValuesMode, maxBalanceUsdAmount]) useEffect(() => { - const areValuesSame = parseFloat(viewAmount) === parseFloat(typedValue) + // Compare the actual string values to preserve trailing decimals + if (viewAmount === typedValue) return + + // Don't override empty input + if (viewAmount === '' && typedValue === '') return - // Don't override typedValue when, for example: viewAmount = 5 and typedValue = 5. - if (areValuesSame) return + // Don't override when typing a decimal + if (typedValue.endsWith('.')) return - // Don't override typedValue, when viewAmount from props and typedValue are zero (0 or 0. or 0.000) - if (!viewAmount && (!typedValue || parseFloat(typedValue) === 0)) return + // Don't override when the values are numerically equal (e.g., "5." and "5") + if (parseFloat(viewAmount || '0') === parseFloat(typedValue || '0')) return setTypedValue(viewAmount) // We don't need triggering from typedValue changes @@ -151,7 +163,7 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) { div { display: flex; - flex-flow: row wrap; align-items: center; color: inherit; } diff --git a/apps/cowswap-frontend/src/common/pure/RateInfo/index.tsx b/apps/cowswap-frontend/src/common/pure/RateInfo/index.tsx index 63ff4d9f56..3d7f3cac53 100644 --- a/apps/cowswap-frontend/src/common/pure/RateInfo/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/RateInfo/index.tsx @@ -240,7 +240,12 @@ export function RateInfo({ 1 ={' '} )} - + {' '} {!!fiatAmount && ( diff --git a/apps/cowswap-frontend/src/legacy/components/NumericalInput/index.tsx b/apps/cowswap-frontend/src/legacy/components/NumericalInput/index.tsx index 9cd1042f43..a08fc1bd7a 100644 --- a/apps/cowswap-frontend/src/legacy/components/NumericalInput/index.tsx +++ b/apps/cowswap-frontend/src/legacy/components/NumericalInput/index.tsx @@ -1,29 +1,40 @@ import React from 'react' -import { escapeRegExp } from '@cowprotocol/common-utils' import { UI } from '@cowprotocol/ui' -import styled from 'styled-components/macro' +import styled, { css } from 'styled-components/macro' import { autofocus } from 'common/utils/autofocus' -const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: string }>` +const textStyle = css<{ error?: boolean; fontSize?: string }>` color: ${({ error }) => (error ? `var(${UI.COLOR_DANGER})` : 'inherit')}; + font-size: ${({ fontSize }) => fontSize ?? '28px'}; + font-weight: 500; +` + +const PrependSymbol = styled.span<{ error?: boolean; fontSize?: string }>` + display: flex; + align-items: center; + justify-content: center; + height: 100%; + user-select: none; + ${textStyle} +` + +const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: string }>` + ${textStyle} width: 0; position: relative; - font-weight: 500; outline: none; border: none; flex: 1 1 auto; background-color: var(${UI.COLOR_PAPER}); - font-size: ${({ fontSize }) => fontSize ?? '28px'}; - text-align: ${({ align }) => align && align}; + text-align: ${({ align }) => align || 'right'}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0px; appearance: textfield; - text-align: right; ::-webkit-search-decoration { -webkit-appearance: none; @@ -43,7 +54,8 @@ const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: s } ` -const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group +// Allow decimal point at any position, including at the end +const inputRegex = /^(\d*\.?\d*)?$/ export const Input = React.memo(function InnerInput({ value, @@ -51,8 +63,8 @@ export const Input = React.memo(function InnerInput({ onUserInput, placeholder, prependSymbol, - type, onFocus, + pattern: _pattern, ...rest }: { value: string | number @@ -62,52 +74,94 @@ export const Input = React.memo(function InnerInput({ fontSize?: string align?: 'right' | 'left' prependSymbol?: string | undefined + pattern?: string } & Omit, 'ref' | 'onChange' | 'as'>) { + // Keep the input strictly as a string + const stringValue = typeof value === 'string' ? value : String(value) + + const titleRef = React.useCallback( + (node: HTMLInputElement | null) => { + if (node) { + node.title = node.scrollWidth > node.clientWidth ? stringValue : '' + } + }, + [stringValue], + ) + const enforcer = (nextUserInput: string) => { - if (nextUserInput === '' || inputRegex.test(escapeRegExp(nextUserInput))) { - onUserInput(nextUserInput) + // Always allow empty input + if (nextUserInput === '') { + onUserInput('') + return + } + + // Convert commas to dots + const sanitizedValue = nextUserInput.replace(/,/g, '.') + + // Allow the value if it matches our number format + if (inputRegex.test(sanitizedValue)) { + onUserInput(sanitizedValue) + } + } + + const handlePaste = (event: React.ClipboardEvent) => { + const pastedText = event.clipboardData.getData('text') + + // Clean up pasted content - only allow numbers and single decimal + const cleanedText = pastedText + .replace(/,/g, '.') // Convert commas to dots + .replace(/[^\d.]/g, '') // Remove non-numeric/dot chars + .replace(/(\..*)\./g, '$1') // Keep only the first decimal point + .replace(/\.$/, '') // Remove trailing decimal point + + if (inputRegex.test(cleanedText)) { + event.preventDefault() + enforcer(cleanedText) } } return ( - { - autofocus(event) - onFocus?.(event) - }} - onChange={(event) => { - if (prependSymbol) { - const value = event.target.value - - // cut off prepended symbol - const formattedValue = value.toString().includes(prependSymbol) - ? value.toString().slice(1, value.toString().length + 1) - : value - - // replace commas with periods, because uniswap exclusively uses period as the decimal separator - enforcer(formattedValue.replace(/,/g, '.')) - } else { - enforcer(event.target.value.replace(/,/g, '.')) - } - }} - // universal input options - inputMode="decimal" - autoComplete="off" - autoCorrect="off" - // text-specific options - type={type || 'text'} - pattern="^[0-9]*[.,]?[0-9]*$" - placeholder={placeholder || '0.0'} - minLength={1} - maxLength={32} - spellCheck="false" - /> + <> + {prependSymbol && ( + + {prependSymbol} + + )} + { + autofocus(event) + onFocus?.(event) + }} + onChange={(event) => { + const rawValue = event.target.value + + if (prependSymbol) { + // Remove prepended symbol if it appears in rawValue + const formattedValue = rawValue.includes(prependSymbol) ? rawValue.slice(prependSymbol.length) : rawValue + enforcer(formattedValue) + } else { + enforcer(rawValue) + } + }} + onPaste={handlePaste} + // Use text inputMode so decimals can be typed + inputMode="decimal" + autoComplete="off" + autoCorrect="off" + // Keep type="text" to preserve trailing decimals + type="text" + placeholder={placeholder || '0'} + // minLength to 0 so empty strings are always valid + minLength={0} + maxLength={79} + spellCheck="false" + /> + ) }) export default Input - -// const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group diff --git a/apps/cowswap-frontend/src/legacy/components/Toggle/index.tsx b/apps/cowswap-frontend/src/legacy/components/Toggle/index.tsx index 9b17657f1a..a3d6f4a23b 100644 --- a/apps/cowswap-frontend/src/legacy/components/Toggle/index.tsx +++ b/apps/cowswap-frontend/src/legacy/components/Toggle/index.tsx @@ -44,7 +44,7 @@ export const ToggleElement = styled.span<{ isActive?: boolean; bgColor?: string; ${({ isActive, isInitialToggleLoad }) => (isInitialToggleLoad ? 'none' : isActive ? turnOnToggle : turnOffToggle)} ease-in; background: ${({ bgColor, isActive }) => - isActive ? bgColor ?? `var(${UI.COLOR_PRIMARY})` : `var(${UI.COLOR_PAPER_DARKER})`}; + isActive ? (bgColor ?? `var(${UI.COLOR_PRIMARY})`) : `var(${UI.COLOR_PAPER_DARKER})`}; border-radius: 50%; height: 24px; :hover { @@ -101,7 +101,9 @@ export interface ToggleProps extends WithClassName { export function Toggle({ id, bgColor, isActive, toggle, className, isDisabled }: ToggleProps) { const [isInitialToggleLoad, setIsInitialToggleLoad] = useState(true) - const switchToggle = () => { + const switchToggle = (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() toggle() if (!isDisabled && isInitialToggleLoad) setIsInitialToggleLoad(false) } diff --git a/apps/cowswap-frontend/src/modules/analytics/events.ts b/apps/cowswap-frontend/src/modules/analytics/events.ts index efb57403b5..12a8439e4e 100644 --- a/apps/cowswap-frontend/src/modules/analytics/events.ts +++ b/apps/cowswap-frontend/src/modules/analytics/events.ts @@ -26,6 +26,7 @@ export enum Category { SURPLUS_MODAL = 'Surplus Modal', PROGRESS_BAR = 'Progress Bar', NOTIFICATIONS = 'Notifications', + LIMIT_ORDER_SETTINGS = 'Limit Order Settings', } export function shareFortuneTwitterAnalytics() { @@ -348,3 +349,57 @@ export function clickOnHooks(event: string) { action: event, }) } + +enum LimitOrderSettingsAction { + TOGGLE_SETTINGS = 'Toggle Limit Order Settings', + CUSTOM_RECIPIENT = 'Custom Recipient', + PARTIAL_EXECUTIONS = 'Enable Partial Executions', + PRICE_POSITION = 'Limit Price Position', + LOCK_PRICE = 'Lock Limit Price', + USD_MODE = 'Global USD Mode', + TABLE_POSITION = 'Orders Table Position', +} + +function sendLimitOrderSettingsAnalytics(action: string, label?: string) { + const event = { + category: Category.LIMIT_ORDER_SETTINGS, + action, + ...(label && { label }), + } + cowAnalytics.sendEvent(event) +} + +function sendToggleAnalytics(action: LimitOrderSettingsAction, enable: boolean, customLabels?: [string, string]) { + sendLimitOrderSettingsAnalytics( + action, + customLabels ? (enable ? customLabels[0] : customLabels[1]) : enable ? 'Enabled' : 'Disabled', + ) +} + +export function openLimitOrderSettingsAnalytics() { + sendLimitOrderSettingsAnalytics(LimitOrderSettingsAction.TOGGLE_SETTINGS) +} + +export function toggleCustomRecipientAnalytics(enable: boolean) { + sendToggleAnalytics(LimitOrderSettingsAction.CUSTOM_RECIPIENT, enable) +} + +export function togglePartialExecutionsAnalytics(enable: boolean) { + sendToggleAnalytics(LimitOrderSettingsAction.PARTIAL_EXECUTIONS, enable) +} + +export function changeLimitPricePositionAnalytics(oldPosition: string, newPosition: string) { + sendLimitOrderSettingsAnalytics(LimitOrderSettingsAction.PRICE_POSITION, `${oldPosition} -> ${newPosition}`) +} + +export function toggleLockLimitPriceAnalytics(enable: boolean) { + sendToggleAnalytics(LimitOrderSettingsAction.LOCK_PRICE, enable) +} + +export function toggleGlobalUsdModeAnalytics(enable: boolean) { + sendToggleAnalytics(LimitOrderSettingsAction.USD_MODE, enable) +} + +export function toggleOrdersTablePositionAnalytics(enable: boolean) { + sendToggleAnalytics(LimitOrderSettingsAction.TABLE_POSITION, enable, ['Left', 'Right']) +} 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 e68bdaab89..8c5bfb0591 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx @@ -40,7 +40,7 @@ export function RateInput() { const updateRate = useUpdateActiveRate() const updateLimitRateState = useSetAtom(updateLimitRateAtom) const executionPrice = useAtomValue(executionPriceAtom) - const { limitPriceLocked, isUsdValuesMode } = useAtomValue(limitOrdersSettingsAtom) + const { limitPriceLocked, isUsdValuesMode, partialFillsEnabled } = useAtomValue(limitOrdersSettingsAtom) const updateLimitOrdersSettings = useSetAtom(updateLimitOrdersSettingsAtom) const executionPriceUsdValue = useExecutionPriceUsdValue(executionPrice) @@ -74,21 +74,52 @@ export function RateInput() { // Handle rate input const handleUserInput = useCallback( (typedValue: string) => { + // Always pass through empty string to allow clearing + if (typedValue === '') { + setTypedTrailingZeros('') + updateLimitRateState({ typedValue: '' }) + updateRate({ + activeRate: null, + isTypedValue: true, + isRateFromUrl: false, + isAlternativeOrderRate: false, + }) + return + } + + // Keep the trailing decimal point or zeros const trailing = typedValue.slice(displayedRate.length) + const hasTrailingDecimal = typedValue.endsWith('.') 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 + * Since we convert USD to token value, we need to handle trailing zeros and decimal points separately. + * If we don't, they will be lost during the conversion between USD and token values. */ - if (onlyTrailingZeroAdded) { + if (hasTrailingDecimal || onlyTrailingZeroAdded) { setTypedTrailingZeros(trailing) + + // For trailing decimal, we also need to update the base value + if (hasTrailingDecimal && !onlyTrailingZeroAdded) { + const baseValue = typedValue.slice(0, -1) // Remove the trailing decimal for conversion + const value = convertUsdToTokenValue(baseValue, isUsdRateMode) + updateLimitRateState({ typedValue: value }) + updateRate({ + activeRate: toFraction(value, isInverted), + isTypedValue: true, + isRateFromUrl: false, + isAlternativeOrderRate: false, + }) + } return } setTypedTrailingZeros('') + // Convert to token value if in USD mode const value = convertUsdToTokenValue(typedValue, isUsdRateMode) + // Update the rate state with the new value updateLimitRateState({ typedValue: value }) updateRate({ activeRate: toFraction(value, isInverted), @@ -193,7 +224,7 @@ export function RateInput() { rateImpact={rateImpact} toggleIcon={ @@ -258,7 +289,7 @@ export function RateInput() { )} - Estimated fill price + {partialFillsEnabled ? 'Est. partial fill price' : '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 c56abdaaa8..75facf2bd0 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/styled.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/styled.ts @@ -27,6 +27,11 @@ export const Header = styled.div` width: 100%; color: inherit; + ${Media.upToSmall()} { + flex-flow: row wrap; + gap: 14px; + } + > span > i { font-style: normal; color: var(${UI.COLOR_TEXT}); @@ -82,7 +87,7 @@ export const Body = styled.div` justify-content: space-between; width: 100%; max-width: 100%; - gap: 8px; + gap: 0; padding: 12px 0 4px; color: inherit; ` @@ -109,6 +114,7 @@ export const CurrencyToggleGroup = styled.div` align-items: center; background: transparent; overflow: hidden; + margin: 0 0 0 8px; ` export const ActiveCurrency = styled.button<{ $active?: boolean }>` 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 f4222a4efc..51d469d922 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/SettingsWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/SettingsWidget/index.tsx @@ -1,11 +1,9 @@ import { useAtomValue, useSetAtom } from 'jotai' -import UsdIcon from '@cowprotocol/assets/images/icon-USD.svg' - 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 { openLimitOrderSettingsAnalytics } from 'modules/analytics' +import { ButtonsContainer, SettingsButton, SettingsIcon } from 'modules/trade/pure/Settings' import { Settings } from '../../pure/Settings' import { limitOrdersSettingsAtom, updateLimitOrdersSettingsAtom } from '../../state/limitOrdersSettingsAtom' @@ -13,21 +11,27 @@ import { limitOrdersSettingsAtom, updateLimitOrdersSettingsAtom } from '../../st export function SettingsWidget() { const settingsState = useAtomValue(limitOrdersSettingsAtom) const updateSettingsState = useSetAtom(updateLimitOrdersSettingsAtom) - const isUsdValuesMode = settingsState.isUsdValuesMode + + const handleClick = () => { + openLimitOrderSettingsAnalytics() + } return ( - updateSettingsState({ isUsdValuesMode: !isUsdValuesMode })} active={isUsdValuesMode}> - - - + - void 0}> - + null}> +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} + > + +
diff --git a/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts b/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts index 140433a0d7..076480d5a9 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts @@ -13,7 +13,7 @@ import { tradeFlow } from 'modules/limitOrders/services/tradeFlow' import { PriceImpactDeclineError, TradeFlowContext } from 'modules/limitOrders/services/types' import { LimitOrdersSettingsState } from 'modules/limitOrders/state/limitOrdersSettingsAtom' import { partiallyFillableOverrideAtom } from 'modules/limitOrders/state/partiallyFillableOverride' -import { useNavigateToOpenOrdersTable } from 'modules/ordersTable' +import { useNavigateToAllOrdersTable } from 'modules/ordersTable' import { useCloseReceiptModal } from 'modules/ordersTable/containers/OrdersReceiptModal/hooks' import { TradeConfirmActions } from 'modules/trade/hooks/useTradeConfirmActions' import { useAlternativeOrder, useHideAlternativeOrderModal } from 'modules/trade/state/alternativeOrder' @@ -28,14 +28,14 @@ export function useHandleOrderPlacement( tradeContext: TradeFlowContext, priceImpact: PriceImpact, settingsState: LimitOrdersSettingsState, - tradeConfirmActions: TradeConfirmActions + tradeConfirmActions: TradeConfirmActions, ): () => Promise { const { confirmPriceImpactWithoutFee } = useConfirmPriceImpactWithoutFee() const updateLimitOrdersState = useUpdateLimitOrdersRawState() const hideAlternativeOrderModal = useHideAlternativeOrderModal() const { isEdit: isAlternativeOrderEdit } = useAlternativeOrder() || {} const closeReceiptModal = useCloseReceiptModal() - const navigateToOpenOrdersTable = useNavigateToOpenOrdersTable() + const navigateToAllOrdersTable = useNavigateToAllOrdersTable() const [partiallyFillableOverride, setPartiallyFillableOverride] = useAtom(partiallyFillableOverrideAtom) // tx bundling stuff const safeBundleFlowContext = useSafeBundleFlowContext(tradeContext) @@ -75,7 +75,7 @@ export function useHandleOrderPlacement( priceImpact, settingsState, confirmPriceImpactWithoutFee, - beforeTrade + beforeTrade, ) } @@ -105,8 +105,8 @@ export function useHandleOrderPlacement( setPartiallyFillableOverride(undefined) // Reset alternative mode if any hideAlternativeOrderModal() - // Navigate to open orders - navigateToOpenOrdersTable() + // Navigate to all orders + navigateToAllOrdersTable() // Close receipt modal closeReceiptModal() @@ -130,7 +130,7 @@ export function useHandleOrderPlacement( updateLimitOrdersState, setPartiallyFillableOverride, isAlternativeOrderEdit, - navigateToOpenOrdersTable, + navigateToAllOrdersTable, closeReceiptModal, hideAlternativeOrderModal, ]) diff --git a/apps/cowswap-frontend/src/modules/limitOrders/hooks/useUpdateCurrencyAmount.ts b/apps/cowswap-frontend/src/modules/limitOrders/hooks/useUpdateCurrencyAmount.ts index 79ccadbbdf..e2be50c2c2 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/hooks/useUpdateCurrencyAmount.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/hooks/useUpdateCurrencyAmount.ts @@ -1,9 +1,8 @@ -import { useAtomValue, useSetAtom } from 'jotai' import { useCallback } from 'react' import { FractionUtils, isSellOrder } from '@cowprotocol/common-utils' import { OrderKind } from '@cowprotocol/cow-sdk' -import { Currency, CurrencyAmount, Fraction, Price } from '@uniswap/sdk-core' +import { Currency, CurrencyAmount, Fraction } from '@uniswap/sdk-core' import { Writeable } from 'types' @@ -12,8 +11,6 @@ import { Field } from 'legacy/state/types' import { useLimitOrdersDerivedState } from 'modules/limitOrders/hooks/useLimitOrdersDerivedState' import { useUpdateLimitOrdersRawState } from 'modules/limitOrders/hooks/useLimitOrdersRawState' import { LimitOrdersRawState } from 'modules/limitOrders/state/limitOrdersRawStateAtom' -import { limitOrdersSettingsAtom } from 'modules/limitOrders/state/limitOrdersSettingsAtom' -import { updateLimitRateAtom } from 'modules/limitOrders/state/limitRateAtom' import { calculateAmountForRate } from 'utils/orderUtils/calculateAmountForRate' @@ -25,46 +22,13 @@ type CurrencyAmountProps = { export function useUpdateCurrencyAmount() { const updateLimitOrdersState = useUpdateLimitOrdersRawState() - const { inputCurrency, outputCurrency, inputCurrencyAmount: currentInputAmount } = useLimitOrdersDerivedState() - const { limitPriceLocked } = useAtomValue(limitOrdersSettingsAtom) - const updateLimitRateState = useSetAtom(updateLimitRateAtom) + const { inputCurrency, outputCurrency } = useLimitOrdersDerivedState() return useCallback( (params: CurrencyAmountProps) => { 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, @@ -88,6 +52,6 @@ export function useUpdateCurrencyAmount() { updateLimitOrdersState(update) }, - [inputCurrency, outputCurrency, updateLimitOrdersState, limitPriceLocked, updateLimitRateState, currentInputAmount], + [inputCurrency, outputCurrency, updateLimitOrdersState], ) } 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 7364eae9f3..b2fd186d29 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/deadlines.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/deadlines.ts @@ -11,35 +11,18 @@ export interface LimitOrderDeadline { export const MIN_CUSTOM_DEADLINE = ms`30min` export const MAX_CUSTOM_DEADLINE = MAX_ORDER_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, -})) +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 }, +] /** * Get limit order deadlines and optionally adds 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 73272e0ef7..ce76e9554d 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/styled.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/styled.tsx @@ -1,4 +1,4 @@ -import { Media, UI } from '@cowprotocol/ui' +import { UI } from '@cowprotocol/ui' import { MenuButton, MenuItem, MenuList } from '@reach/menu-button' import { transparentize } from 'color2k' @@ -44,10 +44,6 @@ export const Current = styled(MenuButton)<{ $custom?: boolean }>` text-overflow: ellipsis; overflow: hidden; - ${Media.upToSmall()} { - font-size: 21px; - } - &:hover { text-decoration: underline; } 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 88ed3dee08..52b75d2e91 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/RateInput/HeadingText.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/RateInput/HeadingText.tsx @@ -40,13 +40,16 @@ const TextWrapper = styled.span<{ clickable: boolean }>` align-items: center; gap: 4px; cursor: ${({ clickable }) => (clickable ? 'pointer' : 'default')}; - transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; + transition: + opacity var(${UI.ANIMATION_DURATION}) ease-in-out, + text-decoration-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_25}); &:hover { - text-decoration: underline; - text-decoration-style: dashed; - text-decoration-thickness: 1px; - text-underline-offset: 2px; text-decoration-color: var(${UI.COLOR_TEXT_OPACITY_70}); } ` 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 b03535f199..b157546d70 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 @@ -8,7 +8,6 @@ const defaultProps: SettingsProps = { customDeadlineTimestamp: null, limitPricePosition: 'between', limitPriceLocked: false, - columnLayout: 'DEFAULT', ordersTableOnLeft: false, isUsdValuesMode: false, }, 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 4b756b6253..3306ace652 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.tsx @@ -5,6 +5,14 @@ import { HelpTooltip } from '@cowprotocol/ui' import styled from 'styled-components/macro' +import { + toggleCustomRecipientAnalytics, + togglePartialExecutionsAnalytics, + changeLimitPricePositionAnalytics, + toggleLockLimitPriceAnalytics, + toggleOrdersTablePositionAnalytics, + toggleGlobalUsdModeAnalytics, +} from 'modules/analytics' import { ORDERS_TABLE_SETTINGS } from 'modules/trade/const/common' import { SettingsBox, SettingsContainer, SettingsTitle } from 'modules/trade/pure/Settings' @@ -110,121 +118,130 @@ const POSITION_LABELS = { 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, limitPricePosition, limitPriceLocked, columnLayout, ordersTableOnLeft } = - state + const { + showRecipient, + partialFillsEnabled, + limitPricePosition, + limitPriceLocked, + ordersTableOnLeft, + isUsdValuesMode, + } = state const [isOpen, setIsOpen] = useState(false) - const [isColumnLayoutOpen, setIsColumnLayoutOpen] = useState(false) const handleSelect = useCallback( (value: LimitOrdersSettingsState['limitPricePosition']) => (e: React.MouseEvent) => { e.stopPropagation() + changeLimitPricePositionAnalytics(limitPricePosition, value) onStateChanged({ limitPricePosition: value }) setIsOpen(false) }, - [onStateChanged], + [onStateChanged, limitPricePosition], ) - 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) => { + const handleRecipientToggle = useCallback(() => { + toggleCustomRecipientAnalytics(!showRecipient) + onStateChanged({ showRecipient: !showRecipient }) + }, [showRecipient, onStateChanged]) + + const handlePartialFillsToggle = useCallback(() => { + togglePartialExecutionsAnalytics(!partialFillsEnabled) + onStateChanged({ partialFillsEnabled: !partialFillsEnabled }) + }, [partialFillsEnabled, onStateChanged]) + + const handleLimitPriceLockedToggle = useCallback(() => { + toggleLockLimitPriceAnalytics(!limitPriceLocked) + onStateChanged({ limitPriceLocked: !limitPriceLocked }) + }, [limitPriceLocked, onStateChanged]) + + const handleOrdersTablePositionToggle = useCallback(() => { + toggleOrdersTablePositionAnalytics(!ordersTableOnLeft) + onStateChanged({ ordersTableOnLeft: !ordersTableOnLeft }) + }, [ordersTableOnLeft, onStateChanged]) + + const handleUsdValuesModeToggle = useCallback(() => { + toggleGlobalUsdModeAnalytics(!isUsdValuesMode) + onStateChanged({ isUsdValuesMode: !isUsdValuesMode }) + }, [isUsdValuesMode, onStateChanged]) + + const handleContainerClick = (e: React.MouseEvent) => { e.stopPropagation() - setIsColumnLayoutOpen(!isColumnLayoutOpen) } return ( - - Limit Order Settings - - onStateChanged({ showRecipient: !showRecipient })} - /> - - - Allow you to chose whether your limit orders will be Partially fillable or Fill or kill. -
-
- Fill or kill orders will either be filled fully or not at all. -
- Partially fillable orders may be filled partially if there isn't enough liquidity to fill the full - amount. - - } - value={partialFillsEnabled} - toggle={() => 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} - - ))} - - - -
+
+ + Limit Order Settings + + + + + Allow you to chose whether your limit orders will be Partially fillable or Fill or kill. +
+
+ Fill or kill orders will either be filled fully or not at all. +
+ Partially fillable orders may be filled partially if there isn't enough liquidity to fill the full + amount. + + } + value={partialFillsEnabled} + toggle={handlePartialFillsToggle} + /> + + + + + + + + + + Limit price position + + + {POSITION_LABELS[limitPricePosition]} + + {Object.entries(POSITION_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 7f1d8c4b11..492c2c3595 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/state/limitOrdersSettingsAtom.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/state/limitOrdersSettingsAtom.ts @@ -12,8 +12,6 @@ import { alternativeOrderReadWriteAtomFactory, } from 'modules/trade/state/alternativeOrder' -export type ColumnLayoutType = 'DEFAULT' | 'VIEW_2' | 'VIEW_3' - export interface LimitOrdersSettingsState { readonly showRecipient: boolean readonly partialFillsEnabled: boolean @@ -21,7 +19,6 @@ export interface LimitOrdersSettingsState { readonly customDeadlineTimestamp: Timestamp | null readonly limitPricePosition: 'top' | 'between' | 'bottom' readonly limitPriceLocked: boolean - readonly columnLayout: ColumnLayoutType readonly ordersTableOnLeft: boolean readonly isUsdValuesMode: boolean } @@ -31,9 +28,8 @@ export const defaultLimitOrdersSettings: LimitOrdersSettingsState = { partialFillsEnabled: true, deadlineMilliseconds: defaultLimitOrderDeadline.value, customDeadlineTimestamp: null, - limitPricePosition: 'top', + limitPricePosition: 'bottom', limitPriceLocked: true, - columnLayout: 'DEFAULT', ordersTableOnLeft: false, isUsdValuesMode: false, } diff --git a/apps/cowswap-frontend/src/modules/ordersTable/const/tabs.ts b/apps/cowswap-frontend/src/modules/ordersTable/const/tabs.ts index fe4fdacbf4..a3767a3339 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/const/tabs.ts +++ b/apps/cowswap-frontend/src/modules/ordersTable/const/tabs.ts @@ -17,6 +17,12 @@ export const UNFILLABLE_TAB: OrderTab = { count: 0, } +export const SIGNING_TAB: OrderTab = { + id: 'signing', + title: 'Signing', + count: 0, +} + export const OPEN_TAB: OrderTab = { id: 'open', title: 'Open', @@ -29,7 +35,7 @@ export const HISTORY_TAB: OrderTab = { count: 0, } -export const ORDERS_TABLE_TABS: OrderTab[] = [ALL_ORDERS_TAB, UNFILLABLE_TAB, OPEN_TAB, HISTORY_TAB] +export const ORDERS_TABLE_TABS: OrderTab[] = [ALL_ORDERS_TAB, UNFILLABLE_TAB, SIGNING_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 ea38bb0bb6..c626fa874c 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/containers/MultipleCancellationMenu/index.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/containers/MultipleCancellationMenu/index.tsx @@ -26,8 +26,8 @@ const Wrapper = styled.div<{ hasSelectedItems: boolean }>` ${Media.upToSmall()} { width: 100%; - justify-content: center; - margin: 15px auto 0; + justify-content: flex-end; + margin: 10px auto 5px; } ` 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 853aab2b91..34bf7f75cc 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 @@ -1,6 +1,7 @@ import { useMemo } from 'react' -import { Order, PENDING_STATES } from 'legacy/state/orders/actions' +import { Order, PENDING_STATES, OrderStatus } from 'legacy/state/orders/actions' +import { useSetIsOrderUnfillable } from 'legacy/state/orders/hooks' import { getIsComposableCowOrder } from 'utils/orderUtils/getIsComposableCowOrder' import { getIsNotComposableCowOrder } from 'utils/orderUtils/getIsNotComposableCowOrder' @@ -14,6 +15,7 @@ export interface OrdersTableList { pending: OrderTableItem[] history: OrderTableItem[] unfillable: OrderTableItem[] + signing: OrderTableItem[] all: OrderTableItem[] } @@ -32,12 +34,16 @@ export function useOrdersTableList( chainId: number, balancesAndAllowances: any, ): OrdersTableList { + const setIsOrderUnfillable = useSetIsOrderUnfillable() + + // First, group and sort all orders const allSortedOrders = useMemo(() => { return groupOrdersTable(allOrders).sort(ordersSorter) }, [allOrders]) + // Then, categorize orders into their respective lists return useMemo(() => { - const { pending, history, unfillable, all } = allSortedOrders.reduce( + const { pending, history, unfillable, signing, all } = allSortedOrders.reduce( (acc, item) => { const order = isParsedOrder(item) ? item : item.parent @@ -53,33 +59,52 @@ export function useOrdersTableList( acc.all.push(item) const isPending = PENDING_STATES.includes(order.status) + const isSigning = order.status === OrderStatus.PRESIGNATURE_PENDING // 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) { + // Update the unfillable flag whenever the state changes, not just when becoming unfillable + if (isPending && order.isUnfillable !== isUnfillable) { + setIsOrderUnfillable({ chainId, id: order.id, isUnfillable }) + } + + // Add to signing if in presignature pending state + if (isSigning) { + acc.signing.push(item) + } + + // Add to unfillable only if pending, unfillable, and not in signing state + if (isPending && isUnfillable && !isSigning) { acc.unfillable.push(item) } // Add to pending or history based on status - if (isPending) { + if (isPending && !isSigning) { acc.pending.push(item) - } else { + } else if (!isPending) { acc.history.push(item) } return acc }, - { pending: [], history: [], unfillable: [], all: [] } as OrdersTableList, + { + pending: [] as OrderTableItem[], + history: [] as OrderTableItem[], + unfillable: [] as OrderTableItem[], + signing: [] as OrderTableItem[], + all: [] as OrderTableItem[], + }, ) + // Return sliced lists to respect ORDERS_LIMIT return { pending: pending.slice(0, ORDERS_LIMIT), history: history.slice(0, ORDERS_LIMIT), unfillable: unfillable.slice(0, ORDERS_LIMIT), + signing: signing.slice(0, ORDERS_LIMIT), all: all.slice(0, ORDERS_LIMIT), } - }, [allSortedOrders, orderType, chainId, balancesAndAllowances]) + }, [allSortedOrders, orderType, chainId, balancesAndAllowances, setIsOrderUnfillable]) } 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 2f727fdaf0..3de5f82530 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/containers/OrdersTableWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/containers/OrdersTableWidget/index.tsx @@ -2,7 +2,7 @@ import { useAtomValue, useSetAtom } from 'jotai' import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import { useTokensAllowances, useTokensBalances } from '@cowprotocol/balances-and-allowances' -import { UI } from '@cowprotocol/ui' +import { Media, UI } from '@cowprotocol/ui' import { useIsSafeViaWc, useWalletDetails, useWalletInfo } from '@cowprotocol/wallet' import { Search } from 'react-feather' @@ -12,7 +12,6 @@ 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' @@ -30,7 +29,6 @@ import { useValidatePageUrlParams } from './hooks/useValidatePageUrlParams' 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' @@ -44,6 +42,10 @@ const SearchInputContainer = styled.div` margin: 0; padding: 0 0 0 16px; position: relative; + + ${Media.upToMedium()} { + padding: 0; + } ` const SearchIcon = styled(Search)` @@ -54,6 +56,10 @@ const SearchIcon = styled(Search)` color: var(${UI.COLOR_TEXT_OPACITY_50}); width: 16px; height: 16px; + + ${Media.upToMedium()} { + left: 10px; + } ` const SearchInput = styled.input` @@ -65,6 +71,12 @@ const SearchInput = styled.input` border-radius: 8px; font-size: 13px; font-weight: 500; + min-height: 36px; + + ${Media.upToMedium()} { + padding: 8px 12px 8px 32px; + border-radius: 12px; + } &::placeholder { color: var(${UI.COLOR_TEXT_OPACITY_50}); @@ -82,6 +94,8 @@ function getOrdersListByIndex(ordersList: OrdersTableList, id: string): OrderTab return ordersList.all case 'unfillable': return ordersList.unfillable + case 'signing': + return ordersList.signing case 'open': return ordersList.pending case 'history': @@ -127,11 +141,6 @@ export function OrdersTableWidget({ 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() @@ -151,18 +160,40 @@ export function OrdersTableWidget({ const { currentTabId, currentPageNumber } = useMemo(() => { const params = parseOrdersTableUrl(location.search) + // If we're on a tab that becomes empty (signing or unfillable), + // default to the all orders tab + if ( + (params.tabId === 'signing' && !ordersList.signing.length) || + (params.tabId === 'unfillable' && !ordersList.unfillable.length) + ) { + return { + currentTabId: ALL_ORDERS_TAB.id, + currentPageNumber: params.pageNumber || 1, + } + } + return { currentTabId: params.tabId || ALL_ORDERS_TAB.id, currentPageNumber: params.pageNumber || 1, } - }, [location.search]) + }, [location.search, ordersList.signing.length, ordersList.unfillable.length]) const orders = useMemo(() => { return getOrdersListByIndex(ordersList, currentTabId) }, [ordersList, currentTabId]) const tabs = useMemo(() => { - return ORDERS_TABLE_TABS.map((tab) => { + return ORDERS_TABLE_TABS.filter((tab) => { + // Only include the unfillable tab if there are unfillable orders + if (tab.id === 'unfillable') { + return getOrdersListByIndex(ordersList, tab.id).length > 0 + } + // Only include the signing tab if there are signing orders + if (tab.id === 'signing') { + return getOrdersListByIndex(ordersList, tab.id).length > 0 + } + return true + }).map((tab) => { return { ...tab, isActive: tab.id === currentTabId, count: getOrdersListByIndex(ordersList, tab.id).length } }) }, [currentTabId, ordersList]) @@ -212,6 +243,11 @@ export function OrdersTableWidget({ useValidatePageUrlParams(orders.length, currentTabId, currentPageNumber) + // Clear selection when changing tabs + useEffect(() => { + updateOrdersToCancel([]) + }, [currentTabId, updateOrdersToCancel]) + const filteredOrders = useMemo(() => { if (!searchTerm) return orders @@ -294,7 +330,6 @@ export function OrdersTableWidget({ pendingActivities={pendingActivity} injectedWidgetParams={injectedWidgetParams} searchTerm={searchTerm} - columnLayout={columnLayout} > {(currentTabId === OPEN_TAB.id || currentTabId === 'all' || currentTabId === 'unfillable') && orders.length > 0 && } diff --git a/apps/cowswap-frontend/src/modules/ordersTable/hooks/useNavigateToAllOrdersTable.ts b/apps/cowswap-frontend/src/modules/ordersTable/hooks/useNavigateToAllOrdersTable.ts new file mode 100644 index 0000000000..4ec805651f --- /dev/null +++ b/apps/cowswap-frontend/src/modules/ordersTable/hooks/useNavigateToAllOrdersTable.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react' + +import { useNavigate } from 'common/hooks/useNavigate' + +import { useGetBuildOrdersTableUrl } from './useGetBuildOrdersTableUrl' + +import { ALL_ORDERS_TAB } from '../const/tabs' + +/** + * Hook to navigate to the ALL ORDERS tab in the orders table + * Used by both limit orders and TWAP orders after placement + */ +export function useNavigateToAllOrdersTable() { + const navigate = useNavigate() + const buildOrdersTableUrl = useGetBuildOrdersTableUrl() + + return useCallback(() => { + navigate(buildOrdersTableUrl({ tabId: ALL_ORDERS_TAB.id, pageNumber: 1 })) + }, [buildOrdersTableUrl, navigate]) +} diff --git a/apps/cowswap-frontend/src/modules/ordersTable/index.tsx b/apps/cowswap-frontend/src/modules/ordersTable/index.tsx index fdaa7edcd2..5e04f0ae72 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/index.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/index.tsx @@ -2,5 +2,6 @@ export * from './const/tabs' export * from './containers/OrdersTableWidget' export * from './hooks/useGetBuildOrdersTableUrl' export * from './hooks/useNavigateToOpenOrdersTable' +export * from './hooks/useNavigateToAllOrdersTable' export * from './types' export * from './utils/buildOrdersTableUrl' diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.test.ts b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.test.ts index 93f4fa82b8..d18dfbe173 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.test.ts +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.test.ts @@ -42,8 +42,8 @@ describe('getOrderStatusTitleAndColor()', () => { when(orderMock.isCancelling).thenReturn(true) const result = getResult() expect(result.title).toBe('Cancelling...') - expect(result.color).toBe(`var(${UI.COLOR_TEXT})`) - expect(result.background).toBe(`var(${UI.COLOR_TEXT_OPACITY_10})`) + expect(result.color).toBe(`var(${UI.COLOR_DANGER_TEXT})`) + expect(result.background).toBe(`var(${UI.COLOR_DANGER_BG})`) }) it('should return correct title and colors for a cancelled order', () => { diff --git a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.ts b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.ts index a3e234a718..14dfd10ca3 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.ts +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.ts @@ -48,8 +48,26 @@ export function getOrderStatusTitleAndColor(order: ParsedOrder): { title: string if (order.isCancelling) { return { title: 'Cancelling...', - color: `var(${UI.COLOR_TEXT})`, - background: `var(${UI.COLOR_TEXT_OPACITY_10})`, + color: `var(${UI.COLOR_DANGER_TEXT})`, + background: `var(${UI.COLOR_DANGER_BG})`, + } + } + + // Handle signing state + if (order.status === OrderStatus.PRESIGNATURE_PENDING) { + return { + title: orderStatusTitleMap[OrderStatus.PRESIGNATURE_PENDING], + color: `var(${UI.COLOR_ALERT_TEXT})`, + background: `var(${UI.COLOR_ALERT_BG})`, + } + } + + // Handle unfillable orders + if (order.isUnfillable) { + return { + title: 'Unfillable', + color: `var(${UI.COLOR_DANGER_TEXT})`, + background: `var(${UI.COLOR_DANGER_BG})`, } } 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 5e40b59f38..87b9bb84b4 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/index.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/index.tsx @@ -1,19 +1,34 @@ +import orderPresignaturePending from '@cowprotocol/assets/cow-swap/order-presignature-pending.svg' import { Command } from '@cowprotocol/types' -import styled from 'styled-components/macro' +import SVG from 'react-inlinesvg' +import styled, { css, keyframes } from 'styled-components/macro' + +import { OrderStatus } from 'legacy/state/orders/actions' import { ParsedOrder } from 'utils/orderUtils/parseOrder' import { getOrderStatusTitleAndColor } from './getOrderStatusTitleAndColor' +const shimmerAnimation = keyframes` + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +` + const Wrapper = styled.div<{ color: string background: string withWarning?: boolean widthAuto?: boolean clickable?: boolean + isCancelling?: boolean + isSigning?: boolean }>` - --height: 28px; + --height: 26px; --statusColor: ${({ color }) => color}; --statusBackground: ${({ background }) => background}; @@ -40,8 +55,28 @@ const Wrapper = styled.div<{ top: 0; background: var(--statusBackground); z-index: 1; - border-radius: ${({ withWarning }) => (withWarning ? '9px 0 0 9px' : '9px')}; + border-radius: 16px; } + + ${({ isCancelling, isSigning }) => + (isCancelling || isSigning) && + css` + overflow: hidden; + border-radius: 16px; + + &::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.3) 50%, transparent 100%); + animation: ${shimmerAnimation} 1.5s infinite; + z-index: 2; + border-radius: 16px; + } + `} ` const StatusContent = styled.div` @@ -50,6 +85,16 @@ const StatusContent = styled.div` gap: 4px; position: relative; z-index: 2; + + svg { + width: 14px; + height: 14px; + fill: currentColor; + } + + svg > path { + fill: currentColor; + } ` type OrderStatusBoxProps = { @@ -63,25 +108,28 @@ type OrderStatusBoxProps = { export function OrderStatusBox({ order, widthAuto, withWarning, onClick, WarningTooltip }: OrderStatusBoxProps) { const { title, color, background } = getOrderStatusTitleAndColor(order) - const content = {title} + const content = ( + + {withWarning && WarningTooltip && {null}} + {order.status === OrderStatus.PRESIGNATURE_PENDING && ( + + )} + {title} + + ) return ( - <> - - {content} - - {withWarning && WarningTooltip && ( - - <> - - )} - + + {content} + ) } 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 cda6d61cd5..e30216a4eb 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,16 +1,15 @@ import AlertTriangle from '@cowprotocol/assets/cow-swap/alert.svg' +import allowanceIcon from '@cowprotocol/assets/images/icon-allowance.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' +import { UI, SymbolElement, TokenAmount, TokenAmountProps, HoverTooltip, ButtonSecondary } from '@cowprotocol/ui' import { Currency, CurrencyAmount, Fraction, Percent } from '@uniswap/sdk-core' import { darken } from 'color2k' import SVG from 'react-inlinesvg' import styled from 'styled-components/macro' -import { HIGH_FEE_WARNING_PERCENTAGE } from 'common/constants/common' +import { PENDING_EXECUTION_THRESHOLD_PERCENTAGE, HIGH_FEE_WARNING_PERCENTAGE } from 'common/constants/common' import * as styledEl from './styled' @@ -50,6 +49,24 @@ const UnfillableLabel = styled.span` align-items: center; justify-content: flex-start; gap: 3px; + + svg { + width: 14px; + height: 14px; + fill: currentColor; + } + + svg > path { + fill: currentColor; + stroke: none; + } +` + +const WarningContent = styled.span` + display: flex; + align-items: center; + gap: 3px; + cursor: help; ` const ApprovalLink = styled.button` @@ -114,17 +131,43 @@ export function EstimatedExecutionPrice(props: EstimatedExecutionPriceProps) { const content = ( <> - + ) const unfillableLabel = ( - {warningText} - {warningText === 'Insufficient allowance' && onApprove && ( - Set approval + {(warningText === 'Insufficient allowance' || warningText === 'Insufficient balance') && WarningTooltip && ( + <> + +

{warningText}

+

+ {warningText === 'Insufficient allowance' + ? 'The order remains open. Execution requires adequate allowance. Approve the token to proceed.' + : 'The order remains open. Execution requires sufficient balance.'} +

+ {warningText === 'Insufficient allowance' && onApprove && ( + + Set approval + + )} + + } + bgColor={`var(${UI.COLOR_DANGER_BG})`} + color={`var(${UI.COLOR_DANGER_TEXT})`} + > + + + {warningText} + +
+ {warningText === 'Insufficient allowance' && onApprove && ( + Set approval + )} + )} - {WarningTooltip && {null}}
) @@ -139,8 +182,10 @@ export function EstimatedExecutionPrice(props: EstimatedExecutionPriceProps) { wrapInContainer={true} content={ - {isNegativeDifference && Math.abs(Number(percentageDifferenceInverted?.toFixed(4) ?? 0)) <= 0.01 ? ( - <>Will execute soon! + {isNegativeDifference && + Math.abs(Number(percentageDifferenceInverted?.toFixed(4) ?? 0)) <= + PENDING_EXECUTION_THRESHOLD_PERCENTAGE ? ( + <>The fill price of this order is close or at the market price and is expected to fill soon ) : ( <> Market price needs to go {marketPriceNeedsToGoDown ? 'down 📉' : 'up 📈'} by  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 index 3f50ba73aa..26ff6244c1 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/OrderWarning.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrderRow/OrderWarning.tsx @@ -1,8 +1,8 @@ import React from 'react' -import AlertTriangle from '@cowprotocol/assets/cow-swap/alert.svg' +import alertCircle from '@cowprotocol/assets/cow-swap/alert-circle.svg' import { Command } from '@cowprotocol/types' -import { ButtonSecondary, TokenSymbol, UI, HoverTooltip } from '@cowprotocol/ui' +import { ButtonSecondary, HoverTooltip, TokenSymbol, UI } from '@cowprotocol/ui' import SVG from 'react-inlinesvg' @@ -104,11 +104,11 @@ export function WarningTooltip({ onApprove, showIcon = false, }: WarningTooltipProps) { - const withAllowanceWarning = hasEnoughAllowance === false + const withAllowanceWarning = !hasEnoughAllowance const tooltipContent = ( - {hasEnoughBalance === false && } + {!hasEnoughBalance && } {withAllowanceWarning && ( )} @@ -117,13 +117,13 @@ export function WarningTooltip({ if (showIcon) { return ( - + } + bgColor={`var(${UI.COLOR_DANGER_BG})`} + color={`var(${UI.COLOR_DANGER_TEXT})`} + Icon={} /> {children} @@ -134,8 +134,8 @@ export function WarningTooltip({ {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 cbd63f9469..e78e8351d5 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,20 +1,30 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' +import orderPresignaturePending from '@cowprotocol/assets/cow-swap/order-presignature-pending.svg' import { ZERO_FRACTION } from '@cowprotocol/common-const' import { useTimeAgo } from '@cowprotocol/common-hooks' -import { getAddress, getEtherscanLink } from '@cowprotocol/common-utils' +import { getAddress, getEtherscanLink, formatDateWithTimezone } from '@cowprotocol/common-utils' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { TokenLogo } from '@cowprotocol/tokens' import { Command, UiOrderType } from '@cowprotocol/types' -import { Loader, TokenAmount, UI } from '@cowprotocol/ui' +import { UI, TokenAmount, Loader, HoverTooltip } from '@cowprotocol/ui' import { PercentDisplay, percentIsAlmostHundred } from '@cowprotocol/ui' +import { useIsSafeWallet } from '@cowprotocol/wallet' import { Currency, CurrencyAmount, Percent, Price } from '@uniswap/sdk-core' -import { CREATING_STATES, OrderStatus } from 'legacy/state/orders/actions' +import { Clock, Zap, Check, X } from 'react-feather' +import SVG from 'react-inlinesvg' + +import { OrderStatus } from 'legacy/state/orders/actions' import { PendingOrderPrices } from 'modules/orders/state/pendingOrdersPricesAtom' import { getIsEthFlowOrder } from 'modules/swap/containers/EthFlowStepper' +import { + PENDING_EXECUTION_THRESHOLD_PERCENTAGE, + GOOD_PRICE_THRESHOLD_PERCENTAGE, + FAIR_PRICE_THRESHOLD_PERCENTAGE, +} from 'common/constants/common' import { useSafeMemo } from 'common/hooks/useSafeMemo' import { RateInfo } from 'common/pure/RateInfo' import { getQuoteCurrency } from 'common/services/getQuoteCurrency' @@ -35,22 +45,18 @@ 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) { + if (absPercentage <= GOOD_PRICE_THRESHOLD_PERCENTAGE) { return `var(${UI.COLOR_SUCCESS})` // Green - good price - } else if (absPercentage <= FAIR_PRICE_THRESHOLD) { + } else if (absPercentage <= FAIR_PRICE_THRESHOLD_PERCENTAGE) { return `var(${UI.COLOR_PRIMARY})` // Blue - fair price } @@ -74,39 +80,42 @@ export interface OrderRowProps { prices: PendingOrderPrices | undefined | null spotPrice: Price | undefined | null isRateInverted: boolean - showLimitPrice: boolean isHistoryTab: boolean isRowSelectable: boolean isRowSelected: boolean isChild?: boolean + isExpanded?: boolean orderParams: OrderParams onClick: Command orderActions: OrderActions children?: React.ReactNode - columnLayout?: ColumnLayout + childOrders?: ParsedOrder[] + isTwapTable?: boolean } export function OrderRow({ order, isRateInverted: isGloballyInverted, - showLimitPrice, isHistoryTab, isRowSelectable, isRowSelected, isChild, + isExpanded, orderActions, orderParams, onClick, prices, spotPrice, children, - columnLayout = ColumnLayout.DEFAULT, + childOrders, + isTwapTable, }: OrderRowProps) { const { buyAmount, rateInfoParams, hasEnoughAllowance, hasEnoughBalance, chainId } = orderParams const { creationTime, expirationTime, status } = order const { filledPercentDisplay, executedPrice } = order.executionData const { inputCurrencyAmount, outputCurrencyAmount } = rateInfoParams const { estimatedExecutionPrice, feeAmount } = prices || {} + const isSafeWallet = useIsSafeWallet() const showCancellationModal = useMemo(() => { return orderActions.getShowCancellationModal(order) @@ -119,7 +128,8 @@ export function OrderRow({ const withAllowanceWarning = hasEnoughAllowance === false const withWarning = (hasEnoughBalance === false || withAllowanceWarning) && - // show the warning only for pending and scheduled orders + // show the warning only for pending and scheduled orders, but not for presignature pending + status !== OrderStatus.PRESIGNATURE_PENDING && (status === OrderStatus.PENDING || status === OrderStatus.SCHEDULED) const isOrderScheduled = order.status === OrderStatus.SCHEDULED @@ -154,7 +164,6 @@ export function OrderRow({ const isExecutedPriceZero = executedPriceInverted !== undefined && executedPriceInverted?.equalTo(ZERO_FRACTION) const isUnfillable = !percentIsAlmostHundred(filledPercentDisplay) && (isExecutedPriceZero || withWarning) - const isOrderCreating = CREATING_STATES.includes(order.status) const inputTokenSymbol = order.inputToken.symbol || '' @@ -190,16 +199,87 @@ export function OrderRow({ ) + const areAllChildOrdersCancelled = (orders: ParsedOrder[] | undefined): boolean => { + if (!orders || orders.length === 0) return false + return orders.every((order) => order.status === OrderStatus.CANCELLED) + } + const renderFillsAt = () => ( <> {getIsFinalizedOrder(order) ? ( - '-' + order.executionData.partiallyFilled || order.status === OrderStatus.FULFILLED ? ( + + + Order {order.partiallyFillable && Number(filledPercentDisplay) < 100 ? 'partially ' : ''}filled + + ) : order.status === OrderStatus.CANCELLED ? ( + // For TWAP parent orders, show cancelled only when ALL child orders are cancelled + children ? ( + childOrders && areAllChildOrdersCancelled(childOrders) ? ( + + + Order cancelled + + ) : ( + '-' + ) + ) : ( + // For non-TWAP orders and TWAP child orders, show cancelled normally + + + Order cancelled + + ) + ) : order.status === OrderStatus.EXPIRED ? ( + + + Order expired + + ) : isUnfillable ? ( + '' + ) : ( + '-' + ) + ) : order.status === OrderStatus.PRESIGNATURE_PENDING ? ( + + + This order needs to be signed and executed with your {isSafeWallet ? 'Safe' : 'Smart contract'} wallet + + } + > + + + Please sign order + + + ) : prices && estimatedExecutionPrice ? ( {!isUnfillable && priceDiffs?.percentage && - Math.abs(Number(priceDiffs.percentage.toFixed(4))) <= MIN_PERCENTAGE_TO_DISPLAY ? ( - ⚡️ Pending execution + Math.abs(Number(priceDiffs.percentage.toFixed(4))) <= PENDING_EXECUTION_THRESHOLD_PERCENTAGE ? ( + + The fill price of this order is close or at the market price ( + + fills at{' '} + + + , {priceDiffs.percentage.toFixed(2)}% from market) and is expected to{' '} + {!percentIsAlmostHundred(filledPercentDisplay) ? 'partially' : ''} fill soon + + } + > + + + Pending execution + + ) : ( orderActions.approveOrderToken(order.inputToken)} - WarningTooltip={renderWarningTooltip()} + WarningTooltip={renderWarningTooltip(true)} + onApprove={withAllowanceWarning ? () => orderActions.approveOrderToken(order.inputToken) : undefined} /> )} - ) : prices === null || !estimatedExecutionPrice || isOrderCreating ? ( - '-' ) : ( - + '-' )} ) const renderFillsAtWithDistance = () => { + // Special case for PRESIGNATURE_PENDING - return just the signing content + if (order.status === OrderStatus.PRESIGNATURE_PENDING) { + return renderFillsAt() + } + + // Handle warning states first, regardless of order type + if (withWarning) { + return ( + + orderActions.approveOrderToken(order.inputToken) : undefined} + /> + + ) + } + + // For TWAP parent orders + if (children && childOrders) { + // Check if all child orders are cancelled first + if (areAllChildOrdersCancelled(childOrders)) { + return ( + + + + + Order cancelled + + + + + ) + } + + const nextScheduledOrder = childOrders.find( + (childOrder) => childOrder.status === OrderStatus.SCHEDULED && !getIsFinalizedOrder(childOrder), + ) + + if (nextScheduledOrder) { + // For scheduled orders, use the execution price if available, otherwise use the estimated price from props + const nextOrderExecutionPrice = + nextScheduledOrder.executionData.executedPrice || prices?.estimatedExecutionPrice + const nextOrderPriceDiffs = nextOrderExecutionPrice + ? calculatePriceDifference({ + referencePrice: spotPrice, + targetPrice: nextOrderExecutionPrice, + isInverted: false, + }) + : null + + // Show the execution price for the next scheduled order + let nextOrderFillsAtContent + if (nextScheduledOrder.status === OrderStatus.CANCELLED || nextScheduledOrder.isUnfillable) { + nextOrderFillsAtContent = '' + } else if (!nextOrderExecutionPrice || nextScheduledOrder.status === OrderStatus.CREATING) { + nextOrderFillsAtContent = '-' + } else { + nextOrderFillsAtContent = ( + + ) + } + + const nextOrderDistance = nextOrderPriceDiffs?.percentage + ? `${nextOrderPriceDiffs.percentage.toFixed(2)}%` + : '-' + + return ( + + {nextOrderFillsAtContent} + + {nextOrderDistance} + + + ) + } + + // If no scheduled orders found, show dash + return ( + + - + + ) + } + + // Regular order display logic const fillsAtContent = renderFillsAt() const distance = - !isUnfillable && priceDiffs?.percentage && Number(priceDiffs?.percentage.toFixed(4)) >= MIN_PERCENTAGE_TO_DISPLAY - ? `${priceDiffs?.percentage.toFixed(2)}%` - : '-' + getIsFinalizedOrder(order) || + order.status === OrderStatus.CANCELLED || + isUnfillable || + (priceDiffs?.percentage && + Math.abs(Number(priceDiffs.percentage.toFixed(4))) <= PENDING_EXECUTION_THRESHOLD_PERCENTAGE) + ? '' + : priceDiffs?.percentage + ? `${priceDiffs?.percentage.toFixed(2)}%` + : '-' return ( @@ -247,31 +432,43 @@ export function OrderRow({ ) } - const renderDistanceToMarket = () => ( - <> - {isUnfillable ? ( - '-' - ) : priceDiffs?.percentage && Number(priceDiffs.percentage.toFixed(4)) >= MIN_PERCENTAGE_TO_DISPLAY ? ( - - {priceDiffs.percentage.toFixed(2)}% - - ) : ( - '-' - )} - - ) + const renderMarketPrice = () => { + // Early return for warning states and non-active orders + if ( + withWarning || + order.status === OrderStatus.CREATING || + order.status === OrderStatus.PRESIGNATURE_PENDING || + getIsFinalizedOrder(order) + ) { + return '-' + } + + // Check children finalization status + if (children && childOrders) { + if (childOrders.every((childOrder) => getIsFinalizedOrder(childOrder))) { + return '-' + } + } + + // Handle spot price cases + if (spotPrice === null) { + return '-' + } + + if (spotPrice) { + return ( + + ) + } - const renderMarketPrice = () => ( - <> - {spotPrice ? ( - - ) : spotPrice === null ? ( - '-' - ) : ( - - )} - - ) + return + } return ( {/*Checkbox for multiple cancellation*/} {isRowSelectable && !isHistoryTab && ( @@ -309,37 +507,24 @@ export function OrderRow({ {/* Non-history tab columns */} {!isHistoryTab ? ( <> - {/* Price columns based on layout */} - {columnLayout === ColumnLayout.DEFAULT && ( - <> - - {showLimitPrice ? renderLimitPrice() : renderFillsAt()} - - {renderDistanceToMarket()} - {renderMarketPrice()} - - )} - - {columnLayout === ColumnLayout.VIEW_2 && ( - <> - {renderLimitPrice()} - {renderFillsAt()} - {renderDistanceToMarket()} - - )} - - {columnLayout === ColumnLayout.VIEW_3 && ( - <> - {renderLimitPrice()} - {renderFillsAtWithDistance()} - {renderMarketPrice()} - - )} + {renderLimitPrice()} + {renderFillsAtWithDistance()} + {renderMarketPrice()} {/* Expires and Created for open orders */} - {expirationTimeAgo} - {isScheduledCreating ? 'Creating...' : creationTimeAgo} + + {shouldShowDashForExpiration(order) ? '-' : expirationTimeAgo} + + + {isScheduledCreating ? 'Creating...' : creationTimeAgo} + ) : ( @@ -365,6 +550,8 @@ export function OrderRow({ amount={executedPriceInverted} tokenSymbol={executedPriceInverted?.quoteCurrency} opacitySymbol + clickable + noTitle /> ) : ( '-' @@ -372,10 +559,18 @@ export function OrderRow({ - {order.status === OrderStatus.FULFILLED && fulfillmentTimeAgo ? fulfillmentTimeAgo : '-'} + {order.status === OrderStatus.FULFILLED && fulfillmentTimeAgo ? ( + + {fulfillmentTimeAgo} + + ) : ( + '-' + )} - {creationTimeAgo} + + {creationTimeAgo} + )} @@ -390,20 +585,28 @@ export function OrderRow({ {/* Status label */} - - - - - + {!children && ( + + + + + + )} {/* Children (e.g. ToggleExpandButton for parent orders) */} {children} + {/* Add empty cell for child TWAP orders */} + {isTwapTable && isChild && } + + {/* Add empty cell for signing orders - only for TWAP */} + {isTwapTable && order.status === OrderStatus.PRESIGNATURE_PENDING && } + {/* Action content menu */} ` --height: 28px; margin: 0; - background: ${({ hasBackground = true }) => (hasBackground ? `var(${UI.COLOR_ALERT_BG})` : 'transparent')}; - color: var(${UI.COLOR_ALERT_TEXT}); + background: ${({ hasBackground = true }) => (hasBackground ? `var(${UI.COLOR_DANGER_BG})` : 'transparent')}; + color: var(${UI.COLOR_DANGER}); line-height: 0; border: 0; - padding: 0 5px; + padding: 0; width: auto; height: var(--height); border-radius: 0 9px 9px 0; svg { + cursor: help; color: inherit; } @@ -112,6 +113,14 @@ export const CellElement = styled.div<{ font-weight: 500; width: 100%; text-align: left; + + &[title] { + cursor: help; + } + } + + > span[title] { + cursor: help; } ${({ doubleRow }) => @@ -123,6 +132,10 @@ export const CellElement = styled.div<{ > i { opacity: 0.7; + + &[title] { + cursor: help; + } } `} ${RateWrapper} { @@ -334,3 +347,53 @@ export const ToggleExpandButton = styled.div<{ isCollapsed?: boolean }>` export const DistanceToMarket = styled.span<{ $color: string }>` color: ${({ $color }: { $color: string }) => $color}; ` + +export const CancelledDisplay = styled.div` + display: flex; + align-items: center; + gap: 4px; + color: var(${UI.COLOR_DANGER}); + font-weight: 500; + font-size: 12px; +` + +export const ExpiredDisplay = styled.div` + display: flex; + align-items: center; + gap: 4px; + color: var(${UI.COLOR_ALERT_TEXT}); +` + +export const FilledDisplay = styled.div` + display: flex; + align-items: center; + gap: 4px; + color: var(${UI.COLOR_SUCCESS}); +` + +export const PendingExecutionDisplay = styled.div` + display: flex; + align-items: center; + gap: 4px; + color: var(${UI.COLOR_TEXT}); + font-weight: 500; + font-size: 12px; +` + +export const SigningDisplay = styled.div` + display: flex; + align-items: center; + gap: 4px; + cursor: help; + color: var(${UI.COLOR_ALERT_TEXT}); + + > svg { + width: 14px; + height: 14px; + fill: currentColor; + } + + svg > path { + fill: currentColor; + } +` 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 a34144ee2c..1b7dc255c2 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTable.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTable.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef } from 'react' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { Media, UI } from '@cowprotocol/ui' @@ -12,11 +12,12 @@ import { BalancesAndAllowances } from 'modules/tokens' import { CancellableOrder } from 'common/utils/isOrderCancellable' import { isOrderOffChainCancellable } from 'common/utils/isOrderOffChainCancellable' +import { getIsComposableCowParentOrder } from 'utils/orderUtils/getIsComposableCowParentOrder' import { OrderRow } from './OrderRow' import { CheckboxCheckmark, TableHeader, TableRowCheckbox, TableRowCheckboxWrapper } from './styled' import { TableGroup } from './TableGroup' -import { ColumnLayout, createTableHeaders } from './tableHeaders' +import { createTableHeaders } from './tableHeaders' import { OrderActions } from './types' import { HISTORY_TAB, ORDERS_TABLE_PAGE_SIZE } from '../../const/tabs' @@ -33,17 +34,13 @@ import { OrdersTablePagination } from '../OrdersTablePagination' // TODO: move elements to styled.jsx const TableBox = styled.div` - display: block; + display: flex; + flex-flow: column wrap; border: none; padding: 0; position: relative; background: var(${UI.COLOR_PAPER}); - - ${Media.upToLargeAlt()} { - width: 100%; - display: flex; - flex-flow: column wrap; - } + width: 100%; ` const TableInner = styled.div` @@ -97,7 +94,6 @@ const Rows = styled.div` export interface OrdersTableProps { currentTab: string allowsOffchainSigning: boolean - currentPageNumber: number chainId: SupportedChainId pendingOrdersPrices: PendingOrdersPrices orders: OrderTableItem[] @@ -105,7 +101,7 @@ export interface OrdersTableProps { balancesAndAllowances: BalancesAndAllowances getSpotPrice: (params: SpotPricesKeyParams) => Price | null orderActions: OrderActions - columnLayout?: ColumnLayout + currentPageNumber: number } export function OrdersTable({ @@ -119,10 +115,8 @@ export function OrdersTable({ getSpotPrice, orderActions, currentPageNumber, - columnLayout, }: OrdersTableProps) { const buildOrdersTableUrl = useGetBuildOrdersTableUrl() - const [showLimitPrice, setShowLimitPrice] = useState(false) const checkboxRef = useRef(null) const step = currentPageNumber * ORDERS_TABLE_PAGE_SIZE @@ -172,10 +166,7 @@ export function OrdersTable({ checkbox.checked = allOrdersSelected }, [allOrdersSelected, selectedOrders.length]) - const tableHeaders = useMemo( - () => createTableHeaders(showLimitPrice, setShowLimitPrice, columnLayout), - [showLimitPrice, columnLayout], - ) + const tableHeaders = useMemo(() => createTableHeaders(), []) const visibleHeaders = useMemo(() => { const isHistoryTab = currentTab === HISTORY_TAB.id @@ -187,6 +178,14 @@ export function OrdersTable({ }) }, [tableHeaders, currentTab]) + // Determine if this is a TWAP table by checking if any order has composableCowInfo with a parentId + const isTwapTable = useMemo(() => { + return orders.some((item) => { + const order = getParsedOrderFromTableItem(item) + return getIsComposableCowParentOrder(order) + }) + }, [orders]) + return ( <> @@ -194,7 +193,7 @@ export function OrdersTable({ {visibleHeaders.map((header) => { if (header.id === 'checkbox' && (!isRowSelectable || currentTab === HISTORY_TAB.id)) { @@ -252,11 +251,10 @@ export function OrdersTable({ spotPrice={spotPrice} prices={pendingOrdersPrices[order.id]} isRateInverted={false} - showLimitPrice={showLimitPrice} orderParams={getOrderParams(chainId, balancesAndAllowances, order)} onClick={() => orderActions.selectReceiptOrder(order)} orderActions={orderActions} - columnLayout={columnLayout} + isTwapTable={isTwapTable} /> ) } else { @@ -272,8 +270,8 @@ export function OrdersTable({ spotPrice={spotPrice} prices={pendingOrdersPrices[item.parent.id]} isRateInverted={false} - showLimitPrice={showLimitPrice} orderActions={orderActions} + isTwapTable={isTwapTable} /> ) } 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 ce129d5d03..6d13ee9625 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTabs.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTabs.tsx @@ -1,9 +1,14 @@ +import alertCircle from '@cowprotocol/assets/cow-swap/alert-circle.svg' +import orderPresignaturePending from '@cowprotocol/assets/cow-swap/order-presignature-pending.svg' import { Media, UI } from '@cowprotocol/ui' import { Trans } from '@lingui/macro' +import SVG from 'react-inlinesvg' import { Link } from 'react-router-dom' import styled from 'styled-components/macro' +import { useNavigate } from 'common/hooks/useNavigate' + import { OrderTab } from '../../const/tabs' import { useGetBuildOrdersTableUrl } from '../../hooks/useGetBuildOrdersTableUrl' @@ -12,15 +17,90 @@ const Tabs = styled.div` flex-flow: row wrap; gap: 4px; margin: 0; + + ${Media.upToMedium()} { + display: none; + } ` -const TabButton = styled(Link)<{ active: string }>` - display: inline-block; - 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')}; +const SelectContainer = styled.div` + display: none; + width: 100%; + margin: 0; + position: relative; + + ${Media.upToMedium()} { + display: block; + } + + &::after { + content: ''; + position: absolute; + right: 16px; + top: 50%; + width: 8px; + height: 8px; + border-right: 2px solid var(${UI.COLOR_TEXT_OPACITY_50}); + border-bottom: 2px solid var(${UI.COLOR_TEXT_OPACITY_50}); + transform: translateY(-70%) rotate(45deg); + pointer-events: none; + transition: border-color var(${UI.ANIMATION_DURATION}) ease-in-out; + } + + &:hover::after { + border-color: var(${UI.COLOR_TEXT}); + } +` + +const Select = styled.select` + width: 100%; + padding: 10px 40px 10px 10px; border-radius: 14px; border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); + background: var(${UI.COLOR_PAPER}); + color: inherit; + font-size: 13px; + font-weight: 400; + cursor: pointer; + outline: none; + appearance: none; + text-align: left; + transition: border-color var(${UI.ANIMATION_DURATION}) ease-in-out; + + &:hover, + &:focus { + border-color: var(${UI.COLOR_TEXT_OPACITY_50}); + } + + option { + padding: 10px; + background: var(${UI.COLOR_PAPER}); + color: inherit; + } +` + +const TabButton = styled(Link)<{ active: string; isUnfillable?: boolean; isSigning?: boolean }>` + display: inline-flex; + align-items: center; + gap: 4px; + background: ${({ active, isUnfillable, isSigning }) => + active === 'true' + ? isUnfillable + ? `var(${UI.COLOR_DANGER_BG})` + : isSigning + ? `var(${UI.COLOR_ALERT_BG})` + : `var(${UI.COLOR_TEXT_OPACITY_10})` + : 'transparent'}; + color: ${({ active, isUnfillable, isSigning }) => + isUnfillable + ? `var(${UI.COLOR_DANGER})` + : isSigning + ? `var(${UI.COLOR_ALERT_TEXT})` + : active === 'true' + ? `var(${UI.COLOR_TEXT_PAPER})` + : 'inherit'}; + font-weight: ${({ active }) => (active === 'true' ? '600' : '400')}; + border-radius: 14px; text-decoration: none; font-size: 13px; padding: 10px; @@ -36,9 +116,26 @@ const TabButton = styled(Link)<{ active: string }>` } &:hover { - background: ${({ active }) => - active === 'true' ? `var(${UI.COLOR_TEXT_OPACITY_10})` : `var(${UI.COLOR_TEXT_OPACITY_10})`}; - color: inherit; + background: ${({ active, isUnfillable, isSigning }) => + active === 'true' + ? isUnfillable + ? `var(${UI.COLOR_DANGER_BG})` + : isSigning + ? `var(${UI.COLOR_ALERT_BG})` + : `var(${UI.COLOR_TEXT_OPACITY_10})` + : `var(${UI.COLOR_TEXT_OPACITY_10})`}; + color: ${({ isUnfillable, isSigning }) => + isUnfillable ? `var(${UI.COLOR_DANGER})` : isSigning ? `var(${UI.COLOR_ALERT_TEXT})` : 'inherit'}; + } + + > svg { + width: 14px; + height: 14px; + fill: currentColor; + } + + > svg > path { + fill: currentColor; } ` @@ -48,22 +145,54 @@ export interface OrdersTabsProps { export function OrdersTabs({ tabs }: OrdersTabsProps) { const buildOrdersTableUrl = useGetBuildOrdersTableUrl() + const navigate = useNavigate() const activeTabIndex = Math.max( tabs.findIndex((i) => i.isActive), 0, ) + const handleSelectChange = (event: React.ChangeEvent) => { + const tabId = event.target.value + navigate(buildOrdersTableUrl({ tabId, pageNumber: 1 })) + } + return ( - - {tabs.map((tab, index) => ( - - {tab.title} ({tab.count}) - - ))} - + <> + + + + + + {tabs.map((tab, index) => { + const isUnfillable = tab.id === 'unfillable' + const isSigning = tab.id === 'signing' + return ( + + {isUnfillable && } + {isSigning && } + {tab.title} ({tab.count}) + + ) + })} + + ) } 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 d545967624..214b925d61 100644 --- a/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/TableGroup.tsx +++ b/apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/TableGroup.tsx @@ -12,6 +12,7 @@ import { PendingOrderPrices } from 'modules/orders/state/pendingOrdersPricesAtom import { BalancesAndAllowances } from 'modules/tokens' import { OrderRow } from './OrderRow' +import { WarningTooltip } from './OrderRow/OrderWarning' import * as styledEl from './OrderRow/styled' import { OrderActions } from './types' @@ -19,6 +20,7 @@ import { ORDERS_TABLE_PAGE_SIZE } from '../../const/tabs' import { getOrderParams } from '../../utils/getOrderParams' import { OrderTableGroup } from '../../utils/orderTableGroupUtils' import { OrdersTablePagination } from '../OrdersTablePagination' +import { OrderStatusBox } from '../OrderStatusBox' const GroupBox = styled.div`` @@ -28,18 +30,83 @@ const Pagination = styled(OrdersTablePagination)` padding: 10px 0; ` +function TwapStatusAndToggle({ + parent, + childrenLength, + isCollapsed, + onToggle, + onClick, + children, +}: { + parent: any + childrenLength: number + isCollapsed: boolean + onToggle: () => void + onClick: () => void + children: any[] +}) { + // Check if any child has insufficient balance or allowance + const hasChildWithWarning = children.some( + (child) => + (child.orderParams?.hasEnoughBalance === false || child.orderParams?.hasEnoughAllowance === false) && + (child.order.status === OrderStatus.PENDING || child.order.status === OrderStatus.SCHEDULED), + ) + + // Get the first child with a warning to use its parameters + const childWithWarning = hasChildWithWarning + ? children.find( + (child) => + (child.orderParams?.hasEnoughBalance === false || child.orderParams?.hasEnoughAllowance === false) && + (child.order.status === OrderStatus.PENDING || child.order.status === OrderStatus.SCHEDULED), + ) + : null + + return ( + <> + ( + childWithWarning.orderActions.approveOrderToken(childWithWarning.order.inputToken)} + showIcon={true} + /> + ) + : undefined + } + /> + + {childrenLength && ( + + {childrenLength} part{childrenLength > 1 && 's'} + + )} +