diff --git a/src/App.tsx b/src/App.tsx index 8b4e6e548e..f9f72aeb3b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,7 @@ import { useCommandMenu } from './hooks/useCommandMenu'; import { useComplianceState } from './hooks/useComplianceState'; import { useInitializePage } from './hooks/useInitializePage'; import { useLocalStorage } from './hooks/useLocalStorage'; +import { useMobileWebEnabled } from './hooks/useMobileWebEnabled'; import { useReferralCode } from './hooks/useReferralCode'; import { useShouldShowFooter } from './hooks/useShouldShowFooter'; import { useSimpleUiEnabled } from './hooks/useSimpleUiEnabled'; @@ -79,7 +80,6 @@ import breakpoints from './styles/breakpoints'; const MarketsPage = lazy(() => import('@/pages/markets/Markets')); const PortfolioPage = lazy(() => import('@/pages/portfolio/Portfolio')); const AlertsPage = lazy(() => import('@/pages/AlertsPage')); -const ProfilePage = lazy(() => import('@/pages/Profile')); const SettingsPage = lazy(() => import('@/pages/settings/Settings')); const TradePage = lazy(() => import('@/pages/trade/Trade')); const SpotPage = lazy(() => import('@/pages/spot/Spot')); @@ -92,6 +92,10 @@ const VaultPage = lazy(() => import('@/pages/vaults/VaultPage')); const SimpleMarketsPage = lazy(() => import('@/pages/markets/simple-ui/MarketsMobile')); const SimpleAssetPage = lazy(() => import('@/pages/trade/simple-ui/AssetPage')); +// Mobile Web +const MobileWebTradePage = lazy(() => import('@/pages/trade/mobile-web/Trade')); +const MobileWebTradeFormPage = lazy(() => import('@/pages/trade/mobile-web/TradeForm')); + const Content = () => { useInitializePage(); useAnalytics(); @@ -111,6 +115,7 @@ const Content = () => { const isShowingFooter = useShouldShowFooter(); const abDefaultToMarkets = useCustomFlagValue(CustomFlags.abDefaultToMarkets); const isSimpleUi = useSimpleUiEnabled(); + const isMobileWebEnabled = useMobileWebEnabled(); const { showComplianceBanner } = useComplianceState(); const isSimpleUiUserMenuOpen = useAppSelector(getIsUserMenuOpen); @@ -128,7 +133,9 @@ const Content = () => { const { dialogAreaRef } = useDialogArea() ?? {}; - if (isSimpleUi) { + const showMobileWeb = isMobileWebEnabled && isTablet; + + if (isSimpleUi && !showMobileWeb) { const matchMarkets = matchPath(AppRoute.Markets, location.pathname); const backgroundColor = matchMarkets && isSimpleUiUserMenuOpen ? 'var(--color-layer-1)' : 'transparent'; @@ -203,8 +210,14 @@ const Content = () => { } /> - } /> - } /> + : } + /> + : } + /> @@ -212,24 +225,22 @@ const Content = () => { } /> + + } /> + } /> + + } /> } /> - {isTablet && ( - <> - } /> - } /> - } /> - - )} - }> } /> + } /> } /> @@ -250,9 +261,9 @@ const Content = () => { - {isTablet ? : } + {showMobileWeb ? : } - + <$DialogArea ref={dialogAreaRef}> diff --git a/src/bonsai/forms/trade/fields.ts b/src/bonsai/forms/trade/fields.ts index abd2ad6a25..5cc9cf734d 100644 --- a/src/bonsai/forms/trade/fields.ts +++ b/src/bonsai/forms/trade/fields.ts @@ -76,6 +76,7 @@ export function getTradeFormFieldStates( goodTil: DEFAULT_GOOD_TIL_TIME, stopLossOrder: undefined, takeProfitOrder: undefined, + isClosingPosition: false, }; // Initialize all fields as not visible @@ -103,7 +104,7 @@ export function getTradeFormFieldStates( states[key] = { ...(states[key] as any), state: 'enabled', - effectiveValue: states[key].effectiveValue ?? states[key].rawValue ?? defaults[key], + effectiveValue: states[key]?.effectiveValue ?? states[key]?.rawValue ?? defaults[key], }; }); } @@ -151,7 +152,14 @@ export function getTradeFormFieldStates( makeTriggersVisibleIfPossible(result); switch (type) { case TradeFormType.MARKET: - makeVisible(result, ['marketId', 'side', 'size', 'marginMode', 'reduceOnly']); + makeVisible(result, [ + 'marketId', + 'side', + 'size', + 'marginMode', + 'reduceOnly', + 'isClosingPosition', + ]); setMarginMode(result); disableReduceOnlyIfIncreasingMarketOrder(result); @@ -166,6 +174,7 @@ export function getTradeFormFieldStates( 'marginMode', 'reduceOnly', 'postOnly', + 'isClosingPosition', ]); defaultSizeIfSizeInputIsInvalid(result); setMarginMode(result); diff --git a/src/bonsai/forms/trade/reducer.ts b/src/bonsai/forms/trade/reducer.ts index 5f8148666d..d950fc8e03 100644 --- a/src/bonsai/forms/trade/reducer.ts +++ b/src/bonsai/forms/trade/reducer.ts @@ -32,6 +32,7 @@ const getMinimumRequiredFields = ( triggerPrice: undefined, stopLossOrder: undefined, takeProfitOrder: undefined, + isClosingPosition: undefined, }; // Add marketId if provided @@ -86,6 +87,11 @@ export const tradeFormReducer = createVanillaReducer({ marginMode, }), + setIsClosingPosition: (state, isClosingPosition: boolean) => ({ + ...state, + isClosingPosition, + }), + // Size related actions setSizeToken: (state, value: string) => ({ ...state, diff --git a/src/bonsai/forms/trade/summary.ts b/src/bonsai/forms/trade/summary.ts index 972d38c88d..5a3b35ff7c 100644 --- a/src/bonsai/forms/trade/summary.ts +++ b/src/bonsai/forms/trade/summary.ts @@ -80,7 +80,7 @@ export function calculateTradeSummary( const fieldStates = getTradeFormFieldStates(state, accountData, baseAccount); - const effectiveTrade = mapValues(fieldStates, (s) => s.effectiveValue) as TradeForm; + const effectiveTrade = mapValues(fieldStates, (s) => s?.effectiveValue) as TradeForm; const options = calculateTradeFormOptions(state.type, fieldStates, baseAccount); diff --git a/src/bonsai/forms/trade/types.ts b/src/bonsai/forms/trade/types.ts index fa003bf60c..0997a47dff 100644 --- a/src/bonsai/forms/trade/types.ts +++ b/src/bonsai/forms/trade/types.ts @@ -120,6 +120,8 @@ export type TradeForm = { // additional triggers stopLossOrder: TriggerOrderState | undefined; takeProfitOrder: TriggerOrderState | undefined; + + isClosingPosition?: boolean; }; // Define the FieldState type with conditional properties diff --git a/src/components/Collapsible.tsx b/src/components/Collapsible.tsx index 177700f781..8c6c9af6f8 100644 --- a/src/components/Collapsible.tsx +++ b/src/components/Collapsible.tsx @@ -85,6 +85,9 @@ const $Root = styled(Root)` &[data-state='open'] { gap: 0.5rem; } + + padding-bottom: 1rem; + border-bottom: solid 1px var(--color-border); `; const $Trigger = styled(Trigger)` diff --git a/src/components/CollapsibleTabs.tsx b/src/components/CollapsibleTabs.tsx index 29efedd0ed..89c77645a7 100644 --- a/src/components/CollapsibleTabs.tsx +++ b/src/components/CollapsibleTabs.tsx @@ -90,17 +90,19 @@ export const CollapsibleTabs = ({ ))} - - {currentTab?.slotToolbar ?? slotToolbar} - - <$IconButton - iconName={IconName.Caret} - isToggle - buttonStyle={ButtonStyle.WithoutBackground} - shape={ButtonShape.Square} - /> - - + {slotToolbar && ( + + {currentTab?.slotToolbar ?? slotToolbar} + + <$IconButton + iconName={IconName.Caret} + isToggle + buttonStyle={ButtonStyle.WithoutBackground} + shape={ButtonShape.Square} + /> + + + )} diff --git a/src/components/MobileDropdownMenu.tsx b/src/components/MobileDropdownMenu.tsx new file mode 100644 index 0000000000..2c22feaf93 --- /dev/null +++ b/src/components/MobileDropdownMenu.tsx @@ -0,0 +1,114 @@ +import { forwardRef } from 'react'; + +import { Content, Item, Portal, Root, Separator, Trigger } from '@radix-ui/react-dropdown-menu'; +import { CaretDownIcon } from '@radix-ui/react-icons'; +import { Fragment } from 'react/jsx-runtime'; +import styled from 'styled-components'; + +import { forwardRefFn } from '@/lib/genericFunctionalComponentUtils'; + +import { Button, ButtonProps } from './Button'; +import { DropdownMenuItem } from './DropdownMenu'; + +type StyleProps = { + align?: 'center' | 'start' | 'end'; + side?: 'top' | 'bottom'; + sideOffset?: number; + className?: string; + withPortal?: boolean; +}; + +export const MobileDropdownMenu = forwardRefFn( + ({ + className, + children, + items, + slotTop, + align, + side, + sideOffset, + withPortal = true, + }: StyleProps & { + children: React.ReactNode; + items: DropdownMenuItem[]; + slotTop?: React.ReactNode; + }) => { + const content = ( + + {slotTop && ( + <> +
{slotTop}
+ + + )} + {items.map((item, idx) => ( + + + {item.label} + {item.icon && {item.icon}} + + {idx !== items.length - 1 && ( + + )} + + ))} +
+ ); + + const dropdownContent = withPortal ? {content} : content; + + return ( + + + {children} + + + {dropdownContent} + + ); + } +); + +export const DropdownMenuTrigger = forwardRef( + ({ children, ...props }, ref) => ( + + {children} + + ) +); + +const DropdownMenuButton = styled(Button)` + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 8px; + border-width: 1.5px; + border-color: var(--color-layer-4); + + &[data-state='open'] { + svg { + transition: rotate 0.3s var(--ease-out-expo); + rotate: -0.5turn; + } + } +`; diff --git a/src/components/NavigationMenu.tsx b/src/components/NavigationMenu.tsx index b23912bc4f..0fbb738b12 100644 --- a/src/components/NavigationMenu.tsx +++ b/src/components/NavigationMenu.tsx @@ -15,6 +15,8 @@ import styled, { css, keyframes } from 'styled-components'; import { MenuConfig, MenuItem } from '@/constants/menus'; +import { useBreakpoints } from '@/hooks/useBreakpoints'; + import { layoutMixins } from '@/styles/layoutMixins'; import { popoverMixins } from '@/styles/popoverMixins'; @@ -63,11 +65,12 @@ const NavItemWithRef = ( ref: Ref ) => { const location = useLocation(); + const { isTablet } = useBreakpoints(); const children = ( <> {slotBefore} - + {label} {tag && ( <> @@ -95,7 +98,7 @@ const NavItemWithRef = ( tw="whitespace-nowrap" {...props} > - {children} + {isTablet ?
{children}
: children} ) : props.onClick ? ( diff --git a/src/components/Slider.tsx b/src/components/Slider.tsx index 82b0eea3fa..a478407d88 100644 --- a/src/components/Slider.tsx +++ b/src/components/Slider.tsx @@ -48,35 +48,28 @@ const $Root = styled(Root)` --radix-slider-thumb-transform: translateX(-65%) !important; --slider-track-background: ; --slider-track-backgroundColor: var(--color-layer-4); - position: relative; - display: flex; align-items: center; - user-select: none; - height: 100%; + touch-action: none; `; const $Track = styled(Track)` position: relative; - display: flex; flex-grow: 1; align-items: center; - height: 0.5rem; margin-right: 0.25rem; // make thumb covers the end of the track - cursor: pointer; background: var(--slider-track-background); - + touch-action: none; &:before { content: ''; width: 100%; height: 100%; - background: linear-gradient( 90deg, transparent, @@ -128,16 +121,14 @@ const $Tick = styled.span<{ $midpoint?: number; $light: boolean; $text?: string const $Thumb = styled(Thumb)` height: 1.375rem; width: 1.375rem; - display: flex; justify-content: center; align-items: center; - background-color: var(--color-layer-6); opacity: 0.8; - border: 1.5px solid var(--color-layer-7); border-radius: 50%; - cursor: grab; + touch-action: none; + -webkit-user-select: none; `; diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 0c2e7e9cc1..76a21d4e40 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -5,6 +5,7 @@ export enum AppRoute { Vault = '/vault', Portfolio = '/portfolio', Trade = '/trade', + TradeForm = '/trade-form', Spot = '/spot', Profile = '/profile', Alerts = '/alerts', diff --git a/src/constants/statsig.ts b/src/constants/statsig.ts index 3656e720dd..47b1f55d14 100644 --- a/src/constants/statsig.ts +++ b/src/constants/statsig.ts @@ -20,6 +20,7 @@ export enum StatsigFlags { ffHideMarketsFilter = 'ff_hide_markets_filter', ffOpenInterestFilter = 'ff_open_interest_filter', abPopupDeposit = 'ab_popup_deposit', + ffMobileWeb = 'ff_mobile_web', ffOnlyShowLiquidationRebates = 'ff_only_show_liquidation_rebates', } diff --git a/src/hooks/useCurrentMarketId.ts b/src/hooks/useCurrentMarketId.ts index f6b0921c35..9aade9f6cb 100644 --- a/src/hooks/useCurrentMarketId.ts +++ b/src/hooks/useCurrentMarketId.ts @@ -25,9 +25,9 @@ import { getLaunchedMarketIds, getMarketIds } from '@/state/perpetualsSelectors' import { useMarketsData } from './useMarketsData'; import { useAppSelectorWithArgs } from './useParameterizedSelector'; -export const useCurrentMarketId = () => { +export const useCurrentMarketId = (route: AppRoute = AppRoute.Trade) => { const navigate = useNavigate(); - const match = useMatch(`/${AppRoute.Trade}/:marketId`); + const match = useMatch(`/${route}/:marketId`); const { marketId } = match?.params ?? {}; const dispatch = useAppDispatch(); const selectedNetwork = useAppSelector(getSelectedNetwork); @@ -98,7 +98,7 @@ export const useCurrentMarketId = () => { dispatch(setCurrentMarketId(validId)); if (validId !== marketId) { - navigate(`${AppRoute.Trade}/${validId}`, { + navigate(`${route}/${validId}`, { replace: true, }); } @@ -136,7 +136,14 @@ export const useCurrentMarketId = () => { dispatch(closeDialogInTradeBox()); } } - }, [hasMarketIds, hasLoadedLaunchableMarkets, isViewingUnlaunchedMarket, marketId, navigate]); + }, [ + hasMarketIds, + hasLoadedLaunchableMarkets, + isViewingUnlaunchedMarket, + marketId, + navigate, + route, + ]); useEffect(() => { if (isViewingUnlaunchedMarket) { @@ -158,6 +165,7 @@ export const useCurrentMarketId = () => { }, []); return { + marketId: validId, isViewingUnlaunchedMarket, hasLoadedMarkets: hasLoadedLaunchableMarkets && hasMarketIds, }; diff --git a/src/hooks/useMobileWebEnabled.ts b/src/hooks/useMobileWebEnabled.ts new file mode 100644 index 0000000000..6e9f8e1349 --- /dev/null +++ b/src/hooks/useMobileWebEnabled.ts @@ -0,0 +1,13 @@ +import { StatsigFlags } from '@/constants/statsig'; + +import { testFlags } from '@/lib/testFlags'; + +import { useStatsigGateValue } from './useStatsig'; + +// Flag for determining whether we show the new mobile web or not. Disables simpleui if enabled +export const useMobileWebEnabled = () => { + const forceMobileWeb = testFlags.enableMobileWeb; + const mobileWebbFF = useStatsigGateValue(StatsigFlags.ffMobileWeb); + + return forceMobileWeb || mobileWebbFF; +}; diff --git a/src/hooks/useShouldShowFooter.ts b/src/hooks/useShouldShowFooter.ts index 4a7bef78e8..1e78b3218d 100644 --- a/src/hooks/useShouldShowFooter.ts +++ b/src/hooks/useShouldShowFooter.ts @@ -13,7 +13,7 @@ export const useShouldShowFooter = () => { const canAccountTrade = useAppSelector(calculateCanAccountTrade); return ( - !isTablet || + isTablet || !( !!(matchPath(TRADE_ROUTE, pathname) && canAccountTrade) || !!(matchPath(AppRoute.Vault, pathname) && canAccountTrade) diff --git a/src/layout/Footer/FooterMobile.tsx b/src/layout/Footer/FooterMobile.tsx index 68cd8866b9..b5c41996bc 100644 --- a/src/layout/Footer/FooterMobile.tsx +++ b/src/layout/Footer/FooterMobile.tsx @@ -1,41 +1,20 @@ -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import tw from 'twin.macro'; -import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; -import { DEFAULT_MARKETID } from '@/constants/markets'; import { AppRoute } from '@/constants/routes'; -import { useComplianceState } from '@/hooks/useComplianceState'; -import { useNotifications } from '@/hooks/useNotifications'; -import { useShouldShowFooter } from '@/hooks/useShouldShowFooter'; import { useStringGetter } from '@/hooks/useStringGetter'; -import { BellIcon, MarketsIcon, PortfolioIcon, ProfileIcon } from '@/icons'; +import { Bar3Icon, ProfileIcon, TradeIcon } from '@/icons'; import { layoutMixins } from '@/styles/layoutMixins'; -import { Icon, IconName } from '@/components/Icon'; +import { Icon } from '@/components/Icon'; import { NavigationMenu } from '@/components/NavigationMenu'; -import { calculateCanAccountTrade } from '@/state/accountCalculators'; -import { useAppDispatch, useAppSelector } from '@/state/appTypes'; -import { getCurrentMarketId } from '@/state/currentMarketSelectors'; -import { openDialog } from '@/state/dialogs'; - export const FooterMobile = () => { - const dispatch = useAppDispatch(); - const stringGetter = useStringGetter(); - const canAccountTrade = useAppSelector(calculateCanAccountTrade); - - const marketId = useAppSelector(getCurrentMarketId); - - const { disableConnectButton } = useComplianceState(); - const { hasUnreadNotifications } = useNotifications(); - - if (!useShouldShowFooter()) return null; - return ( <$MobileNav> <$NavigationMenu @@ -43,64 +22,29 @@ export const FooterMobile = () => { { group: 'navigation', items: [ - canAccountTrade - ? { - value: 'trade', - label: stringGetter({ key: STRING_KEYS.TRADE }), - slotBefore: ( - <$StartIcon> - - - ), - href: `${AppRoute.Trade}/${marketId ?? DEFAULT_MARKETID}`, - } - : { - value: 'onboarding', - label: stringGetter({ key: STRING_KEYS.ONBOARDING }), - slotBefore: ( - <$StartIcon disabled={disableConnectButton}> - - - ), - onClick: () => - !disableConnectButton && dispatch(openDialog(DialogTypes.Onboarding())), - }, { - value: 'portfolio', - label: stringGetter({ key: STRING_KEYS.PORTFOLIO }), - slotBefore: <$Icon iconComponent={PortfolioIcon as any} />, - href: AppRoute.Portfolio, - }, - { - value: 'markets', + value: 'trade', label: stringGetter({ key: STRING_KEYS.MARKETS }), - slotBefore: <$Icon iconComponent={MarketsIcon as any} />, - href: AppRoute.Markets, + slotBefore: <$Icon size="0.75em" iconComponent={Bar3Icon as any} />, + href: AppRoute.Trade, }, { - value: 'alerts', - label: stringGetter({ key: STRING_KEYS.ALERTS }), - slotBefore: ( -
- <$Icon iconComponent={BellIcon as any} /> - {hasUnreadNotifications && ( - <$UnreadIndicator tw="relative right-[-0.35rem] top-[-0.55rem] place-self-center" /> - )} -
- ), - href: AppRoute.Alerts, + value: 'trade-from', + label: stringGetter({ key: STRING_KEYS.TRADE }), + slotBefore: <$Icon size="0.75em" iconComponent={TradeIcon as any} />, + href: AppRoute.TradeForm, }, { - value: 'profile', - label: stringGetter({ key: STRING_KEYS.PROFILE }), - slotBefore: <$Icon iconComponent={ProfileIcon as any} />, - href: AppRoute.Profile, + value: 'portfolio', + label: stringGetter({ key: STRING_KEYS.ACCOUNT }), + slotBefore: <$Icon size="0.75em" iconComponent={ProfileIcon as any} />, + href: AppRoute.Portfolio, }, ], }, ]} orientation="horizontal" - itemOrientation="vertical" + itemOrientation="horizontal" /> ); @@ -118,10 +62,13 @@ const $NavigationMenu = styled(NavigationMenu)` --navigationMenu-item-radius: 0; --navigationMenu-item-checked-backgroundColor: transparent; - --navigationMenu-item-highlighted-backgroundColor: transparent; - --navigationMenu-item-highlighted-textColor: var(--color-text-2); + --navigationMenu-item-highlighted-backgroundColor: var(--color-layer-2); + --navigationMenu-tab-item-highlighted-backgroundColor: var(--color-layer-2); + --navigationMenu-item-checked-textColor: var(--color-accent); + --navigationMenu-item-highlighted-textColor: var(--color-accent); --navigationMenu-item-radius: 0px; --navigationMenu-item-padding: 0px; + --hover-filter-base: none; font: var(--font-tiny-book); @@ -133,25 +80,6 @@ const $NavigationMenu = styled(NavigationMenu)` > li { flex: 1; - - &[data-item='onboarding'], - &[data-item='trade'] { - span { - content-visibility: hidden; - position: absolute; - - @supports not (content-visibility: hidden) { - opacity: 0; - width: 0; - overflow: hidden; - } - } - - + *, - + * + * { - order: -1; - } - } } > li:not(:last-child):after { @@ -168,34 +96,3 @@ const $NavigationMenu = styled(NavigationMenu)` `; const $Icon = tw(Icon)`text-[1.5rem]`; - -const $StartIcon = styled.div<{ disabled?: boolean }>` - display: inline-flex; - flex-direction: row; - justify-content: center; - align-items: center; - margin-top: -0.25rem; - - width: 3.5rem; - height: 3.5rem; - - color: var(--color-text-2); - background-color: var(--color-accent); - border: solid var(--border-width) var(--color-border-white); - border-radius: 50%; - - ${({ disabled }) => - disabled && - css` - background-color: var(--color-layer-2); - color: var(--color-text-0); - `} -`; - -const $UnreadIndicator = styled.div` - width: 0.9rem; - height: 0.9rem; - border-radius: 50%; - background-color: var(--color-accent); - border: 2px solid var(--color-layer-2); -`; diff --git a/src/layout/NotificationsToastArea/NotifcationStack.tsx b/src/layout/NotificationsToastArea/NotifcationStack.tsx index 7f48be0d5c..be78b45a96 100644 --- a/src/layout/NotificationsToastArea/NotifcationStack.tsx +++ b/src/layout/NotificationsToastArea/NotifcationStack.tsx @@ -38,12 +38,12 @@ export const NotificationStack = ({ notifications, className }: ElementProps & S const [shouldStackNotifications, setshouldStackNotifications] = useState(true); const { markUnseen, markSeen, onNotificationAction } = useNotifications(); - const { isMobile } = useBreakpoints(); + const { isTablet } = useBreakpoints(); const hasMultipleToasts = notifications.length > 1; return ( diff --git a/src/layout/NotificationsToastArea/index.tsx b/src/layout/NotificationsToastArea/index.tsx index 3cd62050dd..4afcac48b6 100644 --- a/src/layout/NotificationsToastArea/index.tsx +++ b/src/layout/NotificationsToastArea/index.tsx @@ -13,7 +13,6 @@ import { import { useNotifications } from '@/hooks/useNotifications'; -import breakpoints from '@/styles/breakpoints'; import { layoutMixins } from '@/styles/layoutMixins'; import { NotificationStack } from './NotifcationStack'; @@ -80,8 +79,4 @@ const StyledToastArea = styled.div` mask-image: linear-gradient(to left, transparent, white 0.5rem); pointer-events: none; - - @media ${breakpoints.mobile} { - display: none; // hide notifications on mobile web view - } `; diff --git a/src/lib/testFlags.ts b/src/lib/testFlags.ts index 19198fb8e8..6857e3ef87 100644 --- a/src/lib/testFlags.ts +++ b/src/lib/testFlags.ts @@ -71,6 +71,10 @@ class TestFlags { get spot() { return this.booleanFlag(this.queryParams.spot); } + + get enableMobileWeb() { + return this.booleanFlag(this.queryParams.enable_mobile_web); + } } export const testFlags = new TestFlags(); diff --git a/src/lib/tradingView/utils.ts b/src/lib/tradingView/utils.ts index 9b80da2663..9cf92a8aca 100644 --- a/src/lib/tradingView/utils.ts +++ b/src/lib/tradingView/utils.ts @@ -225,6 +225,8 @@ export const getWidgetOptions = ( 'header_resolutions', ]; + const disabledFeaturesForTablet: TradingTerminalFeatureset[] = ['header_widget']; + const disabledFeatures: TradingTerminalFeatureset[] = [ 'header_symbol_search', 'header_compare', @@ -236,6 +238,7 @@ export const getWidgetOptions = ( 'trading_account_manager', ...(isViewingUnlaunchedMarket ? disabledFeaturesForUnlaunchedMarket : []), ...(isSimpleUi ? disabledFeaturesForSimpleUi : []), + ...(isTablet ? disabledFeaturesForTablet : []), ]; // Needed for iframe loading on some mobile browsers diff --git a/src/pages/portfolio/Portfolio.tsx b/src/pages/portfolio/Portfolio.tsx index 71d372fc94..61d568f1ee 100644 --- a/src/pages/portfolio/Portfolio.tsx +++ b/src/pages/portfolio/Portfolio.tsx @@ -16,6 +16,7 @@ import { useAccountBalance } from '@/hooks/useAccountBalance'; import { useBreakpoints } from '@/hooks/useBreakpoints'; import { useComplianceState } from '@/hooks/useComplianceState'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { useMobileWebEnabled } from '@/hooks/useMobileWebEnabled'; import { useSimpleUiEnabled } from '@/hooks/useSimpleUiEnabled'; import { useStringGetter } from '@/hooks/useStringGetter'; @@ -31,6 +32,7 @@ import { TradeHistoryList } from '@/views/Lists/Trade/TradeHistoryList'; import { AccountHistoryList } from '@/views/Lists/Transfers/AccountHistoryList'; import { FundingHistoryList } from '@/views/Lists/Transfers/FundingHistoryList'; import { VaultTransferList } from '@/views/Lists/Transfers/VaultTransferList'; +import { UserMenuDialog } from '@/views/dialogs/MobileUserMenuDialog'; import { FillsTable, FillsTableColumnKey } from '@/views/tables/FillsTable'; import { TransferHistoryTable } from '@/views/tables/TransferHistoryTable'; @@ -66,6 +68,7 @@ const PortfolioPage = () => { const { complianceState } = useComplianceState(); const initialPageSize = 20; + const isMobileWebEnabled = useMobileWebEnabled(); const isSimpleUi = useSimpleUiEnabled(); const onboardingState = useAppSelector(getOnboardingState); @@ -84,104 +87,108 @@ const PortfolioPage = () => { useDocumentTitle(stringGetter({ key: STRING_KEYS.PORTFOLIO })); - const routesComponent = isSimpleUi ? ( - }> - - }> - } /> - } /> - } /> - } /> - } /> - - } - /> - - - ) : ( - }> - - } /> - } /> - } /> - } /> - } /> - }> - } /> - - } - /> - - } - /> - - } - /> + const routesComponent = + isSimpleUi && !isMobileWebEnabled ? ( + }> + + }> + } /> + } /> + } /> + } /> + } /> + - } + path="*" + element={} /> - - } /> - - - ); + + + ) : ( + }> + + } /> + } /> + } /> + } /> + } /> + }> + } /> + + } + /> + + } + /> + + } + /> + + } + /> + + } /> + + + ); - if (isSimpleUi) { + if (isSimpleUi && !isMobileWebEnabled) { return routesComponent; } return isTablet ? ( - <$PortfolioMobile> - - <$MobileContent>{routesComponent} - + <> + <$PortfolioMobile> + + <$MobileContent>{routesComponent} + + + ) : ( { const stringGetter = useStringGetter(); - const { pathname } = useLocation(); + const dispatch = useDispatch(); const navigate = useNavigate(); + const onboardingState = useAppSelector(getOnboardingState); + const { hasUnreadNotifications } = useNotifications(); + const { dydxAddress } = useAccounts(); - const portfolioRouteItems = [ - { - value: `${AppRoute.Portfolio}/${PortfolioRoute.Overview}`, - label: stringGetter({ key: STRING_KEYS.OVERVIEW }), - description: stringGetter({ key: STRING_KEYS.OVERVIEW_DESCRIPTION }), - }, - { - value: `${AppRoute.Portfolio}/${PortfolioRoute.Positions}`, - label: stringGetter({ key: STRING_KEYS.POSITIONS }), - description: stringGetter({ key: STRING_KEYS.POSITIONS_DESCRIPTION }), - }, - { - value: `${AppRoute.Portfolio}/${PortfolioRoute.Orders}`, - label: stringGetter({ key: STRING_KEYS.ORDERS }), - description: stringGetter({ key: STRING_KEYS.ORDERS_DESCRIPTION }), - }, - { - value: `${AppRoute.Portfolio}/${PortfolioRoute.Fees}`, - label: stringGetter({ key: STRING_KEYS.FEES }), - description: stringGetter({ key: STRING_KEYS.FEE_STRUCTURE }), - }, - { - value: `${AppRoute.Portfolio}/${PortfolioRoute.EquityTiers}`, - label: stringGetter({ key: STRING_KEYS.EQUITY_TIERS }), - description: stringGetter({ key: STRING_KEYS.EQUITY_TIERS_DESCRIPTION }), - }, - { - value: `${AppRoute.Portfolio}/${PortfolioRoute.History}/${HistoryRoute.Trades}`, - label: stringGetter({ key: STRING_KEYS.TRADES }), - description: stringGetter({ key: STRING_KEYS.TRADES_DESCRIPTION }), - }, - { - value: `${AppRoute.Portfolio}/${PortfolioRoute.History}/${HistoryRoute.Transfers}`, - label: stringGetter({ key: STRING_KEYS.TRANSFERS }), - description: stringGetter({ key: STRING_KEYS.TRANSFERS_DESCRIPTION }), - }, - { - value: `${AppRoute.Portfolio}/${PortfolioRoute.History}/${HistoryRoute.VaultTransfers}`, - label: stringGetter({ key: STRING_KEYS.VAULT_TRANSFERS }), - description: stringGetter({ key: STRING_KEYS.MEGAVAULT_TRANSFERS_DESCRIPTION }), - }, - ]; - - const routeMap = Object.fromEntries( - portfolioRouteItems.map(({ value, label }) => [value, { value, label }]) - ); - - const currentRoute = routeMap[pathname]; + const openUserMenu = () => { + dispatch(setIsUserMenuOpen(true)); + }; return ( <$MobilePortfolioHeader> - value !== currentRoute?.value)} - onValueChange={navigate} - > - {currentRoute?.label} - + <$LogoLink to="/"> + + + +
+ {onboardingState === OnboardingState.AccountConnected && ( + + )} + + +

{truncateAddress(dydxAddress)}

+ +
+ ), + value: 'copy', + onSelect: () => navigator.clipboard.writeText(dydxAddress ?? ''), + }, + { + label: ( +
+

{stringGetter({ key: STRING_KEYS.SIGN_OUT })}

+ +
+ ), + value: 'sign-out', + onSelect: () => dispatch(openDialog(DialogTypes.DisconnectWallet())), + }, + ]} + > + + {truncateAddress(dydxAddress)} + + + + ); }; + const $MobilePortfolioHeader = styled.div` ${layoutMixins.stickyHeader} ${layoutMixins.withOuterBorder} @@ -84,4 +110,17 @@ const $MobilePortfolioHeader = styled.div` padding: 1rem; background-color: var(--color-layer-2); z-index: 2; + + justify-content: space-between; +`; + +const $LogoLink = styled(Link)` + display: flex; + align-self: stretch; + + > svg { + margin: auto; + width: 1.6rem; + height: 1.6rem; + } `; diff --git a/src/pages/trade/HorizontalPanel.tsx b/src/pages/trade/HorizontalPanel.tsx index 0e6a242911..ad27d7d52d 100644 --- a/src/pages/trade/HorizontalPanel.tsx +++ b/src/pages/trade/HorizontalPanel.tsx @@ -4,6 +4,7 @@ import { BonsaiCore } from '@/bonsai/ontology'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; +import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; import { EMPTY_ARR } from '@/constants/objects'; import { AppRoute } from '@/constants/routes'; @@ -14,15 +15,16 @@ import { useShouldShowTriggers } from '@/hooks/useShouldShowTriggers'; import { useStringGetter } from '@/hooks/useStringGetter'; import { CollapsibleTabs } from '@/components/CollapsibleTabs'; +import { Icon, IconName } from '@/components/Icon'; import { LoadingSpinner } from '@/components/Loading/LoadingSpinner'; -import { MobileTabs } from '@/components/Tabs'; +import { Output, OutputType } from '@/components/Output'; import { Tag, TagType } from '@/components/Tag'; import { FundingPaymentsTable, FundingPaymentsTableColumnKey, } from '@/pages/funding/FundingPaymentsTable'; -import { PositionInfo } from '@/views/PositionInfo'; import { FillsTable, FillsTableColumnKey } from '@/views/tables/FillsTable'; +import { MobilePositionsTable } from '@/views/tables/MobilePositionsTable'; import { OrdersTable, OrdersTableColumnKey } from '@/views/tables/OrdersTable'; import { PositionsTable, PositionsTableColumnKey } from '@/views/tables/PositionsTable'; @@ -36,19 +38,23 @@ import { createGetUnseenOpenOrdersCount, createGetUnseenOrderHistoryCount, } from '@/state/accountSelectors'; -import { useAppSelector } from '@/state/appTypes'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { getDefaultToAllMarketsInPositionsOrdersFills } from '@/state/appUiConfigsSelectors'; import { getCurrentMarketId } from '@/state/currentMarketSelectors'; +import { openDialog } from '@/state/dialogs'; import { getHasUncommittedOrders } from '@/state/localOrdersSelectors'; import { isTruthy } from '@/lib/isTruthy'; import { shortenNumberForDisplay } from '@/lib/numbers'; +import { orEmptyObj } from '@/lib/typeUtils'; +import MarketStats from './MarketStats'; import { TradeTableSettings } from './TradeTableSettings'; import { MaybeUnopenedIsolatedPositionsDrawer } from './UnopenedIsolatedPositions'; import { MarketTypeFilter, PanelView } from './types'; enum InfoSection { + Details = 'Details', Position = 'Position', Orders = 'Orders', OrderHistory = 'OrderHistory', @@ -65,6 +71,7 @@ type ElementProps = { export const HorizontalPanel = ({ isOpen = true, setIsOpen, handleStartResize }: ElementProps) => { const stringGetter = useStringGetter(); const navigate = useNavigate(); + const dispatch = useAppDispatch(); const { isTablet } = useBreakpoints(); const allMarkets = useAppSelector(getDefaultToAllMarketsInPositionsOrdersFills); @@ -89,6 +96,7 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen, handleStartResize }: showCurrentMarket ? currentMarketId : undefined ); const orderHistoryTagNumber = shortenNumberForDisplay(numUnseenOrderHistory); + const subAccount = orEmptyObj(useAppSelector(BonsaiCore.account.parentSubaccountSummary.data)); const openOrdersCount = useAppSelectorWithArgs( createGetOpenOrdersCount, @@ -115,6 +123,8 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen, handleStartResize }: const initialPageSize = 20; + const { freeCollateral: availableBalance } = subAccount; + const onViewOrders = useCallback( (market: string) => { navigate(`${AppRoute.Trade}/${market}`, { @@ -128,6 +138,17 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen, handleStartResize }: [navigate] ); + const detailsTabItem = useMemo( + () => ({ + value: InfoSection.Details, + label: stringGetter({ + key: STRING_KEYS.DETAILS, + }), + content: , + }), + [stringGetter] + ); + const positionTabItem = useMemo( () => ({ value: InfoSection.Position, @@ -138,7 +159,7 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen, handleStartResize }: tag: showCurrentMarket ? null : shortenNumberForDisplay(numTotalPositions), content: isTablet ? ( - + ) : ( ({ asChild: true, value: InfoSection.Orders, - label: stringGetter({ key: STRING_KEYS.OPEN_ORDERS_HEADER }), + label: stringGetter({ key: isTablet ? STRING_KEYS.ORDERS : STRING_KEYS.OPEN_ORDERS_HEADER }), slotRight: areOrdersLoading || isWaitingForOrderToIndex ? ( @@ -241,7 +262,9 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen, handleStartResize }: () => ({ asChild: true, value: InfoSection.OrderHistory, - label: stringGetter({ key: STRING_KEYS.ORDER_HISTORY_HEADER }), + label: stringGetter({ + key: isTablet ? STRING_KEYS.HISTORY : STRING_KEYS.ORDER_HISTORY_HEADER, + }), slotRight: areOrdersLoading ? ( @@ -352,7 +375,9 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen, handleStartResize }: () => ({ asChild: true, value: InfoSection.Payments, - label: stringGetter({ key: STRING_KEYS.FUNDING_PAYMENTS }), + label: stringGetter({ + key: isTablet ? STRING_KEYS.FUNDING_PAYMENTS_SHORT : STRING_KEYS.FUNDING_PAYMENTS, + }), content: ( [positionTabItem, ordersTabItem, fillsTabItem, orderHistoryTabItem, paymentsTabItem], - [positionTabItem, fillsTabItem, ordersTabItem, orderHistoryTabItem, paymentsTabItem] + () => [ + ...(isTablet ? [detailsTabItem] : []), + positionTabItem, + ordersTabItem, + fillsTabItem, + orderHistoryTabItem, + paymentsTabItem, + ], + [ + detailsTabItem, + positionTabItem, + fillsTabItem, + ordersTabItem, + orderHistoryTabItem, + paymentsTabItem, + isTablet, + ] ); const slotBottom = { + [InfoSection.Details]: null, [InfoSection.Position]: ( ), @@ -397,12 +438,26 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen, handleStartResize }: [InfoSection.Payments]: null, }[tab]; - return isTablet ? ( - - ) : ( + return ( <> {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} <$DragHandle onMouseDown={handleStartResize} /> + {isTablet && ( +
+
dispatch(openDialog(DialogTypes.Deposit2({})))} + > + + {stringGetter({ key: STRING_KEYS.AVAILABLE_TO_TRADE })} + +
+ + +
+
+
+ )} <$CollapsibleTabs defaultTab={InfoSection.Position} @@ -412,13 +467,15 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen, handleStartResize }: onOpenChange={setIsOpen} dividerStyle="underline" slotToolbar={ - + isTablet ? null : ( + + ) } tabItems={tabItems} /> diff --git a/src/pages/trade/LaunchableMarket.tsx b/src/pages/trade/LaunchableMarket.tsx index 77ee0ff03e..b49b8ef93e 100644 --- a/src/pages/trade/LaunchableMarket.tsx +++ b/src/pages/trade/LaunchableMarket.tsx @@ -32,7 +32,7 @@ import { InnerPanel } from './InnerPanel'; import { MarketSelectorAndStats } from './MarketSelectorAndStats'; import { MobileBottomPanel } from './MobileBottomPanel'; import { MobileTopPanel } from './MobileTopPanel'; -import { TradeHeaderMobile } from './TradeHeaderMobile'; +import { TradeHeaderMobile } from './mobile-web/TradeHeader'; import { useResizablePanel } from './useResizablePanel'; const LaunchableMarket = () => { @@ -52,9 +52,9 @@ const LaunchableMarket = () => { [dispatch] ); const { - handleMouseDown, panelHeight: horizontalPanelHeight, isDragging, + handleMouseDown, } = useResizablePanel(horizontalPanelHeightPxBase, setPanelHeight, { min: HORIZONTAL_PANEL_MIN_HEIGHT, max: HORIZONTAL_PANEL_MAX_HEIGHT, diff --git a/src/pages/trade/MarketStats.tsx b/src/pages/trade/MarketStats.tsx new file mode 100644 index 0000000000..701b49c77a --- /dev/null +++ b/src/pages/trade/MarketStats.tsx @@ -0,0 +1,11 @@ +import { CurrentMarketDetails } from '@/views/MarketDetails/CurrentMarketDetails'; + +const MarketStats = () => { + return ( +
+ +
+ ); +}; + +export default MarketStats; diff --git a/src/pages/trade/MobileTopPanel.tsx b/src/pages/trade/MobileTopPanel.tsx index d4cc08f171..24595b2fd4 100644 --- a/src/pages/trade/MobileTopPanel.tsx +++ b/src/pages/trade/MobileTopPanel.tsx @@ -5,15 +5,13 @@ import styled from 'styled-components'; import { STRING_KEYS } from '@/constants/localization'; +import { useBreakpoints } from '@/hooks/useBreakpoints'; import { useStringGetter } from '@/hooks/useStringGetter'; import { layoutMixins } from '@/styles/layoutMixins'; -import { Icon, IconName } from '@/components/Icon'; import { Tabs } from '@/components/Tabs'; import { ToggleButton } from '@/components/ToggleButton'; -import { AccountInfo } from '@/views/AccountInfo'; -import { CanvasOrderbook } from '@/views/CanvasOrderbook/CanvasOrderbook'; import { DepthChart } from '@/views/charts/DepthChart'; import { FundingChart } from '@/views/charts/FundingChart'; import { TvChart } from '@/views/charts/TradingView/TvChart'; @@ -35,15 +33,20 @@ enum Tab { LiveTrades = 'LiveTrades', } -const TabButton = ({ value, label, icon }: { value: Tab; label: string; icon: IconName }) => ( +const TabButton = ({ value, label }: { value: Tab; label: string }) => ( <$TabButton> - {label} ); +enum HeightMode { + Short = 'Short', + Normal = 'Normal', + Mobile = 'Mobile', +} + export const MobileTopPanel = ({ isViewingUnlaunchedMarket, }: { @@ -51,44 +54,21 @@ export const MobileTopPanel = ({ }) => { const stringGetter = useStringGetter(); const selectedLocale = useAppSelector(getSelectedLocale); + const { isTablet } = useBreakpoints(); const [value, setValue] = useState(Tab.Price); const items = [ - { - content: , - label: stringGetter({ key: STRING_KEYS.WALLET }), - value: Tab.Account, - icon: IconName.Coins, - }, { content: , forceMount: true, - label: stringGetter({ key: STRING_KEYS.PRICE }), + label: 'Chart', // stringGetter({ key: STRING_KEYS.CHART }), value: Tab.Price, - icon: IconName.PriceChart, }, !isViewingUnlaunchedMarket && { content: , label: stringGetter({ key: STRING_KEYS.DEPTH_CHART_SHORT }), value: Tab.Depth, - icon: IconName.DepthChart, - }, - !isViewingUnlaunchedMarket && { - content: , - label: stringGetter({ key: STRING_KEYS.FUNDING_RATE_CHART_SHORT }), - value: Tab.Funding, - icon: IconName.FundingChart, - }, - !isViewingUnlaunchedMarket && { - content: ( - <$ScrollableTableContainer> - - - ), - label: stringGetter({ key: STRING_KEYS.ORDERBOOK_SHORT }), - value: Tab.OrderBook, - icon: IconName.Orderbook, }, !isViewingUnlaunchedMarket && { content: ( @@ -96,33 +76,42 @@ export const MobileTopPanel = ({ ), - label: stringGetter({ key: STRING_KEYS.RECENT }), + label: stringGetter({ key: STRING_KEYS.TRADES }), value: Tab.LiveTrades, - icon: IconName.Clock, + }, + !isViewingUnlaunchedMarket && { + content: , + label: stringGetter({ key: STRING_KEYS.FUNDING_RATE_CHART_SHORT }), + value: Tab.Funding, }, ].filter(isTruthy); return ( <$Tabs value={value} - $shortMode={value === Tab.Account} + $heightMode={ + isTablet ? HeightMode.Mobile : value === Tab.Account ? HeightMode.Short : HeightMode.Normal + } onValueChange={setValue} items={items.map((item) => ({ ...item, - customTrigger: ( - - ), + customTrigger: , }))} - side="bottom" + side="top" /> ); }; -type TabsStyleProps = { $shortMode?: boolean }; +type TabsStyleProps = { $heightMode?: HeightMode }; const TabsTypeTemp = getSimpleStyledOutputType(Tabs, {} as TabsStyleProps); const $Tabs = styled(Tabs)` - --scrollArea-height: ${({ $shortMode }) => ($shortMode ? '19rem' : '38rem')}; + --scrollArea-height: ${({ $heightMode }) => + $heightMode === HeightMode.Mobile + ? '23rem' + : $heightMode === HeightMode.Short + ? '19rem' + : '27rem'}; --stickyArea0-background: var(--color-layer-2); --tabContent-height: calc(var(--scrollArea-height) - 2rem - var(--tabs-currentHeight)); @@ -131,7 +120,7 @@ const $Tabs = styled(Tabs)` gap: var(--border-width); > div > header { - padding: 1rem 1.25rem; + ${({ $heightMode }) => ($heightMode !== HeightMode.Mobile ? 'padding: 1rem 1.25rem;' : '')} > [role='tablist'] { margin: auto; @@ -143,21 +132,11 @@ const $Tabs = styled(Tabs)` const $TabButton = styled(ToggleButton)` padding: 0 0.5rem; + height: 2.25rem; span { transition: 0.25s var(--ease-out-expo); } - - &[data-state='inactive'] { - --button-width: var(--button-height); - - gap: 0; - - span { - font-size: 0; - opacity: 0; - } - } `; const $ScrollableTableContainer = styled.div` ${layoutMixins.scrollArea} diff --git a/src/pages/trade/Trade.tsx b/src/pages/trade/Trade.tsx index dfadbfb470..10f51bae58 100644 --- a/src/pages/trade/Trade.tsx +++ b/src/pages/trade/Trade.tsx @@ -30,10 +30,9 @@ import { InnerPanel } from './InnerPanel'; import LaunchableMarket from './LaunchableMarket'; import { MarketSelectorAndStats } from './MarketSelectorAndStats'; import { MobileBottomPanel } from './MobileBottomPanel'; -import { MobileTopPanel } from './MobileTopPanel'; import { TradeDialogTrigger } from './TradeDialogTrigger'; -import { TradeHeaderMobile } from './TradeHeaderMobile'; import { VerticalPanel } from './VerticalPanel'; +import { TradeHeaderMobile } from './mobile-web/TradeHeader'; import { useResizablePanel } from './useResizablePanel'; const TradePage = () => { @@ -53,9 +52,9 @@ const TradePage = () => { [dispatch] ); const { - handleMouseDown, panelHeight: horizontalPanelHeight, isDragging, + handleMouseDown, } = useResizablePanel(horizontalPanelHeightPxBase, setPanelHeight, { min: HORIZONTAL_PANEL_MIN_HEIGHT, max: HORIZONTAL_PANEL_MAX_HEIGHT, @@ -73,10 +72,6 @@ const TradePage = () => {
- - - - diff --git a/src/pages/trade/TradeHeaderMobile.tsx b/src/pages/trade/TradeHeaderMobile.tsx deleted file mode 100644 index b4ed2cbe04..0000000000 --- a/src/pages/trade/TradeHeaderMobile.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { BonsaiHelpers } from '@/bonsai/ontology'; -import { useNavigate } from 'react-router-dom'; -import styled from 'styled-components'; - -import { AppRoute } from '@/constants/routes'; - -import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; - -import { layoutMixins } from '@/styles/layoutMixins'; - -import { AssetIcon } from '@/components/AssetIcon'; -import { BackButton } from '@/components/BackButton'; -import { Output, OutputType } from '@/components/Output'; -import { MidMarketPrice } from '@/views/MidMarketPrice'; - -import { useAppSelector } from '@/state/appTypes'; - -import { getAssetFromMarketId, getDisplayableAssetFromBaseAsset } from '@/lib/assetUtils'; -import { mapIfPresent } from '@/lib/do'; -import { MustBigNumber } from '@/lib/numbers'; -import { orEmptyObj } from '@/lib/typeUtils'; - -export const TradeHeaderMobile = ({ launchableMarketId }: { launchableMarketId?: string }) => { - const id = useAppSelector(BonsaiHelpers.currentMarket.assetId); - const name = useAppSelector(BonsaiHelpers.currentMarket.assetName); - const imageUrl = useAppSelector(BonsaiHelpers.currentMarket.assetLogo); - - const navigate = useNavigate(); - - const { displayableTicker, priceChange24H, percentChange24h } = orEmptyObj( - useAppSelector(BonsaiHelpers.currentMarket.marketInfo) - ); - - const launchableAsset = useAppSelectorWithArgs( - BonsaiHelpers.assets.selectAssetInfo, - mapIfPresent(launchableMarketId, getAssetFromMarketId) - ); - - const assetRow = launchableAsset ? ( -
- {launchableAsset.name} - <$Name> -

{launchableAsset.name}

- {getDisplayableAssetFromBaseAsset(launchableAsset.assetId)} - -
- ) : ( -
- - <$Name> -

{name}

- {displayableTicker} - -
- ); - - return ( - <$Header> - navigate(AppRoute.Markets)} /> - - {assetRow} - - <$Right> - - <$PriceChange - type={OutputType.Percent} - value={MustBigNumber(percentChange24h).abs()} - isNegative={MustBigNumber(priceChange24H).isNegative()} - /> - - - ); -}; -const $Header = styled.header` - ${layoutMixins.contentSectionDetachedScrollable} - - ${layoutMixins.stickyHeader} - z-index: 2; - - ${layoutMixins.row} - - padding-left: 1rem; - padding-right: 1.5rem; - gap: 1rem; - - color: var(--color-text-2); - background-color: var(--color-layer-2); -`; -const $Name = styled.div` - ${layoutMixins.rowColumn} - - h3 { - font: var(--font-large-medium); - } - - > :nth-child(2) { - font: var(--font-mini-book); - color: var(--color-text-0); - } -`; - -const $Right = styled.div` - margin-left: auto; - - ${layoutMixins.rowColumn} - justify-items: flex-end; -`; - -const $PriceChange = styled(Output)<{ isNegative?: boolean }>` - font: var(--font-small-book); - color: ${({ isNegative }) => (isNegative ? `var(--color-negative)` : `var(--color-positive)`)}; -`; diff --git a/src/pages/trade/mobile-web/CloseTradeForm.tsx b/src/pages/trade/mobile-web/CloseTradeForm.tsx new file mode 100644 index 0000000000..01493fbd6f --- /dev/null +++ b/src/pages/trade/mobile-web/CloseTradeForm.tsx @@ -0,0 +1,285 @@ +import { FormEvent, useCallback, useEffect } from 'react'; + +import { OrderSide, OrderSizeInputs, TradeFormType } from '@/bonsai/forms/trade/types'; +import { BonsaiHelpers } from '@/bonsai/ontology'; +import BigNumber from 'bignumber.js'; +import styled, { css } from 'styled-components'; + +import { ButtonAction, ButtonShape, ButtonSize } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; +import { TOKEN_DECIMALS, USD_DECIMALS } from '@/constants/numbers'; + +import { useTradeErrors } from '@/hooks/TradingForm/useTradeErrors'; +import { TradeFormSource, useTradeForm } from '@/hooks/TradingForm/useTradeForm'; +import { useClosePositionFormInputs } from '@/hooks/useClosePositionFormInputs'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { formMixins } from '@/styles/formMixins'; + +import { Button } from '@/components/Button'; +import { FormInput } from '@/components/FormInput'; +import { InputType } from '@/components/Input'; +import { DropdownMenuTrigger, MobileDropdownMenu } from '@/components/MobileDropdownMenu'; +import { Tag } from '@/components/Tag'; +import { WithTooltip } from '@/components/WithTooltip'; +import { TradeFormMessages } from '@/views/TradeFormMessages/TradeFormMessages'; +import { AllocationSlider } from '@/views/forms/TradeForm/AllocationSlider'; +import { PlaceOrderButtonAndReceipt } from '@/views/forms/TradeForm/PlaceOrderButtonAndReceipt'; + +import { getCurrentMarketPositionData } from '@/state/accountSelectors'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { closePositionFormActions } from '@/state/closePositionForm'; +import { + getClosePositionFormSummary, + getClosePositionFormValues, +} from '@/state/tradeFormSelectors'; + +import { mapIfPresent } from '@/lib/do'; +import { AttemptBigNumber, MaybeBigNumber } from '@/lib/numbers'; +import { orEmptyObj } from '@/lib/typeUtils'; + +import { TradeFormHeaderMobile } from './TradeFormHeader'; + +type Props = { + market: string; +}; + +const CloseTradeForm = ({ market }: Props) => { + const dispatch = useAppDispatch(); + const stringGetter = useStringGetter(); + + const { stepSizeDecimals, tickSizeDecimals, displayableAsset } = orEmptyObj( + useAppSelector(BonsaiHelpers.currentMarket.stableMarketInfo) + ); + const { signedSize: positionSize } = orEmptyObj(useAppSelector(getCurrentMarketPositionData)); + + const tradeValues = useAppSelector(getClosePositionFormValues); + const fullSummary = useAppSelector(getClosePositionFormSummary); + const { summary } = fullSummary; + const effectiveSizes = summary.tradeInfo.inputSummary.size; + + const { + amountInput, + onAmountInput, + setLimitPriceToMidPrice, + limitPriceInput, + onLimitPriceInput, + } = useClosePositionFormInputs(); + + // default to market + useEffect(() => { + dispatch(closePositionFormActions.setOrderType(TradeFormType.MARKET)); + dispatch(closePositionFormActions.setSizeAvailablePercent('1')); + }, [dispatch]); + + useEffect(() => { + dispatch(closePositionFormActions.setMarketId(market)); + dispatch(closePositionFormActions.setSizeAvailablePercent('1')); + }, [market, dispatch]); + + const onLastOrderIndexed = useCallback(() => {}, []); + + const { + placeOrderError: closePositionError, + placeOrder, + shouldEnableTrade, + tradingUnavailable, + hasValidationErrors, + } = useTradeForm({ + source: TradeFormSource.ClosePositionForm, + fullFormSummary: fullSummary, + onLastOrderIndexed, + }); + + const { isErrorShownInOrderStatusToast, primaryAlert, shortAlertKey } = useTradeErrors({ + placeOrderError: closePositionError, + isClosingPosition: true, + }); + + const { + type: selectedTradeType, + side = OrderSide.BUY, + // isClosingPosition, + } = tradeValues; + + const useLimit = selectedTradeType === TradeFormType.LIMIT; + + const onTradeTypeChange = useCallback( + (tradeType: TradeFormType) => { + // if (isClosingPosition === true) { + // return; + // } + + dispatch(closePositionFormActions.setOrderType(tradeType)); + }, + [dispatch, side] + ); + + const onClearInputs = () => { + dispatch(closePositionFormActions.setOrderType(TradeFormType.MARKET)); + dispatch(closePositionFormActions.reset()); + }; + + const midMarketPriceButton = ( + <$MidPriceButton onClick={setLimitPriceToMidPrice} size={ButtonSize.XSmall}> + {stringGetter({ key: STRING_KEYS.MID_MARKET_PRICE_SHORT })} + + ); + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + placeOrder({ + onPlaceOrder: () => { + onClearInputs(); + }, + }); + }; + + return ( +
+ +
+
Close {displayableAsset} Position
+ onTradeTypeChange(TradeFormType.MARKET), + }, + { + value: TradeFormType.LIMIT, + active: selectedTradeType === TradeFormType.LIMIT, + label: stringGetter({ key: STRING_KEYS.LIMIT_ORDER_SHORT }), + onSelect: () => onTradeTypeChange(TradeFormType.LIMIT), + }, + ]} + > + + {selectedTradeType === TradeFormType.MARKET + ? stringGetter({ key: STRING_KEYS.MARKET_ORDER_SHORT }) + : stringGetter({ key: STRING_KEYS.LIMIT_ORDER_SHORT })} + + + +
+

Current Position

+ <$PositionSize isLong={positionSize?.isGreaterThanOrEqualTo(0) ?? false}> + {Math.abs(positionSize?.toNumber() ?? 0)} {displayableAsset} + +
+ + {stringGetter({ key: STRING_KEYS.AMOUNT })}} + decimals={stepSizeDecimals ?? TOKEN_DECIMALS} + onInput={onAmountInput} + type={InputType.Number} + value={amountInput} + tw="w-full" + /> + + {useLimit && ( + + + {stringGetter({ key: STRING_KEYS.LIMIT_PRICE })} + + USD + + } + onChange={onLimitPriceInput} + value={limitPriceInput} + decimals={tickSizeDecimals ?? USD_DECIMALS} + slotRight={setLimitPriceToMidPrice ? midMarketPriceButton : undefined} + tw="w-full" + /> + )} + + { + dispatch( + closePositionFormActions.setSizeAvailablePercent( + mapIfPresent(value, (v) => MaybeBigNumber(v)?.div(100).toFixed(2)) ?? '' + ) + ); + }} + /> + +
+ + {/* {receiptArea} */} + <$PlaceOrderButtonAndReceipt + summary={summary} + actionStringKey={shortAlertKey} + hasInput={!!amountInput} + hasValidationErrors={hasValidationErrors} + onClearInputs={onClearInputs} + shouldEnableTrade={shouldEnableTrade} + showDeposit={false} + tradingUnavailable={tradingUnavailable} + confirmButtonConfig={{ + stringKey: STRING_KEYS.CLOSE_ORDER, + buttonTextStringKey: STRING_KEYS.CLOSE_POSITION, + buttonAction: ButtonAction.Destroy, + }} + /> +
+
+ + ); +}; + +export default CloseTradeForm; + +const $PlaceOrderButtonAndReceipt = styled(PlaceOrderButtonAndReceipt)` + --withReceipt-backgroundColor: transparent; +`; + +const $PositionSize = styled.div<{ isLong: boolean }>` + ${({ isLong }) => + isLong + ? css` + color: var(--color-positive) !important; + ` + : css` + color: var(--color-negative) !important; + `} +`; + +const $MidPriceButton = styled(Button)` + ${formMixins.inputInnerButton} +`; diff --git a/src/pages/trade/mobile-web/RegularTradeForm.tsx b/src/pages/trade/mobile-web/RegularTradeForm.tsx new file mode 100644 index 0000000000..d1e600f99e --- /dev/null +++ b/src/pages/trade/mobile-web/RegularTradeForm.tsx @@ -0,0 +1,448 @@ +import { FormEvent, useCallback } from 'react'; + +import { + ExecutionType, + MarginMode, + OrderSide, + TimeInForce, + TradeFormType, +} from '@/bonsai/forms/trade/types'; +import { BonsaiHelpers } from '@/bonsai/ontology'; +import BigNumber from 'bignumber.js'; +import styled, { css } from 'styled-components'; + +import { ButtonAction, ButtonShape, ButtonSize, ButtonStyle } from '@/constants/buttons'; +import { DialogTypes } from '@/constants/dialogs'; +import { STRING_KEYS } from '@/constants/localization'; +import { ORDER_TYPE_STRINGS } from '@/constants/trade'; + +import { useTradeErrors } from '@/hooks/TradingForm/useTradeErrors'; +import { TradeFormSource, useTradeForm } from '@/hooks/TradingForm/useTradeForm'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { formMixins } from '@/styles/formMixins'; + +import { Button } from '@/components/Button'; +import { Icon, IconName } from '@/components/Icon'; +import { DropdownMenuTrigger, MobileDropdownMenu } from '@/components/MobileDropdownMenu'; +import { Output, OutputType, ShowSign } from '@/components/Output'; +import { VerticalSeparator } from '@/components/Separator'; +import { Switch } from '@/components/Switch'; +import { TradeFormMessages } from '@/views/TradeFormMessages/TradeFormMessages'; +import { MarketsMenuDialog } from '@/views/dialogs/MarketsDialog/MarketsDialog'; +import { PlaceOrderButtonAndReceipt } from '@/views/forms/TradeForm/PlaceOrderButtonAndReceipt'; +import { TradeFormInputs } from '@/views/forms/TradeForm/TradeFormInputs'; +import { TradeSizeInputs } from '@/views/forms/TradeForm/TradeSizeInputs'; +import { TradeTriggerOrderInputs } from '@/views/forms/TradeForm/TradeTriggerInput'; + +import { getCurrentMarketPositionData } from '@/state/accountSelectors'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { openDialog } from '@/state/dialogs'; +import { tradeFormActions } from '@/state/tradeForm'; +import { + getTradeFormRawState, + getTradeFormSummary, + getTradeFormValues, +} from '@/state/tradeFormSelectors'; + +import { orEmptyObj } from '@/lib/typeUtils'; + +import { TradeFormHeaderMobile } from './TradeFormHeader'; + +const RegularTradeForm = () => { + const dispatch = useAppDispatch(); + const stringGetter = useStringGetter(); + + const rawInput = useAppSelector(getTradeFormRawState); + const tradeValues = useAppSelector(getTradeFormValues); + const fullFormSummary = useAppSelector(getTradeFormSummary); + const { summary } = fullFormSummary; + const { ticker, tickSizeDecimals, displayableAsset } = orEmptyObj( + useAppSelector(BonsaiHelpers.currentMarket.stableMarketInfo) + ); + const effectiveSelectedLeverage = useAppSelector( + BonsaiHelpers.currentMarket.effectiveSelectedLeverage + ); + const { signedSize: positionSize } = orEmptyObj(useAppSelector(getCurrentMarketPositionData)); + + const { stopLossOrder: stopLossSummary, takeProfitOrder: takeProfitSummary } = orEmptyObj( + summary.triggersSummary + ); + + const { showMarginMode: needsMarginMode, triggerOrdersChecked } = summary.options; + + const isInputFilled = + [ + rawInput.triggerPrice, + rawInput.limitPrice, + rawInput.reduceOnly, + rawInput.goodTil, + rawInput.execution, + rawInput.postOnly, + rawInput.timeInForce, + ].some((v) => v != null && v !== '') || (rawInput.size?.value.value.trim() ?? '') !== ''; + + const availableBalance = ( + summary.accountDetailsBefore?.account?.freeCollateral ?? BigNumber(0) + ).toNumber(); + + const onLastOrderIndexed = useCallback(() => {}, []); + + const { + placeOrderError, + placeOrder, + tradingUnavailable, + shouldEnableTrade, + hasValidationErrors, + } = useTradeForm({ + source: TradeFormSource.SimpleTradeForm, + fullFormSummary, + onLastOrderIndexed, + }); + + const { isErrorShownInOrderStatusToast, primaryAlert, shortAlertKey } = useTradeErrors({ + placeOrderError, + }); + + const { + type: selectedTradeType, + stopLossOrder, + takeProfitOrder, + reduceOnly, + marginMode = MarginMode.CROSS, + side = OrderSide.BUY, + // isClosingPosition, + } = tradeValues; + + const setMarginMode = (mode: MarginMode) => { + dispatch(tradeFormActions.setMarginMode(mode)); + }; + + const setTradeSide = (newSide: OrderSide) => { + dispatch(tradeFormActions.setSide(newSide)); + }; + + const openLeverageDialog = () => { + if (ticker) { + dispatch(openDialog(DialogTypes.SetMarketLeverage({ marketId: ticker }))); + } + }; + + const onTradeTypeChange = useCallback( + (tradeType: TradeFormType) => { + // if (isClosingPosition === true) { + // return; + // } + + dispatch(tradeFormActions.reset()); + dispatch(tradeFormActions.setSide(side)); + dispatch(tradeFormActions.setOrderType(tradeType)); + + if (tradeType === TradeFormType.MARKET) { + dispatch(tradeFormActions.setExecution(ExecutionType.IOC)); + } else { + dispatch(tradeFormActions.setTimeInForce(TimeInForce.GTT)); + } + }, + [dispatch, side] + ); + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + placeOrder({ + onPlaceOrder: () => { + dispatch(tradeFormActions.resetPrimaryInputs()); + }, + }); + }; + + const orderSideAction = { + [OrderSide.BUY]: ButtonAction.Create, + [OrderSide.SELL]: ButtonAction.Destroy, + }[side]; + + return ( +
+ +
+
+
+ setMarginMode(MarginMode.CROSS)} + disabled={!needsMarginMode && marginMode !== MarginMode.CROSS} + > + {stringGetter({ + key: STRING_KEYS.CROSS, + })} + + + setMarginMode(MarginMode.ISOLATED)} + disabled={!needsMarginMode && marginMode !== MarginMode.ISOLATED} + > + {stringGetter({ + key: STRING_KEYS.ISOLATED, + })} + +
+ + +
+
+ setTradeSide(OrderSide.BUY)} + tw="w-full border-0" + > + {stringGetter({ key: STRING_KEYS.LONG_POSITION_SHORT })} + + setTradeSide(OrderSide.SELL)} + tw="w-full border-0" + > + {stringGetter({ key: STRING_KEYS.SHORT_POSITION_SHORT })} + +
+ onTradeTypeChange(TradeFormType.MARKET), + }, + { + value: TradeFormType.LIMIT, + active: selectedTradeType === TradeFormType.LIMIT, + label: stringGetter({ key: STRING_KEYS.LIMIT_ORDER_SHORT }), + onSelect: () => onTradeTypeChange(TradeFormType.LIMIT), + }, + ]} + > + + {selectedTradeType === TradeFormType.MARKET + ? stringGetter({ key: STRING_KEYS.MARKET_ORDER_SHORT }) + : stringGetter({ key: STRING_KEYS.LIMIT_ORDER_SHORT })} + + +
+ {positionSize && + !positionSize.eq(0) && + ((positionSize.gt(0) && side === OrderSide.SELL) || + (positionSize.lt(0) && side === OrderSide.BUY)) && ( +
+

Current Position

+ <$PositionSize + tw="text-small" + isLong={positionSize?.isGreaterThanOrEqualTo(0) ?? false} + > + {Math.abs(positionSize?.toNumber() ?? 0)} {displayableAsset} + +
+ )} + +

Available

+

+ {availableBalance.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{' '} + USDC +

+
+
+ <$InputsColumn> + + + + + Triggers + + dispatch(checked ? tradeFormActions.showTriggers() : tradeFormActions.hideTriggers()) + } + tw="font-mini-book" + /> + + {triggerOrdersChecked && ( +
+ + +
+ )} + + {stringGetter({ key: STRING_KEYS.REDUCE_ONLY })} + dispatch(tradeFormActions.setReduceOnly(checked))} + tw="font-mini-book" + /> + +
+ + {/* {receiptArea} */} + <$PlaceOrderButtonAndReceipt + summary={summary} + actionStringKey={shortAlertKey} + confirmButtonConfig={{ + stringKey: ORDER_TYPE_STRINGS[selectedTradeType].orderTypeKey, + buttonTextStringKey: STRING_KEYS.PLACE_ORDER, + buttonAction: orderSideAction as ButtonAction, + }} + hasInput={isInputFilled} + hasValidationErrors={hasValidationErrors} + onClearInputs={() => dispatch(tradeFormActions.resetPrimaryInputs())} + shouldEnableTrade={shouldEnableTrade} + showDeposit={false} + tradingUnavailable={tradingUnavailable} + /> +
+
+ + + + ); +}; + +export default RegularTradeForm; + +const StyledButton = styled(Button)<{ $isActive?: boolean }>` + ${({ $isActive }) => + $isActive + ? `--button-textColor: var(--color-text-2);` + : `--button-textColor: var(--color-text-0);`} +`; + +const LongButton = styled(Button)<{ $isLong?: boolean }>` + ${({ $isLong }) => + $isLong + ? `--button-textColor: var(--color-green); --button-backgroundColor: var(--color-gradient-positive);` + : '--button-textColor: var(--color-layer-7); --button-backgroundColor: transparent'} + + border-radius: 0.375rem; +`; + +const ShortButton = styled(Button)<{ $isShort?: boolean }>` + ${({ $isShort }) => + $isShort + ? `--button-textColor: var(--color-red); --button-backgroundColor: var(--color-gradient-negative);` + : '--button-textColor: var(--color-layer-7); --button-backgroundColor: transparent'} + + border-radius: 0.375rem; +`; + +const $InputsColumn = styled.div` + ${formMixins.inputsColumn} + + width: 100%; +`; + +const AvailableRow = styled.div` + display: flex; + + justify-content: space-between; + + align-items: center; + + padding: 8 px 4 px; + + margin-bottom: 8 px; + + width: 100%; +`; + +const ToggleRow = styled.div` + display: flex; + + justify-content: space-between; + + align-items: center; + + padding: 0px 0px; + + width: 100%; +`; +const ToggleLabel = styled.span` + color: #9ca3af; + + font-size: 16 px; + + font-weight: 400; +`; + +const $PlaceOrderButtonAndReceipt = styled(PlaceOrderButtonAndReceipt)` + --withReceipt-backgroundColor: transparent; +`; + +const $PositionSize = styled.div<{ isLong: boolean }>` + ${({ isLong }) => + isLong + ? css` + color: var(--color-positive) !important; + ` + : css` + color: var(--color-negative) !important; + `} +`; diff --git a/src/pages/trade/mobile-web/Trade.tsx b/src/pages/trade/mobile-web/Trade.tsx new file mode 100644 index 0000000000..06d7f007a4 --- /dev/null +++ b/src/pages/trade/mobile-web/Trade.tsx @@ -0,0 +1,127 @@ +import { OrderSide } from '@/bonsai/forms/trade/types'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; + +import { ButtonAction, ButtonShape, ButtonSize, ButtonState } from '@/constants/buttons'; +import { ComplianceStates } from '@/constants/compliance'; +import { STRING_KEYS } from '@/constants/localization'; +import { AppRoute } from '@/constants/routes'; + +import { useComplianceState } from '@/hooks/useComplianceState'; +import { useCurrentMarketId } from '@/hooks/useCurrentMarketId'; +import { usePageTitlePriceUpdates } from '@/hooks/usePageTitlePriceUpdates'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +import { Button } from '@/components/Button'; +import { DetachedSection } from '@/components/ContentSection'; +import { MarketsMenuDialog } from '@/views/dialogs/MarketsDialog/MarketsDialog'; +import { UserMenuDialog } from '@/views/dialogs/MobileUserMenuDialog'; +import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton'; + +import { calculateCanAccountTrade } from '@/state/accountCalculators'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { tradeFormActions } from '@/state/tradeForm'; + +import { HorizontalPanel } from '../HorizontalPanel'; +import LaunchableMarket from '../LaunchableMarket'; +import { MobileTopPanel } from '../MobileTopPanel'; +import { TradeHeaderMobile } from './TradeHeader'; + +const TradePage = () => { + const dispatch = useAppDispatch(); + const { isViewingUnlaunchedMarket } = useCurrentMarketId(); + const { complianceState } = useComplianceState(); + const canAccountTrade = useAppSelector(calculateCanAccountTrade); + const stringGetter = useStringGetter(); + const navigate = useNavigate(); + + usePageTitlePriceUpdates(); + + if (isViewingUnlaunchedMarket) { + return ; + } + + const isDisabled = complianceState !== ComplianceStates.FULL_ACCESS; + + return ( +
+ + + <$MobileContent> + + + + + + + + + {/* + + */} + + +
+ {isDisabled ? ( + + ) : canAccountTrade ? ( + <> + + + + + ) : ( + + )} +
+ + + +
+ ); +}; + +export default TradePage; + +const $MobileContent = styled.article` + ${layoutMixins.contentContainerPage} + + padding-bottom: 10rem; +`; diff --git a/src/pages/trade/mobile-web/TradeForm.tsx b/src/pages/trade/mobile-web/TradeForm.tsx new file mode 100644 index 0000000000..daf83e7c4f --- /dev/null +++ b/src/pages/trade/mobile-web/TradeForm.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; + +import { BonsaiHelpers } from '@/bonsai/ontology'; + +import { AppRoute } from '@/constants/routes'; + +import { useCurrentMarketId } from '@/hooks/useCurrentMarketId'; + +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { tradeFormActions } from '@/state/tradeForm'; +import { getTradeFormValues } from '@/state/tradeFormSelectors'; + +import { orEmptyObj } from '@/lib/typeUtils'; + +import CloseTradeForm from './CloseTradeForm'; +import RegularTradeForm from './RegularTradeForm'; + +const TradeForm = () => { + const dispatch = useAppDispatch(); + const { marketId } = useCurrentMarketId(AppRoute.TradeForm); + + const tradeValues = useAppSelector(getTradeFormValues); + const { ticker } = orEmptyObj(useAppSelector(BonsaiHelpers.currentMarket.stableMarketInfo)); + + useEffect(() => { + if (marketId !== tradeValues.marketId) { + dispatch(tradeFormActions.setMarketId(ticker)); + } + }, [ticker, dispatch]); + + const isClosingPosition = tradeValues.isClosingPosition; + + if (isClosingPosition) { + return ; + } + + return ; +}; + +export default TradeForm; diff --git a/src/pages/trade/mobile-web/TradeFormHeader.tsx b/src/pages/trade/mobile-web/TradeFormHeader.tsx new file mode 100644 index 0000000000..af803af18c --- /dev/null +++ b/src/pages/trade/mobile-web/TradeFormHeader.tsx @@ -0,0 +1,84 @@ +import { BonsaiHelpers } from '@/bonsai/ontology'; +import styled from 'styled-components'; + +import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +import { Output, OutputType, ShowSign } from '@/components/Output'; +import { MidMarketPrice } from '@/views/MidMarketPrice'; +import { MobileTradeAssetSelector } from '@/views/mobile/MobileTradeAssetSelector'; + +import { useAppSelector } from '@/state/appTypes'; + +import { MustBigNumber } from '@/lib/numbers'; + +export const TradeFormHeaderMobile = ({ launchableMarketId }: { launchableMarketId?: string }) => { + const assetId = useAppSelector(BonsaiHelpers.currentMarket.assetId); + + const launchableAsset = useAppSelectorWithArgs(BonsaiHelpers.assets.selectAssetInfo, assetId); + + const percentChange24h = launchableAsset?.percentChange24h + ? MustBigNumber(launchableAsset.percentChange24h).div(100).toNumber() + : undefined; + + const assetPrice = launchableAsset ? ( + + ) : ( + + ); + + return ( + <$Header> + + + <$Right> +
+ {assetPrice} + +
+ + + ); +}; + +const $Header = styled.div` + ${layoutMixins.contentSectionDetachedScrollable} + + ${layoutMixins.stickyHeader} + z-index: 2; + + ${layoutMixins.row} + + gap: 1rem; + + color: var(--color-text-2); + background-color: var(--color-layer-2); + height: 60px; + + padding-left: 0.4rem; + padding-right: 1rem; + + border-bottom: 1px solid var(--color-border); + + width: 100%; +`; + +const $Right = styled.div` + margin-left: auto; + + ${layoutMixins.rowColumn} + justify-items: flex-end; +`; diff --git a/src/pages/trade/mobile-web/TradeHeader.tsx b/src/pages/trade/mobile-web/TradeHeader.tsx new file mode 100644 index 0000000000..aaceaf1b5e --- /dev/null +++ b/src/pages/trade/mobile-web/TradeHeader.tsx @@ -0,0 +1,58 @@ +import { BonsaiHelpers } from '@/bonsai/ontology'; +import styled from 'styled-components'; + +import { ButtonShape, ButtonSize } from '@/constants/buttons'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +import { Button } from '@/components/Button'; +import { Icon, IconName } from '@/components/Icon'; +import { MarketStatsDetails } from '@/views/MarketStatsDetails'; +import { MobileTradeAssetSelector } from '@/views/mobile/MobileTradeAssetSelector'; +import { FavoriteButton } from '@/views/tables/MarketsTable/FavoriteButton'; + +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { setIsUserMenuOpen } from '@/state/dialogs'; + +export const TradeHeaderMobile = ({ launchableMarketId }: { launchableMarketId?: string }) => { + const id = useAppSelector(BonsaiHelpers.currentMarket.assetId); + const dispatch = useAppDispatch(); + + const openUserMenu = () => { + dispatch(setIsUserMenuOpen(true)); + }; + + return ( + <$TopHeader> +
+ {id && } + + + + +
+ + + ); +}; + +const $TopHeader = styled.header` + ${layoutMixins.contentSectionDetachedScrollable} + + ${layoutMixins.stickyHeader} + z-index: 2; + + ${layoutMixins.column} + + width: 100%; + + color: var(--color-text-2); + background-color: var(--color-layer-2); +`; diff --git a/src/state/dialogs.ts b/src/state/dialogs.ts index 6f5d1f0297..996e664e59 100644 --- a/src/state/dialogs.ts +++ b/src/state/dialogs.ts @@ -6,6 +6,7 @@ export interface DialogsState { activeDialog?: DialogType; activeTradeBoxDialog?: TradeBoxDialogType; isUserMenuOpen: boolean; + isMarketsMenuOpen: boolean; dialogQueue: DialogType[]; } @@ -13,6 +14,7 @@ const initialState: DialogsState = { activeDialog: undefined, activeTradeBoxDialog: undefined, isUserMenuOpen: false, + isMarketsMenuOpen: false, dialogQueue: [], }; @@ -47,6 +49,9 @@ export const dialogsSlice = createSlice({ setIsUserMenuOpen: (state, action: PayloadAction) => { state.isUserMenuOpen = action.payload; }, + setIsMarketsMenuOpen: (state, action: PayloadAction) => { + state.isMarketsMenuOpen = action.payload; + }, }, }); @@ -57,4 +62,5 @@ export const { openDialog, openDialogInTradeBox, setIsUserMenuOpen, + setIsMarketsMenuOpen, } = dialogsSlice.actions; diff --git a/src/state/dialogsSelectors.ts b/src/state/dialogsSelectors.ts index fb4d189784..0f00411e89 100644 --- a/src/state/dialogsSelectors.ts +++ b/src/state/dialogsSelectors.ts @@ -5,3 +5,5 @@ export const getActiveDialog = (state: RootState) => state.dialogs.activeDialog; export const getActiveTradeBoxDialog = (state: RootState) => state.dialogs.activeTradeBoxDialog; export const getIsUserMenuOpen = (state: RootState) => state.dialogs.isUserMenuOpen; + +export const getIsMarketsMenuOpen = (state: RootState) => state.dialogs.isMarketsMenuOpen; diff --git a/src/styles/constants.css b/src/styles/constants.css index 509ccb8328..ea9db29dea 100644 --- a/src/styles/constants.css +++ b/src/styles/constants.css @@ -6,7 +6,7 @@ --page-header-height: 2.75rem; --page-header-height-mobile: 4rem; --page-footer-height: 2rem; - --page-footer-height-mobile: 4.5rem; + --page-footer-height-mobile: 3.5rem; /* Restriction Warning */ --restriction-warning-height: 3.25rem; diff --git a/src/views/MarketStatsDetails.tsx b/src/views/MarketStatsDetails.tsx index 43b9b1e752..f0e37933d7 100644 --- a/src/views/MarketStatsDetails.tsx +++ b/src/views/MarketStatsDetails.tsx @@ -31,6 +31,8 @@ import { MidMarketPrice } from './MidMarketPrice'; type ElementProps = { showMidMarketPrice?: boolean; + horizontal?: boolean; + withSubscript?: boolean; }; enum MarketStats { @@ -44,7 +46,11 @@ enum MarketStats { MaxLeverage = 'MaxLeverage', } -export const MarketStatsDetails = ({ showMidMarketPrice = true }: ElementProps) => { +export const MarketStatsDetails = ({ + showMidMarketPrice = true, + horizontal = false, + withSubscript, +}: ElementProps) => { const stringGetter = useStringGetter(); const { isTablet } = useBreakpoints(); @@ -120,12 +126,14 @@ export const MarketStatsDetails = ({ showMidMarketPrice = true }: ElementProps) initialMarginFraction={initialMarginFraction} effectiveInitialMarginFraction={effectiveInitialMarginFraction} useFiatDisplayUnit={displayUnit === DisplayUnit.Fiat} + withSubscript={withSubscript} /> ), }))} isLoading={isLoading} - layout={isTablet ? 'grid' : 'rowColumns'} + layout={horizontal ? 'rowColumns' : isTablet ? 'grid' : 'rowColumns'} withSeparators={!isTablet} + $horizontalScroll={horizontal} /> ); @@ -145,17 +153,29 @@ const $MarketDetailsItems = styled.div` } `; -const $Details = styled(Details)` +const $Details = styled(Details)<{ $horizontalScroll?: boolean }>` font: var(--font-mini-book); @media ${breakpoints.tablet} { ${layoutMixins.withOuterAndInnerBorders} - font: var(--font-small-book); > * { padding: 0.625rem 1rem; } + + ${({ $horizontalScroll }) => + $horizontalScroll && + css` + max-width: 100vw; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + + > * { + flex-shrink: 0; + } + `} } `; @@ -206,6 +226,7 @@ const DetailsItem = ({ initialMarginFraction, effectiveInitialMarginFraction, useFiatDisplayUnit, + withSubscript = true, }: { value: string | number | null | undefined; stat: MarketStats; @@ -216,6 +237,7 @@ const DetailsItem = ({ initialMarginFraction: string | null | undefined; effectiveInitialMarginFraction: number | null | undefined; useFiatDisplayUnit: boolean; + withSubscript?: boolean; }) => { const valueBN = MustBigNumber(value); const stringGetter = useStringGetter(); @@ -226,7 +248,7 @@ const DetailsItem = ({ case MarketStats.OraclePrice: { return ( <$Output - withSubscript + withSubscript={withSubscript} type={OutputType.Fiat} value={value} fractionDigits={tickSizeDecimals} diff --git a/src/views/PositionInfo.tsx b/src/views/PositionInfo.tsx index 2c8720d5fa..9c5992f86d 100644 --- a/src/views/PositionInfo.tsx +++ b/src/views/PositionInfo.tsx @@ -452,7 +452,7 @@ const $MobileDetails = styled(Details)` } `; -const $Actions = styled.footer` +const $Actions = styled.div` display: flex; flex-wrap: wrap; gap: 0.5rem; diff --git a/src/views/charts/TradingView/ResolutionSelector.tsx b/src/views/charts/TradingView/ResolutionSelector.tsx index afa1730c31..5cd22ef3b3 100644 --- a/src/views/charts/TradingView/ResolutionSelector.tsx +++ b/src/views/charts/TradingView/ResolutionSelector.tsx @@ -8,6 +8,7 @@ import { RESOLUTION_STRING_TO_LABEL, } from '@/constants/candles'; +import { useBreakpoints } from '@/hooks/useBreakpoints'; import { useStringGetter } from '@/hooks/useStringGetter'; import { objectKeys } from '@/lib/objectHelpers'; @@ -21,6 +22,7 @@ export const ResolutionSelector = ({ onResolutionChange: (resolution: ResolutionString) => void; currentResolution?: ResolutionString; }) => { + const { isTablet } = useBreakpoints(); const stringGetter = useStringGetter(); const getLabel = useCallback( @@ -42,10 +44,16 @@ export const ResolutionSelector = ({ {objectKeys(isLaunchable ? LAUNCHABLE_MARKET_RESOLUTION_CONFIGS : RESOLUTION_MAP).map( (resolution) => ( + +
+ +
+ + { + const { labelIconName, labelStringKey, isNew } = MARKET_FILTER_OPTIONS[value]; + return { + label: labelIconName ? ( + + ) : ( + stringGetter({ + key: labelStringKey, + fallback: value, + }) + ), + slotAfter: isNew && {stringGetter({ key: STRING_KEYS.NEW })}, + value, + }; + }) satisfies MenuItem[] + } + css={{ + '--button-toggle-on-border': 'none', + '--button-toggle-off-border': 'solid var(--default-border-width) var(--color-border)', + '--button-toggle-off-backgroundColor': 'transparent', + '--button-toggle-on-backgroundColor': 'var(--color-layer-4)', + }} + value={filter} + onValueChange={setFilter} + overflow="scroll" + /> +
+ + ); +}; + +export default MarketFilterRow; diff --git a/src/views/dialogs/MarketsDialog/MarketRow.tsx b/src/views/dialogs/MarketsDialog/MarketRow.tsx new file mode 100644 index 0000000000..312b45a98c --- /dev/null +++ b/src/views/dialogs/MarketsDialog/MarketRow.tsx @@ -0,0 +1,92 @@ +import { Link } from 'react-router-dom'; + +import { STRING_KEYS } from '@/constants/localization'; +import { MarketData } from '@/constants/markets'; +import { AppRoute } from '@/constants/routes'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { Output, OutputType } from '@/components/Output'; +import { Tag } from '@/components/Tag'; + +import { useAppDispatch } from '@/state/appTypes'; +import { setIsMarketsMenuOpen } from '@/state/dialogs'; + +import { calculateMarketMaxLeverage } from '@/lib/marketsHelpers'; + +export const MarketRow = ({ className, market }: { className?: string; market: MarketData }) => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + + const closeMarketsMenu = () => { + dispatch(setIsMarketsMenuOpen(false)); + }; + + const percentChangeColor = market.percentChange24h + ? market.percentChange24h >= 0 + ? 'var(--color-positive)' + : 'var(--color-negative)' + : 'var(--color-text-1)'; + + return ( + +
+ +
+
+ + {market.displayableAsset} + + + + + +
+ + {stringGetter({ key: STRING_KEYS.MARKET })} + + } + /> +
+
+ +
+ + +
+ + ); +}; diff --git a/src/views/dialogs/MarketsDialog/MarketsDialog.tsx b/src/views/dialogs/MarketsDialog/MarketsDialog.tsx new file mode 100644 index 0000000000..7da05bcaa3 --- /dev/null +++ b/src/views/dialogs/MarketsDialog/MarketsDialog.tsx @@ -0,0 +1,49 @@ +import styled from 'styled-components'; + +import { STRING_KEYS } from '@/constants/localization'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { Dialog, DialogPlacement } from '@/components/Dialog'; + +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { setIsMarketsMenuOpen } from '@/state/dialogs'; +import { getIsMarketsMenuOpen } from '@/state/dialogsSelectors'; + +import { MarketList } from './MarketsList'; + +const Content = () => { + return ( +
+ +
+ ); +}; + +export const MarketsMenuDialog = () => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + const isMarketsMenuOpen = useAppSelector(getIsMarketsMenuOpen); + + const toggleMarketsMenu = (isOpen: boolean) => { + dispatch(setIsMarketsMenuOpen(isOpen)); + }; + + return ( + <$Dialog + isOpen={isMarketsMenuOpen} + title={stringGetter({ key: STRING_KEYS.MARKETS })} + setIsOpen={toggleMarketsMenu} + placement={DialogPlacement.Inline} + > + + + ); +}; + +const $Dialog = styled(Dialog)` + --dialog-backgroundColor: var(--color-layer-1); + --dialog-header-backgroundColor: var(--color-layer-1); + box-shadow: none; + z-index: 3; +`; diff --git a/src/views/dialogs/MarketsDialog/MarketsList.tsx b/src/views/dialogs/MarketsDialog/MarketsList.tsx new file mode 100644 index 0000000000..80774c13fe --- /dev/null +++ b/src/views/dialogs/MarketsDialog/MarketsList.tsx @@ -0,0 +1,318 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; + +import type { Range } from '@tanstack/react-virtual'; +import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'; +import orderBy from 'lodash/orderBy'; + +import { ButtonShape, ButtonSize } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; +import { ListItem, MarketsSortType } from '@/constants/marketList'; +import { MARKET_FILTER_OPTIONS, MarketData, MarketFilters } from '@/constants/markets'; +import { MenuItem } from '@/constants/menus'; + +import { useMarketsData } from '@/hooks/useMarketsData'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { Button } from '@/components/Button'; +import { Icon, IconName } from '@/components/Icon'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; +import { SearchInput } from '@/components/SearchInput'; +import { SimpleUiDropdownMenu } from '@/components/SimpleUiDropdownMenu'; +import { SortIcon } from '@/components/SortIcon'; +import { NewTag } from '@/components/Tag'; +import { ToggleGroup } from '@/components/ToggleGroup'; + +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { setSimpleUISortMarketsBy } from '@/state/appUiConfigs'; +import { getSimpleUISortMarketsBy } from '@/state/appUiConfigsSelectors'; +import { setMarketFilter } from '@/state/perpetuals'; +import { getMarketFilter } from '@/state/perpetualsSelectors'; + +import { isPresent } from '@/lib/typeUtils'; + +import { MarketRow } from './MarketRow'; + +const sortMarkets = (markets: MarketData[], sortType: MarketsSortType) => { + switch (sortType) { + case MarketsSortType.MarketCap: + return orderBy(markets, (market) => market.marketCap ?? 0, ['desc']); + case MarketsSortType.Volume: + return orderBy(markets, (market) => market.volume24h ?? 0, ['desc']); + case MarketsSortType.Gainers: + return orderBy(markets, (market) => market.percentChange24h ?? 0, ['desc']); + case MarketsSortType.Losers: + return orderBy(markets, (market) => market.percentChange24h ?? 0, ['asc']); + case MarketsSortType.Favorites: + return orderBy(markets, (market) => market.isFavorite, ['desc']); + default: + return markets; + } +}; + +const POSITION_ROW_HEIGHT = 60; +const MARKET_ROW_HEIGHT = 60; +const HEADER_ROW_HEIGHT = 52; + +const getItemHeight = (item: ListItem) => { + const customHeight = item.customHeight; + const itemType = item.itemType; + + if (itemType === 'custom') { + if (customHeight == null) { + throw new Error(`MarketList: ${item} has no custom height`); + } + + return customHeight; + } + + return ( + customHeight ?? + { + position: POSITION_ROW_HEIGHT, + market: MARKET_ROW_HEIGHT, + header: HEADER_ROW_HEIGHT, + }[itemType] + ); +}; + +export const MarketList = () => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + + // Filters and sort + const filter: MarketFilters = useAppSelector(getMarketFilter); + const [searchFilter, setSearchFilter] = useState(); + const sortType = useAppSelector(getSimpleUISortMarketsBy); + + const setSortType = useCallback( + (newSortType: MarketsSortType) => { + dispatch(setSimpleUISortMarketsBy(newSortType)); + }, + [dispatch] + ); + + // Markets and Filters + const { filteredMarkets, hasMarketIds, marketFilters } = useMarketsData({ + filter, + searchFilter, + }); + + const setFilter = useCallback( + (newFilter: MarketFilters) => { + dispatch(setMarketFilter(newFilter)); + }, + [dispatch] + ); + + const sortedMarkets = sortMarkets(filteredMarkets, sortType); + + const sortItems = useMemo( + () => [ + { + label: stringGetter({ key: STRING_KEYS.MARKET_CAP }), + value: MarketsSortType.MarketCap, + active: sortType === MarketsSortType.MarketCap, + icon: , + onSelect: () => setSortType(MarketsSortType.MarketCap), + }, + { + label: stringGetter({ key: STRING_KEYS.VOLUME }), + value: MarketsSortType.Volume, + active: sortType === MarketsSortType.Volume, + icon: , + onSelect: () => setSortType(MarketsSortType.Volume), + }, + { + label: stringGetter({ key: STRING_KEYS.GAINERS }), + value: MarketsSortType.Gainers, + active: sortType === MarketsSortType.Gainers, + icon: , + onSelect: () => setSortType(MarketsSortType.Gainers), + }, + { + label: stringGetter({ key: STRING_KEYS.LOSERS }), + value: MarketsSortType.Losers, + active: sortType === MarketsSortType.Losers, + icon: , + onSelect: () => setSortType(MarketsSortType.Losers), + }, + { + label: stringGetter({ key: STRING_KEYS.FAVORITES }), + value: MarketsSortType.Favorites, + active: sortType === MarketsSortType.Favorites, + icon: , + onSelect: () => setSortType(MarketsSortType.Favorites), + }, + ], + [stringGetter, sortType, setSortType] + ); + + const searchViewItems: ListItem[] = useMemo( + () => [ + ...sortedMarkets.map((market) => ({ + itemType: 'market' as const, + item: market, + })), + ], + [sortedMarkets] + ); + + const items = searchViewItems; + + const parentRef = useRef(null); + + const activeStickyIndexRef = useRef(0); + + const stickyIndexes = useMemo( + () => items.map((item, idx) => (item.isSticky ? idx : undefined)).filter(isPresent), + [items] + ); + + const isSticky = (index: number) => stickyIndexes.includes(index); + + const isActiveSticky = (index: number) => activeStickyIndexRef.current === index; + + const rowVirtualizer = useVirtualizer({ + count: items.length, + estimateSize: (index: number) => getItemHeight(items[index]!), + getScrollElement: () => parentRef.current, + rangeExtractor: useCallback( + (range: Range) => { + activeStickyIndexRef.current = + [...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? 0; + + const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)]); + + return [...next].sort((a, b) => a - b); + }, + [stickyIndexes] + ), + }); + + if (!hasMarketIds) { + return ; + } + + const sortTypeLabel = sortItems.find((item) => item.value === sortType)?.label ?? ''; + + return ( +
+
+
+
+ +
+ {sortTypeLabel} + Sort by} + > + + +
+
+ { + const { labelIconName, labelStringKey, isNew } = MARKET_FILTER_OPTIONS[value]; + return { + label: labelIconName ? ( + + ) : ( + stringGetter({ + key: labelStringKey, + fallback: value, + }) + ), + slotAfter: isNew && {stringGetter({ key: STRING_KEYS.NEW })}, + value, + }; + }) satisfies MenuItem[] + } + css={{ + '--button-toggle-on-border': 'none', + '--button-toggle-off-border': 'solid var(--default-border-width) var(--color-border)', + '--button-toggle-off-backgroundColor': 'transparent', + '--button-toggle-on-backgroundColor': 'var(--color-layer-4)', + }} + value={filter} + onValueChange={setFilter} + overflow="scroll" + /> +
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => ( +
+ +
+ ))} +
+
+ ); +}; + +const ItemRenderer = ({ listItem }: { listItem: ListItem }) => { + const height = getItemHeight(listItem); + + if (listItem.itemType === 'market') { + return ; + } + + if (listItem.itemType === 'custom') { + return
{listItem.item}
; + } + + return ( +
+ {listItem.item} + {(listItem as any)?.slotRight} +
+ ); +}; diff --git a/src/views/dialogs/MobileUserMenuDialog.tsx b/src/views/dialogs/MobileUserMenuDialog.tsx new file mode 100644 index 0000000000..c245d7144a --- /dev/null +++ b/src/views/dialogs/MobileUserMenuDialog.tsx @@ -0,0 +1,343 @@ +import { useState } from 'react'; + +import { BonsaiCore } from '@/bonsai/ontology'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; + +import { OnboardingState } from '@/constants/account'; +import { ButtonAction, ButtonShape, ButtonSize, ButtonStyle } from '@/constants/buttons'; +import { ComplianceStates } from '@/constants/compliance'; +import { MODERATE_DEBOUNCE_MS } from '@/constants/debounce'; +import { DialogTypes } from '@/constants/dialogs'; +import { STRING_KEYS } from '@/constants/localization'; +import { AppRoute } from '@/constants/routes'; + +import { useAccounts } from '@/hooks/useAccounts'; +import { useComplianceState } from '@/hooks/useComplianceState'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { Button } from '@/components/Button'; +import { Dialog, DialogPlacement } from '@/components/Dialog'; +import { Icon, IconName } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { MixedColorFiatOutput } from '@/components/MixedColorFiatOutput'; +import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton'; + +import { calculateCanAccountTrade } from '@/state/accountCalculators'; +import { getOnboardingState } from '@/state/accountSelectors'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { openDialog, setIsUserMenuOpen } from '@/state/dialogs'; +import { getIsUserMenuOpen } from '@/state/dialogsSelectors'; +import { selectIsTurnkeyConnected } from '@/state/walletSelectors'; + +import { isTruthy } from '@/lib/isTruthy'; +import { orEmptyObj } from '@/lib/typeUtils'; +import { truncateAddress } from '@/lib/wallet'; + +const UserMenuContent = () => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const onboardingState = useAppSelector(getOnboardingState); + const { complianceState } = useComplianceState(); + const canAccountTrade = useAppSelector(calculateCanAccountTrade); + const isTurnkeyConnected = useAppSelector(selectIsTurnkeyConnected); + const { equity, freeCollateral } = orEmptyObj( + useAppSelector(BonsaiCore.account.parentSubaccountSummary.data) + ); + + const isLoading = + useAppSelector(BonsaiCore.account.parentSubaccountSummary.loading) === 'pending'; + + const { + sourceAccount: { address }, + dydxAddress, + } = useAccounts(); + + const [walletDisplay, setWalletDisplay] = useState<'chain' | 'source'>('chain'); + + const [copied, setCopied] = useState(false); + + const onCopy = () => { + if (walletDisplay === 'chain') { + if (!dydxAddress) return; + setCopied(true); + navigator.clipboard.writeText(dydxAddress); + setTimeout(() => setCopied(false), MODERATE_DEBOUNCE_MS); + } else { + if (!address) return; + setCopied(true); + navigator.clipboard.writeText(address); + setTimeout(() => setCopied(false), MODERATE_DEBOUNCE_MS); + } + }; + + const menuItems = [ + onboardingState === OnboardingState.AccountConnected && { + key: 'history', + label: stringGetter({ key: STRING_KEYS.HISTORY }), + icon: , + onClick: () => { + navigate(AppRoute.Portfolio); + }, + }, + onboardingState === OnboardingState.AccountConnected && { + key: 'settings', + label: stringGetter({ key: STRING_KEYS.SETTINGS }), + icon: , + onClick: () => { + navigate(AppRoute.Settings); + }, + }, + { + key: 'help', + label: stringGetter({ key: STRING_KEYS.HELP }), + icon: , + onClick: () => { + dispatch(openDialog(DialogTypes.Help())); + }, + }, + onboardingState === OnboardingState.AccountConnected && + (isTurnkeyConnected + ? { + key: 'export-turnkey', + label: stringGetter({ key: STRING_KEYS.ACCOUNT_MANAGEMENT }), + icon: , + onClick: () => { + dispatch(openDialog(DialogTypes.ManageAccount())); + }, + } + : { + key: 'export-keys', + label: stringGetter({ key: STRING_KEYS.EXPORT_DYDX_WALLET }), + icon: , + onClick: () => { + dispatch(openDialog(DialogTypes.MnemonicExport())); + }, + }), + onboardingState !== OnboardingState.Disconnected && { + key: 'disconnect-wallet', + label: stringGetter({ key: STRING_KEYS.SIGN_OUT }), + highlightColor: 'var(--color-red)', + icon: , + withoutCaret: true, + onClick: () => { + dispatch(openDialog(DialogTypes.DisconnectWallet())); + }, + }, + ].filter(isTruthy); + + const walletDisplayRow = canAccountTrade ? ( +
+ + +
+ ) : ( + + ); + + const userContent = ( + <$UserContent> +
+
+
+ profile + + dydx + +
+ +
+ + + {stringGetter({ key: STRING_KEYS.BALANCE })} + +
+
+
+ {walletDisplayRow} + + ); + + const isTransferDisabled = !canAccountTrade || isLoading; + const hasBalance = freeCollateral?.gt(0); + const showWithdrawOnly = canAccountTrade && hasBalance; + const showAllTransferOptions = + canAccountTrade && complianceState === ComplianceStates.FULL_ACCESS; + + const transferContent = showAllTransferOptions ? ( +
+ + { + dispatch(openDialog(DialogTypes.Withdraw2())); + }} + /> + { + dispatch(openDialog(DialogTypes.Transfer({}))); + }} + /> +
+ ) : showWithdrawOnly ? ( +
+ +
+ ) : null; + + const menuContent = ( + <$MenuContent> + {menuItems.map((item) => ( + + ))} + + ); + + return ( +
+ {userContent} + {transferContent} + {menuContent} +
+ ); +}; + +export const UserMenuDialog = () => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + const isUserMenuOpen = useAppSelector(getIsUserMenuOpen); + + const toggleUserMenu = (isOpen: boolean) => { + dispatch(setIsUserMenuOpen(isOpen)); + }; + + return ( + <$Dialog + isOpen={isUserMenuOpen} + title={stringGetter({ key: STRING_KEYS.MENU })} + setIsOpen={toggleUserMenu} + placement={DialogPlacement.Inline} + > + + + ); +}; + +const $UserContent = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + border-radius: 1rem; + border: solid 1.6px var(--color-layer-2); + padding: 1rem; +`; + +const $MenuContent = styled.div` + display: flex; + flex-direction: column; + border-radius: 1rem; + overflow: hidden; + gap: 1px; +`; + +const $Caret = styled(Icon)` + --icon-size: 0.5rem; + transform: rotate(0.75turn); +`; + +const $Dialog = styled(Dialog)` + --dialog-backgroundColor: var(--color-layer-1); + --dialog-header-backgroundColor: var(--color-layer-1); + box-shadow: none; + z-index: 3; +`; diff --git a/src/views/dialogs/SetMarketLeverageDialog.tsx b/src/views/dialogs/SetMarketLeverageDialog.tsx index b775a33b4b..661b6abcde 100644 --- a/src/views/dialogs/SetMarketLeverageDialog.tsx +++ b/src/views/dialogs/SetMarketLeverageDialog.tsx @@ -214,7 +214,7 @@ export const SetMarketLeverageDialog = ({ isOpen setIsOpen={setIsOpen} title={stringGetter({ key: STRING_KEYS.SET_MARKET_LEVERAGE })} - tw="[--dialog-header-paddingBottom:1.5rem] [--dialog-width:25rem]" + tw="[--dialog-header-paddingBottom:1.5rem]" >
diff --git a/src/views/forms/TradeForm/AllocationSlider.tsx b/src/views/forms/TradeForm/AllocationSlider.tsx index b5b071e615..803ae7a9cb 100644 --- a/src/views/forms/TradeForm/AllocationSlider.tsx +++ b/src/views/forms/TradeForm/AllocationSlider.tsx @@ -1,6 +1,6 @@ -import styled from 'styled-components'; +import { useEffect, useState } from 'react'; -import { useQuickUpdatingState } from '@/hooks/useQuickUpdatingState'; +import styled from 'styled-components'; import breakpoints from '@/styles/breakpoints'; import { formMixins } from '@/styles/formMixins'; @@ -8,7 +8,6 @@ import { formMixins } from '@/styles/formMixins'; import { Input, InputType } from '@/components/Input'; import { Slider } from '@/components/Slider'; -import { mapIfPresent } from '@/lib/do'; import { MustBigNumber } from '@/lib/numbers'; export const AllocationSlider = ({ @@ -18,27 +17,21 @@ export const AllocationSlider = ({ allocationPercentInput: string | undefined; setAllocationInput: (val: string | undefined) => void; }) => { - const { - value: allocation, - setValue: setAllocation, - commitValue: commitAllocation, - } = useQuickUpdatingState({ - setValueSlow: setAllocationInput, - slowValue: allocationPercentInput, - debounceMs: 100, - }); + const [localValue, setLocalValue] = useState(allocationPercentInput); - const onSliderDrag = ([newValue]: number[]) => { - const newValueString = mapIfPresent(newValue, (lev) => MustBigNumber(lev).toFixed(0)); - setAllocation(newValueString ?? ''); - }; + useEffect(() => { + setLocalValue(allocationPercentInput); + }, [allocationPercentInput]); - const commitValue = (newValue: string | undefined) => { - commitAllocation(newValue); + const onSliderDrag = ([newValue]: number[]) => { + const newValueString = MustBigNumber(newValue).toFixed(0); + setLocalValue(newValueString); }; const onValueCommit = ([newValue]: number[]) => { - commitValue(MustBigNumber(newValue).toFixed(0)); + const finalValue = MustBigNumber(newValue).toFixed(0); + setLocalValue(finalValue); + setAllocationInput(finalValue); }; return ( @@ -48,20 +41,21 @@ export const AllocationSlider = ({ label="Allocation" min={0} max={100} - step={0.1} - value={MustBigNumber(allocation).toNumber()} + step={1} + value={MustBigNumber(localValue).toNumber()} onSliderDrag={onSliderDrag} onValueCommit={onValueCommit} />
<$InnerInputContainer> { - commitValue(formattedValue); + setLocalValue(formattedValue); + setAllocationInput(formattedValue); }} /> @@ -100,6 +94,7 @@ const $InnerInputContainer = styled.div` const $AllocationSlider = styled(Slider)` height: 1.375rem; + --slider-track-background: linear-gradient( 90deg, var(--color-layer-7) 0%, diff --git a/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx b/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx index 5756d32838..40117e9e09 100644 --- a/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx +++ b/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx @@ -12,6 +12,7 @@ import { StatsigFlags } from '@/constants/statsig'; import { MobilePlaceOrderSteps } from '@/constants/trade'; import { IndexerPositionSide } from '@/types/indexer/indexerApiGen'; +import { useBreakpoints } from '@/hooks/useBreakpoints'; import { useComplianceState } from '@/hooks/useComplianceState'; import { useStatsigGateValue } from '@/hooks/useStatsig'; import { useStringGetter } from '@/hooks/useStringGetter'; @@ -74,6 +75,7 @@ export const PlaceOrderButtonAndReceipt = ({ summary, tradingUnavailable, }: ElementProps) => { + const { isTablet } = useBreakpoints(); const stringGetter = useStringGetter(); const dispatch = useAppDispatch(); const { chainTokenImage, chainTokenLabel } = useTokenConfigs(); @@ -335,35 +337,41 @@ export const PlaceOrderButtonAndReceipt = ({ return ( <$Footer> -
- <$WithSeparators layout="row"> - {[ - hasInput && ( - + ), + <$HideButton + slotRight={} shape={ButtonShape.Pill} size={ButtonSize.XSmall} - onClick={onClearInputs} - key="clear" + onPressedChange={setIsReceiptOpen} + isPressed={isReceiptOpen} + key="hide" > - {stringGetter({ key: STRING_KEYS.CLEAR })} - - ), - <$HideButton - slotRight={} - shape={ButtonShape.Pill} - size={ButtonSize.XSmall} - onPressedChange={setIsReceiptOpen} - isPressed={isReceiptOpen} - key="hide" - > - {stringGetter({ key: STRING_KEYS.RECEIPT })} - , - ].filter(isTruthy)} - -
- + {stringGetter({ key: STRING_KEYS.RECEIPT })} + , + ].filter(isTruthy)} + + + )} + {!canAccountTrade ? ( ) : showDeposit && complianceState === ComplianceStates.FULL_ACCESS ? ( @@ -379,8 +387,8 @@ export const PlaceOrderButtonAndReceipt = ({ const $Footer = styled.footer` ${formMixins.footer} padding-bottom: var(--dialog-content-paddingBottom); - ${layoutMixins.column} + width: 100%; `; const $WithSeparators = styled(WithSeparators)` diff --git a/src/views/mobile/MobileTradeAssetSelector.tsx b/src/views/mobile/MobileTradeAssetSelector.tsx new file mode 100644 index 0000000000..b3d9cc26ac --- /dev/null +++ b/src/views/mobile/MobileTradeAssetSelector.tsx @@ -0,0 +1,92 @@ +import { BonsaiHelpers } from '@/bonsai/ontology'; +import styled from 'styled-components'; + +import { ButtonStyle } from '@/constants/buttons'; + +import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { Button } from '@/components/Button'; +import { Icon, IconName } from '@/components/Icon'; + +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { setIsMarketsMenuOpen } from '@/state/dialogs'; + +import { getAssetFromMarketId, getDisplayableAssetFromBaseAsset } from '@/lib/assetUtils'; +import { mapIfPresent } from '@/lib/do'; +import { orEmptyObj } from '@/lib/typeUtils'; + +export const MobileTradeAssetSelector = ({ + launchableMarketId, +}: { + launchableMarketId?: string; +}) => { + const id = useAppSelector(BonsaiHelpers.currentMarket.assetId); + const imageUrl = useAppSelector(BonsaiHelpers.currentMarket.assetLogo); + const leverage = useAppSelector(BonsaiHelpers.currentMarket.effectiveSelectedLeverage); + + const dispatch = useAppDispatch(); + + const { displayableTicker } = orEmptyObj(useAppSelector(BonsaiHelpers.currentMarket.marketInfo)); + + const launchableAsset = useAppSelectorWithArgs( + BonsaiHelpers.assets.selectAssetInfo, + mapIfPresent(launchableMarketId, getAssetFromMarketId) + ); + + const assetRow = launchableAsset ? ( +
+ {launchableAsset.name} + <$Name> +

{launchableAsset.name}

+ {getDisplayableAssetFromBaseAsset(launchableAsset.assetId)} + +
+ ) : ( +
+ + <$Name> +

{displayableTicker}

+ <$Leverage> + {Math.round(leverage)}x + + + +
+ ); + + const openMarketsDialog = () => { + dispatch(setIsMarketsMenuOpen(true)); + }; + + return ( + + ); +}; + +const $Name = styled.div` + ${layoutMixins.inlineRow} + + h3 { + font: var(--font-large-medium); + } + + color: var(--color-text-2); +`; + +const $Leverage = styled.div` + border-radius: 8px; + padding: 3px 6px; + background-color: #7774ff14; + + font: var(--font-mini-medium); + color: var(--color-accent); +`; diff --git a/src/views/tables/LiveTrades.tsx b/src/views/tables/LiveTrades.tsx index 6c76b12ec6..d0e53397dc 100644 --- a/src/views/tables/LiveTrades.tsx +++ b/src/views/tables/LiveTrades.tsx @@ -185,6 +185,10 @@ const liveTradesTableType = getSimpleStyledOutputType(OrderbookTradesTable, {} a const $LiveTradesTable = styled(OrderbookTradesTable)` background: var(--color-layer-2); + thead { + --stickyArea-totalInsetTop: 0; + } + tr { --histogram-bucket-size: 1; diff --git a/src/views/tables/MobilePositionsTable.tsx b/src/views/tables/MobilePositionsTable.tsx new file mode 100644 index 0000000000..25b9b71045 --- /dev/null +++ b/src/views/tables/MobilePositionsTable.tsx @@ -0,0 +1,330 @@ +import { forwardRef, useCallback, useMemo } from 'react'; + +import { MarginMode } from '@/bonsai/forms/trade/types'; +import { BonsaiCore } from '@/bonsai/ontology'; +import { + PerpetualMarketSummary, + SubaccountOrder, + SubaccountPosition, +} from '@/bonsai/types/summaryTypes'; +import { Trigger } from '@radix-ui/react-collapsible'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; + +import { STRING_KEYS } from '@/constants/localization'; +import { NumberSign, TOKEN_DECIMALS, USD_DECIMALS } from '@/constants/numbers'; +import { EMPTY_ARR } from '@/constants/objects'; +import { AppRoute } from '@/constants/routes'; +import { IndexerPositionSide } from '@/types/indexer/indexerApiGen'; + +import { useEnvFeatures } from '@/hooks/useEnvFeatures'; +import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { Button } from '@/components/Button'; +import { Collapsible } from '@/components/Collapsible'; +import { Icon, IconName } from '@/components/Icon'; +import { Output, OutputType, ShowSign } from '@/components/Output'; +import { marginModeMatchesFilter, MarketTypeFilter } from '@/pages/trade/types'; + +import { calculateIsAccountViewOnly } from '@/state/accountCalculators'; +import { getSubaccountConditionalOrders } from '@/state/accountSelectors'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { tradeFormActions } from '@/state/tradeForm'; + +import { getNumberSign, MaybeNumber } from '@/lib/numbers'; +import { orEmptyRecord } from '@/lib/typeUtils'; + +import { PositionsTriggersCell } from './PositionsTable/PositionsTriggersCell'; + +export enum PositionsTableColumnKey { + Details = 'Details', + IndexEntry = 'IndexEntry', + + Market = 'Market', + Leverage = 'Leverage', + Type = 'Type', + Size = 'Size', + Value = 'Value', + PnL = 'PnL', + Margin = 'Margin', + AverageOpen = 'AverageOpen', + Oracle = 'Oracle', + Liquidation = 'Liquidation', + Triggers = 'Triggers', + NetFunding = 'NetFunding', + Actions = 'Actions', +} + +type PositionTableRow = { + marketSummary: PerpetualMarketSummary | undefined; + stopLossOrders: SubaccountOrder[]; + takeProfitOrders: SubaccountOrder[]; + stepSizeDecimals: number; + tickSizeDecimals: number; + oraclePrice: number | undefined; +} & SubaccountPosition; + +type ElementProps = { + currentRoute?: string; + currentMarket?: string; + marketTypeFilter?: MarketTypeFilter; + onNavigate?: () => void; + navigateToOrders: (market: string) => void; +}; + +export const MobilePositionsTable = forwardRef( + ( + { currentRoute, currentMarket, marketTypeFilter, onNavigate, navigateToOrders }: ElementProps, + _ref + ) => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const { isSlTpLimitOrdersEnabled } = useEnvFeatures(); + + const isAccountViewOnly = useAppSelector(calculateIsAccountViewOnly); + // todo this uses the old subaccount id for now + const marketSummaries = orEmptyRecord(useAppSelector(BonsaiCore.markets.markets.data)); + + const openPositions = + useAppSelector(BonsaiCore.account.parentSubaccountPositions.data) ?? EMPTY_ARR; + + const positions = useMemo(() => { + return openPositions.filter((position) => { + const matchesMarket = currentMarket == null || position.market === currentMarket; + const marginType = position.marginMode; + const matchesType = marginModeMatchesFilter(marginType, marketTypeFilter); + return matchesMarket && matchesType; + }); + }, [currentMarket, marketTypeFilter, openPositions]); + + const tpslOrdersByPositionUniqueId = useAppSelectorWithArgs( + getSubaccountConditionalOrders, + isSlTpLimitOrdersEnabled + ); + + const positionsData = useMemo( + () => + positions.map((position: SubaccountPosition): PositionTableRow => { + const marketSummary = marketSummaries[position.market]; + return { + marketSummary, + stopLossOrders: + tpslOrdersByPositionUniqueId[position.uniqueId]?.stopLossOrders ?? EMPTY_ARR, + takeProfitOrders: + tpslOrdersByPositionUniqueId[position.uniqueId]?.takeProfitOrders ?? EMPTY_ARR, + stepSizeDecimals: marketSummary?.stepSizeDecimals ?? TOKEN_DECIMALS, + tickSizeDecimals: marketSummary?.tickSizeDecimals ?? USD_DECIMALS, + oraclePrice: MaybeNumber(marketSummary?.oraclePrice) ?? undefined, + ...position, + }; + }), + [positions, tpslOrdersByPositionUniqueId, marketSummaries] + ); + + const navigateToMarket = useCallback( + (market: string) => { + if (!currentMarket) { + navigate(`${AppRoute.Trade}/${market}`, { + state: { from: currentRoute }, + }); + onNavigate?.(); + } + }, + [currentMarket] + ); + + const onCloseButtonToggle = (marketId: string) => { + dispatch(tradeFormActions.setMarketId(marketId)); + dispatch(tradeFormActions.setIsClosingPosition(true)); + navigate(`${AppRoute.TradeForm}/${marketId}`); + }; + + return ( +
+ {positionsData.length === 0 && ( +
+ +

{stringGetter({ key: STRING_KEYS.POSITIONS_EMPTY_STATE })}

+
+ )} + {positionsData.length > 0 && + positionsData.map((position) => { + const isLong = position.side === IndexerPositionSide.LONG; + return ( +
+ +
navigateToMarket(position.market)} + > +
+ +
{position.marketSummary?.displayableAsset}
+
+ {isLong ? 'Long' : 'Short'} +
+
+
+
+ {position.marginMode === MarginMode.CROSS ? 'Cross' : 'Isolated'} +
+
+ {Math.round(position.effectiveSelectedLeverage.toNumber())}x +
+
+
+
+
+ {position.unsignedSize.toNumber()}{' '} + {position.marketSummary?.displayableAsset} +
+ +
+
+ <$OutputSigned + sign={getNumberSign(position.updatedUnrealizedPnl)} + type={OutputType.Fiat} + value={position.updatedUnrealizedPnl} + showSign={ShowSign.Negative} + /> + <$OutputSigned + sign={getNumberSign(position.updatedUnrealizedPnlPercent)} + type={OutputType.Percent} + value={position.updatedUnrealizedPnlPercent} + showSign={ShowSign.Negative} + fractionDigits={0} + withParentheses + /> +
+
+ } + label={ + <$Trigger> + <$TriggerIcon> + + + + } + > +
+
+
+ Entry Price + +
+
+ Mark Price + +
+
+ Liq. Price + +
+
+
+
+ Funding + <$OutputSigned + sign={getNumberSign(position.netFunding)} + type={OutputType.Fiat} + value={position.netFunding} + showSign={ShowSign.Negative} + /> +
+
+ <$TriggersContainer tw="">TP/SL + +
+
+ +
+
+
+ +
+ ); + })} + + ); + } +); + +const $OutputSigned = styled(Output)<{ sign: NumberSign }>` + color: ${({ sign }) => + ({ + [NumberSign.Positive]: `var(--color-positive)`, + [NumberSign.Negative]: `var(--color-negative)`, + [NumberSign.Neutral]: `var(--color-text-2)`, + })[sign]}; +`; + +const $TriggersContainer = styled.div` + font: var(--font-small-book); + color: var(--tableStickyRow-textColor, var(--color-text-0)); +`; + +const $Trigger = styled(Trigger)` + --trigger-textColor: inherit; + --trigger-icon-width: 0.75em; + --trigger-icon-color: inherit; + --icon-size: var(--trigger-icon-width); + padding-right: 16px; +`; + +const $TriggerIcon = styled.span` + display: inline-flex; + transition: rotate 0.3s var(--ease-out-expo); + color: var(--trigger-icon-color); + + ${$Trigger}[data-state='open'] & { + rotate: -0.5turn; + } +`; diff --git a/src/views/tables/PositionsTable/PositionsTriggersCell.tsx b/src/views/tables/PositionsTable/PositionsTriggersCell.tsx index 2aec375258..4500fc0388 100644 --- a/src/views/tables/PositionsTable/PositionsTriggersCell.tsx +++ b/src/views/tables/PositionsTable/PositionsTriggersCell.tsx @@ -9,6 +9,7 @@ import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; import { IndexerPositionSide } from '@/types/indexer/indexerApiGen'; +import { useBreakpoints } from '@/hooks/useBreakpoints'; import { useComplianceState } from '@/hooks/useComplianceState'; import { useEnvFeatures } from '@/hooks/useEnvFeatures'; import { useStringGetter } from '@/hooks/useStringGetter'; @@ -59,6 +60,7 @@ export const PositionsTriggersCell = ({ const stringGetter = useStringGetter(); const dispatch = useAppDispatch(); const { isSlTpLimitOrdersEnabled } = useEnvFeatures(); + const { isTablet } = useBreakpoints(); const { complianceState } = useComplianceState(); @@ -93,7 +95,7 @@ export const PositionsTriggersCell = ({ }; const viewOrdersButton = (align: 'left' | 'right') => ( - <$Container $align={align}> + <$Container $align={align} $isTablet={isTablet}> <$ViewOrdersButton action={ButtonAction.Navigation} size={ButtonSize.XSmall} @@ -130,8 +132,12 @@ export const PositionsTriggersCell = ({ }) => { if (orders.length === 0) { return ( - <$Container $align={align} onClick={openTriggersDialog}> - <$Output type={OutputType.Fiat} value={null} $withLiquidationWarning={false} /> + <$Container $align={align} onClick={openTriggersDialog} $isTablet={isTablet}> + {isTablet ? ( + '--' + ) : ( + <$Output type={OutputType.Fiat} value={null} $withLiquidationWarning={false} /> + )} ); } @@ -154,7 +160,7 @@ export const PositionsTriggersCell = ({ ); return ( - <$Container $align={align} onClick={openTriggersDialog}> + <$Container $align={align} onClick={openTriggersDialog} $isTablet={isTablet}> {liquidationWarningSide != null ? ( - {renderOutput({ align: 'right', orders: takeProfitOrders })} - <$VerticalSeparator /> + <$TableCell $isTablet={isTablet}> + {renderOutput({ align: isTablet ? 'left' : 'right', orders: takeProfitOrders })} + {isTablet ?
/
: <$VerticalSeparator />} {renderOutput({ align: 'left', orders: stopLossOrders })} {!isDisabled && complianceState === ComplianceStates.FULL_ACCESS && editButton} ); }; -const $TableCell = styled(TableCell)` +const $TableCell = styled(TableCell)<{ $isTablet: boolean }>` align-items: stretch; - gap: 0.75em; - justify-content: center; - --output-width: 70px; + ${({ $isTablet }) => + `${$isTablet ? '--output-width: 30px; gap: 0.25em;' : '--output-width: 70px; gap: 0.75em; justify-content: center;'}`} `; -const $Container = styled.div<{ $align: 'right' | 'left' }>` - display: inline-flex; +const $Container = styled.div<{ $isTablet: boolean; $align: 'right' | 'left' }>` align-items: center; gap: 0.25em; - width: var(--output-width); + + ${({ $isTablet }) => ($isTablet ? `` : 'width: var(--output-width); display: inline-flex;')} ${({ $align }) => $align &&