diff --git a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx index 9020f7c004..da4ffec05b 100644 --- a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx +++ b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx @@ -163,6 +163,15 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) { {topContent} +
+ {inputTooltip ? ( + + {numericalInput} + + ) : ( + numericalInput + )} +
-
- {inputTooltip ? ( - - {numericalInput} - - ) : ( - numericalInput - )} -
+
+ {amount && ( + + + + )} +
{balance && !disabled && ( - Balance: + {showSetMax && balance.greaterThan(0) && ( Max )} )}
-
- {amount && ( - - - - )} -
diff --git a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/styled.tsx b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/styled.tsx index 81d5492220..cd619d4475 100644 --- a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/styled.tsx +++ b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/styled.tsx @@ -81,6 +81,7 @@ export const NumericalInput = styled(Input)<{ $loading: boolean }>` font-size: 28px; font-weight: 500; color: inherit; + text-align: left; &::placeholder { opacity: 0.7; @@ -150,7 +151,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/modules/limitOrders/containers/LimitOrdersWidget/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/index.tsx index a2420948e3..5b6cf6ad4f 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/LimitOrdersWidget/index.tsx @@ -91,7 +91,7 @@ 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, @@ -176,7 +176,7 @@ const LimitOrders = React.memo((props: LimitOrdersProps) => { handleUnlock={() => updateLimitOrdersState({ isUnlocked: true })} /> ), - middleContent: ( + topContent: ( <> {!isWrapOrUnwrap && ClosableBanner(ZERO_BANNER_STORAGE_KEY, (onClose) => ( @@ -194,17 +194,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 +236,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/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx index e1d21cab06..58f3bb461f 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx @@ -1,23 +1,28 @@ import { useAtomValue, useSetAtom } from 'jotai' import { useCallback, useEffect, useMemo, useState } from 'react' +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 } from '@cowprotocol/common-utils' -import { HelpTooltip, Loader, TokenSymbol } from '@cowprotocol/ui' +import { TokenLogo } from '@cowprotocol/tokens' +import { TokenSymbol, HoverTooltip, HelpTooltip } 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 { ordersTableFeatures } from 'common/constants/featureFlags' import { ExecutionPrice } from 'common/pure/ExecutionPrice' import { getQuoteCurrency, getQuoteCurrencyByStableCoin } from 'common/services/getQuoteCurrency' @@ -26,21 +31,15 @@ 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, typedValue, isTypedValue, initialRate } = + useAtomValue(limitRateAtom) const updateRate = useUpdateActiveRate() const updateLimitRateState = useSetAtom(updateLimitRateAtom) const executionPrice = useAtomValue(executionPriceAtom) const [isQuoteCurrencySet, setIsQuoteCurrencySet] = useState(false) + const [isUsdMode, setIsUsdMode] = useState(false) + const { limitPriceLocked } = useAtomValue(limitOrdersSettingsAtom) + const updateLimitOrdersSettings = useSetAtom(updateLimitOrdersSettingsAtom) // Limit order state const { inputCurrency, outputCurrency, inputCurrencyAmount, outputCurrencyAmount } = useLimitOrdersDerivedState() @@ -84,13 +83,33 @@ export function RateInput() { isAlternativeOrderRate: false, }) }, - [isInverted, updateRate, updateLimitRateState] + [isInverted, updateRate, updateLimitRateState], ) + // Handle toggle USD mode + const handleToggleUsdMode = useCallback(() => { + setIsUsdMode(!isUsdMode) + }, [isUsdMode]) + // Handle toggle primary field const handleToggle = useCallback(() => { - updateLimitRateState({ isInverted: !isInverted, isTypedValue: false }) - }, [isInverted, updateLimitRateState]) + if (isUsdMode) { + // When in USD mode, just switch to token mode without toggling tokens + setIsUsdMode(false) + } else { + // When already in token mode, toggle between tokens + updateLimitRateState({ isInverted: !isInverted, isTypedValue: false }) + } + }, [isInverted, updateLimitRateState, isUsdMode]) + + // Handle toggle price lock + const handleTogglePriceLock = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + updateLimitOrdersSettings({ limitPriceLocked: !limitPriceLocked }) + }, + [limitPriceLocked, updateLimitOrdersSettings], + ) const isDisabledMPrice = useMemo(() => { if (isLoadingMarketRate) return true @@ -144,13 +163,39 @@ export function RateInput() { <> - - - - Set to market - + + + + + + } + onToggle={handleToggle} + /> + + {areBothCurrencies && ( + + Market:{' '} + + {isLoadingMarketRate ? ( + + ) : marketRate && !marketRate.equalTo(0) ? ( + formatInputAmount(isInverted ? marketRate.invert() : marketRate) + ) : ( + '' + )} + + + )} - {isLoading && areBothCurrencies ? ( @@ -163,42 +208,36 @@ export function RateInput() { /> )} - - - - - - - - + + + + + + + + + + + + - {ordersTableFeatures.DISPLAY_EST_EXECUTION_PRICE && ( - - - Order executes at{' '} - {isLoadingMarketRate ? ( - - ) : executionPrice ? ( - - } - /> - ) : null} - - {!isLoadingMarketRate && executionPrice && ( - + + + {isLoadingMarketRate ? ( + + ) : executionPrice ? ( + + ) : ( + '-' )} - - )} + + + 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..5750c21f07 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 UsdIcon from '@cowprotocol/assets/images/icon-USD.svg' import { Menu, MenuItem } from '@reach/menu-button' +import SVG from 'react-inlinesvg' -import { MenuContent, SettingsButton, SettingsIcon } from 'modules/trade/pure/Settings' +import { ButtonsContainer, MenuContent, SettingsButton, SettingsIcon, UsdButton } from 'modules/trade/pure/Settings' import { Settings } from '../../pure/Settings' import { limitOrdersSettingsAtom, updateLimitOrdersSettingsAtom } from '../../state/limitOrdersSettingsAtom' @@ -13,10 +15,15 @@ export function SettingsWidget() { const updateSettingsState = useSetAtom(updateLimitOrdersSettingsAtom) return ( - <> + + {/* TODO: add active state */} + + + - + {/* TODO: add active state */} + void 0}> @@ -24,6 +31,6 @@ export function SettingsWidget() { - + ) } 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 1508275f31..db516ce54d 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeRateDetails/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeRateDetails/index.tsx @@ -2,6 +2,8 @@ import React, { useState, useCallback } from 'react' import { useInjectedWidgetParams } from 'modules/injectedWidget' 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' @@ -11,10 +13,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 widgetParams = useInjectedWidgetParams() const volumeFee = useVolumeFee() const partnerFeeAmount = useLimitOrderPartnerFeeAmount() @@ -23,8 +26,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) { + // When changing BUY amount + if (limitPriceLocked) { + // If price is locked, only update the output amount + const update: Partial> = { + orderKind, + outputCurrencyAmount: FractionUtils.serializeFractionToJSON(amount), + } + updateLimitOrdersState(update) + } else { + // If price is unlocked, update the rate based on the new amounts + const update: Partial> = { + orderKind, + outputCurrencyAmount: FractionUtils.serializeFractionToJSON(amount), + } + updateLimitOrdersState(update) + + // 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 +80,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 b0a04d3be0..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: '6 Months (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/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.tsx b/apps/cowswap-frontend/src/modules/limitOrders/pure/Settings/index.tsx index a12ec03174..94f49afc4b 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,135 @@ +import { useCallback, useState } from 'react' + +import { UI } from '@cowprotocol/ui' +import { HelpTooltip } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + 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', +} + export function Settings({ state, onStateChanged }: SettingsProps) { - const { showRecipient, partialFillsEnabled } = state + const { showRecipient, partialFillsEnabled, limitPricePosition, limitPriceLocked } = state + const [isOpen, setIsOpen] = useState(false) + + const handleSelect = useCallback( + (value: LimitOrdersSettingsState['limitPricePosition']) => (e: React.MouseEvent) => { + e.stopPropagation() + onStateChanged({ limitPricePosition: value }) + setIsOpen(false) + }, + [onStateChanged], + ) + + const toggleDropdown = (e: React.MouseEvent) => { + e.stopPropagation() + setIsOpen(!isOpen) + } return ( - Interface Settings + Limit Order Settings onStateChanged({ partialFillsEnabled: !partialFillsEnabled })} /> + + onStateChanged({ limitPriceLocked: !limitPriceLocked })} + /> + + + + 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 b4df5c2ef4..ee14f8fc04 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/state/limitOrdersSettingsAtom.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/state/limitOrdersSettingsAtom.ts @@ -17,6 +17,8 @@ export interface LimitOrdersSettingsState { readonly partialFillsEnabled: boolean readonly deadlineMilliseconds: Milliseconds readonly customDeadlineTimestamp: Timestamp | null + readonly limitPricePosition: 'top' | 'between' | 'bottom' + readonly limitPriceLocked: boolean } export const defaultLimitOrdersSettings: LimitOrdersSettingsState = { @@ -24,40 +26,42 @@ export const defaultLimitOrdersSettings: LimitOrdersSettingsState = { partialFillsEnabled: true, deadlineMilliseconds: defaultLimitOrderDeadline.value, customDeadlineTimestamp: null, + limitPricePosition: 'top', + limitPriceLocked: true, } // regular const regularLimitOrdersSettingsAtom = atomWithStorage( 'limit-orders-settings-atom:v2', 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/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..9f145ac95c 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; +import { SettingsButtonIcon } from './styled' - &:hover { - opacity: 1; - } -` - -export function SettingsIcon() { +export function SettingsIcon({ active }: { active?: boolean }) { 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..0570b448da 100644 --- a/apps/cowswap-frontend/src/modules/trade/pure/Settings/styled.ts +++ b/apps/cowswap-frontend/src/modules/trade/pure/Settings/styled.ts @@ -2,13 +2,15 @@ import { UI } from '@cowprotocol/ui' import { MenuButton, MenuList } from '@reach/menu-button' import { transparentize } from 'color2k' -import styled from 'styled-components/macro' +import styled, { css } from 'styled-components/macro' export const SettingsTitle = styled.h3` font-weight: 600; - font-size: 14px; + font-size: 18px; color: inherit; - margin: 0 0 12px 0; + margin: 0 auto 21px; + width: 100%; + text-align: center; ` export const SettingsContainer = styled.div` @@ -56,8 +58,56 @@ 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; +` + +export const UsdButton = styled.button<{ active?: boolean }>` + ${iconButtonStyles} + --iconSize: 20px; +` 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