vega-frontend-monorepo/apps/trading/components/rewards-container/active-rewards.tsx
m.ray baf9875c69
feat(trading): filter out suspended transfers (#5640)
Co-authored-by: bwallacee <ben@vega.xyz>
2024-01-23 09:38:09 +00:00

643 lines
19 KiB
TypeScript

import { useActiveRewardsQuery } from './__generated__/Rewards';
import { useT } from '../../lib/use-t';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import classNames from 'classnames';
import {
type IconName,
type VegaIconSize,
Icon,
Intent,
Tooltip,
VegaIcon,
VegaIconNames,
TradingInput,
TinyScroll,
} from '@vegaprotocol/ui-toolkit';
import { IconNames } from '@blueprintjs/icons';
import {
type Maybe,
type Transfer,
type TransferNode,
type RecurringTransfer,
DistributionStrategyDescriptionMapping,
DistributionStrategyMapping,
EntityScope,
EntityScopeMapping,
TransferStatus,
TransferStatusMapping,
DispatchMetric,
DispatchMetricDescription,
DispatchMetricLabels,
EntityScopeLabelMapping,
MarketState,
} from '@vegaprotocol/types';
import { Card } from '../card/card';
import { useMemo, useState } from 'react';
import {
type AssetFieldsFragment,
useAssetsMapProvider,
} from '@vegaprotocol/assets';
import {
type MarketFieldsFragment,
useMarketsMapProvider,
getAsset,
} from '@vegaprotocol/markets';
export type Filter = {
searchTerm: string;
};
export const isActiveReward = (node: TransferNode, currentEpoch: number) => {
const { transfer } = node;
if (transfer.kind.__typename !== 'RecurringTransfer') {
return false;
}
const { dispatchStrategy } = transfer.kind;
if (!dispatchStrategy) {
return false;
}
if (transfer.kind.endEpoch && transfer.kind.endEpoch < currentEpoch) {
return false;
}
if (transfer.status !== TransferStatus.STATUS_PENDING) {
return false;
}
return true;
};
export const applyFilter = (
node: TransferNode & {
asset?: AssetFieldsFragment | null;
markets?: (MarketFieldsFragment | null)[];
},
filter: Filter
) => {
const { transfer } = node;
if (
transfer.kind.__typename !== 'RecurringTransfer' ||
!transfer.kind.dispatchStrategy?.dispatchMetric
) {
return false;
}
if (
DispatchMetricLabels[transfer.kind.dispatchStrategy.dispatchMetric]
.toLowerCase()
.includes(filter.searchTerm.toLowerCase()) ||
transfer.asset?.symbol
.toLowerCase()
.includes(filter.searchTerm.toLowerCase()) ||
EntityScopeLabelMapping[transfer.kind.dispatchStrategy.entityScope]
.toLowerCase()
.includes(filter.searchTerm.toLowerCase()) ||
node.asset?.name
.toLocaleLowerCase()
.includes(filter.searchTerm.toLowerCase()) ||
node.markets?.some((m) =>
m?.tradableInstrument?.instrument?.name
.toLocaleLowerCase()
.includes(filter.searchTerm.toLowerCase())
)
) {
return true;
}
return false;
};
export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
const t = useT();
const { data: activeRewardsData } = useActiveRewardsQuery({
variables: {
isReward: true,
},
});
const [filter, setFilter] = useState<Filter>({
searchTerm: '',
});
const { data: assets } = useAssetsMapProvider();
const { data: markets } = useMarketsMapProvider();
const enrichedTransfers = activeRewardsData?.transfersConnection?.edges
?.map((e) => e?.node as TransferNode)
.filter((node) => isActiveReward(node, currentEpoch))
.map((node) => {
if (node.transfer.kind.__typename !== 'RecurringTransfer') {
return node;
}
const asset =
assets &&
assets[
node.transfer.kind.dispatchStrategy?.dispatchMetricAssetId || ''
];
const marketsInScope =
node.transfer.kind.dispatchStrategy?.marketIdsInScope?.map(
(id) => markets && markets[id]
);
return { ...node, asset, markets: marketsInScope };
});
if (!enrichedTransfers || !enrichedTransfers.length) return null;
return (
<Card title={t('Active rewards')} className="lg:col-span-full">
{enrichedTransfers.length > 1 && (
<TradingInput
onChange={(e) =>
setFilter((curr) => ({ ...curr, searchTerm: e.target.value }))
}
value={filter.searchTerm}
type="text"
placeholder={t(
'Search by reward dispatch metric, entity scope or asset name'
)}
data-testid="search-term"
className="mb-4 w-20 mr-2"
prependElement={<VegaIcon name={VegaIconNames.SEARCH} />}
/>
)}
<TinyScroll className="grid gap-x-8 gap-y-10 h-fit grid-cols-[repeat(auto-fill,_minmax(230px,_1fr))] md:grid-cols-[repeat(auto-fill,_minmax(230px,_1fr))] lg:grid-cols-[repeat(auto-fill,_minmax(320px,_1fr))] xl:grid-cols-[repeat(auto-fill,_minmax(335px,_1fr))] max-h-[40rem] overflow-auto pr-2">
{enrichedTransfers
.filter((n) => applyFilter(n, filter))
.map((node, i) => {
const { transfer } = node;
if (
transfer.kind.__typename !== 'RecurringTransfer' ||
!transfer.kind.dispatchStrategy?.dispatchMetric
) {
return null;
}
return (
node && (
<ActiveRewardCard
key={i}
transferNode={node}
kind={transfer.kind}
currentEpoch={currentEpoch}
allMarkets={markets || {}}
/>
)
);
})}
</TinyScroll>
</Card>
);
};
// This was built to be a status indicator for the rewards based on the transfer status
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const StatusIndicator = ({
status,
reason,
}: {
status: TransferStatus;
reason?: Maybe<string> | undefined;
}) => {
const t = useT();
const getIconIntent = (status: string) => {
switch (status) {
case TransferStatus.STATUS_DONE:
return { icon: IconNames.TICK_CIRCLE, intent: Intent.Success };
case TransferStatus.STATUS_REJECTED:
return { icon: IconNames.ERROR, intent: Intent.Danger };
default:
return { icon: IconNames.HELP, intent: Intent.Primary };
}
};
const { icon, intent } = getIconIntent(status);
return (
<Tooltip
description={
<span>
{t('Transfer status: {{status}} {{reason}}', {
status: TransferStatusMapping[status],
reason: reason ? `(${reason})` : '',
})}
</span>
}
>
<span
className={classNames(
{
'text-gray-700 dark:text-gray-300': intent === Intent.None,
'text-vega-blue': intent === Intent.Primary,
'text-vega-green dark:text-vega-green': intent === Intent.Success,
'dark:text-yellow text-yellow-600': intent === Intent.Warning,
'text-vega-red': intent === Intent.Danger,
},
'flex items-start p-1 align-text-bottom'
)}
>
<Icon size={3} name={icon as IconName} />
</span>
</Tooltip>
);
};
export const ActiveRewardCard = ({
transferNode,
currentEpoch,
kind,
allMarkets,
}: {
transferNode: TransferNode & {
asset?: AssetFieldsFragment | null;
markets?: (MarketFieldsFragment | null)[];
};
currentEpoch: number;
kind: RecurringTransfer;
allMarkets?: Record<string, MarketFieldsFragment | null>;
}) => {
const t = useT();
const { transfer } = transferNode;
const { dispatchStrategy } = kind;
const marketIdsInScope = dispatchStrategy?.marketIdsInScope;
const firstMarketData = transferNode.markets?.[0];
const specificMarkets = useMemo(() => {
if (
!firstMarketData ||
!marketIdsInScope ||
marketIdsInScope.length === 0
) {
return null;
}
if (marketIdsInScope.length > 1) {
const marketNames =
allMarkets &&
marketIdsInScope
.map((id) => allMarkets[id]?.tradableInstrument?.instrument?.name)
.join(', ');
return (
<Tooltip description={marketNames}>
<span>Specific markets</span>
</Tooltip>
);
}
return (
<span>{firstMarketData?.tradableInstrument?.instrument?.name || ''}</span>
);
}, [firstMarketData, marketIdsInScope, allMarkets]);
const dispatchAsset = transferNode.asset;
if (!dispatchStrategy) {
return null;
}
// Gray out/hide the cards that are related to not trading markets
const marketSettled = transferNode.markets?.some(
(m) =>
m?.state &&
[
MarketState.STATE_TRADING_TERMINATED,
MarketState.STATE_SETTLED,
MarketState.STATE_CANCELLED,
MarketState.STATE_CLOSED,
].includes(m.state)
);
const assetInSettledMarket =
allMarkets &&
Object.values(allMarkets).some((m: MarketFieldsFragment | null) => {
if (m && getAsset(m).id === dispatchStrategy.dispatchMetricAssetId) {
return (
m?.state &&
[
MarketState.STATE_TRADING_TERMINATED,
MarketState.STATE_SETTLED,
MarketState.STATE_CANCELLED,
MarketState.STATE_CLOSED,
].includes(m.state)
);
}
return false;
});
if (marketSettled) {
return null;
}
// Gray out the cards that are related to suspended markets
const suspended = transferNode.markets?.some(
(m) =>
m?.state === MarketState.STATE_SUSPENDED ||
m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
);
const assetInSuspendedMarket =
allMarkets &&
Object.values(allMarkets).some((m: MarketFieldsFragment | null) => {
if (m && getAsset(m).id === dispatchStrategy.dispatchMetricAssetId) {
return (
m?.state === MarketState.STATE_SUSPENDED ||
m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
);
}
return false;
});
// Gray out the cards that are related to suspended markets
const { gradientClassName, mainClassName } =
suspended || assetInSuspendedMarket || assetInSettledMarket
? {
gradientClassName: 'from-vega-cdark-500 to-vega-clight-400',
mainClassName: 'from-vega-cdark-400 dark:from-vega-cdark-600 to-20%',
}
: getGradientClasses(dispatchStrategy.dispatchMetric);
const entityScope = dispatchStrategy.entityScope;
return (
<div>
<div
className={classNames(
'bg-gradient-to-r col-span-full p-0.5 lg:col-auto h-full',
'rounded-lg',
gradientClassName
)}
>
<div
className={classNames(
mainClassName,
'bg-gradient-to-b bg-vega-clight-800 dark:bg-vega-cdark-800 h-full w-full rounded p-4 flex flex-col gap-4'
)}
>
<div className="flex justify-between gap-4">
<div className="flex flex-col gap-2 items-center text-center">
<EntityIcon transfer={transfer} />
{entityScope && (
<span className="text-muted text-xs">
{EntityScopeLabelMapping[entityScope] || t('Unspecified')}
</span>
)}
</div>
<div className="flex flex-col gap-2 items-center text-center">
<h3 className="flex flex-col gap-1 text-2xl shrink-1 text-center">
<span className="font-glitch">
{addDecimalsFormatNumber(
transferNode.transfer.amount,
transferNode.transfer.asset?.decimals || 0,
6
)}
</span>
<span className="font-alpha">
{transferNode.transfer.asset?.symbol}
</span>
</h3>
{
<Tooltip
description={t(
DistributionStrategyDescriptionMapping[
dispatchStrategy.distributionStrategy
]
)}
underline={true}
>
<span className="text-xs">
{
DistributionStrategyMapping[
dispatchStrategy.distributionStrategy
]
}
</span>
</Tooltip>
}
</div>
<div className="flex flex-col gap-2 items-center text-center">
<CardIcon
iconName={VegaIconNames.LOCK}
tooltip={t(
'Number of epochs after distribution to delay vesting of rewards by'
)}
/>
<span className="text-muted text-xs whitespace-nowrap">
{t('numberEpochs', '{{count}} epochs', {
count: kind.dispatchStrategy?.lockPeriod,
})}
</span>
</div>
</div>
<span className="border-[0.5px] border-gray-700" />
<span>
{DispatchMetricLabels[dispatchStrategy.dispatchMetric]} {' '}
<Tooltip
underline={suspended}
description={
(suspended || assetInSuspendedMarket) &&
(specificMarkets
? t('Eligible market(s) currently suspended')
: assetInSuspendedMarket
? t('Currently no markets eligible for reward')
: '')
}
>
<span>{specificMarkets || dispatchAsset?.name}</span>
</Tooltip>
</span>
<div className="flex items-center gap-8 flex-wrap">
{kind.endEpoch && (
<span className="flex flex-col">
<span className="text-muted text-xs">{t('Ends in')}</span>
<span>
{t('numberEpochs', '{{count}} epochs', {
count: kind.endEpoch - currentEpoch,
})}
</span>
</span>
)}
{
<span className="flex flex-col">
<span className="text-muted text-xs">{t('Assessed over')}</span>
<span>
{t('numberEpochs', '{{count}} epochs', {
count: dispatchStrategy.windowLength,
})}
</span>
</span>
}
</div>
{dispatchStrategy?.dispatchMetric && (
<span className="text-muted text-sm h-[2rem]">
{t(DispatchMetricDescription[dispatchStrategy?.dispatchMetric])}
</span>
)}
<span className="border-[0.5px] border-gray-700" />
<div className="flex justify-between flex-wrap items-center gap-3 text-xs">
<span className="flex flex-col gap-1">
<span className="flex items-center gap-1 text-muted">
{t('Entity scope')}{' '}
</span>
<span className="flex items-center gap-1">
{kind.dispatchStrategy?.teamScope && (
<Tooltip
description={
<span>{kind.dispatchStrategy?.teamScope}</span>
}
>
<span className="flex items-center p-1 rounded-full border border-gray-600">
{<VegaIcon name={VegaIconNames.TEAM} size={16} />}
</span>
</Tooltip>
)}
{kind.dispatchStrategy?.individualScope && (
<Tooltip
description={
<span>{kind.dispatchStrategy?.individualScope}</span>
}
>
<span className="flex items-center p-1 rounded-full border border-gray-600">
{<VegaIcon name={VegaIconNames.MAN} size={16} />}
</span>
</Tooltip>
)}
{/* Shows transfer status */}
{/* <StatusIndicator
status={transfer.status}
reason={transfer.reason}
/> */}
</span>
</span>
<span className="flex flex-col gap-1">
<span className="flex items-center gap-1 text-muted">
{t('Staked VEGA')}{' '}
</span>
<span className="flex items-center gap-1">
{addDecimalsFormatNumber(
kind.dispatchStrategy?.stakingRequirement || 0,
transfer.asset?.decimals || 0
)}
</span>
</span>
<span className="flex flex-col gap-1">
<span className="flex items-center gap-1 text-muted">
{t('Average position')}{' '}
</span>
<span className="flex items-center gap-1">
{addDecimalsFormatNumber(
kind.dispatchStrategy
?.notionalTimeWeightedAveragePositionRequirement || 0,
transfer.asset?.decimals || 0
)}
</span>
</span>
</div>
</div>
</div>
</div>
);
};
const getGradientClasses = (d: DispatchMetric | undefined) => {
switch (d) {
case DispatchMetric.DISPATCH_METRIC_AVERAGE_POSITION:
return {
gradientClassName: 'from-vega-pink-500 to-vega-purple-400',
mainClassName: 'from-vega-pink-400 dark:from-vega-pink-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_LP_FEES_RECEIVED:
return {
gradientClassName: 'from-vega-green-500 to-vega-yellow-500',
mainClassName: 'from-vega-green-400 dark:from-vega-green-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_MAKER_FEES_PAID:
return {
gradientClassName: 'from-vega-orange-500 to-vega-pink-400',
mainClassName: 'from-vega-orange-400 dark:from-vega-orange-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_MARKET_VALUE:
case DispatchMetric.DISPATCH_METRIC_RELATIVE_RETURN:
return {
gradientClassName: 'from-vega-purple-500 to-vega-blue-400',
mainClassName: 'from-vega-purple-400 dark:from-vega-purple-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_RETURN_VOLATILITY:
return {
gradientClassName: 'from-vega-blue-500 to-vega-green-400',
mainClassName: 'from-vega-blue-400 dark:from-vega-blue-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_VALIDATOR_RANKING:
default:
return {
gradientClassName: 'from-vega-pink-500 to-vega-purple-400',
mainClassName: 'from-vega-pink-400 dark:from-vega-pink-600 to-20%',
};
}
};
const CardIcon = ({
size = 18,
iconName,
tooltip,
}: {
size?: VegaIconSize;
iconName: VegaIconNames;
tooltip: string;
}) => {
return (
<Tooltip description={<span>{tooltip}</span>}>
<span className="flex items-center p-2 rounded-full border border-gray-600">
<VegaIcon name={iconName} size={size} />
</span>
</Tooltip>
);
};
const EntityIcon = ({
transfer,
size = 18,
}: {
transfer: Transfer;
size?: VegaIconSize;
}) => {
if (transfer.kind.__typename !== 'RecurringTransfer') {
return null;
}
const entityScope = transfer.kind.dispatchStrategy?.entityScope;
const getIconName = () => {
switch (entityScope) {
case EntityScope.ENTITY_SCOPE_TEAMS:
return VegaIconNames.TEAM;
case EntityScope.ENTITY_SCOPE_INDIVIDUALS:
return VegaIconNames.MAN;
default:
return VegaIconNames.QUESTION_MARK;
}
};
const iconName = getIconName();
return (
<Tooltip
description={
<span>{entityScope ? EntityScopeMapping[entityScope] : ''}</span>
}
>
<span className="flex items-center p-2 rounded-full border border-gray-600">
{iconName && <VegaIcon name={iconName} size={size} />}
</span>
</Tooltip>
);
};