import React, { useState, useEffect, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' import styled, { css, keyframes } from 'styled-components' import { darken, lighten } from 'polished' import { isTronAddress, amountFormatter } from '../../utils' import { useDebounce } from '../../hooks' import question from '../../assets/images/question.svg' import NewContextualInfo from '../../components/ContextualInfoNew' const WARNING_TYPE = Object.freeze({ none: 'none', emptyInput: 'emptyInput', invalidEntryBound: 'invalidEntryBound', riskyEntryHigh: 'riskyEntryHigh', riskyEntryLow: 'riskyEntryLow' }) const Flex = styled.div` display: flex; justify-content: center; ` const FlexBetween = styled.div` display: flex; justify-content: space-between; align-items: center; height: 100%; ` const WrappedSlippageRow = ({ wrap, ...rest }) => <Flex {...rest} /> const SlippageRow = styled(WrappedSlippageRow)` position: relative; flex-wrap: ${({ wrap }) => wrap && 'wrap'}; flex-direction: row; justify-content: flex-start; align-items: center; width: 100%; padding: 0; padding-top: ${({ wrap }) => wrap && '0.25rem'}; ` const QuestionWrapper = styled.button` display: flex; align-items: center; justify-content: center; margin: 0; padding: 0; margin-left: 0.4rem; padding: 0.2rem; border: none; background: none; outline: none; cursor: default; border-radius: 36px; :hover, :focus { opacity: 0.7; } ` const HelpCircleStyled = styled.img` height: 18px; width: 18px; ` const fadeIn = keyframes` from { opacity : 0; } to { opacity : 1; } ` const Popup = styled(Flex)` position: absolute; width: 228px; left: -78px; top: -94px; flex-direction: column; align-items: center; padding: 0.6rem 1rem; line-height: 150%; background: ${({ theme }) => theme.inputBackground}; border: 1px solid ${({ theme }) => theme.mercuryGray}; border-radius: 8px; animation: ${fadeIn} 0.15s linear; color: ${({ theme }) => theme.textColor}; font-style: italic; ${({ theme }) => theme.mediaWidth.upToSmall` left: -20px; `} ` const FancyButton = styled.button` color: ${({ theme }) => theme.textColor}; align-items: center; min-width: 55px; height: 2rem; border-radius: 36px; font-size: 12px; border: 1px solid ${({ theme }) => theme.mercuryGray}; outline: none; background: ${({ theme }) => theme.inputBackground}; :hover { cursor: inherit; border: 1px solid ${({ theme }) => theme.chaliceGray}; } :focus { border: 1px solid ${({ theme }) => theme.royalBlue}; } ` const Option = styled(FancyButton)` margin-right: 8px; margin-top: 6px; :hover { cursor: pointer; } ${({ active, theme }) => active && css` background-color: ${({ theme }) => theme.royalBlue}; color: ${({ theme }) => theme.white}; border: none; :hover { border: none; box-shadow: none; background-color: ${({ theme }) => darken(0.05, theme.royalBlue)}; } :focus { border: none; box-shadow: none; background-color: ${({ theme }) => lighten(0.05, theme.royalBlue)}; } :active { background-color: ${({ theme }) => darken(0.05, theme.royalBlue)}; } :hover:focus { background-color: ${({ theme }) => theme.royalBlue}; } :hover:focus:active { background-color: ${({ theme }) => darken(0.05, theme.royalBlue)}; } `} ` const OptionLarge = styled(Option)` width: 120px; ` const Input = styled.input` background: ${({ theme }) => theme.inputBackground}; flex-grow: 1; font-size: 12px; min-width: 20px; outline: none; box-sizing: border-box; &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { -webkit-appearance: none; } cursor: inherit; color: ${({ theme }) => theme.doveGray}; text-align: left; ${({ active }) => active && css` color: initial; cursor: initial; text-align: right; `} ${({ placeholder }) => placeholder !== 'Custom' && css` text-align: right; color: ${({ theme }) => theme.textColor}; `} ${({ color }) => color === 'red' && css` color: ${({ theme }) => theme.salmonRed}; `} ` const BottomError = styled.div` ${({ show }) => show && css` padding-top: 12px; `} color: ${({ theme }) => theme.doveGray}; ${({ color }) => color === 'red' && css` color: ${({ theme }) => theme.salmonRed}; `} ` const OptionCustom = styled(FancyButton)` height: 2rem; position: relative; width: 120px; margin-top: 6px; padding: 0 0.75rem; ${({ active }) => active && css` border: 1px solid ${({ theme }) => theme.royalBlue}; :hover { border: 1px solid ${({ theme }) => darken(0.1, theme.royalBlue)}; } `} ${({ color }) => color === 'red' && css` border: 1px solid ${({ theme }) => theme.salmonRed}; `} input { width: 100%; height: 100%; border: 0px; border-radius: 2rem; } ` const Bold = styled.span` font-weight: 500; ` const LastSummaryText = styled.div` padding-top: 0.5rem; ` const SlippageSelector = styled.div` background-color: ${({ theme }) => darken(0.04, theme.concreteGray)}; padding: 1rem 1.25rem 1rem 1.25rem; border-radius: 12px 12px 0 0; ` const Percent = styled.div` color: inherit; font-size: 0, 8rem; flex-grow: 0; ${({ color, theme }) => (color === 'faded' && css` color: ${theme.doveGray}; `) || (color === 'red' && css` color: ${theme.salmonRed}; `)}; ` const Faded = styled.span` opacity: 0.7; ` const TransactionInfo = styled.div` padding: 1.25rem 1.25rem 1rem 1.25rem; ` const ValueWrapper = styled.span` padding: 0.125rem 0.3rem 0.1rem 0.3rem; background-color: ${({ theme }) => darken(0.04, theme.concreteGray)}; border-radius: 12px; font-variant: tabular-nums; ` const DeadlineSelector = styled.div` background-color: ${({ theme }) => darken(0.04, theme.concreteGray)}; padding: 1rem 1.25rem 1rem 1.25rem; border-radius: 0 0 12px 12px; ` const DeadlineRow = SlippageRow const DeadlineInput = OptionCustom export default function TransactionDetails(props) { const { t } = useTranslation() const [activeIndex, setActiveIndex] = useState(3) const [warningType, setWarningType] = useState(WARNING_TYPE.none) const inputRef = useRef() const [showPopup, setPopup] = useState(false) const [userInput, setUserInput] = useState('') const debouncedInput = useDebounce(userInput, 150) useEffect(() => { if (activeIndex === 4) { checkBounds(debouncedInput) } }) const [deadlineInput, setDeadlineInput] = useState('') function renderSummary() { let contextualInfo = '' let isError = false if (props.brokenTokenWarning) { contextualInfo = t('brokenToken') isError = true } else if (props.inputError || props.independentError) { contextualInfo = props.inputError || props.independentError isError = true } else if (!props.inputCurrency || !props.outputCurrency) { contextualInfo = t('selectTokenCont') } else if (!props.independentValue) { contextualInfo = t('enterValueCont') } else if (props.sending && !props.recipientAddress) { contextualInfo = t('noRecipient') } else if (props.sending && !isTronAddress(props.recipientAddress)) { contextualInfo = t('invalidRecipient') } else if (!props.account) { contextualInfo = t('noWallet') isError = true } const slippageWarningText = props.highSlippageWarning ? t('highSlippageWarning') : props.slippageWarning ? t('slippageWarning') : '' return ( <NewContextualInfo openDetailsText={t('transactionDetails')} closeDetailsText={t('hideDetails')} contextualInfo={contextualInfo ? contextualInfo : slippageWarningText} allowExpand={ !!( !props.brokenTokenWarning && props.inputCurrency && props.outputCurrency && props.inputValueParsed && props.outputValueParsed && (props.sending ? props.recipientAddress : true) ) } isError={isError} slippageWarning={props.slippageWarning && !contextualInfo} highSlippageWarning={props.highSlippageWarning && !contextualInfo} brokenTokenWarning={props.brokenTokenWarning} renderTransactionDetails={renderTransactionDetails} dropDownContent={dropDownContent} /> ) } const dropDownContent = () => { return ( <> {renderTransactionDetails()} <SlippageSelector> <SlippageRow> Limit additional price slippage <QuestionWrapper onClick={() => { setPopup(!showPopup) }} onMouseEnter={() => { setPopup(true) }} onMouseLeave={() => { setPopup(false) }} > <HelpCircleStyled src={question} alt="popup" /> </QuestionWrapper> {showPopup ? ( <Popup> Lowering this limit decreases your risk of frontrunning. However, this makes it more likely that your transaction will fail due to normal price movements. </Popup> ) : ( '' )} </SlippageRow> <SlippageRow wrap> <Option onClick={() => { setFromFixed(1, 0.1) }} active={activeIndex === 1} > 0.1% </Option> <OptionLarge onClick={() => { setFromFixed(2, 0.5) }} active={activeIndex === 2} > 0.5% <Faded>(suggested)</Faded> </OptionLarge> <Option onClick={() => { setFromFixed(3, 1) }} active={activeIndex === 3} > 1% </Option> <OptionCustom active={activeIndex === 4} color={ warningType === WARNING_TYPE.emptyInput ? '' : warningType !== WARNING_TYPE.none && warningType !== WARNING_TYPE.riskyEntryLow ? 'red' : '' } onClick={() => { setFromCustom() }} > <FlexBetween> {!(warningType === WARNING_TYPE.none || warningType === WARNING_TYPE.emptyInput) && ( <span role="img" aria-label="warning"> ⚠️ </span> )} <Input tabIndex={-1} ref={inputRef} active={activeIndex === 4} placeholder={ activeIndex === 4 ? !!userInput ? '' : '0' : activeIndex !== 4 && userInput !== '' ? userInput : 'Custom' } value={activeIndex === 4 ? userInput : ''} onChange={parseInput} color={ warningType === WARNING_TYPE.emptyInput ? '' : warningType !== WARNING_TYPE.none && warningType !== WARNING_TYPE.riskyEntryLow ? 'red' : '' } /> <Percent color={ activeIndex !== 4 ? 'faded' : warningType === WARNING_TYPE.riskyEntryHigh || warningType === WARNING_TYPE.invalidEntryBound ? 'red' : '' } > % </Percent> </FlexBetween> </OptionCustom> </SlippageRow> <SlippageRow> <BottomError show={activeIndex === 4} color={ warningType === WARNING_TYPE.emptyInput ? '' : warningType !== WARNING_TYPE.none && warningType !== WARNING_TYPE.riskyEntryLow ? 'red' : '' } > {activeIndex === 4 && warningType.toString() === 'none' && 'Custom slippage value'} {warningType === WARNING_TYPE.emptyInput && 'Enter a slippage percentage'} {warningType === WARNING_TYPE.invalidEntryBound && 'Please select a value no greater than 50%'} {warningType === WARNING_TYPE.riskyEntryHigh && 'Your transaction may be frontrun'} {warningType === WARNING_TYPE.riskyEntryLow && 'Your transaction may fail'} </BottomError> </SlippageRow> </SlippageSelector> <DeadlineSelector> Set swap deadline (minutes from now) <DeadlineRow wrap> <DeadlineInput> <Input placeholder={'Deadline'} value={deadlineInput} onChange={parseDeadlineInput} /> </DeadlineInput> </DeadlineRow> </DeadlineSelector> </> ) } const setFromCustom = () => { setActiveIndex(4) inputRef.current.focus() // if there's a value, evaluate the bounds checkBounds(debouncedInput) } // destructure props for to limit effect callbacks const setRawSlippage = props.setRawSlippage const setRawTokenSlippage = props.setRawTokenSlippage const setcustomSlippageError = props.setcustomSlippageError const setDeadline = props.setDeadline const updateSlippage = useCallback( newSlippage => { // round to 2 decimals to prevent ethers error let numParsed = parseInt(newSlippage * 100) // set both slippage values in parents setRawSlippage(numParsed) setRawTokenSlippage(numParsed) }, [setRawSlippage, setRawTokenSlippage] ) // used for slippage presets const setFromFixed = useCallback( (index, slippage) => { // update slippage in parent, reset errors and input state updateSlippage(slippage) setWarningType(WARNING_TYPE.none) setActiveIndex(index) setcustomSlippageError('valid`') }, [setcustomSlippageError, updateSlippage] ) /** * @todo * Breaks without useState here, able to * break input parsing if typing is faster than * debounce time */ const [initialSlippage] = useState(props.rawSlippage) useEffect(() => { switch (Number.parseInt(initialSlippage)) { case 10: setFromFixed(1, 0.1) break case 50: setFromFixed(2, 0.5) break case 100: setFromFixed(3, 1) break default: // restrict to 2 decimal places let acceptableValues = [/^$/, /^\d{1,2}$/, /^\d{0,2}\.\d{0,2}$/] // if its within accepted decimal limit, update the input state if (acceptableValues.some(val => val.test(initialSlippage / 100))) { setUserInput(initialSlippage / 100) setActiveIndex(4) } } }, [initialSlippage, setFromFixed]) const checkBounds = useCallback( slippageValue => { setWarningType(WARNING_TYPE.none) setcustomSlippageError('valid') if (slippageValue === '' || slippageValue === '.') { setcustomSlippageError('invalid') return setWarningType(WARNING_TYPE.emptyInput) } // check bounds and set errors if (Number(slippageValue) < 0 || Number(slippageValue) > 50) { setcustomSlippageError('invalid') return setWarningType(WARNING_TYPE.invalidEntryBound) } if (Number(slippageValue) >= 0 && Number(slippageValue) < 0.1) { setcustomSlippageError('valid') setWarningType(WARNING_TYPE.riskyEntryLow) } if (Number(slippageValue) > 5) { setcustomSlippageError('warning') setWarningType(WARNING_TYPE.riskyEntryHigh) } //update the actual slippage value in parent updateSlippage(Number(slippageValue)) }, [setcustomSlippageError, updateSlippage] ) // check that the theyve entered number and correct decimal const parseInput = e => { let input = e.target.value // restrict to 2 decimal places let acceptableValues = [/^$/, /^\d{1,2}$/, /^\d{0,2}\.\d{0,2}$/] // if its within accepted decimal limit, update the input state if (acceptableValues.some(a => a.test(input))) { setUserInput(input) } } const [initialDeadline] = useState(props.deadline) useEffect(() => { setDeadlineInput(initialDeadline / 60) }, [initialDeadline]) const parseDeadlineInput = e => { const input = e.target.value const acceptableValues = [/^$/, /^\d+$/] if (acceptableValues.some(re => re.test(input))) { setDeadlineInput(input) setDeadline(parseInt(input) * 60) } } const b = text => <Bold>{text}</Bold> const renderTransactionDetails = () => { if (props.independentField === props.INPUT) { return props.sending ? ( <TransactionInfo> <div> {t('youAreSelling')}{' '} <ValueWrapper> {b( `${amountFormatter( props.independentValueParsed, props.independentDecimals, Math.min(4, props.independentDecimals) )} ${props.inputSymbol}` )} </ValueWrapper> </div> <LastSummaryText> {b(props.recipientAddress)} {t('willReceive')}{' '} <ValueWrapper> {b( `${amountFormatter( props.dependentValueMinumum, props.dependentDecimals, Math.min(4, props.dependentDecimals) )} ${props.outputSymbol}` )} </ValueWrapper>{' '} </LastSummaryText> <LastSummaryText> {t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper> </LastSummaryText> </TransactionInfo> ) : ( <TransactionInfo> <div> {t('youAreSelling')}{' '} <ValueWrapper> {b( `${amountFormatter( props.independentValueParsed, props.independentDecimals, Math.min(4, props.independentDecimals) )} ${props.inputSymbol}` )} </ValueWrapper>{' '} {t('forAtLeast')} <ValueWrapper> {b( `${amountFormatter( props.dependentValueMinumum, props.dependentDecimals, Math.min(4, props.dependentDecimals) )} ${props.outputSymbol}` )} </ValueWrapper> </div> <LastSummaryText> {t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper> </LastSummaryText> </TransactionInfo> ) } else { return props.sending ? ( <TransactionInfo> <div> {t('youAreSending')}{' '} <ValueWrapper> {b( `${amountFormatter( props.independentValueParsed, props.independentDecimals, Math.min(4, props.independentDecimals) )} ${props.outputSymbol}` )} </ValueWrapper>{' '} {t('to')} {b(props.recipientAddress)} {t('forAtMost')}{' '} <ValueWrapper> {b( `${amountFormatter( props.dependentValueMaximum, props.dependentDecimals, Math.min(4, props.dependentDecimals) )} ${props.inputSymbol}` )} </ValueWrapper>{' '} </div> <LastSummaryText> {t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper> </LastSummaryText> </TransactionInfo> ) : ( <TransactionInfo> {t('youAreBuying')}{' '} <ValueWrapper> {b( `${amountFormatter( props.independentValueParsed, props.independentDecimals, Math.min(4, props.independentDecimals) )} ${props.outputSymbol}` )} </ValueWrapper>{' '} {t('forAtMost')}{' '} <ValueWrapper> {b( `${amountFormatter( props.dependentValueMaximum, props.dependentDecimals, Math.min(4, props.dependentDecimals) )} ${props.inputSymbol}` )} </ValueWrapper>{' '} <LastSummaryText> {t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper> </LastSummaryText> </TransactionInfo> ) } } return <>{renderSummary()}</> }