vega-frontend-monorepo/libs/deal-ticket/src/components/deal-ticket/deal-ticket.tsx

514 lines
16 KiB
TypeScript

import { t } from '@vegaprotocol/i18n';
import * as Schema from '@vegaprotocol/types';
import { memo, useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { Controller } from 'react-hook-form';
import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketButton } from './deal-ticket-button';
import { DealTicketFeeDetails } from './deal-ticket-fee-details';
import { ExpirySelector } from './expiry-selector';
import { SideSelector } from './side-selector';
import { TimeInForceSelector } from './time-in-force-selector';
import { TypeSelector } from './type-selector';
import type { OrderSubmission } from '@vegaprotocol/wallet';
import {
normalizeOrderSubmission,
useVegaWallet,
useVegaWalletDialogStore,
} from '@vegaprotocol/wallet';
import {
Checkbox,
ExternalLink,
InputError,
Intent,
Notification,
Tooltip,
TinyScroll,
} from '@vegaprotocol/ui-toolkit';
import {
validateExpiration,
validateMarketState,
validateMarketTradingMode,
validateTimeInForce,
validateType,
} from '../../utils';
import { ZeroBalanceError } from '../deal-ticket-validation/zero-balance-error';
import { SummaryValidationType } from '../../constants';
import { useInitialMargin } from '../../hooks/use-initial-margin';
import type { Market, MarketData } from '@vegaprotocol/market-list';
import { MarginWarning } from '../deal-ticket-validation/margin-warning';
import {
useMarketAccountBalance,
useAccountBalance,
} from '@vegaprotocol/accounts';
import { OrderTimeInForce, OrderType } from '@vegaprotocol/types';
import { useOrderForm } from '../../hooks/use-order-form';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import { marketMarginDataProvider } from '@vegaprotocol/positions';
export interface DealTicketProps {
market: Market;
marketData: MarketData;
submit: (order: OrderSubmission) => void;
onClickCollateral?: () => void;
}
export const DealTicket = ({
market,
marketData,
submit,
onClickCollateral,
}: DealTicketProps) => {
const { pubKey, isReadOnly } = useVegaWallet();
// store last used tif for market so that when changing OrderType the previous TIF
// selection for that type is used when switching back
const [lastTIF, setLastTIF] = useState({
[OrderType.TYPE_MARKET]: OrderTimeInForce.TIME_IN_FORCE_IOC,
[OrderType.TYPE_LIMIT]: OrderTimeInForce.TIME_IN_FORCE_GTC,
});
const {
control,
errors,
order,
setError,
clearErrors,
update,
handleSubmit,
} = useOrderForm(market.id);
const lastSubmitTime = useRef(0);
const asset = market.tradableInstrument.instrument.product.settlementAsset;
const { accountBalance: marginAccountBalance } = useMarketAccountBalance(
market.id
);
const { accountBalance: generalAccountBalance } = useAccountBalance(asset.id);
const balance = (
BigInt(marginAccountBalance) + BigInt(generalAccountBalance)
).toString();
const { marketState, marketTradingMode } = marketData;
const normalizedOrder =
order &&
normalizeOrderSubmission(
order,
market.decimalPlaces,
market.positionDecimalPlaces
);
const { margin, totalMargin } = useInitialMargin(market.id, normalizedOrder);
const { data: currentMargins } = useDataProvider({
dataProvider: marketMarginDataProvider,
variables: { marketId: market.id, partyId: pubKey || '' },
skip: !pubKey,
});
useEffect(() => {
if (!pubKey) {
setError('summary', {
message: t('No public key selected'),
type: SummaryValidationType.NoPubKey,
});
return;
}
const marketStateError = validateMarketState(marketState);
if (marketStateError !== true) {
setError('summary', {
message: marketStateError,
type: SummaryValidationType.MarketState,
});
return;
}
const hasNoBalance = !BigInt(generalAccountBalance);
if (hasNoBalance) {
setError('summary', {
message: SummaryValidationType.NoCollateral,
type: SummaryValidationType.NoCollateral,
});
return;
}
const marketTradingModeError = validateMarketTradingMode(marketTradingMode);
if (marketTradingModeError !== true) {
setError('summary', {
message: marketTradingModeError,
type: SummaryValidationType.TradingMode,
});
return;
}
clearErrors('summary');
}, [
marketState,
marketTradingMode,
generalAccountBalance,
pubKey,
setError,
clearErrors,
]);
const disablePostOnlyCheckbox = useMemo(() => {
const disabled = order
? [
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK,
].includes(order.timeInForce)
: true;
return disabled;
}, [order]);
const onSubmit = useCallback(
(order: OrderSubmission) => {
const now = new Date().getTime();
if (lastSubmitTime.current && now - lastSubmitTime.current < 1000) {
return;
}
submit(
normalizeOrderSubmission(
order,
market.decimalPlaces,
market.positionDecimalPlaces
)
);
lastSubmitTime.current = now;
},
[submit, market.decimalPlaces, market.positionDecimalPlaces]
);
// if an order doesn't exist one will be created by the store immediately
if (!order || !normalizedOrder) return null;
return (
<TinyScroll className="h-full overflow-auto">
<form
onSubmit={isReadOnly ? undefined : handleSubmit(onSubmit)}
className="p-4"
noValidate
>
<Controller
name="type"
control={control}
rules={{
validate: validateType(
marketData.marketTradingMode,
marketData.trigger
),
}}
render={() => (
<TypeSelector
value={order.type}
onSelect={(type) => {
if (type === OrderType.TYPE_NETWORK) return;
update({
type,
// when changing type also update the tif to what was last used of new type
timeInForce: lastTIF[type] || order.timeInForce,
expiresAt: undefined,
});
clearErrors('expiresAt');
}}
market={market}
marketData={marketData}
errorMessage={errors.type?.message}
/>
)}
/>
<Controller
name="side"
control={control}
render={() => (
<SideSelector
value={order.side}
onSelect={(side) => {
update({ side });
}}
/>
)}
/>
<DealTicketAmount
control={control}
orderType={order.type}
market={market}
marketData={marketData}
sizeError={errors.size?.message}
priceError={errors.price?.message}
update={update}
size={order.size}
price={order.price}
/>
<Controller
name="timeInForce"
control={control}
rules={{
validate: validateTimeInForce(
marketData.marketTradingMode,
marketData.trigger
),
}}
render={() => (
<TimeInForceSelector
value={order.timeInForce}
orderType={order.type}
onSelect={(timeInForce) => {
update({ timeInForce, postOnly: false, reduceOnly: false });
// Set tif value for the given order type, so that when switching
// types we know the last used TIF for the given order type
setLastTIF((curr) => ({
...curr,
[order.type]: timeInForce,
expiresAt: undefined,
}));
clearErrors('expiresAt');
}}
market={market}
marketData={marketData}
errorMessage={errors.timeInForce?.message}
/>
)}
/>
{order.type === Schema.OrderType.TYPE_LIMIT &&
order.timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_GTT && (
<Controller
name="expiresAt"
control={control}
rules={{
validate: validateExpiration,
}}
render={() => (
<ExpirySelector
value={order.expiresAt}
onSelect={(expiresAt) =>
update({
expiresAt: expiresAt || undefined,
})
}
errorMessage={errors.expiresAt?.message}
/>
)}
/>
)}
<div className="flex gap-2 pb-2 justify-between">
<Controller
name="postOnly"
control={control}
render={() => (
<Checkbox
name="post-only"
checked={order.postOnly}
disabled={disablePostOnlyCheckbox}
onCheckedChange={() => {
update({ postOnly: !order.postOnly, reduceOnly: false });
}}
label={
<Tooltip
description={
<span>
{disablePostOnlyCheckbox
? t(
'"Post only" can not be used on "Fill or Kill" or "Immediate or Cancel" orders.'
)
: t(
'"Post only" will ensure the order is not filled immediately but is placed on the order book as a passive order. When the order is processed it is either stopped (if it would not be filled immediately), or placed in the order book as a passive order until the price taker matches with it.'
)}
</span>
}
>
<span className="text-xs">{t('Post only')}</span>
</Tooltip>
}
/>
)}
/>
<Controller
name="reduceOnly"
control={control}
render={() => (
<Checkbox
name="reduce-only"
checked={order.reduceOnly}
onCheckedChange={() => {
update({ postOnly: false, reduceOnly: !order.reduceOnly });
}}
label={
<Tooltip
description={
<span>
{t(
'"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.'
)}
</span>
}
>
<span className="text-xs">{t('Reduce only')}</span>
</Tooltip>
}
/>
)}
/>
</div>
<SummaryMessage
errorMessage={errors.summary?.message}
asset={asset}
marketTradingMode={marketData.marketTradingMode}
balance={balance}
margin={totalMargin}
isReadOnly={isReadOnly}
pubKey={pubKey}
onClickCollateral={onClickCollateral}
/>
<DealTicketButton
disabled={Object.keys(errors).length >= 1 || isReadOnly}
variant={
order.side === Schema.Side.SIDE_BUY ? 'ternary' : 'secondary'
}
/>
<DealTicketFeeDetails
order={normalizedOrder}
market={market}
marketData={marketData}
estimatedInitialMargin={margin}
estimatedTotalInitialMargin={totalMargin}
currentInitialMargin={currentMargins?.initialLevel}
currentMaintenanceMargin={currentMargins?.maintenanceLevel}
marginAccountBalance={marginAccountBalance}
generalAccountBalance={generalAccountBalance}
/>
</form>
</TinyScroll>
);
};
/**
* Renders an error message if errors.summary is present otherwise
* renders warnings about current state of the market
*/
interface SummaryMessageProps {
errorMessage?: string;
asset: { id: string; symbol: string; name: string; decimals: number };
marketTradingMode: MarketData['marketTradingMode'];
balance: string;
margin: string;
isReadOnly: boolean;
pubKey: string | null;
onClickCollateral?: () => void;
}
const SummaryMessage = memo(
({
errorMessage,
asset,
marketTradingMode,
balance,
margin,
isReadOnly,
pubKey,
onClickCollateral,
}: SummaryMessageProps) => {
// Specific error UI for if balance is so we can
// render a deposit dialog
const assetSymbol = asset.symbol;
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
if (isReadOnly) {
return (
<div className="mb-2">
<InputError testId="dealticket-error-message-summary">
{
'You need to connect your own wallet to start trading on this market'
}
</InputError>
</div>
);
}
if (!pubKey) {
return (
<div className="mb-2">
<Notification
testId={'deal-ticket-connect-wallet'}
intent={Intent.Warning}
message={
<p className="text-sm pb-2">
You need a{' '}
<ExternalLink href="https://vega.xyz/wallet">
Vega wallet
</ExternalLink>{' '}
with {assetSymbol} to start trading in this market.
</p>
}
buttonProps={{
text: t('Connect wallet'),
action: openVegaWalletDialog,
dataTestId: 'order-connect-wallet',
size: 'md',
}}
/>
</div>
);
}
if (errorMessage === SummaryValidationType.NoCollateral) {
return (
<div className="mb-2">
<ZeroBalanceError
asset={asset}
onClickCollateral={onClickCollateral}
/>
</div>
);
}
// If we have any other full error which prevents
// submission render that first
if (errorMessage) {
return (
<div className="mb-2">
<InputError testId="dealticket-error-message-summary">
{errorMessage}
</InputError>
</div>
);
}
// If there is no blocking error but user doesn't have enough
// balance render the margin warning, but still allow submission
if (BigInt(balance) < BigInt(margin) && BigInt(balance) > BigInt(0)) {
return (
<div className="mb-2">
<MarginWarning balance={balance} margin={margin} asset={asset} />
</div>
);
}
// Show auction mode warning
if (
[
Schema.MarketTradingMode.TRADING_MODE_BATCH_AUCTION,
Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION,
Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION,
].includes(marketTradingMode)
) {
return (
<div className="mb-2">
<Notification
intent={Intent.Warning}
testId={'dealticket-warning-auction'}
message={t(
'Any orders placed now will not trade until the auction ends'
)}
/>
</div>
);
}
return null;
}
);