diff --git a/src/components/FeeControlGroup/CustomFeeInput.tsx b/src/components/FeeControlGroup/CustomFeeInput.tsx new file mode 100644 index 0000000000..38e6b85d51 --- /dev/null +++ b/src/components/FeeControlGroup/CustomFeeInput.tsx @@ -0,0 +1,169 @@ +import { t } from '@lingui/macro' +import React, { useEffect, useRef, useState } from 'react' +import { Text } from 'rebass' +import styled, { css } from 'styled-components' + +import Tooltip from 'components/Tooltip' +import { DEFAULT_TIPS } from 'constants/index' +import { formatSlippage } from 'utils/slippage' + +const parseTipInput = (str: string): number => Math.round(Number.parseFloat(str) * 100) + +const getFeeText = (fee: number) => { + const isCustom = !DEFAULT_TIPS.includes(fee) + if (!isCustom) { + return '' + } + return formatSlippage(fee, false) +} + +const feeOptionCSS = css` + height: 100%; + padding: 0; + border-radius: 20px; + border: 1px solid transparent; + + background-color: ${({ theme }) => theme.tabBackground}; + color: ${({ theme }) => theme.subText}; + text-align: center; + + font-size: 12px; + font-weight: 400; + line-height: 16px; + + outline: none; + cursor: pointer; + + :hover { + border-color: ${({ theme }) => theme.bg4}; + } + :focus { + border-color: ${({ theme }) => theme.bg4}; + } + + &[data-active='true'] { + background-color: ${({ theme }) => theme.tabActive}; + color: ${({ theme }) => theme.text}; + border-color: ${({ theme }) => theme.primary}; + + font-weight: 500; + } +` + +const CustomFeeOption = styled.div` + ${feeOptionCSS}; + + flex: 0 0 24%; + + display: inline-flex; + align-items: center; + padding: 0 4px; + column-gap: 2px; + flex: 1; + + transition: all 150ms linear; + + &[data-active='true'] { + color: ${({ theme }) => theme.text}; + font-weight: 500; + } +` + +const CustomInput = styled.input` + width: 100%; + height: 100%; + border: 0px; + border-radius: inherit; + + color: inherit; + background: transparent; + outline: none; + text-align: right; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + } + &::placeholder { + font-size: 12px; + } + @media only screen and (max-width: 375px) { + font-size: 10px; + } +` + +export type Props = { + fee: number + onFeeChange: (value: number) => void +} + +const CustomFeeInput = ({ fee, onFeeChange }: Props) => { + const [text, setText] = useState(getFeeText(fee)) + const [tooltip, setTooltip] = useState('') + const inputRef = useRef(null) + const isCustomOptionActive = !DEFAULT_TIPS.includes(fee) + + const handleChangeInput = (e: React.ChangeEvent) => { + setTooltip('') + const value = e.target.value + + if (value === '') { + setText(value) + onFeeChange(50) + return + } + + const numberRegex = /^(\d+)\.?(\d{1,2})?$/ + if (!value.match(numberRegex)) { + e.preventDefault() + return + } + + const parsedValue = parseTipInput(value) + if (Number.isNaN(parsedValue)) { + e.preventDefault() + return + } + + const maxCustomFee = 9999 + if (parsedValue > maxCustomFee) { + const format = formatSlippage(maxCustomFee) + setTooltip(t`Max is ${format}`) + e.preventDefault() + return + } + + setText(value) + onFeeChange(parsedValue) + } + + const handleCommitChange = () => { + setTooltip('') + setText(getFeeText(fee)) + } + + useEffect(() => { + if (inputRef.current !== document.activeElement) { + setText(getFeeText(fee)) + } + }, [fee]) + + return ( + + + + + % + + + + ) +} + +export default CustomFeeInput diff --git a/src/components/FeeControlGroup/index.tsx b/src/components/FeeControlGroup/index.tsx new file mode 100644 index 0000000000..c427af397d --- /dev/null +++ b/src/components/FeeControlGroup/index.tsx @@ -0,0 +1,116 @@ +import { Trans } from '@lingui/macro' +import { useSearchParams } from 'react-router-dom' +import { Flex, Text } from 'rebass' +import styled, { css } from 'styled-components' + +import useGetFeeConfig from 'components/SwapForm/hooks/useGetFeeConfig' +import { DEFAULT_TIPS } from 'constants/index' +import useTheme from 'hooks/useTheme' + +import CustomFeeInput from './CustomFeeInput' + +const feeOptionCSS = css` + height: 100%; + padding: 0; + border-radius: 20px; + border: 1px solid transparent; + + background-color: ${({ theme }) => theme.tabBackground}; + color: ${({ theme }) => theme.subText}; + text-align: center; + + font-size: 12px; + font-weight: 400; + line-height: 16px; + + outline: none; + cursor: pointer; + + :hover { + border-color: ${({ theme }) => theme.bg4}; + } + :focus { + border-color: ${({ theme }) => theme.bg4}; + } + + &[data-active='true'] { + background-color: ${({ theme }) => theme.tabActive}; + color: ${({ theme }) => theme.text}; + border-color: ${({ theme }) => theme.primary}; + + font-weight: 500; + } +` + +const DefaultFeeOption = styled.button` + ${feeOptionCSS}; + flex: 0 0 18%; + + @media only screen and (max-width: 375px) { + font-size: 10px; + flex: 0 0 15%; + } +` + +const FeeControlGroup = () => { + const theme = useTheme() + const { feeAmount, enableTip } = useGetFeeConfig() ?? {} + const [searchParams, setSearchParams] = useSearchParams() + const feeValue = Number.parseFloat(feeAmount ?? '0') + + const handleFeeChange = (feeValue: number) => { + if (enableTip) { + searchParams.set('feeAmount', feeValue.toString()) + setSearchParams(searchParams) + } + } + + if (!enableTip) { + return null + } + + return ( + + + Tip: + + + No hidden fees - Your optional tips support DEX Screener! + + + + {DEFAULT_TIPS.map(tip => ( + { + handleFeeChange(tip) + }} + data-active={tip === feeValue} + > + {tip ? `${tip / 100}%` : No tip} + + ))} + + + + ) +} + +export default FeeControlGroup diff --git a/src/components/SlippageControl/CustomSlippageInput.tsx b/src/components/SlippageControl/CustomSlippageInput.tsx index a3bd47c46a..d370b21ca3 100644 --- a/src/components/SlippageControl/CustomSlippageInput.tsx +++ b/src/components/SlippageControl/CustomSlippageInput.tsx @@ -171,20 +171,6 @@ const CustomSlippageInput: React.FC = ({ rawSlippage, setRawSlippage, isW mixpanelHandler(MIXPANEL_TYPE.SLIPPAGE_CHANGED, { new_slippage: Number(formatSlippage(rawSlippage, false)) }) } - const handleKeyPressInput = (e: React.KeyboardEvent) => { - const key = e.key - if (key === '.' || ('0' <= key && key <= '9')) { - return - } - - if (key === 'Enter') { - inputRef.current?.blur() - return - } - - e.preventDefault() - } - useEffect(() => { if (inputRef.current !== document.activeElement) { setRawText(getSlippageText(rawSlippage)) @@ -207,7 +193,6 @@ const CustomSlippageInput: React.FC = ({ rawSlippage, setRawSlippage, isW placeholder={t`Custom`} value={rawText} onChange={handleChangeInput} - onKeyPress={handleKeyPressInput} onBlur={handleCommitChange} /> - Max Slippage + Max Slippage: - : { + const [searchParams] = useSearchParams() + + const feeAmount = searchParams.get('feeAmount') || '' + const chargeFeeBy = (searchParams.get('chargeFeeBy') as ChargeFeeBy) || ChargeFeeBy.NONE + const enableTip = convertStringToBoolean(searchParams.get('enableTip') || '') + const isInBps = searchParams.get('isInBps') || '' + const feeReceiver = searchParams.get('feeReceiver') || '' + + const feeConfigFromUrl = useMemo(() => { + if (feeAmount && chargeFeeBy && (enableTip || isInBps) && feeReceiver) + return { + feeAmount, + chargeFeeBy, + enableTip, + isInBps: enableTip ? '1' : isInBps, + feeReceiver, + } + return null + }, [feeAmount, chargeFeeBy, enableTip, isInBps, feeReceiver]) + + return feeConfigFromUrl +} + +export default useGetFeeConfig diff --git a/src/components/SwapForm/hooks/useGetRoute.ts b/src/components/SwapForm/hooks/useGetRoute.ts index bd4f9bcea9..f0d02e35ac 100644 --- a/src/components/SwapForm/hooks/useGetRoute.ts +++ b/src/components/SwapForm/hooks/useGetRoute.ts @@ -1,10 +1,10 @@ import { ChainId, Currency, CurrencyAmount } from '@kyberswap/ks-sdk-core' import debounce from 'lodash/debounce' import { useCallback, useEffect, useMemo, useRef } from 'react' -import { useSearchParams } from 'react-router-dom' import routeApi from 'services/route' import { GetRouteParams } from 'services/route/types/getRoute' +import useGetFeeConfig from 'components/SwapForm/hooks/useGetFeeConfig' import useGetSwapFeeConfig, { SwapFeeConfig } from 'components/SwapForm/hooks/useGetSwapFeeConfig' import useSelectedDexes from 'components/SwapForm/hooks/useSelectedDexes' import { AGGREGATOR_API } from 'constants/env' @@ -80,23 +80,7 @@ const useGetRoute = (args: ArgsGetRoute) => { const { chainId: currentChain } = useActiveWeb3React() const chainId = customChain || currentChain - const [searchParams] = useSearchParams() - - const feeAmount = searchParams.get('feeAmount') || '' - const chargeFeeBy = (searchParams.get('chargeFeeBy') as ChargeFeeBy) || ChargeFeeBy.NONE - const isInBps = searchParams.get('isInBps') || '' - const feeReceiver = searchParams.get('feeReceiver') || '' - - const feeConfigFromUrl = useMemo(() => { - if (feeAmount && chargeFeeBy && isInBps && feeReceiver) - return { - feeAmount, - chargeFeeBy, - isInBps, - feeReceiver, - } - return null - }, [feeAmount, chargeFeeBy, isInBps, feeReceiver]) + const feeConfigFromUrl = useGetFeeConfig() const [trigger, _result] = routeApi.useLazyGetRouteQuery() const aggregatorDomain = useRouteApiDomain() diff --git a/src/components/SwapForm/index.tsx b/src/components/SwapForm/index.tsx index d3be063070..629a2b19ad 100644 --- a/src/components/SwapForm/index.tsx +++ b/src/components/SwapForm/index.tsx @@ -10,6 +10,7 @@ import { parseGetRouteResponse } from 'services/route/utils' import styled from 'styled-components' import AddressInputPanel from 'components/AddressInputPanel' +import FeeControlGroup from 'components/FeeControlGroup' import { Clock } from 'components/Icons' import { NetworkSelector } from 'components/NetworkSelector' import { AutoRow } from 'components/Row' @@ -32,6 +33,7 @@ import { useActiveWeb3React } from 'hooks' import useTheme from 'hooks/useTheme' import useWrapCallback, { WrapType } from 'hooks/useWrapCallback' import { PROFILE_MANAGE_ROUTES } from 'pages/NotificationCenter/const' +import useUpdateSlippageInStableCoinSwap from 'pages/SwapV3/useUpdateSlippageInStableCoinSwap' import { Field } from 'state/swap/actions' import { useSwapActionHandlers, useSwapState } from 'state/swap/hooks' import { MEDIA_WIDTHS } from 'theme' @@ -114,6 +116,7 @@ const SwapForm: React.FC = props => { const [isSaveGas, setSaveGas] = useState(false) const theme = useTheme() const upToExtraSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToExtraSmall}px)`) + useUpdateSlippageInStableCoinSwap() const { onUserInput: updateInputAmount } = useSwapActionHandlers() const onUserInput = useCallback( @@ -261,6 +264,7 @@ const SwapForm: React.FC = props => { )} + diff --git a/src/constants/index.ts b/src/constants/index.ts index 1703e63178..60ef14fd15 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -134,6 +134,7 @@ export const SWR_KEYS = { export const MAX_NORMAL_SLIPPAGE_IN_BIPS = 1999 export const MAX_DEGEN_SLIPPAGE_IN_BIPS = 5000 export const DEFAULT_SLIPPAGES = [5, 10, 50, 100] +export const DEFAULT_TIPS = [0, 10, 50, 100] export const DEFAULT_SLIPPAGE = 50 export const DEFAULT_SLIPPAGE_STABLE_PAIR_SWAP = 5 diff --git a/src/pages/PartnerSwap/index.tsx b/src/pages/PartnerSwap/index.tsx index 3ebd814119..b7857f30b9 100644 --- a/src/pages/PartnerSwap/index.tsx +++ b/src/pages/PartnerSwap/index.tsx @@ -87,7 +87,7 @@ export const RoutingIconWrapper = styled(RoutingIcon)` } ` -export default function Swap() { +export default function PartnerSwap() { const { account, chainId: walletChainId } = useActiveWeb3React() const { changeNetwork } = useChangeNetwork() const [searchParams, setSearchParams] = useSearchParams() diff --git a/src/pages/SwapV3/PopulatedSwapForm.tsx b/src/pages/SwapV3/PopulatedSwapForm.tsx index 4b30877ddf..c9d6c20f06 100644 --- a/src/pages/SwapV3/PopulatedSwapForm.tsx +++ b/src/pages/SwapV3/PopulatedSwapForm.tsx @@ -5,7 +5,6 @@ import { useLocation, useSearchParams } from 'react-router-dom' import SwapForm, { SwapFormProps } from 'components/SwapForm' import { APP_PATHS } from 'constants/index' import useSyncTokenSymbolToUrl from 'hooks/useSyncTokenSymbolToUrl' -import useUpdateSlippageInStableCoinSwap from 'pages/SwapV3/useUpdateSlippageInStableCoinSwap' import { useAppSelector } from 'state/hooks' import { Field } from 'state/swap/actions' import { useInputCurrency, useOutputCurrency, useSwapActionHandlers } from 'state/swap/hooks' @@ -43,8 +42,6 @@ const PopulatedSwapForm: React.FC = ({ const { onCurrencySelection, onResetSelectCurrency } = useSwapActionHandlers() - useUpdateSlippageInStableCoinSwap() - const { pathname } = useLocation() const [searchParams, setSearchParams] = useSearchParams() const isPartnerSwap = pathname.startsWith(APP_PATHS.PARTNER_SWAP) diff --git a/src/pages/SwapV3/useUpdateSlippageInStableCoinSwap.tsx b/src/pages/SwapV3/useUpdateSlippageInStableCoinSwap.tsx index 32552c8bef..5197593010 100644 --- a/src/pages/SwapV3/useUpdateSlippageInStableCoinSwap.tsx +++ b/src/pages/SwapV3/useUpdateSlippageInStableCoinSwap.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react' import { useSelector } from 'react-redux' +import { useSearchParams } from 'react-router-dom' import { usePrevious } from 'react-use' import { DEFAULT_SLIPPAGE, DEFAULT_SLIPPAGE_STABLE_PAIR_SWAP } from 'constants/index' @@ -12,12 +13,18 @@ import { useUserSlippageTolerance } from 'state/user/hooks' const useUpdateSlippageInStableCoinSwap = () => { const { chainId } = useActiveWeb3React() const { isStableCoin } = useStableCoins(chainId) - const inputCurrencyId = useSelector((state: AppState) => state.swap[Field.INPUT].currencyId) - const previousInputCurrencyId = usePrevious(inputCurrencyId) - const outputCurrencyId = useSelector((state: AppState) => state.swap[Field.OUTPUT].currencyId) - const previousOutputCurrencyId = usePrevious(outputCurrencyId) + + const [searchParams] = useSearchParams() const [slippage, setSlippage] = useUserSlippageTolerance() + const inputTokenFromParam = searchParams.get('inputCurrency') ?? '' + const outputTokenFromParam = searchParams.get('outputCurrency') ?? '' + + const inputCurrencyId = useSelector((state: AppState) => state.swap[Field.INPUT].currencyId) || inputTokenFromParam + const outputCurrencyId = useSelector((state: AppState) => state.swap[Field.OUTPUT].currencyId) || outputTokenFromParam + + const previousInputCurrencyId = usePrevious(inputCurrencyId) + const previousOutputCurrencyId = usePrevious(outputCurrencyId) const rawSlippageRef = useRef(slippage) rawSlippageRef.current = slippage diff --git a/src/services/route/types/getRoute.ts b/src/services/route/types/getRoute.ts index 46b054b0d4..40ad3bd645 100644 --- a/src/services/route/types/getRoute.ts +++ b/src/services/route/types/getRoute.ts @@ -1,4 +1,4 @@ -import { ChargeFeeBy, Route } from 'types/route' +import { ChargeFeeBy, ExtraFeeConfig, Route } from 'types/route' export type GetRouteParams = { tokenIn: string @@ -31,13 +31,7 @@ export type RouteSummary = { gasUsd: string gasPrice: string - extraFee: { - feeAmount: string - chargeFeeBy: ChargeFeeBy - isInBps: boolean - feeReceiver: string - feeAmountUsd: string - } + extraFee: ExtraFeeConfig route: Route[][] } diff --git a/src/types/route.ts b/src/types/route.ts index bbd86f7758..6d36706083 100644 --- a/src/types/route.ts +++ b/src/types/route.ts @@ -22,7 +22,7 @@ export enum ChargeFeeBy { NONE = '', } -type ExtraFeeConfig = { +export type ExtraFeeConfig = { feeAmount: string feeAmountUsd: string chargeFeeBy: ChargeFeeBy diff --git a/src/utils/string.ts b/src/utils/string.ts index 3e0f7d6358..e782e1ec4e 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -49,3 +49,8 @@ export function capitalizeFirstLetter(str?: string) { const string = str || '' return string.charAt(0).toUpperCase() + string.slice(1) } + +export function convertStringToBoolean(value?: string) { + if (!value) return false + return ['1', 'true', 'yes'].includes(value.toLowerCase()) +}