diff --git a/src/assets/svg/QR.svg b/src/assets/svg/QR.svg new file mode 100644 index 0000000000..dabbb02dff --- /dev/null +++ b/src/assets/svg/QR.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Button/index.js b/src/components/Button/index.js index 8ed04be1da..6537a0996a 100644 --- a/src/components/Button/index.js +++ b/src/components/Button/index.js @@ -89,6 +89,27 @@ export const ButtonEmpty = styled(Base)` } ` +export const ButtonWhite = styled(Base)` + border: 1px solid #edeef2; + background-color: ${({ theme }) => theme.panelBackground}; + }; + color: black; + + &:focus { + box-shadow: 0 0 0 1pt ${({ theme }) => darken(0.05, '#edeef2')}; + } + &:hover { + box-shadow: 0 0 0 1pt ${({ theme }) => darken(0.1, '#edeef2')}; + } + &:active { + box-shadow: 0 0 0 1pt ${({ theme }) => darken(0.1, '#edeef2')}; + } + &:disabled { + opacity: 50%; + cursor: auto; + } +` + const ButtonConfirmedStyle = styled(Base)` background-color: ${({ theme }) => lighten(0.5, theme.connectedGreen)}; border: 1px solid ${({ theme }) => theme.connectedGreen}; @@ -160,7 +181,7 @@ export function ButtonDropwdownLight({ disabled, children, ...rest }) { export function ButtonRadio({ active, children, ...rest }) { if (!active) { - return {children} + return {children} } else { return {children} } diff --git a/src/components/CurrencyInputPanel/index.js b/src/components/CurrencyInputPanel/index.js index 659bbde913..280b51e299 100644 --- a/src/components/CurrencyInputPanel/index.js +++ b/src/components/CurrencyInputPanel/index.js @@ -48,10 +48,9 @@ const InputRow = styled.div` const CurrencySelect = styled.button` align-items: center; height: 2.2rem; - font-size: 20px; - background-color: ${({ selected, theme }) => (selected ? theme.buttonBackgroundPlain : theme.zumthorBlue)}; - color: ${({ selected, theme }) => (selected ? theme.textColor : theme.royalBlue)}; + background-color: ${({ selected, theme }) => (selected ? theme.buttonBackgroundPlain : theme.royalBlue)}; + color: ${({ selected, theme }) => (selected ? theme.textColor : theme.white)}; border: 1px solid ${({ selected, theme, disableTokenSelect }) => disableTokenSelect ? theme.buttonBackgroundPlain : selected ? theme.buttonOutlinePlain : theme.royalBlue}; @@ -70,7 +69,8 @@ const CurrencySelect = styled.button` } :active { - background-color: ${({ theme }) => theme.zumthorBlue}; + background-color: ${({ selected, theme }) => + selected ? darken(0.1, theme.zumthorBlue) : darken(0.1, theme.royalBlue)}; } ` @@ -85,20 +85,20 @@ const StyledDropDown = styled(DropDown)` height: 35%; path { - stroke: ${({ selected, theme }) => (selected ? theme.textColor : theme.royalBlue)}; + stroke: ${({ selected, theme }) => (selected ? theme.textColor : theme.white)}; } ` const InputPanel = styled.div` ${({ theme }) => theme.flexColumnNoWrap} position: relative; - border-radius: 1.25rem; + border-radius: ${({ hideInput }) => (hideInput ? '8px' : '20px')}; background-color: ${({ theme }) => theme.inputBackground}; z-index: 1; ` const Container = styled.div` - border-radius: 1.25rem; + border-radius: ${({ hideInput }) => (hideInput ? '8px' : '20px')}; border: 1px solid ${({ error, theme }) => (error ? theme.salmonRed : theme.mercuryGray)}; background-color: ${({ theme }) => theme.inputBackground}; @@ -174,7 +174,10 @@ export default function CurrencyInputPanel({ hideBalance = false, isExchange = false, exchange = null, // used for double token logo - customBalance = null // used for LP balances instead of token balance + customBalance = null, // used for LP balances instead of token balance + hideInput = false, + showSendWithSwap = false, + onTokenSelectSendWithSwap = null }) { const { account, chainId } = useWeb3React() const { t } = useTranslation() @@ -236,7 +239,7 @@ export default function CurrencyInputPanel({ return ( - + {!hideBalance && ( @@ -250,15 +253,19 @@ export default function CurrencyInputPanel({ )} - - { - onUserInput(field, val) - }} - /> - {!!token?.address && !atMax && MAX} - {renderUnlockButton()} + + {!hideInput && ( + <> + { + onUserInput(field, val) + }} + /> + {!!token?.address && !atMax && MAX} + {renderUnlockButton()} + + )} { @@ -296,6 +303,8 @@ export default function CurrencyInputPanel({ urlAddedTokens={urlAddedTokens} field={field} onTokenSelect={onTokenSelection} + showSendWithSwap={showSendWithSwap} + onTokenSelectSendWithSwap={onTokenSelectSendWithSwap} /> )} diff --git a/src/components/ExchangePage/index.tsx b/src/components/ExchangePage/index.tsx index b0c5307523..0a93a9872f 100644 --- a/src/components/ExchangePage/index.tsx +++ b/src/components/ExchangePage/index.tsx @@ -4,6 +4,7 @@ import { ethers } from 'ethers' import { parseUnits, parseEther } from '@ethersproject/units' import { WETH, TradeType, Route, Trade, TokenAmount, JSBI } from '@uniswap/sdk' +import QR from '../../assets/svg/QR.svg' import TokenLogo from '../TokenLogo' import QuestionHelper from '../Question' import NumericalInput from '../NumericalInput' @@ -11,23 +12,23 @@ import ConfirmationModal from '../ConfirmationModal' import CurrencyInputPanel from '../CurrencyInputPanel' import { Link } from '../../theme/components' import { Text } from 'rebass' -import ThemeProvider, { TYPE } from '../../theme' -import { GreyCard } from '../../components/Card' +import { TYPE } from '../../theme' +import { GreyCard, LightCard } from '../../components/Card' import { ArrowDown, ArrowUp } from 'react-feather' +import { ButtonPrimary, ButtonError, ButtonRadio } from '../Button' import { AutoColumn, ColumnCenter } from '../../components/Column' -import { ButtonError, ButtonRadio } from '../Button' import Row, { RowBetween, RowFixed } from '../../components/Row' import { usePopups } from '../../contexts/Application' import { useToken } from '../../contexts/Tokens' import { useExchange } from '../../contexts/Exchanges' -import { useWeb3React } from '../../hooks' +import { useWeb3React, useTokenContract } from '../../hooks' import { useAddressBalance } from '../../contexts/Balances' import { useTransactionAdder } from '../../contexts/Transactions' import { useAddressAllowance } from '../../contexts/Allowances' import { ROUTER_ADDRESSES } from '../../constants' -import { getRouterContract, calculateGasMargin } from '../../utils' +import { getRouterContract, calculateGasMargin, isAddress, getProviderOrSigner } from '../../utils' const Wrapper = styled.div` position: relative; @@ -64,7 +65,66 @@ const InputWrapper = styled(RowBetween)` border-radius: 8px; padding: 4px 8px; border: 1px solid transparent; - border: ${({ active, theme }) => active && '1px solid ' + theme.royalBlue}; + border: ${({ active, error, theme }) => + error ? '1px solid ' + theme.salmonRed : active ? '1px solid ' + theme.royalBlue : ''}; +` + +const InputGroup = styled(AutoColumn)` + position: relative; + padding: 40px 0; +` + +const QRWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + border: 1px solid ${({ theme }) => theme.outlineGrey}; + background: #fbfbfb; + padding: 4px; + border-radius: 8px; +` + +const StyledInput = styled.input` + width: ${({ width }) => width}; + border: none; + outline: none; + font-size: 20px; + + ::placeholder { + color: #edeef2; + } +` + +const StyledNumerical = styled(NumericalInput)` + text-align: center; + font-size: 48px; + font-weight: 500px; + width: 100%; + + ::placeholder { + color: #edeef2; + } +` + +const MaxButton = styled.button` + position: absolute; + right: 70px; + padding: 0.5rem 1rem; + background-color: ${({ theme }) => theme.zumthorBlue}; + border: 1px solid ${({ theme }) => theme.zumthorBlue}; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + margin-right: 0.5rem; + color: ${({ theme }) => theme.royalBlue}; + :hover { + border: 1px solid ${({ theme }) => theme.royalBlue}; + } + :focus { + border: 1px solid ${({ theme }) => theme.royalBlue}; + outline: none; + } ` enum Field { @@ -91,7 +151,7 @@ function initializeSwapState(inputAddress?: string, outputAddress?: string): Swa address: inputAddress }, [Field.OUTPUT]: { - address: '0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735' + address: outputAddress } } } @@ -192,22 +252,28 @@ const INITIAL_ALLOWED_SLIPPAGE = 200 const DEFAULT_DEADLINE_FROM_NOW = 60 * 15 // used for warning states based on slippage in bips -const ALLOWED_IMPACT_MEDIUM = 100 -const ALLOWED_IMPACT_HIGH = 500 +const ALLOWED_SLIPPAGE_MEDIUM = 100 +const ALLOWED_SLIPPAGE_HIGH = 500 -export default function ExchangePage() { +export default function ExchangePage({ sendingInput = false }) { const { chainId, account, library } = useWeb3React() const routerAddress = ROUTER_ADDRESSES[chainId] // adding notifications on txns const [, addPopup] = usePopups() + const addTransaction = useTransactionAdder() + + // sending state + const [sending, setSending] = useState(sendingInput) + const [sendingWithSwap, setSendingWithSwap] = useState(false) + const [recipient, setRecipient] = useState('') // input details const [state, dispatch] = useReducer(reducer, WETH[chainId].address, initializeSwapState) const { independentField, typedValue, ...fieldData } = state const dependentField = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT const tradeType = independentField === Field.INPUT ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT - const [tradeError, setTradeError] = useState('') // error for thinsg liek reserve sizes + const [tradeError, setTradeError] = useState('') // error for things like reserve size or route const tokens = { [Field.INPUT]: useToken(fieldData[Field.INPUT].address), @@ -217,12 +283,18 @@ export default function ExchangePage() { const exchange = useExchange(tokens[Field.INPUT], tokens[Field.OUTPUT]) const route = !!exchange ? new Route([exchange], tokens[Field.INPUT]) : undefined - // modal state - const addTransaction = useTransactionAdder() - const [showConfirm, setShowConfirm] = useState(true) + // modal and loading + const [showConfirm, setShowConfirm] = useState(false) const [pendingConfirmation, setPendingConfirmation] = useState(true) // waiting for user confirmation const [attemptingTxn, setAttemptingTxn] = useState(false) // clicked confirmed + // advanced settings + const [showAdvanced, setShowAdvanced] = useState(false) + const [activeIndex, setActiveIndex] = useState(SLIPPAGE_INDEX[3]) + const [customSlippage, setCustomSlippage] = useState() + const [customDeadline, setCustomDeadline] = useState(DEFAULT_DEADLINE_FROM_NOW / 60) + const [slippageInputError, setSlippageInputError] = useState(null) + // txn values const [txHash, setTxHash] = useState() const [deadline, setDeadline] = useState(DEFAULT_DEADLINE_FROM_NOW) @@ -240,10 +312,9 @@ export default function ExchangePage() { const parsedAmounts: { [field: number]: TokenAmount } = {} // try to parse typed value - // if (typedValue !== '' && typedValue !== '.' && tokens[independentField]) { - if (tokens[independentField]) { + if (typedValue !== '' && typedValue !== '.' && tokens[independentField]) { try { - const typedValueParsed = parseUnits('0.0001', tokens[independentField].decimals).toString() + const typedValueParsed = parseUnits(typedValue, tokens[independentField].decimals).toString() if (typedValueParsed !== '0') parsedAmounts[independentField] = new TokenAmount(tokens[independentField], typedValueParsed) } catch (error) { @@ -313,7 +384,7 @@ export default function ExchangePage() { }) }, []) - const MIN_ETHER = new TokenAmount(WETH[chainId], JSBI.BigInt(parseEther('.01'))) + const MIN_ETHER = chainId && new TokenAmount(WETH[chainId], JSBI.BigInt(parseEther('.01'))) const maxAmountInput = !!userBalances[Field.INPUT] && JSBI.greaterThan( @@ -388,16 +459,14 @@ export default function ExchangePage() { outputApproval && JSBI.greaterThan(parsedAmounts[Field.OUTPUT].raw, outputApproval.raw) - // modal state - const [showAdvanced, setShowAdvanced] = useState(true) - const [activeIndex, setActiveIndex] = useState(SLIPPAGE_INDEX[3]) - const [customSlippage, setCustomSlippage] = useState() - const [customDeadline, setCustomDeadline] = useState(DEFAULT_DEADLINE_FROM_NOW / 60) - - const [slippageInputError, setSlippageInputError] = useState(null) - + // parse the input for custom slippage function parseCustomInput(val) { const acceptableValues = [/^$/, /^\d{1,2}$/, /^\d{0,2}\.\d{0,2}$/] + if (val > 5) { + setSlippageInputError('Your transaction may be front-run.') + } else { + setSlippageInputError(null) + } if (acceptableValues.some(a => a.test(val))) { setCustomSlippage(val) setAllowedSlippage(val * 100) @@ -412,6 +481,62 @@ export default function ExchangePage() { } } + const tokenContract = useTokenContract(tokens[Field.INPUT]?.address) + + // function for a pure send + async function onSend() { + setAttemptingTxn(true) + + const signer = await getProviderOrSigner(library, account) + // get token contract if needed + let estimate: Function, method: Function, args, value + if (tokens[Field.INPUT] === WETH[chainId]) { + signer + .sendTransaction({ to: recipient.toString(), value: hex(parsedAmounts[Field.INPUT].raw) }) + .then(response => { + console.log(response) + setTxHash(response.hash) + addTransaction(response) + setPendingConfirmation(false) + }) + .catch(e => { + addPopup( + + Transaction Failed: try again. + + ) + resetModal() + setShowConfirm(false) + }) + } else { + estimate = tokenContract.estimate.transfer + method = tokenContract.transfer + args = [recipient, parsedAmounts[Field.INPUT].raw.toString()] + value = ethers.constants.Zero + const estimatedGasLimit = await estimate(...args, { value }).catch(e => { + console.log('error getting gas limit') + }) + method(...args, { + value, + gasLimit: calculateGasMargin(estimatedGasLimit, GAS_MARGIN) + }) + .then(response => { + setTxHash(response.hash) + addTransaction(response) + setPendingConfirmation(false) + }) + .catch(e => { + addPopup( + + Transaction Failed: try again. + + ) + resetModal() + setShowConfirm(false) + }) + } + } + async function onSwap() { const routerContract = getRouterContract(chainId, library, account) setAttemptingTxn(true) @@ -497,7 +622,7 @@ export default function ExchangePage() { gasLimit: calculateGasMargin(estimatedGasLimit, GAS_MARGIN) }) .then(response => { - setTxHash(response) + setTxHash(response.hash) addTransaction(response) setPendingConfirmation(false) }) @@ -513,17 +638,38 @@ export default function ExchangePage() { } // errors + const [generalError, setGeneralError] = useState('') const [inputError, setInputError] = useState('') const [outputError, setOutputError] = useState('') + const [recipientError, setRecipientError] = useState('') const [isValid, setIsValid] = useState(false) + const ignoreOutput = sending ? !sendingWithSwap : false + useEffect(() => { // reset errors + setGeneralError(null) setInputError(null) setOutputError(null) setTradeError(null) + setRecipientError(null) setIsValid(true) + if (!isAddress(recipient) && sending) { + setRecipientError('Invalid Recipient') + setIsValid(false) + } + + if (!parsedAmounts[Field.INPUT]) { + setGeneralError('Enter an amount') + setIsValid(false) + } + + if (!parsedAmounts[Field.OUTPUT] && !ignoreOutput) { + setGeneralError('Enter an amount') + setIsValid(false) + } + if ( parsedAmounts[Field.INPUT] && exchange && @@ -534,6 +680,7 @@ export default function ExchangePage() { } if ( + !ignoreOutput && parsedAmounts[Field.OUTPUT] && exchange && JSBI.greaterThan(parsedAmounts[Field.OUTPUT].raw, exchange.reserveOf(tokens[Field.OUTPUT]).raw) @@ -542,12 +689,12 @@ export default function ExchangePage() { setIsValid(false) } - if (showInputUnlock) { + if (showInputUnlock && !(sending && !sendingWithSwap)) { setInputError('Approval Needed') setIsValid(false) } - if (showOutputUnlock) { + if (showOutputUnlock && !ignoreOutput) { setOutputError('Approval Needed') setIsValid(false) } @@ -560,19 +707,22 @@ export default function ExchangePage() { setInputError('Insufficient balance.') setIsValid(false) } + }, [ + exchange, + ignoreOutput, + parsedAmounts, + recipient, + sending, + sendingWithSwap, + showInputUnlock, + showOutputUnlock, + tokens, + userBalances + ]) - if ( - userBalances[Field.OUTPUT] && - parsedAmounts[Field.OUTPUT] && - JSBI.lessThan(userBalances[Field.OUTPUT].raw, parsedAmounts[Field.OUTPUT]?.raw) - ) { - setOutputError('Insufficient balance.') - setIsValid(false) - } - }, [exchange, parsedAmounts, showInputUnlock, showOutputUnlock, tokens, userBalances]) - - const warningMedium = slippageFromTrade && parseFloat(slippageFromTrade.toFixed(4)) > ALLOWED_IMPACT_MEDIUM / 100 - const warningHigh = slippageFromTrade && parseFloat(slippageFromTrade.toFixed(4)) > ALLOWED_IMPACT_HIGH / 100 + // warnings on slippage + const warningMedium = slippageFromTrade && parseFloat(slippageFromTrade.toFixed(4)) > ALLOWED_SLIPPAGE_MEDIUM / 100 + const warningHigh = slippageFromTrade && parseFloat(slippageFromTrade.toFixed(4)) > ALLOWED_SLIPPAGE_HIGH / 100 function resetModal() { setPendingConfirmation(true) @@ -581,169 +731,221 @@ export default function ExchangePage() { } function modalHeader() { - return ( - - - - {!!slippageAdjustedAmounts[Field.INPUT] && slippageAdjustedAmounts[Field.INPUT].toSignificant(6)} - - - - - {tokens[Field.INPUT]?.symbol || ''} + if (sending && !sendingWithSwap) { + return ( + + + + {parsedAmounts[Field.INPUT]?.toFixed(8)} - - - - - - - - {!!slippageAdjustedAmounts[Field.OUTPUT] && slippageAdjustedAmounts[Field.OUTPUT].toSignificant(6)} - - - - - {tokens[Field.OUTPUT]?.symbol || ''} + + + + + {recipient?.slice(0, 6)}...{recipient?.slice(36, 42)} + + + ) + } + + if (sending && sendingWithSwap) { + } + + if (!sending) { + return ( + + + + {!!slippageAdjustedAmounts[Field.INPUT] && slippageAdjustedAmounts[Field.INPUT].toSignificant(6)} + + + + {tokens[Field.INPUT]?.symbol || ''} + + + + + - - - ) + + + {!!slippageAdjustedAmounts[Field.OUTPUT] && slippageAdjustedAmounts[Field.OUTPUT].toSignificant(6)} + + + + + {tokens[Field.OUTPUT]?.symbol || ''} + + + + + ) + } } function modalBottom() { - return showAdvanced ? ( - - { - setShowAdvanced(false) - }} - > - back - - - Limit additional price slippage - - - - { - setActiveIndex(SLIPPAGE_INDEX[1]) - setAllowedSlippage(10) - }} - > - 0.1% - - { - setActiveIndex(SLIPPAGE_INDEX[2]) - setAllowedSlippage(100) - }} - > - 1% - - { - setActiveIndex(SLIPPAGE_INDEX[3]) - setAllowedSlippage(200) - }} - > - 2% (suggested) - - - - - { - parseCustomInput(val) - setActiveIndex(SLIPPAGE_INDEX[4]) - }} - placeHolder="Custom" - onClick={() => { - setActiveIndex(SLIPPAGE_INDEX[4]) - if (customSlippage) { - parseCustomInput(customSlippage) - } - }} - /> - % - - - - Adjust deadline (minutes from now) - - - { - parseCustomDeadline(val) - }} - /> - - - ) : ( - <> - - - Price - - - {`1 ${tokens[Field.INPUT]?.symbol} = ${route && route.midPrice && route.midPrice.adjusted.toFixed(8)} ${ - tokens[Field.OUTPUT]?.symbol - }`} - - - - - Slippage setShowAdvanced(true)}>(edit limits) - - - {slippageFromTrade && slippageFromTrade.toFixed(4)}% - - - - - {warningHigh ? 'Swap Anyway' : 'Swap'} - - - - {`Output is estimated. You will receive at least ${slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(6)} ${ - tokens[Field.OUTPUT]?.symbol - } or the transaction will revert.`} - - + if (sending && !sendingWithSwap) { + return ( + + + + Confirm send + + + + ) + } + + if (sending && sendingWithSwap) { + } + + if (showAdvanced) { + return ( + { - setShowAdvanced(true) + setShowAdvanced(false) }} > - Advanced Options + back + + Limit additional price impact + + + + { + setActiveIndex(SLIPPAGE_INDEX[1]) + setAllowedSlippage(10) + }} + > + 0.1% + + { + setActiveIndex(SLIPPAGE_INDEX[2]) + setAllowedSlippage(100) + }} + > + 1% + + { + setActiveIndex(SLIPPAGE_INDEX[3]) + setAllowedSlippage(200) + }} + > + 2% (suggested) + + + + + { + parseCustomInput(val) + setActiveIndex(SLIPPAGE_INDEX[4]) + }} + placeHolder="Custom" + onClick={() => { + setActiveIndex(SLIPPAGE_INDEX[4]) + if (customSlippage) { + parseCustomInput(customSlippage) + } + }} + /> + % + + {slippageInputError && ( + + Your transaction may be front-run + + )} + + + Adjust deadline (minutes from now) + + + + { + parseCustomDeadline(val) + }} + /> + + - - ) + ) + } + + if (!sending) { + return ( + <> + + + Price + + + {`1 ${tokens[Field.INPUT]?.symbol} = ${route && route.midPrice && route.midPrice.adjusted.toFixed(8)} ${ + tokens[Field.OUTPUT]?.symbol + }`} + + + + + Slippage setShowAdvanced(true)}>(edit limits) + + + {slippageFromTrade && slippageFromTrade.toFixed(4)}% + + + + + {warningHigh ? 'Swap Anyway' : 'Swap'} + + + + + {`Output is estimated. You will receive at least ${slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant( + 6 + )} ${tokens[Field.OUTPUT]?.symbol} or the transaction will revert.`} + + { + setShowAdvanced(true) + }} + > + Advanced Options + + + + ) + } } - const pendingText = ` Swapped ${parsedAmounts[Field.INPUT]?.toSignificant(6)} ${ - tokens[Field.INPUT]?.symbol - } for ${parsedAmounts[Field.OUTPUT]?.toSignificant(6)} ${tokens[Field.OUTPUT]?.symbol}` + const pendingText = sending + ? `Sending ${parsedAmounts[Field.INPUT]?.toSignificant(6)} ${tokens[Field.INPUT]?.symbol} to ${recipient}` + : ` Swapped ${parsedAmounts[Field.INPUT]?.toSignificant(6)} ${tokens[Field.INPUT]?.symbol} for ${parsedAmounts[ + Field.OUTPUT + ]?.toSignificant(6)} ${tokens[Field.OUTPUT]?.symbol}` return ( @@ -756,69 +958,128 @@ export default function ExchangePage() { attemptingTxn={attemptingTxn} pendingConfirmation={pendingConfirmation} hash={txHash ? txHash : ''} - topContent={() => modalHeader()} + topContent={modalHeader} bottomContent={modalBottom} pendingText={pendingText} - title="Confirm Swap" + title={sendingWithSwap ? 'Confirm swap and send' : sending ? 'Confirm Send' : 'Confirm Swap'} /> + + {sending && !sendingWithSwap && ( + <> + + {!atMaxAmountInput && ( + { + maxAmountInput && onMaxInput(maxAmountInput.toExact()) + }} + > + Max + + )} + onUserInput(Field.INPUT, val)} /> + {!parsedAmounts[Field.INPUT] && Enter an amount.} + onUserInput(Field.INPUT, val)} + onMax={() => { + maxAmountInput && onMaxInput(maxAmountInput.toExact()) + }} + atMax={atMaxAmountInput} + token={tokens[Field.INPUT]} + onTokenSelection={address => onTokenSelection(Field.INPUT, address)} + onTokenSelectSendWithSwap={address => { + onTokenSelection(Field.OUTPUT, address) + setSendingWithSwap(true) + }} + title={'Input'} + error={inputError} + exchange={exchange} + showUnlock={showInputUnlock} + hideBalance={true} + hideInput={true} + showSendWithSwap={true} + /> + + + )} + - { - maxAmountInput && onMaxInput(maxAmountInput.toExact()) - }} - atMax={atMaxAmountInput} - token={tokens[Field.INPUT]} - onTokenSelection={address => onTokenSelection(Field.INPUT, address)} - title={'Input'} - error={inputError} - exchange={exchange} - showUnlock={showInputUnlock} - /> - - - - - - - { - maxAmountOutput && onMaxOutput(maxAmountOutput.toExact()) - }} - atMax={atMaxAmountOutput} - token={tokens[Field.OUTPUT]} - onTokenSelection={address => onTokenSelection(Field.OUTPUT, address)} - title={'Output'} - error={outputError} - exchange={exchange} - showUnlock={showOutputUnlock} - /> - - - Price - - - {exchange - ? `1 ${tokens[Field.INPUT].symbol} = ${route?.midPrice.toSignificant(6)} ${tokens[Field.OUTPUT].symbol}` - : '-'} - - - {warningMedium && ( - - - Slippage - - - {slippageFromTrade.toFixed(4)}% - - + {(!sending || sendingWithSwap) && ( + <> + { + maxAmountInput && onMaxInput(maxAmountInput.toExact()) + }} + atMax={atMaxAmountInput} + token={tokens[Field.INPUT]} + onTokenSelection={address => onTokenSelection(Field.INPUT, address)} + title={'Input'} + error={inputError} + exchange={exchange} + showUnlock={showInputUnlock} + /> + + + + + + + { + maxAmountOutput && onMaxOutput(maxAmountOutput.toExact()) + }} + atMax={atMaxAmountOutput} + token={tokens[Field.OUTPUT]} + onTokenSelection={address => onTokenSelection(Field.OUTPUT, address)} + title={'Output'} + error={outputError} + exchange={exchange} + showUnlock={showOutputUnlock} + /> + + + Price + + + {exchange + ? `1 ${tokens[Field.INPUT].symbol} = ${route?.midPrice.toSignificant(6)} ${ + tokens[Field.OUTPUT].symbol + }` + : '-'} + + + {warningMedium && ( + + + Slippage + + + {slippageFromTrade.toFixed(4)}% + + + )} + )} + {sending && ( + + + + setRecipient(e.target.value)} /> + + + + + + + )} { setShowConfirm(true) @@ -827,10 +1088,14 @@ export default function ExchangePage() { error={!!warningHigh} > - {inputError + {generalError + ? generalError + : inputError ? inputError : outputError ? outputError + : recipientError + ? recipientError : tradeError ? tradeError : warningHigh @@ -839,6 +1104,7 @@ export default function ExchangePage() { + {warningHigh && ( diff --git a/src/components/NumericalInput/index.tsx b/src/components/NumericalInput/index.tsx index f9eda5a3db..dd0ed67644 100644 --- a/src/components/NumericalInput/index.tsx +++ b/src/components/NumericalInput/index.tsx @@ -5,11 +5,11 @@ const StyledInput = styled.input` color: ${({ error, theme }) => error && theme.salmonRed}; background-color: ${({ theme }) => theme.inputBackground}; color: ${({ theme }) => theme.textColor}; + width: 0; font-size: 20px; outline: none; border: none; flex: 1 1 auto; - width: 0; background-color: ${({ theme }) => theme.inputBackground}; font-size: ${({ fontSize }) => fontSize && fontSize}; text-align: ${({ align }) => align && align}; diff --git a/src/components/SearchModal/index.js b/src/components/SearchModal/index.js index b707c2d500..4045211c91 100644 --- a/src/components/SearchModal/index.js +++ b/src/components/SearchModal/index.js @@ -1,28 +1,32 @@ import React, { useState, useRef, useMemo, useEffect } from 'react' -import { withRouter } from 'react-router-dom' -import { Link } from 'react-router-dom' -import { Link as StyledLink } from '../../theme/components' -import { useTranslation } from 'react-i18next' -import { ethers } from 'ethers' +import '@reach/tooltip/styles.css' import styled from 'styled-components' import escapeStringRegex from 'escape-string-regexp' -import '@reach/tooltip/styles.css' +import { Link } from 'react-router-dom' +import { ethers } from 'ethers' import { isMobile } from 'react-device-detect' +import { withRouter } from 'react-router-dom' +import { JSBI } from '@uniswap/sdk' + +import { Link as StyledLink } from '../../theme/components' -import { Text } from 'rebass' -import Column, { AutoColumn } from '../Column' -import { RowBetween, RowFixed } from '../Row' -import TokenLogo from '../TokenLogo' -import { CloseIcon } from '../../theme/components' -import DoubleTokenLogo from '../DoubleLogo' -import { useWeb3React } from '../../hooks' -import { isAddress } from '../../utils' import Modal from '../Modal' -import { useToken, useAllTokens, INITIAL_TOKENS_CONTEXT } from '../../contexts/Tokens' -import { Spinner } from '../../theme' import Circle from '../../assets/images/circle.svg' +import TokenLogo from '../TokenLogo' +import DoubleTokenLogo from '../DoubleLogo' +import Column, { AutoColumn } from '../Column' +import { Text } from 'rebass' +import { Spinner } from '../../theme' +import { CloseIcon } from '../../theme/components' +import { ColumnCenter } from '../../components/Column' +import { RowBetween, RowFixed } from '../Row' + +import { isAddress } from '../../utils' +import { useWeb3React } from '../../hooks' import { useAllBalances } from '../../contexts/Balances' +import { useTranslation } from 'react-i18next' import { useAllExchanges } from '../../contexts/Exchanges' +import { useToken, useAllTokens, INITIAL_TOKENS_CONTEXT } from '../../contexts/Tokens' const TokenModalInfo = styled.div` ${({ theme }) => theme.flexRowNoWrap} @@ -108,7 +112,17 @@ const MenuItem = styled(PaddedItem)` background-color: ${({ theme }) => theme.tokenRowHover}; } ` -function SearchModal({ history, isOpen, onDismiss, onTokenSelect, urlAddedTokens, filterType, hiddenToken }) { +function SearchModal({ + history, + isOpen, + onDismiss, + onTokenSelect, + urlAddedTokens, + filterType, + hiddenToken, + showSendWithSwap, + onTokenSelectSendWithSwap +}) { const { t } = useTranslation() const { account, chainId } = useWeb3React() @@ -171,6 +185,7 @@ function SearchModal({ history, isOpen, onDismiss, onTokenSelect, urlAddedTokens let balance // only update if we have data balance = allBalances?.[account]?.[k] + return { name: allTokens[k].name, symbol: allTokens[k].symbol, @@ -203,10 +218,16 @@ function SearchModal({ history, isOpen, onDismiss, onTokenSelect, urlAddedTokens }) }, [tokenList, searchQuery]) - function _onTokenSelect(address) { - setSearchQuery('') - onTokenSelect(address) - onDismiss() + function _onTokenSelect(address, sendWithSwap = false) { + if (sendWithSwap) { + setSearchQuery('') + onTokenSelectSendWithSwap(address) + onDismiss() + } else { + setSearchQuery('') + onTokenSelect(address) + onDismiss() + } } // manage focus on modal show @@ -340,8 +361,11 @@ function SearchModal({ history, isOpen, onDismiss, onTokenSelect, urlAddedTokens INITIAL_TOKENS_CONTEXT[chainId] && !INITIAL_TOKENS_CONTEXT[chainId].hasOwnProperty(address) && !urlAdded + + const zeroBalance = JSBI.equal(JSBI.BigInt(0), balance.raw) + return ( - _onTokenSelect(address)}> + (zeroBalance ? _onTokenSelect(address, true) : _onTokenSelect(address))}> @@ -353,7 +377,22 @@ function SearchModal({ history, isOpen, onDismiss, onTokenSelect, urlAddedTokens {balance ? ( - {balance ? balance.toSignificant(6) : '-'} + + {zeroBalance && showSendWithSwap ? ( + + + Send With Swap + + + ) : balance ? ( + balance.toSignificant(6) + ) : ( + '-' + )} + ) : account ? ( ) : ( diff --git a/src/components/Slider/index.js b/src/components/Slider/index.js index 070bb8a089..37ba7e5d13 100644 --- a/src/components/Slider/index.js +++ b/src/components/Slider/index.js @@ -71,7 +71,7 @@ export default function InputSlider({ value, onChange }) { value={typeof value === 'number' ? value : 0} onChange={onChange} aria-labelledby="input-slider" - marks={marks} + // marks={marks} /> ) } diff --git a/src/components/TokenLogo/index.js b/src/components/TokenLogo/index.js index 4996a9fb4e..0aa9484f6d 100644 --- a/src/components/TokenLogo/index.js +++ b/src/components/TokenLogo/index.js @@ -52,7 +52,7 @@ export default function TokenLogo({ address, size = '24px', ...rest }) { if (address === 'ETH') { return } else if (!error && !BAD_IMAGES[address]) { - path = TOKEN_ICON_API(address.toLowerCase()) + path = TOKEN_ICON_API(address?.toLowerCase()) } else { return ( diff --git a/src/pages/Send/index.js b/src/pages/Send/index.js deleted file mode 100644 index da996d64bf..0000000000 --- a/src/pages/Send/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react' -import ExchangePage from '../../components/ExchangePage' - -export default function Send({ initialCurrency, params }) { - return -} diff --git a/src/pages/Send/index.tsx b/src/pages/Send/index.tsx new file mode 100644 index 0000000000..331e04ff84 --- /dev/null +++ b/src/pages/Send/index.tsx @@ -0,0 +1,251 @@ +import React, { useState, useEffect } from 'react' +import styled from 'styled-components' +import { darken } from 'polished' +import { TokenAmount, JSBI } from '@uniswap/sdk' + +import QR from '../../assets/svg/QR.svg' +import TokenLogo from '../../components/TokenLogo' + +import SearchModal from '../../components/SearchModal' +import ExchangePage from '../../components/ExchangePage' +import NumericalInput from '../../components/NumericalInput' +import ConfirmationModal from '../../components/ConfirmationModal' +import { Text } from 'rebass' +import { TYPE } from '../../theme' +import { LightCard } from '../../components/Card' +import { ArrowDown } from 'react-feather' +import { AutoColumn } from '../../components/Column' +import { ButtonPrimary } from '../../components/Button' +import { ReactComponent as DropDown } from '../../assets/images/dropdown.svg' + +import { useToken } from '../../contexts/Tokens' +import { RowBetween } from '../../components/Row' +import { useENSName } from '../../hooks' +import { useWeb3React } from '@web3-react/core' +import { useAddressBalance } from '../../contexts/Balances' + +import { parseUnits } from '@ethersproject/units' +import { isAddress } from '../../utils' + +const CurrencySelect = styled.button` + display: flex; + align-items: center; + justify-content: space-between; + font-size: 20px; + width: ${({ selected }) => (selected ? '128px' : '180px')} + padding: 8px 12px; + background-color: ${({ selected, theme }) => (selected ? theme.buttonBackgroundPlain : theme.royalBlue)}; + color: ${({ selected, theme }) => (selected ? theme.textColor : theme.white)}; + border: 1px solid + ${({ selected, theme }) => (selected ? theme.outlineGrey : theme.royalBlue)}; + border-radius: 8px; + outline: none; + cursor: pointer; + user-select: none; + + :hover { + border: 1px solid + ${({ selected, theme }) => (selected ? darken(0.1, theme.outlineGrey) : darken(0.1, theme.royalBlue))}; + } + + :focus { + border: 1px solid ${({ selected, theme }) => + selected ? darken(0.1, theme.outlineGrey) : darken(0.1, theme.royalBlue)}; + } + + :active { + background-color: ${({ selected, theme }) => (selected ? theme.buttonBackgroundPlain : theme.royalBlue)}; + } +` + +const StyledDropDown = styled(DropDown)` + height: 35%; + + path { + stroke: ${({ selected, theme }) => (selected ? theme.textColor : theme.white)}; + } +` + +const InputGroup = styled(AutoColumn)` + position: relative; + padding: 40px 0; +` + +const QRWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + border: 1px solid ${({ theme }) => theme.outlineGrey}; + background: #fbfbfb; + padding: 4px; + border-radius: 8px; +` + +const StyledInput = styled.input` + width: ${({ width }) => width}; + border: none; + outline: none; + font-size: 20px; + + ::placeholder { + color: #edeef2; + } +` + +const StyledNumerical = styled(NumericalInput)` + text-align: center; + font-size: 48px; + font-weight: 500px; + width: 100%; + + ::placeholder { + color: #edeef2; + } +` + +const MaxButton = styled.button` + position: absolute; + right: 70px; + padding: 0.5rem 1rem; + background-color: ${({ theme }) => theme.zumthorBlue}; + border: 1px solid ${({ theme }) => theme.zumthorBlue}; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + margin-right: 0.5rem; + color: ${({ theme }) => theme.royalBlue}; + :hover { + border: 1px solid ${({ theme }) => theme.royalBlue}; + } + :focus { + border: 1px solid ${({ theme }) => theme.royalBlue}; + outline: none; + } +` + +export default function Send() { + const { account } = useWeb3React() + + // setting for send with swap or regular swap + const [withSwap, setWithSwap] = useState(true) + + // modals + const [modalOpen, setModalOpen] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + + // token selected + const [activeTokenAddress, setActiveTokenAddress] = useState() + const token = useToken(activeTokenAddress) + + // user inputs + const [typedValue, setTypedValue] = useState('') + const [amount, setAmount] = useState(null) + const [recipient, setRecipient] = useState('0x74Aa01d162E6dC6A657caC857418C403D48E2D77') + + //ENS + const recipientENS = useENSName(recipient) + + // balances + const userBalance = useAddressBalance(account, token) + + //errors + const [generalError, setGeneralError] = useState('') + const [amountError, setAmountError] = useState('') + const [recipientError, setRecipientError] = useState('') + + function parseInputAmount(newtypedValue) { + setTypedValue(newtypedValue) + if (!!token && newtypedValue !== '' && newtypedValue !== '.') { + const typedValueParsed = parseUnits(newtypedValue, token.decimals).toString() + setAmount(new TokenAmount(token, typedValueParsed)) + } + } + + function onMax() { + if (userBalance) { + setTypedValue(userBalance.toExact()) + setAmount(userBalance) + } + } + + const atMax = amount && userBalance && JSBI.equal(amount.raw, userBalance.raw) ? true : false + + //error detection + useEffect(() => { + setGeneralError('') + setRecipientError('') + setAmountError('') + + if (!amount) { + setGeneralError('Enter an amount') + } + if (!isAddress(recipient)) { + setRecipientError('Enter a valid address') + } + if (!!!token) { + setGeneralError('Select a token') + } + if (amount && userBalance && JSBI.greaterThan(amount.raw, userBalance.raw)) { + setAmountError('Insufficient Balance') + } + }, [recipient, token, amount, userBalance]) + + const TopContent = () => { + return ( + + + + {amount?.toFixed(8)} + + + + + + {recipient?.slice(0, 6)}...{recipient?.slice(36, 42)} + + + ) + } + + const BottomContent = () => { + return ( + + + + Confirm send + + + + ) + } + + const [attemptedSend, setAttemptedSend] = useState(false) // clicke confirm + const [pendingConfirmation, setPendingConfirmation] = useState(true) // waiting for + + return withSwap ? ( + + ) : ( + <> + { + setModalOpen(false) + }} + filterType="tokens" + onTokenSelect={tokenAddress => setActiveTokenAddress(tokenAddress)} + /> + setShowConfirm(false)} + hash="" + title="Confirm Send" + topContent={TopContent} + bottomContent={BottomContent} + attemptingTxn={attemptedSend} + pendingConfirmation={pendingConfirmation} + pendingText="" + /> + + ) +} diff --git a/src/pages/Supply/RemoveLiquidity.tsx b/src/pages/Supply/RemoveLiquidity.tsx index 71c77f5473..39092c552d 100644 --- a/src/pages/Supply/RemoveLiquidity.tsx +++ b/src/pages/Supply/RemoveLiquidity.tsx @@ -41,7 +41,7 @@ const Wrapper = styled.div` const FixedBottom = styled.div` position: absolute; - bottom: -240px; + bottom: -200px; width: 100%; ` diff --git a/src/pages/Swap/index.js b/src/pages/Swap/index.js index 52fd99162a..6b691e3db8 100644 --- a/src/pages/Swap/index.js +++ b/src/pages/Swap/index.js @@ -2,5 +2,5 @@ import React from 'react' import ExchangePage from '../../components/ExchangePage' export default function Swap({ initialCurrency, params }) { - return + return } diff --git a/src/theme/index.js b/src/theme/index.js index 5c2f32892d..13b5457ea4 100644 --- a/src/theme/index.js +++ b/src/theme/index.js @@ -128,6 +128,21 @@ export const TYPE = { {children} + ), + gray: ({ children, ...rest }) => ( + + {children} + + ), + italic: ({ children, ...rest }) => ( + + {children} + + ), + error: ({ children, error, ...rest }) => ( + + {children} + ) } diff --git a/src/utils/price.js b/src/utils/price.js deleted file mode 100644 index d710b89a3c..0000000000 --- a/src/utils/price.js +++ /dev/null @@ -1,25 +0,0 @@ -import { getMarketDetails } from '@uniswap/sdk' -import { getMedian, getMean } from './math' - -const DAI = 'DAI' -const USDC = 'USDC' -const TUSD = 'TUSD' - -const USD_STABLECOINS = [DAI, USDC, TUSD] - -function forEachStablecoin(runner) { - return USD_STABLECOINS.map((stablecoin, index) => runner(index, stablecoin)) -} - -export function getUSDPrice(reserves) { - const marketDetails = forEachStablecoin(i => getMarketDetails(reserves[i], undefined)) - const ethPrices = forEachStablecoin(i => marketDetails[i].marketRate.rateInverted) - - const [median] = getMedian(ethPrices) - const [mean] = getMean(ethPrices) - const [weightedMean] = getMean( - ethPrices, - forEachStablecoin(i => reserves[i].ethReserve.amount) - ) - return getMean([median, mean, weightedMean])[0] -}