diff --git a/package.json b/package.json index 694f6afbf1..9cab16ff26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kwenta", - "version": "7.4.4", + "version": "7.4.5", "description": "Kwenta", "main": "index.js", "scripts": { diff --git a/packages/app/package.json b/packages/app/package.json index 4894f1c7b0..c1fe6d28fa 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@kwenta/app", - "version": "7.4.4", + "version": "7.4.5", "scripts": { "dev": "next", "build": "next build", diff --git a/packages/app/src/__tests__/pages/market.test.tsx b/packages/app/src/__tests__/pages/market.test.tsx index 6cfeb239a9..67af0e02c1 100644 --- a/packages/app/src/__tests__/pages/market.test.tsx +++ b/packages/app/src/__tests__/pages/market.test.tsx @@ -1,13 +1,15 @@ -import { FuturesMarket } from '@kwenta/sdk/dist/types' +import { FuturesMarket, PositionSide } from '@kwenta/sdk/types' import { wei } from '@synthetixio/wei' import { fireEvent, render, waitFor } from '@testing-library/react' import { ReactNode } from 'react' +import { mockFuturesService } from 'state/__mocks__/sdk' import { fetchMarkets } from 'state/futures/actions' import { mockResizeObserver } from '../../../testing/unit/mocks/app' import { PRELOADED_STATE } from '../../../testing/unit/mocks/data/app' import { + MOCK_TRADE_PREVIEW, mockSmartMarginAccount, preloadedStateWithSmartMarginAccount, SDK_MARKETS, @@ -46,6 +48,12 @@ describe('Futures market page - smart margin', () => { mockConnector() }) + beforeEach(() => { + // Reset the SDK mock + // @ts-ignore + sdk.futures = mockFuturesService() + }) + test('Calculates correct fees from trade preview', async () => { const { findByTestId, findByText } = render( { expect(submitButton).toBeDisabled() }) }) + +describe('Futures market page - stop loss validation', () => { + beforeAll(() => { + jest.setTimeout(60000) + mockUseWindowSize() + mockReactQuery() + mockResizeObserver() + mockConnector() + }) + + beforeEach(() => { + // Reset the SDK mock + // @ts-ignore + sdk.futures = mockFuturesService() + }) + + test('Restricts stop loss for LONG trade at correct price depending on leverage', async () => { + const store = setupStore(preloadedStateWithSmartMarginAccount()) + const { findByTestId, findByText } = render( + + + + ) + + const marginInput = await findByTestId('set-order-margin-susd-desktop') + fireEvent.change(marginInput, { target: { value: '100' } }) + + const sizeInput = await findByTestId('set-order-size-amount-susd-desktop') + fireEvent.change(sizeInput, { target: { value: '1000' } }) + + const fees = await findByText('$1.69') + expect(fees).toBeTruthy() + + const submitButton = await findByTestId('trade-panel-submit-button') + expect(submitButton).toBeEnabled() + + const expandButton = await findByTestId('expand-sl-tp-button') + fireEvent.click(expandButton) + + const approveButton = await findByTestId('sl-tp-ack-proceed') + fireEvent.click(approveButton) + + const stopLossInput = await findByTestId('trade-panel-stop-loss-input') + fireEvent.change(stopLossInput, { target: { value: '1700' } }) + + // Min / Max SL is shown when invalid + const slMinMaxLabel = await findByText('Min: 1,735.52') + expect(slMinMaxLabel).toBeTruthy() + expect(submitButton).toBeDisabled() + + // Input valid when above min + fireEvent.change(stopLossInput, { target: { value: '1750' } }) + expect(submitButton).toBeEnabled() + }) + + test('Restricts stop loss for SHORT trade at correct price depending on leverage', async () => { + const store = setupStore(preloadedStateWithSmartMarginAccount()) + const { findByTestId, findByText } = render( + + + + ) + + sdk.futures.getCrossMarginTradePreview = () => + Promise.resolve({ + ...MOCK_TRADE_PREVIEW, + liqPrice: wei('2172.467580351348039045'), + side: PositionSide.SHORT, + size: wei('-0.541100000000000000'), + }) + + const shortToggle = await findByTestId('position-side-short-button') + fireEvent.click(shortToggle) + + const marginInput = await findByTestId('set-order-margin-susd-desktop') + fireEvent.change(marginInput, { target: { value: '100' } }) + + const sizeInput = await findByTestId('set-order-size-amount-susd-desktop') + fireEvent.change(sizeInput, { target: { value: '1000' } }) + + const fees = await findByText('$1.69') + expect(fees).toBeTruthy() + + const submitButton = await findByTestId('trade-panel-submit-button') + expect(submitButton).toBeEnabled() + + const expandButton = await findByTestId('expand-sl-tp-button') + fireEvent.click(expandButton) + + const approveButton = await findByTestId('sl-tp-ack-proceed') + fireEvent.click(approveButton) + + const stopLossInput = await findByTestId('trade-panel-stop-loss-input') + fireEvent.change(stopLossInput, { target: { value: '2150' } }) + + // Min / Max SL is shown when invalid + // Liqudation price is 2,172.46 and stop is limited to 2,100.07 + const slMinMaxLabel = await findByText('Max: 2,107.29') + expect(slMinMaxLabel).toBeTruthy() + + expect(submitButton).toBeDisabled() + + // Input valid when below max + fireEvent.change(stopLossInput, { target: { value: '2099' } }) + expect(submitButton).toBeEnabled() + }) + + test('Stop loss becomes disabled above a certain leverage', async () => { + const store = setupStore(preloadedStateWithSmartMarginAccount()) + const { findByTestId, findByText } = render( + + + + ) + + sdk.futures.getCrossMarginTradePreview = () => + Promise.resolve({ + ...MOCK_TRADE_PREVIEW, + liqPrice: wei('1760'), + size: wei('1.1'), + leverage: wei('20'), + }) + + const marginInput = await findByTestId('set-order-margin-susd-desktop') + fireEvent.change(marginInput, { target: { value: '100' } }) + + const sizeInput = await findByTestId('set-order-size-amount-susd-desktop') + fireEvent.change(sizeInput, { target: { value: '2000' } }) + + const fees = await findByText('$1.69') + expect(fees).toBeTruthy() + + const submitButton = await findByTestId('trade-panel-submit-button') + expect(submitButton).toBeEnabled() + + const expandButton = await findByTestId('expand-sl-tp-button') + fireEvent.click(expandButton) + + const approveButton = await findByTestId('sl-tp-ack-proceed') + fireEvent.click(approveButton) + + const stopLossInput = await findByTestId('trade-panel-stop-loss-input') + + await findByText('Leverage Too High') + expect(stopLossInput).toBeDisabled() + }) + + test('Displays stop-loss warning in confirmation view when within 5% of liquidation price', async () => { + const store = setupStore(preloadedStateWithSmartMarginAccount()) + const { findByTestId, findByText } = render( + + + + ) + + sdk.futures.getCrossMarginTradePreview = () => + Promise.resolve({ + ...MOCK_TRADE_PREVIEW, + liqPrice: wei('2172.467580351348039045'), + side: PositionSide.SHORT, + size: wei('-0.5411'), + }) + + const shortToggle = await findByTestId('position-side-short-button') + fireEvent.click(shortToggle) + + const marginInput = await findByTestId('set-order-margin-susd-desktop') + fireEvent.change(marginInput, { target: { value: '100' } }) + + const sizeInput = await findByTestId('set-order-size-amount-susd-desktop') + fireEvent.change(sizeInput, { target: { value: '1000' } }) + + const fees = await findByText('$1.69') + expect(fees).toBeTruthy() + + const expandButton = await findByTestId('expand-sl-tp-button') + fireEvent.click(expandButton) + + const approveButton = await findByTestId('sl-tp-ack-proceed') + fireEvent.click(approveButton) + + const stopLossInput = await findByTestId('trade-panel-stop-loss-input') + fireEvent.change(stopLossInput, { target: { value: '2090' } }) + + const submitButton = await findByTestId('trade-panel-submit-button') + fireEvent.click(submitButton) + + // Trade confirm button is disabled until the user acknowledges the warning + const confirmButton = await findByTestId('trade-confirm-order-button') + expect(confirmButton).toBeDisabled() + + const warningCheck = await findByTestId('sl-risk-warning') + fireEvent.click(warningCheck) + expect(confirmButton).toBeEnabled() + }) +}) diff --git a/packages/app/src/assets/svg/app/docs.svg b/packages/app/src/assets/svg/app/docs.svg new file mode 100644 index 0000000000..33aae457e3 --- /dev/null +++ b/packages/app/src/assets/svg/app/docs.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/src/assets/svg/app/support.svg b/packages/app/src/assets/svg/app/support.svg new file mode 100644 index 0000000000..01daa5365a --- /dev/null +++ b/packages/app/src/assets/svg/app/support.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/src/components/AcceptWarningView.tsx b/packages/app/src/components/AcceptWarningView.tsx new file mode 100644 index 0000000000..bc6906b19b --- /dev/null +++ b/packages/app/src/components/AcceptWarningView.tsx @@ -0,0 +1,38 @@ +import styled from 'styled-components' + +import { Checkbox } from 'components/Checkbox' + +type Props = { + checked: boolean + message: string + style?: Record + id?: string + onChangeChecked: (checked: boolean) => void +} + +export default function AcceptWarningView({ checked, id, onChangeChecked, style, message }: Props) { + return ( + + onChangeChecked(!checked)} + /> + + ) +} + +const Container = styled.div<{ style?: Record }>` + color: ${(props) => props.theme.colors.selectedTheme.button.yellow.text}; + font-size: 12px; + margin: ${(props) => props.style?.margin ?? '0'}; + padding: 15px; + border: 1px solid rgba(239, 104, 104, 0); + background: ${(props) => props.theme.colors.selectedTheme.button.yellow.fill}; + border-radius: 8px; + cursor: default; +` diff --git a/packages/app/src/components/Checkbox.tsx b/packages/app/src/components/Checkbox.tsx index c2db014f67..7b1fd73cd0 100644 --- a/packages/app/src/components/Checkbox.tsx +++ b/packages/app/src/components/Checkbox.tsx @@ -8,54 +8,76 @@ type CheckboxProps = { label: string checked: boolean checkSide?: 'left' | 'right' - variant?: 'item' | 'table' + variant?: 'tick' | 'fill' + color?: 'default' | 'yellow' onChange: () => void } export const Checkbox: FC = memo( - ({ id, label, checked, onChange, checkSide = 'left', variant = 'item', ...props }) => ( - + ({ + id, + label, + checked, + color = 'default', + onChange, + checkSide = 'left', + variant = 'tick', + ...props + }) => ( + {checkSide === 'left' && ( - +
+ +
)} - + + + {checkSide === 'right' && ( - +
+ +
)}
) ) -const CheckboxContainer = styled.div` - color: ${(props) => props.theme.colors.selectedTheme.gray}; +const CheckboxContainer = styled.div<{ color: 'default' | 'yellow' }>` + color: ${(props) => props.theme.colors.selectedTheme.newTheme.checkBox[props.color].text}}; display: flex; + flex-direction: row; justify-content: space-between; align-items: center; cursor: pointer; gap: 8px; ` -const Input = styled.input<{ variant: 'item' | 'table' }>` +const LabelContainer = styled.div`` + +const Input = styled.input<{ variant: 'tick' | 'fill'; color: 'yellow' | 'default' }>` -webkit-appearance: none; -moz-appearance: none; -o-appearance: none; appearance: none; + flex: 1; background-color: var(--form-background); margin: 0; @@ -64,9 +86,9 @@ const Input = styled.input<{ variant: 'item' | 'table' }>` color: currentColor; width: 22px; height: 22px; - border: 1px solid ${(props) => props.theme.colors.selectedTheme.newTheme.checkBox.border}; + border: 1px solid + ${(props) => props.theme.colors.selectedTheme.newTheme.checkBox[props.color].border}; border-radius: 3px; - transform: translateY(-0.075em); display: grid; place-content: center; @@ -74,12 +96,11 @@ const Input = styled.input<{ variant: 'item' | 'table' }>` &::before { content: ''; - width: 0.9em; - height: 0.9em; + width: 0.8em; + height: 0.8em; clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); transform: scale(0); transform-origin: bottom left; - transition: 100ms transform ease-in-out; box-shadow: inset 1em 1em var(--form-control-color); background-color: ${(props) => props.theme.colors.selectedTheme.newTheme.text.primary}; } @@ -89,9 +110,10 @@ const Input = styled.input<{ variant: 'item' | 'table' }>` } ${(props) => - props.variant === 'table' && + props.variant === 'fill' && css` - background-color: ${(props) => props.theme.colors.selectedTheme.newTheme.checkBox.background}; + background-color: ${(props) => + props.theme.colors.selectedTheme.newTheme.checkBox.default.background}; width: 13px; height: 13px; border-radius: 4px; @@ -100,7 +122,7 @@ const Input = styled.input<{ variant: 'item' | 'table' }>` clip-path: none; } &:checked::before { - background-color: ${props.theme.colors.selectedTheme.newTheme.checkBox.checked}; + background-color: ${props.theme.colors.selectedTheme.newTheme.checkBox.default.checked}; border-radius: 4px; } `} diff --git a/packages/app/src/components/SelectorButtons.tsx b/packages/app/src/components/SelectorButtons.tsx index 0fbd33da69..1c13d798e6 100644 --- a/packages/app/src/components/SelectorButtons.tsx +++ b/packages/app/src/components/SelectorButtons.tsx @@ -6,11 +6,17 @@ import { StyleType } from 'components/SegmentedControl' type Props = { options: string[] + disabled?: boolean onSelect: (index: number) => void type?: StyleType } -export default function SelectorButtons({ onSelect, options, type = 'pill-button' }: Props) { +export default function SelectorButtons({ + onSelect, + disabled, + options, + type = 'pill-button', +}: Props) { return ( {type === 'pill-button' ? ( @@ -18,6 +24,7 @@ export default function SelectorButtons({ onSelect, options, type = 'pill-button ) : ( options.map((option, i) => ( diff --git a/packages/app/src/sections/futures/Trade/SLTPInputField.tsx b/packages/app/src/sections/futures/Trade/SLTPInputField.tsx new file mode 100644 index 0000000000..a40de38554 --- /dev/null +++ b/packages/app/src/sections/futures/Trade/SLTPInputField.tsx @@ -0,0 +1,82 @@ +import { PositionSide } from '@kwenta/sdk/types' +import { formatNumber } from '@kwenta/sdk/utils' +import Wei from '@synthetixio/wei' +import { ChangeEvent, memo } from 'react' +import { useTranslation } from 'react-i18next' + +import NumericInput from 'components/Input/NumericInput' +import { Body } from 'components/Text' + +import ShowPercentage from './ShowPercentage' + +export type SLTPInputFieldProps = { + type: 'take-profit' | 'stop-loss' + value: string + invalid: boolean + currentPrice: Wei + leverage: Wei + minMaxPrice?: Wei + dataTestId?: string + positionSide: PositionSide + disabledReason?: string + disabled?: boolean + onChange: (_: ChangeEvent, v: string) => void +} + +const SLTPInputField: React.FC = memo( + ({ + type, + value, + invalid, + currentPrice, + minMaxPrice, + positionSide, + leverage, + dataTestId, + disabledReason, + disabled, + onChange, + }) => { + const { t } = useTranslation() + + return ( +
+ { + if (!disabled) onChange(e, v) + }} + disabled={disabled} + placeholder={ + type === 'take-profit' + ? t('futures.market.trade.edit-sl-tp.no-tp') + : t('futures.market.trade.edit-sl-tp.no-sl') + } + right={ + disabledReason || (invalid && minMaxPrice) ? ( + + {disabledReason ?? + `${positionSide === PositionSide.LONG ? 'Min' : 'Max'}: ${formatNumber( + minMaxPrice!.toString(), + { suggestDecimals: true } + )}`} + + ) : ( + + ) + } + /> +
+ ) + } +) + +export default SLTPInputField diff --git a/packages/app/src/sections/futures/Trade/SLTPInputs.tsx b/packages/app/src/sections/futures/Trade/SLTPInputs.tsx index 3fb700f59f..c61d63b188 100644 --- a/packages/app/src/sections/futures/Trade/SLTPInputs.tsx +++ b/packages/app/src/sections/futures/Trade/SLTPInputs.tsx @@ -1,12 +1,12 @@ import { suggestedDecimals } from '@kwenta/sdk/utils' import { wei } from '@synthetixio/wei' import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react' +import { useSelector } from 'react-redux' import styled from 'styled-components' import Button from 'components/Button' import InputHeaderRow from 'components/Input/InputHeaderRow' import InputTitle from 'components/Input/InputTitle' -import NumericInput from 'components/Input/NumericInput' import { FlexDivRow } from 'components/layout/flex' import { StyledCaretDownIcon } from 'components/Select' import SelectorButtons from 'components/SelectorButtons' @@ -18,11 +18,12 @@ import { selectLeverageSide, selectMarketIndexPrice, selectSlTpTradeInputs, + selectTradePanelSLValidity, } from 'state/futures/selectors' import { useAppDispatch, useAppSelector } from 'state/hooks' import OrderAcknowledgement from './OrderAcknowledgement' -import ShowPercentage from './ShowPercentage' +import SLTPInputField from './SLTPInputField' const TP_OPTIONS = ['5%', '10%', '25%', '50%', '100%'] const SL_OPTIONS = ['2%', '5%', '10%', '20%', '50%'] @@ -34,6 +35,7 @@ export default function SLTPInputs() { const leverageSide = useAppSelector(selectLeverageSide) const leverage = useAppSelector(selectLeverageInput) const hideWarning = useAppSelector(selectAckedOrdersWarning) + const slValidity = useSelector(selectTradePanelSLValidity) const [showInputs, setShowInputs] = useState(false) const [showOrderWarning, setShowOrderWarning] = useState(false) @@ -53,6 +55,7 @@ export default function SLTPInputs() { const onSelectStopLossPercent = useCallback( (index: number) => { + if (slValidity.disabled) return const option = SL_OPTIONS[index] const percent = Math.abs(Number(option.replace('%', ''))) / 100 const relativePercent = wei(percent).div(leverageWei) @@ -63,7 +66,7 @@ export default function SLTPInputs() { const dp = suggestedDecimals(stopLoss) dispatch(setCrossMarginTradeStopLoss(stopLoss.toString(dp))) }, - [currentPrice, dispatch, leverageSide, leverageWei] + [currentPrice, dispatch, leverageSide, leverageWei, slValidity.disabled] ) const onSelectTakeProfit = useCallback( @@ -95,14 +98,6 @@ export default function SLTPInputs() { [dispatch] ) - const slInvalid = useMemo(() => { - if (leverageSide === 'long') { - return !!stopLossPrice && wei(stopLossPrice || 0).gt(currentPrice) - } else { - return !!stopLossPrice && wei(stopLossPrice || 0).lt(currentPrice) - } - }, [stopLossPrice, currentPrice, leverageSide]) - const tpInvalid = useMemo(() => { if (leverageSide === 'long') { return !!takeProfitPrice && wei(takeProfitPrice || 0).lt(currentPrice) @@ -116,6 +111,7 @@ export default function SLTPInputs() { setShowInputs(!showInputs)}> Stop Loss / Take Profit