diff --git a/src/app/components/OrderInput.tsx b/src/app/components/OrderInput.tsx index 0754912f..5d0535a7 100644 --- a/src/app/components/OrderInput.tsx +++ b/src/app/components/OrderInput.tsx @@ -1,5 +1,8 @@ -import { useEffect, useState, ChangeEvent } from "react"; +import { useEffect, useState, useRef, ChangeEvent, useCallback } from "react"; import { AiOutlineInfoCircle } from "react-icons/ai"; +import Tippy from "@tippyjs/react"; +import "tippy.js/dist/tippy.css"; +import "tippy.js/dist/svg-arrow.css"; import { getPrecision, @@ -361,6 +364,7 @@ function SubmitButton() { side, type, token1, + token2, quote, quoteDescription, quoteError, @@ -373,7 +377,9 @@ function SubmitButton() { const hasQuoteError = quoteError !== undefined; const isLimitOrder = type === OrderType.LIMIT; const isBuyOrder = side === OrderSide.BUY; + const isZeroAmount = token1.amount === 0 || token2.amount === 0; const disabled = + isZeroAmount || !hasQuote || hasQuoteError || !isConnected || @@ -442,13 +448,71 @@ function SubmitButton() { } function UserInputContainer() { - const { side, type } = useAppSelector((state) => state.orderInput); + const dispatch = useAppDispatch(); + const { side, type, token1, token2 } = useAppSelector( + (state) => state.orderInput + ); + const balanceToken1 = + useAppSelector((state) => selectBalanceByAddress(state, token1.address)) || + 0; + const balanceToken2 = + useAppSelector((state) => selectBalanceByAddress(state, token2.address)) || + 0; + const bestBuy = useAppSelector((state) => state.orderBook.bestBuy) || 0; + const bestSell = useAppSelector((state) => state.orderBook.bestSell) || 0; const isMarketOrder = type === "MARKET"; const isLimitOrder = type === "LIMIT"; const isBuyOrder = side === "BUY"; const isSellOrder = side === "SELL"; + const sliderCallback = useCallback( + (newPercentage: number) => { + const isXRDToken = isBuyOrder + ? token2.symbol === "XRD" + : token1.symbol === "XRD"; + let balance = isBuyOrder ? balanceToken2 : balanceToken1; + + if (newPercentage === 100 && isXRDToken) { + balance = Math.max(balance - XRD_FEE_ALLOWANCE, 0); + } + + const amount = Calculator.divide( + Calculator.multiply(balance, newPercentage), + 100 + ); + + const specifiedToken = isBuyOrder + ? SpecifiedToken.TOKEN_2 + : SpecifiedToken.TOKEN_1; + + dispatch( + orderInputSlice.actions.setTokenAmount({ + amount, + bestBuy, + bestSell, + balanceToken1, + balanceToken2, + specifiedToken, + }) + ); + }, + [ + isBuyOrder, + token1.symbol, + token2.symbol, + balanceToken1, + balanceToken2, + bestBuy, + bestSell, + dispatch, + ] + ); + + useEffect(() => { + sliderCallback(0); + }, [isBuyOrder, isSellOrder, isMarketOrder, isLimitOrder, sliderCallback]); + return (
{isMarketOrder && ( @@ -457,20 +521,36 @@ function UserInputContainer() { userAction={UserAction.UPDATE_PRICE} disabled={true} /> - {isSellOrder && ( // specify "Quantity" )} {isBuyOrder && ( // specify "Total" )} + + sliderCallback(newPercentage) + } + isLimitOrder={isLimitOrder} + isBuyOrder={isBuyOrder} + isSellOrder={isSellOrder} + /> )} {isLimitOrder && ( <> - + + sliderCallback(newPercentage) + } + isLimitOrder={isLimitOrder} + isBuyOrder={isBuyOrder} + isSellOrder={isSellOrder} + /> {isLimitOrder && } @@ -487,6 +567,7 @@ function CurrencyInputGroupSettings( ): CurrencyInputGroupConfig { const t = useTranslations(); const dispatch = useAppDispatch(); + const { side, type, @@ -796,10 +877,197 @@ function InputTooltip({ message }: { message: string }) { } // TODO(dcts): implement percentage slider in future PR -function PercentageSlider() { - return <>; +interface PercentageSliderProps { + initialPercentage: number; + callbackOnPercentageUpdate: (newPercentage: number) => void; + isLimitOrder: boolean; + isBuyOrder: boolean; + isSellOrder: boolean; } +const PercentageSlider: React.FC = ({ + initialPercentage, + callbackOnPercentageUpdate, + isLimitOrder, + isBuyOrder, + isSellOrder, +}) => { + const [percentage, setPercentage] = useState(initialPercentage); + const [toolTipVisible, setToolTipVisible] = useState(false); + const sliderRef = useRef(null); + const { token1, token2 } = useAppSelector((state) => state.orderInput); + + const inputToken1 = useAppSelector((state) => state.orderInput.token1.amount); + const inputToken2 = useAppSelector((state) => state.orderInput.token2.amount); + + const balanceToken1 = + useAppSelector((state) => selectBalanceByAddress(state, token1.address)) || + 0; + const balanceToken2 = + useAppSelector((state) => selectBalanceByAddress(state, token2.address)) || + 0; + + const handleChange = (e: React.ChangeEvent) => { + const newPercentage = parseInt(e.target.value, 10); + setPercentage(newPercentage); + callbackOnPercentageUpdate(newPercentage); + }; + + useEffect(() => { + if (!sliderRef.current) { + return; + } + + sliderRef.current.value = "0"; + sliderRef.current.style.backgroundSize = `0% 100%`; + setPercentage(0); + + if (inputToken1 > 0 && isLimitOrder && isBuyOrder) { + return; + } else if (inputToken2 > 0 && isLimitOrder && isSellOrder) { + return; + } else if (balanceToken2 && inputToken2 > 0) { + const newPercentage = Calculator.multiply( + Calculator.divide(inputToken2, balanceToken2), + 100 + ); + setPercentage(newPercentage); + sliderRef.current.style.backgroundSize = `${newPercentage}% 100%`; + } else if (balanceToken1 && inputToken1 > 0) { + const newPercentage = Calculator.multiply( + Calculator.divide(inputToken1, balanceToken1), + 100 + ); + setPercentage(newPercentage); + sliderRef.current.style.backgroundSize = `${newPercentage}% 100%`; + } + }, [ + inputToken1, + balanceToken1, + inputToken2, + balanceToken2, + isLimitOrder, + isBuyOrder, + isSellOrder, + ]); + + const handleClickOnLabel = (newPercentage: number) => { + setPercentage(newPercentage); + callbackOnPercentageUpdate(newPercentage); + }; + + return ( + <> +
+
+ setToolTipVisible(true)} + onMouseUp={() => setToolTipVisible(false)} + onMouseEnter={() => setToolTipVisible(true)} + onMouseLeave={() => setToolTipVisible(false)} + /> + {Math.round(percentage)}%} + visible={toolTipVisible} + onClickOutside={() => setToolTipVisible(false)} + arrow={false} + theme="custom" + placement="top" + > +
+
+
+ +
+
+ {Array(5) + .fill(0) + .map((_, index) => ( + + ))} +
+
+
+
+
+ handleClickOnLabel(0)} + > + 0% + + handleClickOnLabel(25)} + > + 25% + + handleClickOnLabel(50)} + > + 50% + + handleClickOnLabel(75)} + > + 75% + + handleClickOnLabel(100)} + > + 100% + +
+
+
+
+ + ); +}; + // Mimics IMask with improved onAccept, triggered only by user input to avoid rerender bugs. function CustomNumericIMask({ value, diff --git a/src/app/state/locales/en/errors.json b/src/app/state/locales/en/errors.json index 7ed58ff1..2ff0e430 100644 --- a/src/app/state/locales/en/errors.json +++ b/src/app/state/locales/en/errors.json @@ -1,7 +1,6 @@ { "UNSPECIFIED_PRICE": "Price must be specified", "NONZERO_PRICE": "Price must be greater than 0", - "NONZERO_AMOUNT": "Amount must be greater than 0", "HIGH_PRICE": "Price is significantly higher than best sell", "LOW_PRICE": "Price is significantly lower than best buy", "EXCESSIVE_DECIMALS": "Too many decimal places", diff --git a/src/app/state/locales/pt/errors.json b/src/app/state/locales/pt/errors.json index dbd78f14..dda644fc 100644 --- a/src/app/state/locales/pt/errors.json +++ b/src/app/state/locales/pt/errors.json @@ -1,7 +1,6 @@ { "UNSPECIFIED_PRICE": "Preço deve ser especificado", "NONZERO_PRICE": "Preço deve ser maior que 0", - "NONZERO_AMOUNT": "Quantidade deve ser maior que 0", "HIGH_PRICE": "Preço está significativamente maior que a melhor oferta de venda", "LOW_PRICE": "Preço está significativamente menor que a melhor oferta de compra", "EXCESSIVE_DECIMALS": "Excesso de casas decimais", diff --git a/src/app/state/orderInputSlice.test.ts b/src/app/state/orderInputSlice.test.ts index 1d3d07f3..a8a919bd 100644 --- a/src/app/state/orderInputSlice.test.ts +++ b/src/app/state/orderInputSlice.test.ts @@ -66,32 +66,6 @@ describe("OrderInputSlice", () => { ); }); - it("Validation works for zero token1 amount", () => { - store.dispatch( - orderInputSlice.actions.setTokenAmount({ - amount: 0, - specifiedToken: SpecifiedToken.TOKEN_1, - }) - ); - expect(store.getState().orderInput.validationToken1.valid).toBe(false); - expect(store.getState().orderInput.validationToken1.message).toBe( - ErrorMessage.NONZERO_AMOUNT - ); - }); - - it("Validation works for zero token2 amount", () => { - store.dispatch( - orderInputSlice.actions.setTokenAmount({ - amount: 0, - specifiedToken: SpecifiedToken.TOKEN_2, - }) - ); - expect(store.getState().orderInput.validationToken2.valid).toBe(false); - expect(store.getState().orderInput.validationToken2.message).toBe( - ErrorMessage.NONZERO_AMOUNT - ); - }); - it("Validation works for insufficient balance for token1 on sell order ", () => { store.dispatch(orderInputSlice.actions.setSide(OrderSide.SELL)); store.dispatch( diff --git a/src/app/state/orderInputSlice.ts b/src/app/state/orderInputSlice.ts index 5f5ceeb0..28f7751f 100644 --- a/src/app/state/orderInputSlice.ts +++ b/src/app/state/orderInputSlice.ts @@ -40,7 +40,6 @@ export enum SpecifiedToken { export enum ErrorMessage { NONZERO_PRICE = "NONZERO_PRICE", - NONZERO_AMOUNT = "NONZERO_AMOUNT", INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS", COULD_NOT_GET_QUOTE = "COULD_NOT_GET_QUOTE", INSUFFICIENT_LIQUDITIY = "INSUFFICIENT_LIQUDITIY", @@ -422,21 +421,6 @@ export const orderInputSlice = createSlice({ state.price = bestPrice; } - // Set zero amount error - if (amount === 0) { - if (specifiedToken === SpecifiedToken.TOKEN_1) { - state.validationToken1 = { - valid: false, - message: ErrorMessage.NONZERO_AMOUNT, - }; - } else if (specifiedToken === SpecifiedToken.TOKEN_2) { - state.validationToken2 = { - valid: false, - message: ErrorMessage.NONZERO_AMOUNT, - }; - } - } - // Set insufficient balance for specifiedToken if ( specifiedToken === SpecifiedToken.TOKEN_1 && diff --git a/src/app/styles/globals.css b/src/app/styles/globals.css index d2b40260..dbb2c799 100644 --- a/src/app/styles/globals.css +++ b/src/app/styles/globals.css @@ -239,3 +239,58 @@ input[type="number"]::-ms-clear { .account-history table thead tr th:last-child { padding-right: 16px; } + +/* pseudo-classes for the percentage slider */ +input[type="range"]::-webit-slider-thumb { + appearance: none; + height: 14px; + width: 14px; + border-radius: 50%; + background-color: #474d52; + color: #474d52; + border: 3.5px #ffffff solid; + position: relative; + z-index: 2; + cursor: pointer; +} + +input[type="range"]::-moz-range-thumb { + appearance: none; + height: 14px; + width: 14px; + background-color: #474d52; + border-radius: 50%; + cursor: pointer; +} + +input[type="range"]::-ms-thumb { + appearance: none; + height: 14px; + width: 14px; + background-color: #474d52; + border-radius: 50%; + cursor: pointer; +} + +input[type="range"]::-webkit-slider-runnable-track { + -webkit-appearance: none; + box-shadow: none; + border: none; + width: full; + cursor: pointer; + z-index: 99; +} + +input[type="range"]::-moz-range-track { + appearance: none; + box-shadow: none; + border: none; + cursor: pointer; +} + +input[type="range"]::-ms-track { + appearance: none; + box-shadow: none; + border: none; + cursor: pointer; +} diff --git a/tailwind.config.js b/tailwind.config.js index 1da2c7e7..eb11f539 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -33,6 +33,10 @@ module.exports = { "dexter-grey-dark": "#141414", "dexter-grey-light": "#191B1D", "content-dark": "#212A09", + "slider-grey": "#474d52", + }, + fontSize: { + xxs: "0.6rem", }, keyframes: { blueLight: {