Compare commits

...
141 changed files with 4977 additions and 1644 deletions
@@ -32,6 +32,7 @@ import { TxDetailsCreateReferralSet } from './tx-create-referral-set';
import { TxDetailsApplyReferralCode } from './tx-apply-referral-code';
import { TxDetailsUpdateReferralSet } from './tx-update-referral-set';
import { TxDetailsJoinTeam } from './tx-join-team';
import { TxDetailsUpdateMarginMode } from './tx-update-margin-mode';
interface TxDetailsWrapperProps {
txData: BlockExplorerTransactionResult | undefined;
@@ -133,6 +134,8 @@ function getTransactionComponent(txData?: BlockExplorerTransactionResult) {
return TxDetailsApplyReferralCode;
case 'Join Team':
return TxDetailsJoinTeam;
case 'Update Margin Mode':
return TxDetailsUpdateMarginMode;
default:
return TxDetailsGeneric;
}
@@ -0,0 +1,60 @@
import { t } from '@vegaprotocol/i18n';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import type { TendermintBlocksResponse } from '../../../routes/blocks/tendermint-blocks-response';
import { TxDetailsShared } from './shared/tx-details-shared';
import { TableCell, TableRow, TableWithTbody } from '../../table';
import type { components } from '../../../../types/explorer';
import { MarketLink } from '../../links';
interface TxDetailsUpdateMarginModeProps {
txData: BlockExplorerTransactionResult | undefined;
pubKey: string | undefined;
blockData: TendermintBlocksResponse | undefined;
}
type Mode = components['schemas']['UpdateMarginModeMode'];
const MarginModeLabels: Record<Mode, string> = {
MODE_CROSS_MARGIN: t('Cross margin'),
MODE_ISOLATED_MARGIN: t('Isolated margin'),
MODE_UNSPECIFIED: t('Unspecified'),
};
export const TxDetailsUpdateMarginMode = ({
txData,
pubKey,
blockData,
}: TxDetailsUpdateMarginModeProps) => {
if (!txData || !txData.command.updateMarginMode) {
return <>{t('Awaiting Block Explorer transaction details')}</>;
}
const u: components['schemas']['v1UpdateMarginMode'] =
txData.command.updateMarginMode;
return (
<TableWithTbody className="mb-8" allowWrap={true}>
<TxDetailsShared txData={txData} pubKey={pubKey} blockData={blockData} />
{u.marketId && (
<TableRow modifier="bordered">
<TableCell>{t('Market ID')}</TableCell>
<TableCell>
<MarketLink id={u.marketId} />
</TableCell>
</TableRow>
)}
{u.mode && (
<TableRow modifier="bordered">
<TableCell>{t('New margin mode')}</TableCell>
<TableCell>{MarginModeLabels[u.mode]}</TableCell>
</TableRow>
)}
{u.marginFactor && (
<TableRow modifier="bordered">
<TableCell>{t('Margin factor')}</TableCell>
<TableCell>{u.marginFactor}</TableCell>
</TableRow>
)}
</TableWithTbody>
);
};
@@ -44,6 +44,7 @@ export type FilterOption =
| 'Transfer Funds'
| 'Undelegate'
| 'Update Referral Set'
| 'Update Margin Mode'
| 'Validator Heartbeat'
| 'Vote on Proposal'
| 'Withdraw';
@@ -59,6 +60,7 @@ export const filterOptions: Record<string, FilterOption[]> = {
'Stop Orders Submission',
'Stop Orders Cancellation',
'Submit Order',
'Update Margin Mode',
],
'Transfers and Withdrawals': [
'Transfer Funds',
+1
View File
@@ -26,6 +26,7 @@ NX_ICEBERG_ORDERS=true
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
# NX_DISABLE_CLOSE_POSITION=false
NX_TEAM_COMPETITION=true
NX_TENDERMINT_URL=https://be.vega.community
NX_TENDERMINT_WEBSOCKET_URL=wss://be.vega.community/websocket
+5
View File
@@ -28,3 +28,8 @@ NX_REFERRALS=true
NX_TENDERMINT_URL=https://tm.be.devnet1.vega.xyz/
NX_TENDERMINT_WEBSOCKET_URL=wss://be.devnet1.vega.xyz/websocket
NX_TEAM_COMPETITION=true
NX_CHARTING_LIBRARY_PATH=https://assets.vega.community/trading-view-bundle/v0.0.1/
NX_CHARTING_LIBRARY_HASH=PDjWaqPFndDp+LCvqbKvntWriaqNzNpZ5i9R/BULzCg=
+1
View File
@@ -26,6 +26,7 @@ NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
NX_TEAM_COMPETITION=true
NX_CHARTING_LIBRARY_PATH=https://assets.vega.community/trading-view-bundle/v0.0.1/
NX_CHARTING_LIBRARY_HASH=PDjWaqPFndDp+LCvqbKvntWriaqNzNpZ5i9R/BULzCg=
+1
View File
@@ -26,6 +26,7 @@ NX_ISOLATED_MARGIN=true
NX_ICEBERG_ORDERS=true
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
NX_TEAM_COMPETITION=true
NX_TENDERMINT_URL=https://tm.be.testnet.vega.xyz
NX_TENDERMINT_WEBSOCKET_URL=wss://be.testnet.vega.xyz/websocket
@@ -0,0 +1,131 @@
import { useSearchParams } from 'react-router-dom';
import { Intent, TradingAnchorButton } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { useT } from '../../lib/use-t';
import { useReferralSetTransaction } from '../../lib/hooks/use-referral-set-transaction';
import { DApp, TokenStaticLinks, useLinks } from '@vegaprotocol/environment';
import { RainbowButton } from '../../components/rainbow-button';
import { usePageTitle } from '../../lib/hooks/use-page-title';
import { ErrorBoundary } from '../../components/error-boundary';
import { Box } from '../../components/competitions/box';
import { LayoutWithGradient } from '../../components/layouts-inner';
import { Links } from '../../lib/links';
import { TeamForm, TransactionType } from './team-form';
export const CompetitionsCreateTeam = () => {
const [searchParams] = useSearchParams();
const isSolo = Boolean(searchParams.get('solo'));
const t = useT();
usePageTitle(t('Create a team'));
const { isReadOnly, pubKey } = useVegaWallet();
const openWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
return (
<ErrorBoundary feature="create-team">
<LayoutWithGradient>
<div className="mx-auto md:w-2/3 max-w-xl">
<Box className="flex flex-col gap-4">
<h1 className="calt text-2xl lg:text-3xl xl:text-4xl">
{isSolo ? t('Create solo team') : t('Create a team')}
</h1>
{pubKey && !isReadOnly ? (
<CreateTeamFormContainer isSolo={isSolo} />
) : (
<>
<p>
{t(
'Create a team to participate in team based rewards as well as access the discount benefits of the current referral program.'
)}
</p>
<RainbowButton variant="border" onClick={openWalletDialog}>
{t('Connect wallet')}
</RainbowButton>
</>
)}
</Box>
</div>
</LayoutWithGradient>
</ErrorBoundary>
);
};
const CreateTeamFormContainer = ({ isSolo }: { isSolo: boolean }) => {
const t = useT();
const createLink = useLinks(DApp.Governance);
const { err, status, code, isEligible, requiredStake, onSubmit } =
useReferralSetTransaction({
onSuccess: (code) => {
// For some reason team creation takes a long time, too long even to make
// polling viable, so its not feasible to navigate to the team page
// after creation
//
// navigate(Links.COMPETITIONS_TEAM(code));
},
});
if (status === 'confirmed') {
return (
<div className="flex flex-col items-start gap-2">
<p className="text-sm">{t('Team creation transaction successful')}</p>
{code && (
<>
<p className="text-sm">
Your team ID is:{' '}
<span className="font-mono break-all">{code}</span>
</p>
<TradingAnchorButton
href={Links.COMPETITIONS_TEAM(code)}
intent={Intent.Info}
size="small"
>
{t('View team')}
</TradingAnchorButton>
</>
)}
</div>
);
}
if (!isEligible) {
return (
<div className="flex flex-col gap-4">
{requiredStake !== undefined && (
<p>
{t(
'You need at least {{requiredStake}} VEGA staked to generate a referral code and participate in the referral program.',
{
requiredStake: addDecimalsFormatNumber(
requiredStake.toString(),
18
),
}
)}
</p>
)}
<TradingAnchorButton
href={createLink(TokenStaticLinks.ASSOCIATE)}
intent={Intent.Primary}
target="_blank"
>
{t('Stake some $VEGA now')}
</TradingAnchorButton>
</div>
);
}
return (
<TeamForm
type={TransactionType.CreateReferralSet}
onSubmit={onSubmit}
status={status}
err={err}
isCreatingSoloTeam={isSolo}
/>
);
};
@@ -0,0 +1,130 @@
import { useT } from '../../lib/use-t';
import { ErrorBoundary } from '@sentry/react';
import { CompetitionsHeader } from '../../components/competitions/competitions-header';
import { Intent, Loader, TradingButton } from '@vegaprotocol/ui-toolkit';
import { useGames } from '../../lib/hooks/use-games';
import { useCurrentEpochInfoQuery } from '../referrals/hooks/__generated__/Epoch';
import { Link, useNavigate } from 'react-router-dom';
import { Links } from '../../lib/links';
import {
CompetitionsAction,
CompetitionsActionsContainer,
} from '../../components/competitions/competitions-cta';
import { GamesContainer } from '../../components/competitions/games-container';
import { CompetitionsLeaderboard } from '../../components/competitions/competitions-leaderboard';
import { useTeams } from '../../lib/hooks/use-teams';
import take from 'lodash/take';
import { usePageTitle } from '../../lib/hooks/use-page-title';
export const CompetitionsHome = () => {
const t = useT();
const navigate = useNavigate();
usePageTitle(t('Competitions'));
const { data: epochData } = useCurrentEpochInfoQuery();
const currentEpoch = Number(epochData?.epoch.id);
const { data: gamesData, loading: gamesLoading } = useGames({
onlyActive: true,
currentEpoch,
});
const { data: teamsData, loading: teamsLoading } = useTeams();
return (
<ErrorBoundary>
<CompetitionsHeader title={t('Competitions')}>
<p className="text-lg mb-1">
{t(
'Be a team player! Participate in games and work together to rake in as much profit to win.'
)}
</p>
</CompetitionsHeader>
{/** Get started */}
<h2 className="text-2xl mb-6">{t('Get started')}</h2>
<CompetitionsActionsContainer>
<CompetitionsAction
variant="A"
title={t('Create a team')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)}
actionElement={
<TradingButton
intent={Intent.Primary}
onClick={(e) => {
e.preventDefault();
navigate(Links.COMPETITIONS_CREATE_TEAM());
}}
>
{t('Create a public team')}
</TradingButton>
}
/>
<CompetitionsAction
variant="B"
title={t('Solo team / lone wolf')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)}
actionElement={
<TradingButton
intent={Intent.Primary}
onClick={(e) => {
e.preventDefault();
navigate(Links.COMPETITIONS_CREATE_TEAM_SOLO());
}}
>
{t('Create a private team')}
</TradingButton>
}
/>
<CompetitionsAction
variant="C"
title={t('Join a team')}
description={t(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit placeat ipsum minus nemo error dicta.'
)}
actionElement={
<TradingButton
intent={Intent.Primary}
onClick={(e) => {
e.preventDefault();
navigate(Links.COMPETITIONS_TEAMS());
}}
>
{t('Choose a team')}
</TradingButton>
}
/>
</CompetitionsActionsContainer>
{/** List of available games */}
<h2 className="text-2xl mb-6">{t('Games')}</h2>
{gamesLoading ? (
<Loader size="small" />
) : (
<GamesContainer data={gamesData} currentEpoch={currentEpoch} />
)}
{/** The teams ranking */}
<div className="mb-6 flex flex-row items-baseline justify-between">
<h2 className="text-2xl">{t('Leaderboard')}</h2>
<Link to={Links.COMPETITIONS_TEAMS()} className="text-sm underline">
{t('View all teams')}
</Link>
</div>
{teamsLoading ? (
<Loader size="small" />
) : (
<CompetitionsLeaderboard data={take(teamsData, 10)} />
)}
</ErrorBoundary>
);
};
@@ -0,0 +1,255 @@
import { useState, type ButtonHTMLAttributes } from 'react';
import { Link, useParams } from 'react-router-dom';
import orderBy from 'lodash/orderBy';
import { Splash, truncateMiddle, Loader } from '@vegaprotocol/ui-toolkit';
import { DispatchMetricLabels, type DispatchMetric } from '@vegaprotocol/types';
import classNames from 'classnames';
import { useT } from '../../lib/use-t';
import { Table } from '../../components/table';
import { formatNumber, getDateTimeFormat } from '@vegaprotocol/utils';
import {
useTeam,
type TeamStats as ITeamStats,
type Team as TeamType,
type Member,
type TeamGame,
} from '../../lib/hooks/use-team';
import { DApp, EXPLORER_PARTIES, useLinks } from '@vegaprotocol/environment';
import { TeamAvatar } from '../../components/competitions/team-avatar';
import { TeamStats } from '../../components/competitions/team-stats';
import { usePageTitle } from '../../lib/hooks/use-page-title';
import { ErrorBoundary } from '../../components/error-boundary';
import { LayoutWithGradient } from '../../components/layouts-inner';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { JoinTeam } from './join-team';
import { UpdateTeamButton } from './update-team-button';
export const CompetitionsTeam = () => {
const t = useT();
const { teamId } = useParams<{ teamId: string }>();
usePageTitle([t('Competitions'), t('Team')]);
return (
<ErrorBoundary feature="team">
<TeamPageContainer teamId={teamId} />
</ErrorBoundary>
);
};
const TeamPageContainer = ({ teamId }: { teamId: string | undefined }) => {
const t = useT();
const { pubKey } = useVegaWallet();
const { data, team, partyTeam, stats, members, games, loading, refetch } =
useTeam(teamId, pubKey || undefined);
// only show spinner on first load so when users join teams its smoother
if (!data && loading) {
return (
<Splash>
<Loader />
</Splash>
);
}
if (!team) {
return (
<Splash>
<p>{t('Page not found')}</p>
</Splash>
);
}
return (
<TeamPage
team={team}
partyTeam={partyTeam}
stats={stats}
members={members}
games={games}
refetch={refetch}
/>
);
};
const TeamPage = ({
team,
partyTeam,
stats,
members,
games,
refetch,
}: {
team: TeamType;
partyTeam?: TeamType;
stats?: ITeamStats;
members?: Member[];
games?: TeamGame[];
refetch: () => void;
}) => {
const t = useT();
const [showGames, setShowGames] = useState(true);
return (
<LayoutWithGradient>
<header className="flex gap-3 lg:gap-4 pt-5 lg:pt-10">
<TeamAvatar teamId={team.teamId} imgUrl={team.avatarUrl} />
<div className="flex flex-col items-start gap-1 lg:gap-3">
<h1
className="calt text-2xl lg:text-3xl xl:text-5xl"
data-testid="team-name"
>
{team.name}
</h1>
<div className="flex gap-2">
<JoinTeam team={team} partyTeam={partyTeam} refetch={refetch} />
<UpdateTeamButton team={team} />
</div>
</div>
</header>
<TeamStats stats={stats} members={members} games={games} />
<section>
<div className="flex gap-4 lg:gap-8 mb-4 border-b border-default">
<ToggleButton
active={showGames}
onClick={() => setShowGames(true)}
data-testid="games-toggle"
>
{t('Games ({{count}})', { count: games ? games.length : 0 })}
</ToggleButton>
<ToggleButton
active={!showGames}
onClick={() => setShowGames(false)}
data-testid="members-toggle"
>
{t('Members ({{count}})', {
count: members ? members.length : 0,
})}
</ToggleButton>
</div>
{showGames ? <Games games={games} /> : <Members members={members} />}
</section>
</LayoutWithGradient>
);
};
const Games = ({ games }: { games?: TeamGame[] }) => {
const t = useT();
if (!games?.length) {
return <p>{t('No games')}</p>;
}
return (
<Table
columns={[
{ name: 'rank', displayName: t('Rank') },
{
name: 'epoch',
displayName: t('Epoch'),
headerClassName: 'hidden md:table-cell',
className: 'hidden md:table-cell',
},
{ name: 'type', displayName: t('Type') },
{ name: 'amount', displayName: t('Amount earned') },
{
name: 'participatingTeams',
displayName: t('No. of participating teams'),
headerClassName: 'hidden md:table-cell',
className: 'hidden md:table-cell',
},
{
name: 'participatingMembers',
displayName: t('No. of participating members'),
headerClassName: 'hidden md:table-cell',
className: 'hidden md:table-cell',
},
]}
data={games.map((game) => ({
rank: game.team.rank,
epoch: game.epoch,
type: DispatchMetricLabels[game.team.rewardMetric as DispatchMetric],
amount: formatNumber(game.team.totalRewardsEarned),
participatingTeams: game.entities.length,
participatingMembers: game.numberOfParticipants,
}))}
noCollapse={true}
/>
);
};
const Members = ({ members }: { members?: Member[] }) => {
const t = useT();
if (!members?.length) {
return <p>{t('No members')}</p>;
}
const data = orderBy(
members.map((m) => ({
referee: <RefereeLink pubkey={m.referee} isCreator={m.isCreator} />,
rewards: formatNumber(m.totalQuantumRewards),
volume: formatNumber(m.totalQuantumVolume),
gamesPlayed: formatNumber(m.totalGamesPlayed),
joinedAt: getDateTimeFormat().format(new Date(m.joinedAt)),
joinedAtEpoch: Number(m.joinedAtEpoch),
})),
'joinedAtEpoch',
'desc'
);
return (
<Table
columns={[
{ name: 'referee', displayName: t('Member ID') },
{ name: 'rewards', displayName: t('Rewards earned') },
{ name: 'volume', displayName: t('Total volume') },
{ name: 'gamesPlayed', displayName: t('Games played') },
{
name: 'joinedAt',
displayName: t('Joined at'),
},
{
name: 'joinedAtEpoch',
displayName: t('Joined epoch'),
},
]}
data={data}
noCollapse={true}
/>
);
};
const RefereeLink = ({
pubkey,
isCreator,
}: {
pubkey: string;
isCreator: boolean;
}) => {
const t = useT();
const linkCreator = useLinks(DApp.Explorer);
const link = linkCreator(EXPLORER_PARTIES.replace(':id', pubkey));
return (
<>
<Link to={link} target="_blank" className="underline underline-offset-4">
{truncateMiddle(pubkey)}
</Link>{' '}
<span className="text-muted text-xs">{isCreator ? t('Owner') : ''}</span>
</>
);
};
const ToggleButton = ({
active,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { active: boolean }) => {
return (
<button
{...props}
className={classNames('relative top-px uppercase border-b-2 py-4', {
'text-muted border-transparent': !active,
'border-vega-yellow': active,
})}
/>
);
};
@@ -0,0 +1,64 @@
import { ErrorBoundary } from '@sentry/react';
import { CompetitionsHeader } from '../../components/competitions/competitions-header';
import { useRef, useState } from 'react';
import { useT } from '../../lib/use-t';
import { useTeams } from '../../lib/hooks/use-teams';
import { CompetitionsLeaderboard } from '../../components/competitions/competitions-leaderboard';
import {
Input,
Loader,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { usePageTitle } from '../../lib/hooks/use-page-title';
export const CompetitionsTeams = () => {
const t = useT();
usePageTitle([t('Competitions'), t('Teams')]);
const { data: teamsData, loading: teamsLoading } = useTeams();
const inputRef = useRef<HTMLInputElement>(null);
const [filter, setFilter] = useState<string | null | undefined>(undefined);
return (
<ErrorBoundary>
<CompetitionsHeader title={t('Join a team')}>
<p className="text-lg mb-1">{t('Choose a team to get involved')}</p>
</CompetitionsHeader>
<div className="mb-6 flex justify-end">
<div className="w-full md:w-60 h-10 relative">
<span className="absolute z-10 pointer-events-none opacity-90 top-[5px] left-[5px]">
<VegaIcon name={VegaIconNames.SEARCH} size={18} />
</span>
<Input
ref={inputRef}
className="opacity-90 text-right"
placeholder={t('Name')}
onKeyUp={() => {
const value = inputRef.current?.value;
if (value != filter) setFilter(value);
}}
/>
</div>
</div>
<div>
{teamsLoading ? (
<Loader size="small" />
) : (
<CompetitionsLeaderboard
data={teamsData.filter((td) => {
if (filter && filter.length > 0) {
const re = new RegExp(filter, 'i');
return re.test(td.name);
}
return true;
})}
/>
)}
</div>
</ErrorBoundary>
);
};
@@ -0,0 +1,106 @@
import { ErrorBoundary } from '../../components/error-boundary';
import { usePageTitle } from '../../lib/hooks/use-page-title';
import { Box } from '../../components/competitions/box';
import { useT } from '../../lib/use-t';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { Loader, Splash } from '@vegaprotocol/ui-toolkit';
import { RainbowButton } from '../../components/rainbow-button';
import { Link, Navigate, useParams } from 'react-router-dom';
import { Links } from '../../lib/links';
import { useReferralSetTransaction } from '../../lib/hooks/use-referral-set-transaction';
import { type FormFields, TeamForm, TransactionType } from './team-form';
import { useTeam } from '../../lib/hooks/use-team';
import { LayoutWithGradient } from '../../components/layouts-inner';
export const CompetitionsUpdateTeam = () => {
const t = useT();
usePageTitle([t('Competitions'), t('Update a team')]);
const { pubKey, isReadOnly } = useVegaWallet();
const openWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
const { teamId } = useParams<{ teamId: string }>();
if (!teamId) {
return <Navigate to={Links.COMPETITIONS()} />;
}
return (
<ErrorBoundary feature="update-team">
<LayoutWithGradient>
<div className="mx-auto md:w-2/3 max-w-xl">
<Box className="flex flex-col gap-4">
<h1 className="calt text-2xl lg:text-3xl xl:text-5xl">
{t('Update a team')}
</h1>
{pubKey && !isReadOnly ? (
<UpdateTeamFormContainer teamId={teamId} pubKey={pubKey} />
) : (
<>
<p>{t('Connect to update the details of your team.')}</p>
<RainbowButton variant="border" onClick={openWalletDialog}>
{t('Connect wallet')}
</RainbowButton>
</>
)}
</Box>
</div>
</LayoutWithGradient>
</ErrorBoundary>
);
};
const UpdateTeamFormContainer = ({
teamId,
pubKey,
}: {
teamId: string;
pubKey: string;
}) => {
const t = useT();
const { team, loading, error } = useTeam(teamId, pubKey);
const { err, status, onSubmit } = useReferralSetTransaction({
onSuccess: () => {
// NOOP
},
});
if (loading) {
return <Loader size="small" />;
}
if (error) {
return (
<Splash className="gap-1">
<span>{t('Something went wrong.')}</span>
<Link to={Links.COMPETITIONS_TEAM(teamId)} className="underline">
{t("Go back to the team's profile")}
</Link>
</Splash>
);
}
const isMyTeam = team?.referrer === pubKey;
if (!isMyTeam) {
return <Navigate to={Links.COMPETITIONS_TEAM(teamId)} />;
}
const defaultValues: FormFields = {
id: team.teamId,
name: team.name,
url: team.teamUrl,
avatarUrl: team.avatarUrl,
private: team.closed,
allowList: team.allowList.join(','),
};
return (
<TeamForm
type={TransactionType.UpdateReferralSet}
status={status}
err={err}
isCreatingSoloTeam={team.closed}
onSubmit={onSubmit}
defaultValues={defaultValues}
/>
);
};
@@ -0,0 +1,88 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { JoinButton } from './join-team';
import { type Team } from '../../lib/hooks/use-team';
describe('JoinButton', () => {
const teamA = {
teamId: 'teamA',
name: 'Team A',
referrer: 'referrerA',
} as Team;
const teamB = {
teamId: 'teamB',
name: 'Team B',
referrer: 'referrerrB',
} as Team;
const props = {
pubKey: 'pubkey',
isReadOnly: false,
team: teamA,
partyTeam: teamB,
onJoin: jest.fn(),
};
beforeEach(() => {
props.onJoin.mockClear();
});
it('disables button if not connected', async () => {
render(<JoinButton {...props} pubKey={null} />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
await userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent(/Connect your wallet/);
});
it('disables button if you created the current team', () => {
render(
<JoinButton
{...props}
pubKey={teamA.referrer}
team={teamA}
partyTeam={teamA}
/>
);
const button = screen.getByRole('button', { name: /Owner/ });
expect(button).toBeDisabled();
});
it('disables button if you created a team', async () => {
render(<JoinButton {...props} pubKey={teamB.referrer} />);
const button = screen.getByRole('button', { name: /Switch team/ });
expect(button).toBeDisabled();
await userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent(/As a team creator/);
});
it('shows if party is already in team', async () => {
render(<JoinButton {...props} team={teamA} partyTeam={teamA} />);
const button = screen.getByRole('button', { name: /Joined/ });
expect(button).toBeDisabled();
});
it('enables switch team if party is in a different team', async () => {
render(<JoinButton {...props} />);
const button = screen.getByRole('button', { name: /Switch team/ });
expect(button).toBeEnabled();
await userEvent.click(button);
expect(props.onJoin).toHaveBeenCalledWith('switch');
});
it('enables join team if party is not in a team', async () => {
render(<JoinButton {...props} partyTeam={undefined} />);
const button = screen.getByRole('button', { name: /Join team/ });
expect(button).toBeEnabled();
await userEvent.click(button);
expect(props.onJoin).toHaveBeenCalledWith('join');
});
});
@@ -0,0 +1,227 @@
import {
TradingButton as Button,
Dialog,
Intent,
Tooltip,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { useSimpleTransaction, useVegaWallet } from '@vegaprotocol/wallet';
import { useT } from '../../lib/use-t';
import { type Team } from '../../lib/hooks/use-team';
import { useState } from 'react';
type JoinType = 'switch' | 'join';
export const JoinTeam = ({
team,
partyTeam,
refetch,
}: {
team: Team;
partyTeam?: Team;
refetch: () => void;
}) => {
const { pubKey, isReadOnly } = useVegaWallet();
const [confirmDialog, setConfirmDialog] = useState<JoinType>();
return (
<>
<JoinButton
team={team}
partyTeam={partyTeam}
pubKey={pubKey}
isReadOnly={isReadOnly}
onJoin={setConfirmDialog}
/>
<Dialog
open={confirmDialog !== undefined}
onChange={() => setConfirmDialog(undefined)}
>
{confirmDialog !== undefined && (
<DialogContent
type={confirmDialog}
team={team}
partyTeam={partyTeam}
onCancel={() => setConfirmDialog(undefined)}
refetch={refetch}
/>
)}
</Dialog>
</>
);
};
export const JoinButton = ({
pubKey,
isReadOnly,
team,
partyTeam,
onJoin,
}: {
pubKey: string | null;
isReadOnly: boolean;
team: Team;
partyTeam?: Team;
onJoin: (type: JoinType) => void;
}) => {
const t = useT();
if (!pubKey || isReadOnly) {
return (
<Tooltip description={t('Connect your wallet to join the team')}>
<Button intent={Intent.Primary} disabled={true}>
{t('Join team')}{' '}
</Button>
</Tooltip>
);
}
// Party is the creator of a team
else if (partyTeam && partyTeam.referrer === pubKey) {
// Party is the creator of THIS team
if (partyTeam.teamId === team.teamId) {
return (
<Button intent={Intent.None} disabled={true}>
<span className="flex items-center gap-2">
{t('Owner')}{' '}
<span className="text-vega-green-600 dark:text-vega-green">
<VegaIcon name={VegaIconNames.TICK} />
</span>
</span>
</Button>
);
} else {
// Not creator of the team, but still can't switch because
// creators cannot leave their own team
return (
<Tooltip description={t('As a team creator, you cannot switch teams')}>
<Button intent={Intent.Primary} disabled={true}>
{t('Switch team')}{' '}
</Button>
</Tooltip>
);
}
}
// Party is in a team, but not this one
else if (partyTeam && partyTeam.teamId !== team.teamId) {
return (
<Button onClick={() => onJoin('switch')} intent={Intent.Primary}>
{t('Switch team')}{' '}
</Button>
);
}
// Joined. Current party is already in this team
else if (partyTeam && partyTeam.teamId === team.teamId) {
return (
<Button intent={Intent.None} disabled={true}>
<span className="flex items-center gap-2">
{t('Joined')}{' '}
<span className="text-vega-green-600 dark:text-vega-green">
<VegaIcon name={VegaIconNames.TICK} />
</span>
</span>
</Button>
);
}
return (
<Button onClick={() => onJoin('join')} intent={Intent.Primary}>
{t('Join team')}
</Button>
);
};
const DialogContent = ({
type,
team,
partyTeam,
onCancel,
refetch,
}: {
type: JoinType;
team: Team;
partyTeam?: Team;
onCancel: () => void;
refetch: () => void;
}) => {
const t = useT();
const { send, status, error } = useSimpleTransaction({
onSuccess: refetch,
});
const joinTeam = () => {
send({
joinTeam: {
id: team.teamId,
},
});
};
if (error) {
return (
<p className="text-vega-red break-words first-letter:capitalize">
{error}
</p>
);
}
if (status === 'requested') {
return <p>{t('Confirm in wallet...')}</p>;
}
if (status === 'pending') {
return <p>{t('Confirming transaction...')}</p>;
}
if (status === 'confirmed') {
if (type === 'switch') {
return (
<p>
{t(
'Team switch successful. You will switch team at the end of the epoch.'
)}
</p>
);
}
return <p>{t('Team joined')}</p>;
}
return (
<div className="flex flex-col gap-4">
{type === 'switch' && (
<>
<h2 className="font-alpha text-xl">{t('Switch team')}</h2>
<p>
{t(
"Switching team will move you from '{{fromTeam}}' to '{{toTeam}}' at the end of the epoch. Are you sure?",
{
fromTeam: partyTeam?.name,
toTeam: team.name,
}
)}
</p>
</>
)}
{type === 'join' && (
<>
<h2 className="font-alpha text-xl">{t('Join team')}</h2>
<p>
{t('Are you sure you want to join team: {{team}}', {
team: team.name,
})}
</p>
</>
)}
<div className="flex justify-between gap-2">
<Button onClick={joinTeam} intent={Intent.Success}>
{t('Confirm')}
</Button>
<Button onClick={onCancel} intent={Intent.Danger}>
{t('Cancel')}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,260 @@
import {
TradingFormGroup,
TradingInput,
TradingInputError,
TradingCheckbox,
TextArea,
TradingButton,
Intent,
} from '@vegaprotocol/ui-toolkit';
import { URL_REGEX, isValidVegaPublicKey } from '@vegaprotocol/utils';
import { type useReferralSetTransaction } from '../../lib/hooks/use-referral-set-transaction';
import { useT } from '../../lib/use-t';
import { useForm, Controller } from 'react-hook-form';
import type {
CreateReferralSet,
UpdateReferralSet,
Status,
} from '@vegaprotocol/wallet';
export type FormFields = {
id: string;
name: string;
url: string;
avatarUrl: string;
private: boolean;
allowList: string;
};
export enum TransactionType {
CreateReferralSet = 'CreateReferralSet',
UpdateReferralSet = 'UpdateReferralSet',
}
const prepareTransaction = (
type: TransactionType,
fields: FormFields
): CreateReferralSet | UpdateReferralSet => {
switch (type) {
case TransactionType.CreateReferralSet:
return {
createReferralSet: {
isTeam: true,
team: {
name: fields.name,
teamUrl: fields.url,
avatarUrl: fields.avatarUrl,
closed: fields.private,
allowList: fields.private
? parseAllowListText(fields.allowList)
: [],
},
},
};
case TransactionType.UpdateReferralSet:
return {
updateReferralSet: {
id: fields.id,
isTeam: true,
team: {
name: fields.name,
teamUrl: fields.url,
avatarUrl: fields.avatarUrl,
closed: fields.private,
allowList: fields.private
? parseAllowListText(fields.allowList)
: [],
},
},
};
}
};
export const TeamForm = ({
type,
status,
err,
isCreatingSoloTeam,
onSubmit,
defaultValues,
}: {
type: TransactionType;
status: ReturnType<typeof useReferralSetTransaction>['status'];
err: ReturnType<typeof useReferralSetTransaction>['err'];
isCreatingSoloTeam: boolean;
onSubmit: ReturnType<typeof useReferralSetTransaction>['onSubmit'];
defaultValues?: FormFields;
}) => {
const t = useT();
const {
register,
handleSubmit,
control,
watch,
formState: { errors },
} = useForm<FormFields>({
defaultValues: {
private: isCreatingSoloTeam,
...defaultValues,
},
});
const isPrivate = watch('private');
const sendTransaction = (fields: FormFields) => {
onSubmit(prepareTransaction(type, fields));
};
return (
<form onSubmit={handleSubmit(sendTransaction)}>
<input type="hidden" {...register('id')} />
<TradingFormGroup label={t('Team name')} labelFor="name">
<TradingInput {...register('name', { required: t('Required') })} />
{errors.name?.message && (
<TradingInputError forInput="name">
{errors.name.message}
</TradingInputError>
)}
</TradingFormGroup>
<TradingFormGroup
label={t('URL')}
labelFor="url"
labelDescription={t(
'Provide a link so users can learn more about your team'
)}
>
<TradingInput
{...register('url', {
pattern: { value: URL_REGEX, message: t('Invalid URL') },
})}
/>
{errors.url?.message && (
<TradingInputError forInput="url">
{errors.url.message}
</TradingInputError>
)}
</TradingFormGroup>
<TradingFormGroup
label={t('Avatar URL')}
labelFor="avatarUrl"
labelDescription={t('Provide a URL to a hosted image')}
>
<TradingInput
{...register('avatarUrl', {
pattern: {
value: URL_REGEX,
message: t('Invalid image URL'),
},
})}
/>
{errors.avatarUrl?.message && (
<TradingInputError forInput="avatarUrl">
{errors.avatarUrl.message}
</TradingInputError>
)}
</TradingFormGroup>
{
// allow changing to private/public if editing, but don't show these options if making a solo team
(type === TransactionType.UpdateReferralSet || !isCreatingSoloTeam) && (
<>
<TradingFormGroup
label={t('Make team private')}
labelFor="private"
hideLabel={true}
>
<Controller
name="private"
control={control}
render={({ field }) => {
return (
<TradingCheckbox
label={t('Make team private')}
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
}}
/>
);
}}
/>
</TradingFormGroup>
{isPrivate && (
<TradingFormGroup
label={t('Public key allow list')}
labelFor="allowList"
labelDescription={t(
'Use a comma separated list to allow only specific public keys to join the team'
)}
>
<TextArea
{...register('allowList', {
required: t('Required'),
validate: {
allowList: (value) => {
const publicKeys = parseAllowListText(value);
if (
publicKeys.every((pk) => isValidVegaPublicKey(pk))
) {
return true;
}
return t('Invalid public key found in allow list');
},
},
})}
/>
{errors.allowList?.message && (
<TradingInputError forInput="avatarUrl">
{errors.allowList.message}
</TradingInputError>
)}
</TradingFormGroup>
)}
</>
)
}
{err && (
<p className="text-danger text-xs mb-4 first-letter:capitalize">
{err}
</p>
)}
<SubmitButton type={type} status={status} />
</form>
);
};
const SubmitButton = ({
type,
status,
}: {
type?: TransactionType;
status: Status;
}) => {
const t = useT();
const disabled = status === 'pending' || status === 'requested';
let text = t('Create');
if (type === TransactionType.UpdateReferralSet) {
text = t('Update');
}
if (status === 'requested') {
text = t('Confirm in wallet...');
} else if (status === 'pending') {
text = t('Confirming transaction...');
}
return (
<TradingButton type="submit" intent={Intent.Info} disabled={disabled}>
{text}
</TradingButton>
);
};
const parseAllowListText = (str: string = '') => {
return str
.split(',')
.map((v) => v.trim())
.filter(Boolean);
};
@@ -0,0 +1,24 @@
import { useVegaWallet } from '@vegaprotocol/wallet';
import { type Team } from '../../lib/hooks/use-team';
import { Intent, TradingAnchorButton } from '@vegaprotocol/ui-toolkit';
import { Links } from '../../lib/links';
import { useT } from '../../lib/use-t';
export const UpdateTeamButton = ({ team }: { team: Team }) => {
const t = useT();
const { pubKey, isReadOnly } = useVegaWallet();
if (pubKey && !isReadOnly && pubKey === team.referrer) {
return (
<TradingAnchorButton
data-testid="update-team-button"
href={Links.COMPETITIONS_UPDATE_TEAM(team.teamId)}
intent={Intent.Info}
>
{t('Update team')}
</TradingAnchorButton>
);
}
return null;
};
@@ -5,17 +5,19 @@ import {
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import type { FieldValues } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import classNames from 'classnames';
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom';
import type { ButtonHTMLAttributes, MouseEventHandler } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { RainbowButton } from './buttons';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { useCallback } from 'react';
import { RainbowButton } from '../../components/rainbow-button';
import {
useSimpleTransaction,
useVegaWallet,
useVegaWalletDialogStore,
} from '@vegaprotocol/wallet';
import { useIsInReferralSet, useReferral } from './hooks/use-referral';
import { Routes } from '../../lib/links';
import { useTransactionEventSubscription } from '@vegaprotocol/web3';
import { Statistics, useStats } from './referral-statistics';
import { useReferralProgram } from './hooks/use-referral-program';
import { ns, useT } from '../../lib/use-t';
@@ -73,6 +75,10 @@ export const ApplyCodeFormContainer = ({
return <ApplyCodeForm onSuccess={onSuccess} />;
};
type FormFields = {
code: string;
};
export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
const t = useT();
const program = useReferralProgram();
@@ -81,31 +87,47 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
(store) => store.openVegaWalletDialog
);
const [status, setStatus] = useState<
'requested' | 'no-funds' | 'successful' | null
>(null);
const txHash = useRef<string | null>(null);
const { isReadOnly, pubKey, sendTx } = useVegaWallet();
const { isReadOnly, pubKey } = useVegaWallet();
const { isEligible, requiredFunds } = useFundsAvailable();
const currentRouteId = useGetCurrentRouteId();
const setViews = useSidebar((s) => s.setViews);
const [params] = useSearchParams();
const {
register,
handleSubmit,
formState: { errors },
setValue,
setError,
watch,
} = useForm();
const [params] = useSearchParams();
} = useForm<FormFields>({
defaultValues: {
code: params.get('code') || '',
},
});
const codeField = watch('code');
const { data: previewData, loading: previewLoading } = useReferral({
code: validateCode(codeField, t) ? codeField : undefined,
});
const { send, status } = useSimpleTransaction({
onSuccess: () => {
// go to main page when successfully applied
setTimeout(() => {
if (onSuccess) onSuccess();
navigate(Routes.REFERRALS);
}, RELOAD_DELAY);
},
onError: (msg) => {
setError('code', {
type: 'required',
message: msg,
});
},
});
/**
* Validates if a connected party can apply a code (min funds span protection)
*/
@@ -135,99 +157,55 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
return true;
}, [codeField, previewData, previewLoading, t]);
useEffect(() => {
const code = params.get('code');
if (code) setValue('code', code);
}, [params, setValue]);
const noFunds = validateFundsAvailable() !== true ? true : false;
useEffect(() => {
const err = validateFundsAvailable();
if (err !== true) {
setStatus('no-funds');
} else {
setStatus(null);
}
}, [isEligible, validateFundsAvailable]);
const onSubmit = ({ code }: FieldValues) => {
const onSubmit = ({ code }: FormFields) => {
if (isReadOnly || !pubKey || !code || code.length === 0) {
return;
}
setStatus('requested');
sendTx(pubKey, {
send({
applyReferralCode: {
id: code as string,
},
})
.then((res) => {
if (!res) {
setError('code', {
type: 'required',
message: t('The transaction could not be sent'),
});
}
if (res) {
txHash.current = res.transactionHash.toLowerCase();
}
})
.catch((err) => {
if (err.message.includes('user rejected')) {
setStatus(null);
} else {
setStatus(null);
setError('code', {
type: 'required',
message:
err instanceof Error
? err.message
: t('Your code has been rejected'),
});
}
});
};
});
useTransactionEventSubscription({
variables: { partyId: pubKey || '' },
skip: !pubKey,
fetchPolicy: 'no-cache',
onData: ({ data: result }) =>
result.data?.busEvents?.forEach((event) => {
if (event.event.__typename === 'TransactionResult') {
const hash = event.event.hash.toLowerCase();
if (txHash.current && txHash.current === hash) {
const err = event.event.error;
const status = event.event.status;
if (err) {
setStatus(null);
setError('code', {
type: 'required',
message: err,
});
}
if (status && !err) {
setStatus('successful');
}
}
}
}),
});
// sendTx(pubKey, {
// applyReferralCode: {
// id: code as string,
// },
// })
// .then((res) => {
// if (!res) {
// setError('code', {
// type: 'required',
// message: t('The transaction could not be sent'),
// });
// }
// if (res) {
// txHash.current = res.transactionHash.toLowerCase();
// }
// })
// .catch((err) => {
// if (err.message.includes('user rejected')) {
// setStatus(null);
// } else {
// setStatus(null);
// setError('code', {
// type: 'required',
// message:
// err instanceof Error
// ? err.message
// : t('Your code has been rejected'),
// });
// }
// });
};
const { epochsValue, nextBenefitTierValue } = useStats({ program });
// go to main page when successfully applied
useEffect(() => {
if (status === 'successful') {
setTimeout(() => {
if (onSuccess) onSuccess();
navigate(Routes.REFERRALS);
}, RELOAD_DELAY);
}
}, [navigate, onSuccess, status]);
// show "code applied" message when successfully applied
if (status === 'successful') {
if (status === 'confirmed') {
return (
<div className="mx-auto w-1/2">
<h3 className="calt mb-5 flex flex-row items-center justify-center gap-2 text-center text-xl uppercase">
@@ -261,7 +239,7 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
};
}
if (status === 'no-funds') {
if (noFunds) {
return {
disabled: false,
children: t('Deposit funds'),
@@ -332,7 +310,7 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
</label>
<RainbowButton variant="border" {...getButtonProps()} />
</form>
{status === 'no-funds' ? (
{noFunds ? (
<InputError intent="warning" className="overflow-auto break-words">
<span>
<SpamProtectionErr requiredFunds={requiredFunds?.toString()} />
@@ -4,41 +4,6 @@ import type { ComponentProps, ButtonHTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { NavLink } from 'react-router-dom';
type RainbowButtonProps = {
variant?: 'full' | 'border';
};
export const RainbowButton = ({
variant = 'full',
children,
className,
...props
}: RainbowButtonProps & ButtonHTMLAttributes<HTMLButtonElement>) => (
<button
className={classNames(
'bg-rainbow rounded-lg overflow-hidden disabled:opacity-40',
'hover:bg-rainbow-180 hover:animate-spin-rainbow',
{
'px-5 py-3 text-white': variant === 'full',
'p-[0.125rem]': variant === 'border',
}
)}
{...props}
>
<div
className={classNames(
{
'bg-vega-clight-800 dark:bg-vega-cdark-800 text-black dark:text-white px-5 py-3 rounded-[0.35rem] overflow-hidden':
variant === 'border',
},
className
)}
>
{children}
</div>
</button>
);
const RAINBOW_TAB_STYLE = classNames(
'inline-block',
'bg-vega-clight-500 dark:bg-vega-cdark-500',
@@ -2,9 +2,6 @@ export const BORDER_COLOR = 'border-vega-clight-500 dark:border-vega-cdark-500';
export const GRADIENT =
'bg-gradient-to-b from-vega-clight-800 dark:from-vega-cdark-800 to-transparent';
export const SKY_BACKGROUND =
'bg-[url(/sky-light.png)] dark:bg-[url(/sky-dark.png)] bg-[37%_0px] bg-[length:1440px] bg-no-repeat bg-local';
// TODO: Update the links to use the correct referral related pages
export const REFERRAL_DOCS_LINK =
'https://docs.vega.xyz/mainnet/concepts/trading-on-vega/discounts-rewards#referral-program';
@@ -1,9 +1,5 @@
import {
useVegaWallet,
useVegaWalletDialogStore,
determineId,
} from '@vegaprotocol/wallet';
import { RainbowButton } from './buttons';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { RainbowButton } from '../../components/rainbow-button';
import { useState } from 'react';
import {
CopyWithTooltip,
@@ -11,6 +7,7 @@ import {
ExternalLink,
InputError,
Intent,
Tooltip,
TradingAnchorButton,
TradingButton,
VegaIcon,
@@ -18,34 +15,28 @@ import {
} from '@vegaprotocol/ui-toolkit';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { DApp, TokenStaticLinks, useLinks } from '@vegaprotocol/environment';
import { useStakeAvailable } from './hooks/use-stake-available';
import { ABOUT_REFERRAL_DOCS_LINK } from './constants';
import { useIsInReferralSet, useReferral } from './hooks/use-referral';
import { useT } from '../../lib/use-t';
import { Navigate } from 'react-router-dom';
import { Routes } from '../../lib/links';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { Links, Routes } from '../../lib/links';
import { useReferralProgram } from './hooks/use-referral-program';
import { useReferralSetTransaction } from '../../lib/hooks/use-referral-set-transaction';
import { Trans } from 'react-i18next';
export const CreateCodeContainer = () => {
const { pubKey } = useVegaWallet();
const t = useT();
const { pubKey, isReadOnly } = useVegaWallet();
const isInReferralSet = useIsInReferralSet(pubKey);
const openWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
// Navigate to the index page when already in the referral set.
if (isInReferralSet) {
return <Navigate to={Routes.REFERRALS} />;
}
return <CreateCodeForm />;
};
export const CreateCodeForm = () => {
const t = useT();
const [dialogOpen, setDialogOpen] = useState(false);
const openWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
const { pubKey, isReadOnly } = useVegaWallet();
return (
<div
data-testid="referral-create-code-form"
@@ -60,22 +51,82 @@ export const CreateCodeForm = () => {
)}
</p>
<div className="w-full flex flex-col">
<RainbowButton
variant="border"
disabled={isReadOnly}
onClick={() => {
if (pubKey) {
setDialogOpen(true);
} else {
openWalletDialog();
}
}}
>
{pubKey ? t('Create a referral code') : t('Connect wallet')}
</RainbowButton>
<div className="w-full flex flex-col gap-4 items-stretch">
{pubKey ? (
<CreateCodeForm />
) : (
<RainbowButton
variant="border"
disabled={isReadOnly}
onClick={openWalletDialog}
>
{t('Connect wallet')}
</RainbowButton>
)}
</div>
</div>
);
};
export const CreateCodeForm = () => {
const t = useT();
const navigate = useNavigate();
const [dialogOpen, setDialogOpen] = useState(false);
const { isReadOnly } = useVegaWallet();
return (
<>
<Tooltip
description={t(
'Create a simple referral code to enjoy the referrer commission outlined in the current referral program'
)}
>
<span>
<RainbowButton
variant="border"
disabled={isReadOnly}
onClick={() => setDialogOpen(true)}
className="w-full"
>
{t('Create a referral code')}
</RainbowButton>
</span>
</Tooltip>
<Tooltip
description={
<Trans
i18nKey={
'Make your referral code a Team to compete in Competitions with your friends, appear in leaderboards on the <0>Competitions Homepage</0>, and earn rewards'
}
components={[
<Link
key="homepage-link"
to={Links.COMPETITIONS()}
className="underline"
>
Compeitionts Homepage
</Link>,
]}
/>
}
>
<span>
<RainbowButton
role="link"
variant="border"
disabled={isReadOnly}
onClick={() => navigate(Links.COMPETITIONS_CREATE_TEAM())}
className="w-full"
>
{t('Create a team')}
</RainbowButton>
</span>
</Tooltip>
<p className="text-xs">
<Link className="underline" to={Links.COMPETITIONS()}>
{t('Go to competitions')}
</Link>
</p>
<Dialog
title={t('Create a referral code')}
open={dialogOpen}
@@ -84,7 +135,7 @@ export const CreateCodeForm = () => {
>
<CreateCodeDialog setDialogOpen={setDialogOpen} />
</Dialog>
</div>
</>
);
};
@@ -95,67 +146,42 @@ const CreateCodeDialog = ({
}) => {
const t = useT();
const createLink = useLinks(DApp.Governance);
const { isReadOnly, pubKey, sendTx } = useVegaWallet();
const { pubKey } = useVegaWallet();
const { refetch } = useReferral({ pubKey, role: 'referrer' });
const [err, setErr] = useState<string | null>(null);
const [code, setCode] = useState<string | null>(null);
const [status, setStatus] = useState<
'idle' | 'loading' | 'success' | 'error'
>('idle');
const { stakeAvailable: currentStakeAvailable, requiredStake } =
useStakeAvailable();
const {
err,
code,
status,
stakeAvailable: currentStakeAvailable,
requiredStake,
onSubmit,
} = useReferralSetTransaction();
const { details: programDetails } = useReferralProgram();
const onSubmit = () => {
if (isReadOnly || !pubKey) {
setErr('Not connected');
} else {
setErr(null);
setStatus('loading');
setCode(null);
sendTx(pubKey, {
createReferralSet: {
isTeam: false,
},
})
.then((res) => {
if (!res) {
setErr(`Invalid response: ${JSON.stringify(res)}`);
return;
}
const code = determineId(res.signature);
setCode(code);
setStatus('success');
})
.catch((err) => {
if (err.message.includes('user rejected')) {
setStatus('idle');
return;
}
setStatus('error');
setErr(err.message);
});
}
};
const getButtonProps = () => {
if (status === 'idle' || status === 'error') {
if (status === 'idle') {
return {
children: t('Generate code'),
onClick: () => onSubmit(),
onClick: () => onSubmit({ createReferralSet: { isTeam: false } }),
};
}
if (status === 'loading') {
if (status === 'requested') {
return {
children: t('Confirm in wallet...'),
disabled: true,
};
}
if (status === 'success') {
if (status === 'pending') {
return {
children: t('Waiting for transaction...'),
disabled: true,
};
}
if (status === 'confirmed') {
return {
children: t('Close'),
intent: Intent.Success,
@@ -209,7 +235,10 @@ const CreateCodeDialog = ({
if (!programDetails) {
return (
<div className="flex flex-col gap-4">
{(status === 'idle' || status === 'loading' || status === 'error') && (
{(status === 'idle' ||
status === 'requested' ||
status === 'pending' ||
err) && (
<>
{
<p>
@@ -220,7 +249,7 @@ const CreateCodeDialog = ({
}
</>
)}
{status === 'success' && code && (
{status === 'confirmed' && code && (
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 p-2 text-sm rounded bg-vega-clight-700 dark:bg-vega-cdark-700">
<p className="overflow-hidden whitespace-nowrap text-ellipsis">
@@ -240,7 +269,7 @@ const CreateCodeDialog = ({
<TradingButton
fill={true}
intent={Intent.Primary}
onClick={() => onSubmit()}
onClick={() => onSubmit({ createReferralSet: { isTeam: false } })}
{...getButtonProps()}
>
{t('Yes')}
@@ -269,14 +298,17 @@ const CreateCodeDialog = ({
return (
<div className="flex flex-col gap-4">
{(status === 'idle' || status === 'loading' || status === 'error') && (
{(status === 'idle' ||
status === 'requested' ||
status === 'pending' ||
err) && (
<p>
{t(
'Generate a referral code to share with your friends and access the commission benefits of the current program.'
)}
</p>
)}
{status === 'success' && code && (
{status === 'confirmed' && code && (
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 p-2 text-sm rounded bg-vega-clight-700 dark:bg-vega-cdark-700">
<p className="overflow-hidden whitespace-nowrap text-ellipsis">
@@ -1,53 +1,10 @@
import { isRouteErrorResponse, useNavigate, useRouteError } from 'react-router';
import { RainbowButton } from './buttons';
import { useNavigate } from 'react-router';
import { RainbowButton } from '../../components/rainbow-button';
import { LayoutWithSky } from '../../components/layouts-inner';
import { AnimatedDudeWithWire } from './graphics/dude';
import { LayoutWithSky } from './layout';
import { Routes } from '../../lib/links';
import { useT } from '../../lib/use-t';
export const ErrorBoundary = () => {
const t = useT();
const error = useRouteError();
const navigate = useNavigate();
const title = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: t('Something went wrong');
const code = isRouteErrorResponse(error) ? error.status : 0;
const messages: Record<number, string> = {
0: t('An unknown error occurred.'),
404: t("The page you're looking for doesn't exists."),
};
return (
<LayoutWithSky className="pt-32">
<div
aria-hidden
className="absolute top-64 right-[220px] md:right-[340px] max-sm:hidden"
>
<AnimatedDudeWithWire className="animate-spin" />
</div>
<h1 className="text-6xl font-alpha calt mb-10">{title}</h1>
{Object.keys(messages).includes(code.toString()) ? (
<p className="text-lg mb-10">{messages[code]}</p>
) : null}
<p className="text-lg mb-10">
<RainbowButton
onClick={() => navigate('..')}
variant="border"
className="text-xs"
>
{t('Go back and try again')}
</RainbowButton>
</p>
</LayoutWithSky>
);
};
export const NotFound = () => {
const t = useT();
const navigate = useNavigate();
@@ -8,13 +8,13 @@ import type {
ReferralSetsQueryVariables,
} from './__generated__/ReferralSets';
import { useReferralSetsQuery } from './__generated__/ReferralSets';
import { useStakeAvailable } from './use-stake-available';
import { useStakeAvailable } from '../../../lib/hooks/use-stake-available';
export const DEFAULT_AGGREGATION_DAYS = 30;
export type Role = 'referrer' | 'referee';
type UseReferralArgs = (
| { code: string }
| { code: string | undefined }
| { pubKey: string | null; role: Role }
) & {
aggregationEpochs?: number;
@@ -1,6 +1,9 @@
import { MockedProvider, type MockedResponse } from '@apollo/react-testing';
import { render, waitFor } from '@testing-library/react';
import { type VegaWalletContextShape } from '@vegaprotocol/wallet';
import { render, screen, waitFor } from '@testing-library/react';
import {
VegaWalletContext,
type VegaWalletContextShape,
} from '@vegaprotocol/wallet';
import { ReferralStatistics } from './referral-statistics';
import {
ReferralProgramDocument,
@@ -15,7 +18,7 @@ import {
StakeAvailableDocument,
type StakeAvailableQueryVariables,
type StakeAvailableQuery,
} from './hooks/__generated__/StakeAvailable';
} from '../../lib/hooks/__generated__/StakeAvailable';
import {
RefereesDocument,
type RefereesQueryVariables,
@@ -296,122 +299,99 @@ const refereesMock30: MockedResponse<RefereesQuery, RefereesQueryVariables> = {
},
};
jest.mock('@vegaprotocol/wallet', () => {
return {
...jest.requireActual('@vegaprotocol/wallet'),
useVegaWallet: () => {
const ctx: Partial<VegaWalletContextShape> = {
pubKey: MOCK_PUBKEY,
};
return ctx;
},
};
});
describe('ReferralStatistics', () => {
it('displays apply code when no data has been found for given pubkey', () => {
const { queryByTestId } = render(
const renderComponent = (mocks: MockedResponse[]) => {
const walletContext = {
pubKey: MOCK_PUBKEY,
isReadOnly: false,
sendTx: jest.fn(),
} as unknown as VegaWalletContextShape;
return render(
<MemoryRouter>
<MockedProvider mocks={[]} showWarnings={false}>
<ReferralStatistics />
</MockedProvider>
<VegaWalletContext.Provider value={walletContext}>
<MockedProvider mocks={mocks} showWarnings={false}>
<ReferralStatistics />
</MockedProvider>
</VegaWalletContext.Provider>
</MemoryRouter>
);
};
expect(queryByTestId('referral-apply-code-form')).toBeInTheDocument();
it('displays apply code when no data has been found for given pubkey', () => {
renderComponent([]);
expect(
screen.queryByTestId('referral-apply-code-form')
).toBeInTheDocument();
});
it('displays referrer stats when given pubkey is a referrer', async () => {
const { queryByTestId } = render(
<MemoryRouter>
<MockedProvider
mocks={[
programMock,
referralSetAsReferrerMock,
noReferralSetAsRefereeMock,
stakeAvailableMock,
refereesMock,
refereesMock30,
]}
showWarnings={false}
>
<ReferralStatistics />
</MockedProvider>
</MemoryRouter>
);
renderComponent([
programMock,
referralSetAsReferrerMock,
noReferralSetAsRefereeMock,
stakeAvailableMock,
refereesMock,
refereesMock30,
]);
await waitFor(() => {
expect(
queryByTestId('referral-create-code-form')
screen.queryByTestId('referral-create-code-form')
).not.toBeInTheDocument();
expect(queryByTestId('referral-statistics')).toBeInTheDocument();
expect(queryByTestId('referral-statistics')?.dataset.as).toEqual(
expect(screen.queryByTestId('referral-statistics')).toBeInTheDocument();
expect(screen.queryByTestId('referral-statistics')?.dataset.as).toEqual(
'referrer'
);
// gets commision from 30 epochs query
expect(queryByTestId('total-commission-value')).toHaveTextContent(
expect(screen.queryByTestId('total-commission-value')).toHaveTextContent(
'12,340'
);
});
});
it('displays referee stats when given pubkey is a referee', async () => {
const { queryByTestId } = render(
<MemoryRouter>
<MockedProvider
mocks={[
programMock,
noReferralSetAsReferrerMock,
referralSetAsRefereeMock,
stakeAvailableMock,
refereesMock,
]}
showWarnings={false}
>
<ReferralStatistics />
</MockedProvider>
</MemoryRouter>
);
renderComponent([
programMock,
noReferralSetAsReferrerMock,
referralSetAsRefereeMock,
stakeAvailableMock,
refereesMock,
]);
await waitFor(() => {
expect(
queryByTestId('referral-create-code-form')
screen.queryByTestId('referral-create-code-form')
).not.toBeInTheDocument();
expect(queryByTestId('referral-statistics')).toBeInTheDocument();
expect(queryByTestId('referral-statistics')?.dataset.as).toEqual(
expect(screen.queryByTestId('referral-statistics')).toBeInTheDocument();
expect(screen.queryByTestId('referral-statistics')?.dataset.as).toEqual(
'referee'
);
});
});
it('displays eligibility warning when the set is no longer valid due to the referrers stake', async () => {
const { queryByTestId } = render(
<MemoryRouter>
<MockedProvider
mocks={[
programMock,
noReferralSetAsReferrerMock,
referralSetAsRefereeMock,
nonEligibleStakeAvailableMock,
refereesMock,
]}
showWarnings={false}
>
<ReferralStatistics />
</MockedProvider>
</MemoryRouter>
);
renderComponent([
programMock,
noReferralSetAsReferrerMock,
referralSetAsRefereeMock,
nonEligibleStakeAvailableMock,
refereesMock,
]);
await waitFor(() => {
expect(
queryByTestId('referral-create-code-form')
screen.queryByTestId('referral-create-code-form')
).not.toBeInTheDocument();
expect(queryByTestId('referral-statistics')).toBeInTheDocument();
expect(queryByTestId('referral-statistics')?.dataset.as).toEqual(
expect(screen.queryByTestId('referral-statistics')).toBeInTheDocument();
expect(screen.queryByTestId('referral-statistics')?.dataset.as).toEqual(
'referee'
);
expect(queryByTestId('referral-eligibility-warning')).toBeInTheDocument();
expect(queryByTestId('referral-apply-code-form')).toBeInTheDocument();
expect(
screen.queryByTestId('referral-eligibility-warning')
).toBeInTheDocument();
expect(
screen.queryByTestId('referral-apply-code-form')
).toBeInTheDocument();
});
});
});
@@ -1,20 +1,17 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import BigNumber from 'bignumber.js';
import minBy from 'lodash/minBy';
import { CodeTile, StatTile } from './tile';
import sortBy from 'lodash/sortBy';
import compact from 'lodash/compact';
import { Trans } from 'react-i18next';
import classNames from 'classnames';
import {
VegaIcon,
VegaIconNames,
truncateMiddle,
TextChildrenTooltip as Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import {
DEFAULT_AGGREGATION_DAYS,
useReferral,
useUpdateReferees,
} from './hooks/use-referral';
import classNames from 'classnames';
import { Table } from '../../components/table';
import {
addDecimalsFormatNumber,
getDateFormat,
@@ -24,17 +21,22 @@ import {
removePaginationWrapper,
} from '@vegaprotocol/utils';
import { useReferralSetStatsQuery } from './hooks/__generated__/ReferralSetStats';
import compact from 'lodash/compact';
import { useReferralProgram } from './hooks/use-referral-program';
import { useStakeAvailable } from './hooks/use-stake-available';
import sortBy from 'lodash/sortBy';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useCurrentEpochInfoQuery } from './hooks/__generated__/Epoch';
import BigNumber from 'bignumber.js';
import { useStakeAvailable } from '../../lib/hooks/use-stake-available';
import { useT, ns } from '../../lib/use-t';
import { Trans } from 'react-i18next';
import { useTeam } from '../../lib/hooks/use-team';
import { TeamAvatar } from '../../components/competitions/team-avatar';
import { TeamStats } from '../../components/competitions/team-stats';
import { Table } from '../../components/table';
import {
DEFAULT_AGGREGATION_DAYS,
useReferral,
useUpdateReferees,
} from './hooks/use-referral';
import { ApplyCodeForm, ApplyCodeFormContainer } from './apply-code-form';
import { useReferralProgram } from './hooks/use-referral-program';
import { useCurrentEpochInfoQuery } from './hooks/__generated__/Epoch';
import { QUSDTooltip } from './qusd-tooltip';
import { CodeTile, StatTile, Tile } from './tile';
export const ReferralStatistics = () => {
const { pubKey } = useVegaWallet();
@@ -192,10 +194,7 @@ export const Statistics = ({
nextBenefitTierEpochsValue,
} = useStats({ data, program });
const isApplyCodePreview = useMemo(
() => data.referee === null,
[data.referee]
);
const isApplyCodePreview = data.referee === null;
const { benefitTiers } = useReferralProgram();
@@ -328,23 +327,6 @@ export const Statistics = ({
</StatTile>
);
const referrerTiles = (
<>
<div className="grid grid-rows-1 gap-5 grid-cols-1 md:grid-cols-3">
{baseCommissionTile}
{stakingMultiplierTile}
{finalCommissionTile}
</div>
<div className="grid grid-rows-1 gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
{codeTile}
{referrerVolumeTile}
{numberOfTradersTile}
{totalCommissionTile}
</div>
</>
);
const currentBenefitTierTile = (
<StatTile
title={t('Current tier')}
@@ -416,8 +398,41 @@ export const Statistics = ({
</StatTile>
);
const eligibilityWarningOverlay = as === 'referee' && !isEligible && (
<div
data-testid="referral-eligibility-warning"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center w-1/2 lg:w-1/3"
>
<h2 className="text-2xl mb-2">{t('Referral code no longer valid')}</h2>
<p>
{t(
'Your referral code is no longer valid as the referrer no longer meets the minimum requirements. Apply a new code to continue receiving discounts.'
)}
</p>
</div>
);
const referrerTiles = (
<>
<Team teamId={data.code} />
<div className="grid grid-rows-1 gap-5 grid-cols-1 md:grid-cols-3">
{baseCommissionTile}
{stakingMultiplierTile}
{finalCommissionTile}
</div>
<div className="grid grid-rows-1 gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
{codeTile}
{referrerVolumeTile}
{numberOfTradersTile}
{totalCommissionTile}
</div>
</>
);
const refereeTiles = (
<>
<Team teamId={data.code} />
<div className="grid grid-rows-1 gap-5 grid-cols-1 md:grid-cols-3">
{currentBenefitTierTile}
{runningVolumeTile}
@@ -432,20 +447,6 @@ export const Statistics = ({
</>
);
const eligibilityWarning = as === 'referee' && !isEligible && (
<div
data-testid="referral-eligibility-warning"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center w-1/2 lg:w-1/3"
>
<h2 className="text-2xl mb-2">{t('Referral code no longer valid')}</h2>
<p>
{t(
'Your referral code is no longer valid as the referrer no longer meets the minimum requirements. Apply a new code to continue receiving discounts.'
)}
</p>
</div>
);
return (
<div
data-testid="referral-statistics"
@@ -460,8 +461,7 @@ export const Statistics = ({
{as === 'referrer' && referrerTiles}
{as === 'referee' && refereeTiles}
</div>
{eligibilityWarning}
{eligibilityWarningOverlay}
</div>
);
};
@@ -574,3 +574,19 @@ export const RefereesTable = ({
</>
);
};
const Team = ({ teamId }: { teamId?: string }) => {
const { team, games, members } = useTeam(teamId);
if (!team) return null;
return (
<Tile className="flex gap-3 lg:gap-4">
<TeamAvatar teamId={team.teamId} imgUrl={team.avatarUrl} />
<div className="flex flex-col items-start gap-1 lg:gap-3">
<h1 className="calt text-2xl lg:text-3xl xl:text-5xl">{team.name}</h1>
<TeamStats members={members} games={games} />
</div>
</Tile>
);
};
@@ -13,11 +13,9 @@ import { useVegaWallet } from '@vegaprotocol/wallet';
import { useReferral } from './hooks/use-referral';
import { REFERRAL_DOCS_LINK } from './constants';
import classNames from 'classnames';
import { usePageTitleStore } from '../../stores';
import { useEffect } from 'react';
import { titlefy } from '@vegaprotocol/utils';
import { useT } from '../../lib/use-t';
import { ErrorBoundary } from '../../components/error-boundary';
import { usePageTitle } from '../../lib/hooks/use-page-title';
const Nav = () => {
const t = useT();
@@ -57,13 +55,7 @@ export const Referrals = () => {
const loading = refereeLoading || referrerLoading;
const showNav = !loading && !error && !referrer && !referee;
const { updateTitle } = usePageTitleStore((store) => ({
updateTitle: store.updateTitle,
}));
useEffect(() => {
updateTitle(titlefy([t('Referrals')]));
}, [updateTitle, t]);
usePageTitle(t('Referrals'));
return (
<ErrorBoundary feature="referrals">
-1
View File
@@ -1 +0,0 @@
export { Teams } from './teams';
@@ -1,7 +0,0 @@
export const Teams = () => {
return (
<div>
<h1>Teams</h1>
</div>
);
};
@@ -0,0 +1,21 @@
import classNames from 'classnames';
import { type HTMLAttributes } from 'react';
export const BORDER_COLOR = 'border-vega-clight-500 dark:border-vega-cdark-500';
export const GRADIENT =
'bg-gradient-to-b from-vega-clight-800 dark:from-vega-cdark-800 to-transparent';
export const Box = (props: HTMLAttributes<HTMLDivElement>) => {
return (
<div
{...props}
className={classNames(
BORDER_COLOR,
GRADIENT,
'border rounded-lg',
'p-6',
props.className
)}
/>
);
};
@@ -0,0 +1,41 @@
import { Box } from './box';
import { type ComponentProps, type ReactElement, type ReactNode } from 'react';
import { DudeBadge } from './graphics/dude-badge';
export const CompetitionsActionsContainer = ({
children,
}: {
children:
| ReactElement<typeof CompetitionsAction>
| Iterable<ReactElement<typeof CompetitionsAction>>;
}) => (
<div
className="grid grid-cols-1 md:grid-cols-3 grid-rows-4'
gap-6 mb-12"
>
{children}
</div>
);
export const CompetitionsAction = ({
variant,
title,
description,
actionElement,
}: {
variant: ComponentProps<typeof DudeBadge>['variant'];
title: string;
description?: string;
actionElement: ReactNode;
}) => {
return (
<Box className="grid md:grid-rows-[subgrid] gap-6 row-span-4 text-center">
<div className="flex justify-center">
<DudeBadge variant={variant} />
</div>
<h2 className="text-2xl">{title}</h2>
{description && <p className="text-muted">{description}</p>}
<div className="flex justify-center">{actionElement}</div>
</Box>
);
};
@@ -0,0 +1,27 @@
import { AnimatedDudeWithWire } from '../../client-pages/referrals/graphics/dude';
import { type ReactNode } from 'react';
export const CompetitionsHeader = ({
title,
children,
}: {
title: string;
children?: ReactNode;
}) => {
return (
<div className="relative mb-4 lg:mb-20">
<div
aria-hidden
className="absolute top-20 right-[220px] md:right-[240px] max-sm:hidden"
>
<AnimatedDudeWithWire />
</div>
<div className="pt-6 lg:pt-20 sm:w-1/2">
<h1 className="text-3xl lg:text-6xl leading-[1em] font-alpha calt mb-2 lg:mb-10">
{title}
</h1>
{children}
</div>
</div>
);
};
@@ -0,0 +1,71 @@
import { Link } from 'react-router-dom';
import { Splash } from '@vegaprotocol/ui-toolkit';
import { getNumberFormat } from '@vegaprotocol/utils';
import { type useTeams } from '../../lib/hooks/use-teams';
import { useT } from '../../lib/use-t';
import { Table } from '../table';
import { Rank } from './graphics/rank';
import { Links } from '../../lib/links';
import { TeamAvatar } from './team-avatar';
export const CompetitionsLeaderboard = ({
data,
}: {
data: ReturnType<typeof useTeams>['data'];
}) => {
const t = useT();
const num = (n?: number | string) =>
!n ? '-' : getNumberFormat(0).format(Number(n));
if (!data || data.length === 0) {
return <Splash>{t('Could not find any teams')}</Splash>;
}
return (
<Table
columns={[
{ name: 'rank', displayName: '#' },
{ name: 'avatar', displayName: '' },
{ name: 'team', displayName: t('Team') },
{ name: 'earned', displayName: t('Rewards earned') },
{ name: 'games', displayName: t('Total games') },
{ name: 'status', displayName: t('Status') },
{ name: 'volume', displayName: t('Volume') },
]}
data={data.map((td, i) => {
// leaderboard place or medal
let rank: number | React.ReactNode = i + 1;
if (rank === 1) rank = <Rank variant="gold" />;
if (rank === 2) rank = <Rank variant="silver" />;
if (rank === 3) rank = <Rank variant="bronze" />;
const avatar = (
<TeamAvatar
teamId={td.teamId}
imgUrl={td.avatarUrl}
alt={td.name}
size="small"
/>
);
return {
rank,
avatar,
team: (
<Link
className="hover:underline"
to={Links.COMPETITIONS_TEAM(td.teamId)}
>
{td.name}
</Link>
),
earned: num(td.totalQuantumRewards),
games: num(td.totalGamesPlayed),
status: td.closed ? t('Closed') : t('Open'),
volume: num(td.totalQuantumVolume),
};
})}
/>
);
};
@@ -0,0 +1,44 @@
import { type TransferNode } from '@vegaprotocol/types';
import { ActiveRewardCard } from '../rewards-container/active-rewards';
import { useT } from '../../lib/use-t';
export const GamesContainer = ({
data,
currentEpoch,
}: {
data: TransferNode[];
currentEpoch: number;
}) => {
const t = useT();
if (!data || data.length === 0) {
return (
<p className="mb-6 text-muted">
{t('There are currently no games available.')}
</p>
);
}
return (
<div className="mb-12 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.map((game, i) => {
// TODO: Remove `kind` prop from ActiveRewardCard
const { transfer } = game;
if (
transfer.kind.__typename !== 'RecurringTransfer' ||
!transfer.kind.dispatchStrategy?.dispatchMetric
) {
return null;
}
return (
<ActiveRewardCard
key={i}
transferNode={game}
currentEpoch={currentEpoch}
kind={transfer.kind}
/>
);
})}
</div>
);
};
@@ -0,0 +1,40 @@
import classNames from 'classnames';
import { DudeWithFlag } from './dude-with-flag';
/**
* Pre-defined badge gradients
*/
export const BADGE_GRADIENT_VARIANT_A =
'bg-gradient-to-r from-vega-blue-500 via-vega-purple-500 to-vega-pink-500';
export const BADGE_GRADIENT_VARIANT_B =
'bg-gradient-to-r from-vega-purple-500 via-vega-green-500 to-vega-blue-500';
export const BADGE_GRADIENT_VARIANT_C =
'bg-gradient-to-r from-vega-blue-500 via-vega-purple-500 to-vega-green-500';
/** Badge */
export const DudeBadge = ({
variant,
className,
}: {
variant: 'A' | 'B' | 'C' | undefined;
className?: classNames.Argument;
}) => {
return (
<div
className={classNames(
'w-24 h-24 rounded-full bg-black relative',
'rotate-12',
{
[BADGE_GRADIENT_VARIANT_A]: variant === 'A',
[BADGE_GRADIENT_VARIANT_B]: variant === 'B',
[BADGE_GRADIENT_VARIANT_C]: variant === 'C',
},
className
)}
>
<DudeWithFlag className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 -rotate-12" />
</div>
);
};
@@ -0,0 +1,50 @@
import { theme } from '@vegaprotocol/tailwindcss-config';
type DudeWithFlagProps = {
flagColor?: string;
withStar?: boolean;
className?: string;
};
const DEFAULT_FLAG_COLOR = theme.colors.vega.green[500];
export const DudeWithFlag = ({
flagColor = DEFAULT_FLAG_COLOR,
withStar = true,
className,
}: DudeWithFlagProps) => {
return (
<svg
width="49"
height="43"
viewBox="0 0 49 43"
fill="none"
className={className}
>
{withStar && (
<>
<path d="M3.99992 0H2V1.99993H3.99992V0Z" fill="white" />
<path
d="M2 1.99993L0 1.99981V3.99974H1.99992L2 1.99993Z"
fill="white"
/>
<path
d="M3.99995 3.99992L1.99992 3.99974L2 5.99988H3.99995V3.99992Z"
fill="white"
/>
<path
d="M5.99997 1.99981L3.99992 1.99993L3.99995 3.99992L5.99997 3.99974V1.99981Z"
fill="white"
/>
</>
)}
<path
d="M32 4H11V33H15V43H20V33H23V43H28V33H32V4ZM20 17H15V12H20V17ZM28 17H23V12H28V17Z"
fill="white"
/>
<path d="M41 25L32 25L32 20L41 20L41 25Z" fill="white" />
<path d="M36 29V4H35V29" fill="white" />
<path d="M36 13H49L44.55 8.5L49 4H36V13Z" fill={flagColor} />
</svg>
);
};
@@ -0,0 +1,110 @@
import { useThemeSwitcher } from '@vegaprotocol/react-helpers';
import classNames from 'classnames';
export const Rank = ({
variant,
className,
}: {
variant?: 'gold' | 'silver' | 'bronze';
className?: classNames.Argument;
}) => {
const { theme } = useThemeSwitcher();
return (
<div
title={classNames({
'1': variant === 'gold',
'2': variant === 'silver',
'3': variant === 'bronze',
})}
className={classNames(
{
'text-yellow-300': variant === 'gold',
'text-vega-clight-500': variant === 'silver',
'text-vega-orange-500': variant === 'bronze',
'text-black dark:text-white': variant === undefined,
},
className
)}
>
<svg width="18" height="30" viewBox="0 0 18 30" fill="none">
<defs>
<linearGradient x1="0" y1="0" x2="100%" y2="100%" id="medal">
<stop offset="33%" stopColor="transparent" />
<stop offset="100%" stopColor="black" stopOpacity="50%" />
</linearGradient>
<clipPath id="shape">
<path d="M2 2H4V4H2V2Z" />
<path d="M2 2H4V4H2V2Z" />
<path d="M2 2H6V4H2V2Z" />
<path d="M2 2H6V4H2V2Z" />
<path d="M0 4H4V6H0V4Z" />
<path d="M0 4H4V6H0V4Z" />
<path d="M4 0H14V2H4V0Z" />
<path d="M4 0H14V2H4V0Z" />
<path d="M0 14V4H2V14H0Z" />
<path d="M0 14V4H2V14H0Z" />
<path d="M2 30L2 18H4L4 30H2Z" />
<path d="M2 30L2 18H4L4 30H2Z" />
<path d="M14 30L14 18H16L16 30H14Z" />
<path d="M14 30L14 18H16L16 30H14Z" />
<path d="M16 14L16 4H18V14H16Z" />
<path d="M16 14L16 4H18V14H16Z" />
<path d="M2 6V2H4V6H2Z" />
<path d="M2 6V2H4V6H2Z" />
<path d="M16 2V6H14V2H16Z" />
<path d="M16 2V6H14V2H16Z" />
<path d="M12 2H16L16 4H12V2Z" />
<path d="M12 2H16L16 4H12V2Z" />
<path d="M14 4H18V6H14V4Z" />
<path d="M14 4H18V6H14V4Z" />
<path d="M16 16H12V14H16V16Z" />
<path d="M16 16H12V14H16V16Z" />
<path d="M14 18H4L4 16L14 16L14 18Z" />
<path d="M14 18H4L4 16L14 16L14 18Z" />
<path d="M16 12V16H14V12H16Z" />
<path d="M16 12V16H14V12H16Z" />
<path d="M6 16H2V14H6V16Z" />
<path d="M6 16H2V14H6V16Z" />
<path d="M6 28H4L4 26H6V28Z" />
<path d="M6 28H4L4 26H6V28Z" />
<path d="M8 26H6L6 24H8V26Z" />
<path d="M8 26H6L6 24H8V26Z" />
<path d="M10 24H8V22H10V24Z" />
<path d="M10 24H8V22H10V24Z" />
<path d="M12 26H10L10 24H12V26Z" />
<path d="M12 26H10L10 24H12V26Z" />
<path d="M14 28H12L12 26H14V28Z" />
<path d="M14 28H12L12 26H14V28Z" />
<path d="M4 14H0L2.04189e-07 12H4V14Z" />
<path d="M4 14H0L2.04189e-07 12H4V14Z" />
<path d="M6 4H12V14H6V4Z" />
<path d="M6 4H12V14H6V4Z" />
<path d="M4 6H14V12H4V6Z" />
<path d="M4 6H14V12H4V6Z" />
</clipPath>
</defs>
<rect
rx="0"
ry="0"
width="18"
height="30"
fill="currentColor"
clipPath="url(#shape)"
/>
<rect
rx="0"
ry="0"
width="18"
height="30"
fill="url(#medal)"
clipPath="url(#shape)"
style={{ mixBlendMode: theme === 'dark' ? 'darken' : 'overlay' }}
/>
<g style={{ mixBlendMode: 'overlay' }}>
<path d="M10.5 6H8.5V8H10.5V6Z" fill="white" />
<path d="M12.5 8H10.5V10H12.5V8Z" fill="white" />
</g>
</svg>
</div>
);
};
@@ -0,0 +1,41 @@
import classNames from 'classnames';
const NUM_AVATARS = 20;
const AVATAR_PATHNAME_PATTERN = '/team-avatars/{id}.png';
const getFallbackAvatar = (teamId: string) => {
const avatarId = ((parseInt(teamId, 16) % NUM_AVATARS) + 1)
.toString()
.padStart(2, '0'); // between 01 - 20
return AVATAR_PATHNAME_PATTERN.replace('{id}', avatarId);
};
export const TeamAvatar = ({
teamId,
imgUrl,
alt,
size = 'large',
}: {
teamId: string;
imgUrl: string;
alt?: string;
size?: 'large' | 'small';
}) => {
const img = imgUrl && imgUrl.length > 0 ? imgUrl : getFallbackAvatar(teamId);
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={img}
alt={alt || 'Team avatar'}
className={classNames(
'rounded-full bg-vega-clight-700 dark:bg-vega-cdark-700 shrink-0',
{
'w-20 h-20 lg:w-[112px] lg:h-[112px]': size === 'large',
'w-10 h-10': size === 'small',
}
)}
referrerPolicy="no-referrer"
/>
);
};
@@ -0,0 +1,195 @@
import { type ReactNode } from 'react';
import BigNumber from 'bignumber.js';
import countBy from 'lodash/countBy';
import {
Pill,
VegaIcon,
VegaIconNames,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { formatNumberRounded } from '@vegaprotocol/utils';
import {
type TeamStats as ITeamStats,
type Member,
type TeamGame,
} from '../../lib/hooks/use-team';
import { useT } from '../../lib/use-t';
import { DispatchMetricLabels, type DispatchMetric } from '@vegaprotocol/types';
export const TeamStats = ({
stats,
members,
games,
}: {
stats?: ITeamStats;
members?: Member[];
games?: TeamGame[];
}) => {
const t = useT();
return (
<>
<StatSection>
<StatList>
<Stat
value={members ? members.length : 0}
label={t('Members')}
valueTestId="members-count-stat"
/>
<Stat
value={stats ? stats.totalGamesPlayed : 0}
label={t('Total games')}
tooltip={t('Total number of games this team has participated in')}
valueTestId="total-games-stat"
/>
<StatSectionSeparator />
<Stat
value={
stats
? formatNumberRounded(
new BigNumber(stats.totalQuantumVolume || 0),
'1e3'
)
: 0
}
label={t('Total volume')}
valueTestId="total-volume-stat"
/>
<Stat
value={
stats
? formatNumberRounded(
new BigNumber(stats.totalQuantumRewards || 0),
'1e3'
)
: 0
}
label={t('Rewards paid out')}
tooltip={'Total amount of rewards paid out to this team in qUSD'}
valueTestId="rewards-paid-stat"
/>
</StatList>
</StatSection>
{games && games.length ? (
<StatSection>
<FavoriteGame games={games} />
<StatSectionSeparator />
<LatestResults games={games} />
</StatSection>
) : null}
</>
);
};
const LatestResults = ({ games }: { games: TeamGame[] }) => {
const t = useT();
const latestGames = games.slice(0, 5);
return (
<dl className="flex flex-col gap-1">
<dt className="text-muted text-sm">
{t('gameCount', { count: latestGames.length })}
</dt>
<dd className="flex gap-1">
{latestGames.map((game) => {
return (
<Pill key={game.id} className="text-sm">
{t('place', { count: game.team.rank, ordinal: true })}
</Pill>
);
})}
</dd>
</dl>
);
};
const FavoriteGame = ({ games }: { games: TeamGame[] }) => {
const t = useT();
const rewardMetrics = games.map(
(game) => game.team.rewardMetric as DispatchMetric
);
const count = countBy(rewardMetrics);
let favoriteMetric = '';
let mostOccurances = 0;
for (const key in count) {
if (count[key] > mostOccurances) {
favoriteMetric = key;
mostOccurances = count[key];
}
}
if (!favoriteMetric) return null;
// rewardMetric is a string, should be typed as DispatchMetric
const favoriteMetricLabel =
DispatchMetricLabels[favoriteMetric as DispatchMetric];
return (
<dl className="flex flex-col gap-1">
<dt className="text-muted text-sm">{t('Favorite game')}</dt>
<dd>
<Pill className="inline-flex items-center gap-1 bg-transparent text-sm">
<VegaIcon
name={VegaIconNames.STAR}
className="text-vega-yellow-400 relative top-[-1px]"
/>{' '}
{favoriteMetricLabel}
</Pill>
</dd>
</dl>
);
};
const StatSection = ({ children }: { children: ReactNode }) => {
return (
<section className="flex flex-col lg:flex-row gap-4 lg:gap-8">
{children}
</section>
);
};
const StatSectionSeparator = () => {
return <div className="hidden md:block border-r border-default" />;
};
const StatList = ({ children }: { children: ReactNode }) => {
return (
<dl className="grid grid-cols-2 md:flex gap-4 md:gap-6 lg:gap-8 whitespace-nowrap">
{children}
</dl>
);
};
const Stat = ({
value,
label,
tooltip,
valueTestId,
}: {
value: ReactNode;
label: ReactNode;
tooltip?: string;
valueTestId?: string;
}) => {
return (
<div>
<dd className="text-3xl lg:text-4xl" data-testid={valueTestId}>
{value}
</dd>
<dt className="text-sm text-muted">
{tooltip ? (
<Tooltip description={tooltip} underline={false}>
<span className="flex items-center gap-2">
{label}
<VegaIcon name={VegaIconNames.INFO} size={12} />
</span>
</Tooltip>
) : (
label
)}
</dt>
</div>
);
};
@@ -0,0 +1,2 @@
export { LayoutWithSky } from './layout-with-sky';
export { LayoutWithGradient } from './layout-with-gradient';
@@ -0,0 +1,14 @@
import { type ReactNode } from 'react';
export const LayoutWithGradient = ({ children }: { children: ReactNode }) => {
return (
<div className="relative h-full pt-5 overflow-y-auto">
<div className="absolute top-0 left-0 w-full h-[40%] -z-10 bg-[40%_0px] bg-cover bg-no-repeat bg-local bg-[url(/cover.png)]">
<div className="absolute top-o left-0 w-full h-full bg-gradient-to-t from-white dark:from-vega-cdark-900 to-transparent from-20% to-60%" />
</div>
<div className="flex flex-col gap-4 lg:gap-6 container p-4 mx-auto">
{children}
</div>
</div>
);
};
@@ -1,10 +1,12 @@
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { SKY_BACKGROUND } from './constants';
import { Outlet } from 'react-router-dom';
import { TinyScroll } from '@vegaprotocol/ui-toolkit';
export const Layout = ({
export const SKY_BACKGROUND =
'bg-[url(/sky-light.png)] dark:bg-[url(/sky-dark.png)] bg-[37%_0px] bg-[length:1440px] bg-no-repeat bg-local';
const Layout = ({
className,
children,
...props
+2 -1
View File
@@ -1 +1,2 @@
export * from './layout-with-sidebar';
export { LayoutWithSidebar } from './layout-with-sidebar';
export { LayoutCentered } from './layout-centered';
@@ -204,6 +204,13 @@ const NavbarMenu = ({ onClick }: { onClick: () => void }) => {
{t('Portfolio')}
</NavbarLink>
</NavbarItem>
{featureFlags.TEAM_COMPETITION && (
<NavbarItem>
<NavbarLink to={Links.COMPETITIONS()} onClick={onClick}>
{t('Competitions')}
</NavbarLink>
</NavbarItem>
)}
{featureFlags.REFERRALS && (
<NavbarItem>
<NavbarLink end={false} to={Links.REFERRALS()} onClick={onClick}>
@@ -0,0 +1 @@
export { RainbowButton } from './rainbow-button';
@@ -0,0 +1,35 @@
import classNames from 'classnames';
import { type ButtonHTMLAttributes } from 'react';
type RainbowButtonProps = {
variant?: 'full' | 'border';
};
export const RainbowButton = ({
variant = 'full',
children,
className,
...props
}: RainbowButtonProps & ButtonHTMLAttributes<HTMLButtonElement>) => (
<button
className={classNames(
'bg-rainbow rounded-lg overflow-hidden disabled:opacity-40',
'hover:bg-rainbow-180 hover:animate-spin-rainbow',
{
'px-5 py-3 text-white': variant === 'full',
'p-[0.125rem]': variant === 'border',
},
className
)}
{...props}
>
<div
className={classNames({
'bg-vega-clight-800 dark:bg-vega-cdark-800 text-black dark:text-white px-5 py-3 rounded-[0.35rem] overflow-hidden':
variant === 'border',
})}
>
{children}
</div>
</button>
);
@@ -105,7 +105,7 @@ describe('ActiveRewards', () => {
expect(
screen.getByText(/Liquidity provision fees received/i)
).toBeInTheDocument();
expect(screen.getByText('Entity scope')).toBeInTheDocument();
expect(screen.getByText('Individual scope')).toBeInTheDocument();
expect(screen.getByText('Average position')).toBeInTheDocument();
expect(screen.getByText('Ends in')).toBeInTheDocument();
expect(screen.getByText('115431 epochs')).toBeInTheDocument();
@@ -12,6 +12,7 @@ import {
VegaIconNames,
TradingInput,
TinyScroll,
truncateMiddle,
} from '@vegaprotocol/ui-toolkit';
import { IconNames } from '@blueprintjs/icons';
import {
@@ -30,6 +31,9 @@ import {
DispatchMetricLabels,
EntityScopeLabelMapping,
MarketState,
type DispatchStrategy,
IndividualScopeMapping,
IndividualScopeDescriptionMapping,
} from '@vegaprotocol/types';
import { Card } from '../card/card';
import { useMemo, useState } from 'react';
@@ -308,6 +312,9 @@ export const ActiveRewardCard = ({
MarketState.STATE_CLOSED,
].includes(m.state)
);
if (marketSettled) {
return null;
}
const assetInSettledMarket =
allMarkets &&
@@ -326,10 +333,6 @@ export const ActiveRewardCard = ({
return false;
});
if (marketSettled) {
return null;
}
// Gray out the cards that are related to suspended markets
const suspended = transferNode.markets?.some(
(m) =>
@@ -359,6 +362,7 @@ export const ActiveRewardCard = ({
: getGradientClasses(dispatchStrategy.dispatchMetric);
const entityScope = dispatchStrategy.entityScope;
return (
<div>
<div
@@ -474,83 +478,127 @@ export const ActiveRewardCard = ({
</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>
{kind.dispatchStrategy && (
<RewardRequirements
dispatchStrategy={kind.dispatchStrategy}
assetDecimalPlaces={transfer.asset?.decimals}
/>
)}
</div>
</div>
</div>
);
};
const RewardRequirements = ({
dispatchStrategy,
assetDecimalPlaces = 0,
}: {
dispatchStrategy: DispatchStrategy;
assetDecimalPlaces: number | undefined;
}) => {
const t = useT();
return (
<dl className="flex justify-between flex-wrap items-center gap-3 text-xs">
<div className="flex flex-col gap-1">
<dt className="flex items-center gap-1 text-muted">
{t('{{entity}} scope', {
entity: EntityScopeLabelMapping[dispatchStrategy.entityScope],
})}
</dt>
<dd className="flex items-center gap-1">
<RewardEntityScope dispatchStrategy={dispatchStrategy} />
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="flex items-center gap-1 text-muted">
{t('Staked VEGA')}
</dt>
<dd className="flex items-center gap-1">
{addDecimalsFormatNumber(
dispatchStrategy?.stakingRequirement || 0,
assetDecimalPlaces
)}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="flex items-center gap-1 text-muted">
{t('Average position')}
</dt>
<dd className="flex items-center gap-1">
{addDecimalsFormatNumber(
dispatchStrategy?.notionalTimeWeightedAveragePositionRequirement ||
0,
assetDecimalPlaces
)}
</dd>
</div>
</dl>
);
};
const RewardEntityScope = ({
dispatchStrategy,
}: {
dispatchStrategy: DispatchStrategy;
}) => {
const t = useT();
if (dispatchStrategy.entityScope === EntityScope.ENTITY_SCOPE_TEAMS) {
return (
<Tooltip
description={
dispatchStrategy.teamScope?.length ? (
<div className="text-xs">
<p className="mb-1">{t('Eligible teams')}</p>
<ul>
{dispatchStrategy.teamScope.map((teamId) => {
if (!teamId) return null;
return <li key={teamId}>{truncateMiddle(teamId)}</li>;
})}
</ul>
</div>
) : (
t('All teams are eligible')
)
}
>
<span>
{dispatchStrategy.teamScope?.length
? t('Some teams')
: t('All teams')}
</span>
</Tooltip>
);
}
if (
dispatchStrategy.entityScope === EntityScope.ENTITY_SCOPE_INDIVIDUALS &&
dispatchStrategy.individualScope
) {
return (
<Tooltip
description={
IndividualScopeDescriptionMapping[dispatchStrategy.individualScope]
}
>
<span>{IndividualScopeMapping[dispatchStrategy.individualScope]}</span>
</Tooltip>
);
}
return null;
};
const getGradientClasses = (d: DispatchMetric | undefined) => {
switch (d) {
case DispatchMetric.DISPATCH_METRIC_AVERAGE_POSITION:
+22 -6
View File
@@ -11,14 +11,21 @@ type TableColumnDefinition = {
name: string;
tooltip?: string;
className?: string;
headerClassName?: string;
testId?: string;
};
type DataEntry = {
[key: TableColumnDefinition['name']]: ReactNode;
className?: string;
};
type TableProps = {
columns: TableColumnDefinition[];
data: Record<TableColumnDefinition['name'] | 'className', React.ReactNode>[];
data: DataEntry[];
noHeader?: boolean;
noCollapse?: boolean;
onRowClick?: (index: number) => void;
};
const INNER_BORDER_STYLE = `border-b ${BORDER_COLOR}`;
@@ -34,6 +41,7 @@ export const Table = forwardRef<
noHeader = false,
noCollapse = false,
className,
onRowClick,
...props
},
ref
@@ -41,13 +49,14 @@ export const Table = forwardRef<
const header = (
<thead className={classNames({ 'max-md:hidden': !noCollapse })}>
<tr>
{columns.map(({ displayName, name, tooltip }) => (
{columns.map(({ displayName, name, tooltip, headerClassName }) => (
<th
key={name}
col-id={name}
className={classNames(
'px-5 py-3 text-xs text-vega-clight-100 dark:text-vega-cdark-100 font-normal',
INNER_BORDER_STYLE
INNER_BORDER_STYLE,
headerClassName
)}
>
<span className="flex flex-row items-center gap-2">
@@ -79,12 +88,17 @@ export const Table = forwardRef<
>
{!noHeader && header}
<tbody>
{data.map((d, i) => (
{data.map((dataEntry, i) => (
<tr
key={i}
className={classNames(d['className'] as string, {
className={classNames(dataEntry['className'] as string, {
'max-md:flex flex-col w-full': !noCollapse,
})}
onClick={() => {
if (onRowClick) {
onRowClick(i);
}
}}
>
{columns.map(({ name, displayName, className, testId }, j) => (
<td
@@ -114,7 +128,9 @@ export const Table = forwardRef<
{displayName}
</span>
)}
<span data-testid={`${testId || name}-${i}`}>{d[name]}</span>
<span data-testid={`${testId || name}-${i}`}>
{dataEntry[name]}
</span>
</td>
))}
</tr>
+1 -1
View File
@@ -1,3 +1,3 @@
CONSOLE_IMAGE_NAME=vegaprotocol/trading:latest
VEGA_VERSION=v0.74.0-preview.7
VEGA_VERSION=v0.74.0-preview.8
LOCAL_SERVER=false
@@ -38,7 +38,7 @@ def test_switch_cross_isolated_margin(
expect(page.get_by_test_id("toast-content")).to_have_text(
"ConfirmedYour transaction has been confirmedView in block explorerUpdate margin modeBTC:DAI_2023Isolated margin mode, leverage: 1.0x")
expect(page.locator(margin_row).nth(1)
).to_have_text("11,109.99996Isolated1.0x")
).to_have_text("22,109.99996Isolated1.0x")
# tbd - tooltip is not visible without this wait
page.wait_for_timeout(1000)
page.get_by_test_id(tab_positions).get_by_text("Isolated").hover()
@@ -23,7 +23,6 @@ def continuous_market(vega):
return setup_continuous_market(vega)
@pytest.mark.skip("marked id issue #5681")
@pytest.mark.usefixtures("auth", "risk_accepted")
def test_should_display_info_and_button_for_deposit(continuous_market, page: Page):
page.goto(f"/#/markets/{continuous_market}")
+201
View File
@@ -0,0 +1,201 @@
import pytest
from playwright.sync_api import expect, Page
import vega_sim.proto.vega as vega_protos
from vega_sim.null_service import VegaServiceNull
from conftest import init_vega
from actions.utils import next_epoch
from fixtures.market import setup_continuous_market
from conftest import auth_setup, init_page, init_vega, risk_accepted_setup
from wallet_config import PARTY_A, PARTY_B, PARTY_C, PARTY_D, MM_WALLET
@pytest.fixture(scope="module")
def vega(request):
with init_vega(request) as vega:
yield vega
@pytest.fixture(scope="module")
def team_page(vega, browser, request, setup_teams_and_games):
with init_page(vega, browser, request) as page:
risk_accepted_setup(page)
auth_setup(vega, page)
team_id = setup_teams_and_games["team_id"]
page.goto(f"/#/competitions/teams/{team_id}")
yield page
@pytest.fixture(scope="module")
def setup_teams_and_games(vega: VegaServiceNull):
tDAI_market = setup_continuous_market(vega)
tDAI_asset_id = vega.find_asset_id(symbol="tDAI")
vega.mint(key_name=PARTY_B.name, asset=tDAI_asset_id, amount=100000)
vega.mint(key_name=PARTY_C.name, asset=tDAI_asset_id, amount=100000)
vega.mint(key_name=PARTY_A.name, asset=tDAI_asset_id, amount=100000)
vega.mint(key_name=PARTY_D.name, asset=tDAI_asset_id, amount=100000)
next_epoch(vega=vega)
tDAI_asset_id = vega.find_asset_id(symbol="tDAI")
vega.update_network_parameter(
MM_WALLET.name, parameter="reward.asset", new_value=tDAI_asset_id
)
next_epoch(vega=vega)
team_name = create_team(vega)
next_epoch(vega)
teams = vega.list_teams()
# list_teams actually returns a dictionary {"team_id": Team}
team_id = list(teams.keys())[0]
vega.apply_referral_code(PARTY_B.name, team_id)
# go to next epoch so we can check joinedAt and joinedAtEpoch appropriately
next_epoch(vega)
vega.apply_referral_code(PARTY_C.name, team_id)
next_epoch(vega)
vega.apply_referral_code(PARTY_D.name, team_id)
vega.wait_fn(1)
vega.wait_for_total_catchup()
current_epoch = vega.statistics().epoch_seq
game_start = current_epoch + 1
game_end = current_epoch + 11
current_epoch = vega.statistics().epoch_seq
print(f"[EPOCH: {current_epoch}] creating recurring transfer")
print(f"Game start: {game_start}")
print(f"Game game end: {game_end}")
vega.recurring_transfer(
from_key_name=PARTY_A.name,
from_account_type=vega_protos.vega.ACCOUNT_TYPE_GENERAL,
to_account_type=vega_protos.vega.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES,
asset=tDAI_asset_id,
reference="reward",
asset_for_metric=tDAI_asset_id,
metric=vega_protos.vega.DISPATCH_METRIC_MAKER_FEES_PAID,
entity_scope=vega_protos.vega.ENTITY_SCOPE_TEAMS,
n_top_performers=1,
amount=100,
factor=1.0,
start_epoch=game_start,
end_epoch=game_end,
window_length=10
)
next_epoch(vega)
print(f"[EPOCH: {vega.statistics().epoch_seq}] starting order activity")
# Team statistics will only return data when team has been active
# for DEFAULT_AGGREGATION_EPOCHS epochs
#
# https://vegaprotocol.slack.com/archives/C02KVKMAE82/p1706635625851769?thread_ts=1706631542.576449&cid=C02KVKMAE82
# Create trading activity for 10 epochs (which is the default)
for i in range(10):
vega.submit_order(
trading_key=PARTY_B.name,
market_id=tDAI_market,
order_type="TYPE_MARKET",
time_in_force="TIME_IN_FORCE_IOC",
side="SIDE_BUY",
volume=1,
)
vega.submit_order(
trading_key=PARTY_A.name,
market_id=tDAI_market,
order_type="TYPE_MARKET",
time_in_force="TIME_IN_FORCE_IOC",
side="SIDE_BUY",
volume=1,
)
next_epoch(vega)
print(f"[EPOCH: {vega.statistics().epoch_seq}] {i} epoch passed")
return {
"market_id": tDAI_market,
"asset_id": tDAI_asset_id,
"team_id": team_id,
"team_name": team_name,
}
def create_team(vega: VegaServiceNull):
team_name = "Foobar"
vega.create_referral_set(
key_name=PARTY_A.name,
name=team_name,
team_url="https://vega.xyz",
avatar_url="http://placekitten.com/200/200",
closed=False,
)
return team_name
def test_team_page_games_table(team_page: Page):
team_page.pause()
team_page.get_by_test_id("games-toggle").click()
expect(team_page.get_by_test_id("games-toggle")).to_have_text("Games (1)")
expect(team_page.get_by_test_id("rank-0")).to_have_text("1")
expect(team_page.get_by_test_id("epoch-0")).to_have_text("18")
expect(team_page.get_by_test_id("type-0")).to_have_text("Price maker fees paid")
expect(team_page.get_by_test_id("amount-0")).to_have_text("100,000,000")
expect(team_page.get_by_test_id("participatingTeams-0")).to_have_text(
"1"
)
expect(team_page.get_by_test_id("participatingMembers-0")).to_have_text(
"2"
)
def test_team_page_members_table(team_page: Page):
team_page.get_by_test_id("members-toggle").click()
expect(team_page.get_by_test_id("members-toggle")).to_have_text("Members (4)")
expect(team_page.get_by_test_id("referee-0")).to_be_visible()
expect(team_page.get_by_test_id("joinedAt-0")).to_be_visible()
expect(team_page.get_by_test_id("joinedAtEpoch-0")).to_have_text("8")
def test_team_page_headline(team_page: Page, setup_teams_and_games
):
team_name = setup_teams_and_games["team_name"]
expect(team_page.get_by_test_id("team-name")).to_have_text(team_name)
expect(team_page.get_by_test_id("members-count-stat")).to_have_text("4")
expect(team_page.get_by_test_id("total-games-stat")).to_have_text(
"1"
)
# TODO this still seems wrong as its always 0
expect(team_page.get_by_test_id("total-volume-stat")).to_have_text(
"0"
)
expect(team_page.get_by_test_id("rewards-paid-stat")).to_have_text(
"100m"
)
@pytest.fixture(scope="module")
def competitions_page(vega, browser, request):
with init_page(vega, browser, request) as page:
risk_accepted_setup(page)
auth_setup(vega, page)
yield page
def test_leaderboard(competitions_page: Page, setup_teams_and_games):
team_name = setup_teams_and_games["team_name"]
competitions_page.goto(f"/#/competitions/")
expect(competitions_page.get_by_test_id("rank-0").locator(".text-yellow-300")).to_have_count(1)
expect(competitions_page.get_by_test_id("team-0")).to_have_text(team_name)
expect(competitions_page.get_by_test_id("status-0")).to_have_text("Open")
expect(competitions_page.get_by_test_id("earned-0")).to_have_text("100,000,000")
expect(competitions_page.get_by_test_id("games-0")).to_have_text("1")
# TODO still odd that this is 0
expect(competitions_page.get_by_test_id("volume-0")).to_have_text("-")
#TODO def test_games(competitions_page: Page):
#TODO currently no games appear which i think is a bug
+107
View File
@@ -0,0 +1,107 @@
fragment TeamFields on Team {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
allowList
}
fragment TeamStatsFields on TeamStatistics {
teamId
totalQuantumVolume
totalQuantumRewards
totalGamesPlayed
quantumRewards {
epoch
totalQuantumRewards
}
gamesPlayed
}
fragment TeamRefereeFields on TeamReferee {
teamId
referee
joinedAt
joinedAtEpoch
}
fragment TeamEntity on TeamGameEntity {
rank
volume
rewardMetric
rewardEarned
totalRewardsEarned
team {
teamId
}
}
fragment TeamGameFields on Game {
id
epoch
numberOfParticipants
entities {
... on TeamGameEntity {
...TeamEntity
}
}
}
fragment TeamMemberStatsFields on TeamMemberStatistics {
partyId
totalQuantumVolume
totalQuantumRewards
totalGamesPlayed
}
query Team($teamId: ID!, $partyId: ID, $aggregationEpochs: Int) {
teams(teamId: $teamId) {
edges {
node {
...TeamFields
}
}
}
partyTeams: teams(partyId: $partyId) {
edges {
node {
...TeamFields
}
}
}
teamsStatistics(teamId: $teamId, aggregationEpochs: $aggregationEpochs) {
edges {
node {
...TeamStatsFields
}
}
}
teamReferees(teamId: $teamId) {
edges {
node {
...TeamRefereeFields
}
}
}
games(entityScope: ENTITY_SCOPE_TEAMS) {
edges {
node {
...TeamGameFields
}
}
}
teamMembersStatistics(
teamId: $teamId
aggregationEpochs: $aggregationEpochs
) {
edges {
node {
...TeamMemberStatsFields
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
query Teams($teamId: ID, $partyId: ID) {
teams(teamId: $teamId, partyId: $partyId) {
edges {
node {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
}
}
}
}
@@ -0,0 +1,13 @@
query TeamsStatistics($teamId: ID, $aggregationEpochs: Int) {
teamsStatistics(teamId: $teamId, aggregationEpochs: $aggregationEpochs) {
edges {
node {
teamId
totalQuantumVolume
totalQuantumRewards
totalGamesPlayed
gamesPlayed
}
}
}
}
+172
View File
@@ -0,0 +1,172 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type TeamFieldsFragment = { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean, allowList: Array<string> };
export type TeamStatsFieldsFragment = { __typename?: 'TeamStatistics', teamId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number, gamesPlayed: Array<string>, quantumRewards: Array<{ __typename?: 'QuantumRewardsPerEpoch', epoch: number, totalQuantumRewards: string }> };
export type TeamRefereeFieldsFragment = { __typename?: 'TeamReferee', teamId: string, referee: string, joinedAt: any, joinedAtEpoch: number };
export type TeamEntityFragment = { __typename?: 'TeamGameEntity', rank: number, volume: string, rewardMetric: Types.DispatchMetric, rewardEarned: string, totalRewardsEarned: string, team: { __typename?: 'TeamParticipation', teamId: string } };
export type TeamGameFieldsFragment = { __typename?: 'Game', id: string, epoch: number, numberOfParticipants: number, entities: Array<{ __typename?: 'IndividualGameEntity' } | { __typename?: 'TeamGameEntity', rank: number, volume: string, rewardMetric: Types.DispatchMetric, rewardEarned: string, totalRewardsEarned: string, team: { __typename?: 'TeamParticipation', teamId: string } }> };
export type TeamMemberStatsFieldsFragment = { __typename?: 'TeamMemberStatistics', partyId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number };
export type TeamQueryVariables = Types.Exact<{
teamId: Types.Scalars['ID'];
partyId?: Types.InputMaybe<Types.Scalars['ID']>;
aggregationEpochs?: Types.InputMaybe<Types.Scalars['Int']>;
}>;
export type TeamQuery = { __typename?: 'Query', teams?: { __typename?: 'TeamConnection', edges: Array<{ __typename?: 'TeamEdge', node: { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean, allowList: Array<string> } }> } | null, partyTeams?: { __typename?: 'TeamConnection', edges: Array<{ __typename?: 'TeamEdge', node: { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean, allowList: Array<string> } }> } | null, teamsStatistics?: { __typename?: 'TeamsStatisticsConnection', edges: Array<{ __typename?: 'TeamStatisticsEdge', node: { __typename?: 'TeamStatistics', teamId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number, gamesPlayed: Array<string>, quantumRewards: Array<{ __typename?: 'QuantumRewardsPerEpoch', epoch: number, totalQuantumRewards: string }> } }> } | null, teamReferees?: { __typename?: 'TeamRefereeConnection', edges: Array<{ __typename?: 'TeamRefereeEdge', node: { __typename?: 'TeamReferee', teamId: string, referee: string, joinedAt: any, joinedAtEpoch: number } }> } | null, games: { __typename?: 'GamesConnection', edges?: Array<{ __typename?: 'GameEdge', node: { __typename?: 'Game', id: string, epoch: number, numberOfParticipants: number, entities: Array<{ __typename?: 'IndividualGameEntity' } | { __typename?: 'TeamGameEntity', rank: number, volume: string, rewardMetric: Types.DispatchMetric, rewardEarned: string, totalRewardsEarned: string, team: { __typename?: 'TeamParticipation', teamId: string } }> } } | null> | null }, teamMembersStatistics?: { __typename?: 'TeamMembersStatisticsConnection', edges: Array<{ __typename?: 'TeamMemberStatisticsEdge', node: { __typename?: 'TeamMemberStatistics', partyId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number } }> } | null };
export const TeamFieldsFragmentDoc = gql`
fragment TeamFields on Team {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
allowList
}
`;
export const TeamStatsFieldsFragmentDoc = gql`
fragment TeamStatsFields on TeamStatistics {
teamId
totalQuantumVolume
totalQuantumRewards
totalGamesPlayed
quantumRewards {
epoch
totalQuantumRewards
}
gamesPlayed
}
`;
export const TeamRefereeFieldsFragmentDoc = gql`
fragment TeamRefereeFields on TeamReferee {
teamId
referee
joinedAt
joinedAtEpoch
}
`;
export const TeamEntityFragmentDoc = gql`
fragment TeamEntity on TeamGameEntity {
rank
volume
rewardMetric
rewardEarned
totalRewardsEarned
team {
teamId
}
}
`;
export const TeamGameFieldsFragmentDoc = gql`
fragment TeamGameFields on Game {
id
epoch
numberOfParticipants
entities {
... on TeamGameEntity {
...TeamEntity
}
}
}
${TeamEntityFragmentDoc}`;
export const TeamMemberStatsFieldsFragmentDoc = gql`
fragment TeamMemberStatsFields on TeamMemberStatistics {
partyId
totalQuantumVolume
totalQuantumRewards
totalGamesPlayed
}
`;
export const TeamDocument = gql`
query Team($teamId: ID!, $partyId: ID, $aggregationEpochs: Int) {
teams(teamId: $teamId) {
edges {
node {
...TeamFields
}
}
}
partyTeams: teams(partyId: $partyId) {
edges {
node {
...TeamFields
}
}
}
teamsStatistics(teamId: $teamId, aggregationEpochs: $aggregationEpochs) {
edges {
node {
...TeamStatsFields
}
}
}
teamReferees(teamId: $teamId) {
edges {
node {
...TeamRefereeFields
}
}
}
games(entityScope: ENTITY_SCOPE_TEAMS) {
edges {
node {
...TeamGameFields
}
}
}
teamMembersStatistics(teamId: $teamId, aggregationEpochs: $aggregationEpochs) {
edges {
node {
...TeamMemberStatsFields
}
}
}
}
${TeamFieldsFragmentDoc}
${TeamStatsFieldsFragmentDoc}
${TeamRefereeFieldsFragmentDoc}
${TeamGameFieldsFragmentDoc}
${TeamMemberStatsFieldsFragmentDoc}`;
/**
* __useTeamQuery__
*
* To run a query within a React component, call `useTeamQuery` and pass it any options that fit your needs.
* When your component renders, `useTeamQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useTeamQuery({
* variables: {
* teamId: // value for 'teamId'
* partyId: // value for 'partyId'
* aggregationEpochs: // value for 'aggregationEpochs'
* },
* });
*/
export function useTeamQuery(baseOptions: Apollo.QueryHookOptions<TeamQuery, TeamQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<TeamQuery, TeamQueryVariables>(TeamDocument, options);
}
export function useTeamLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<TeamQuery, TeamQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<TeamQuery, TeamQueryVariables>(TeamDocument, options);
}
export type TeamQueryHookResult = ReturnType<typeof useTeamQuery>;
export type TeamLazyQueryHookResult = ReturnType<typeof useTeamLazyQuery>;
export type TeamQueryResult = Apollo.QueryResult<TeamQuery, TeamQueryVariables>;
+61
View File
@@ -0,0 +1,61 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type TeamsQueryVariables = Types.Exact<{
teamId?: Types.InputMaybe<Types.Scalars['ID']>;
partyId?: Types.InputMaybe<Types.Scalars['ID']>;
}>;
export type TeamsQuery = { __typename?: 'Query', teams?: { __typename?: 'TeamConnection', edges: Array<{ __typename?: 'TeamEdge', node: { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean } }> } | null };
export const TeamsDocument = gql`
query Teams($teamId: ID, $partyId: ID) {
teams(teamId: $teamId, partyId: $partyId) {
edges {
node {
teamId
referrer
name
teamUrl
avatarUrl
createdAt
createdAtEpoch
closed
}
}
}
}
`;
/**
* __useTeamsQuery__
*
* To run a query within a React component, call `useTeamsQuery` and pass it any options that fit your needs.
* When your component renders, `useTeamsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useTeamsQuery({
* variables: {
* teamId: // value for 'teamId'
* partyId: // value for 'partyId'
* },
* });
*/
export function useTeamsQuery(baseOptions?: Apollo.QueryHookOptions<TeamsQuery, TeamsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<TeamsQuery, TeamsQueryVariables>(TeamsDocument, options);
}
export function useTeamsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<TeamsQuery, TeamsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<TeamsQuery, TeamsQueryVariables>(TeamsDocument, options);
}
export type TeamsQueryHookResult = ReturnType<typeof useTeamsQuery>;
export type TeamsLazyQueryHookResult = ReturnType<typeof useTeamsLazyQuery>;
export type TeamsQueryResult = Apollo.QueryResult<TeamsQuery, TeamsQueryVariables>;
+58
View File
@@ -0,0 +1,58 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type TeamsStatisticsQueryVariables = Types.Exact<{
teamId?: Types.InputMaybe<Types.Scalars['ID']>;
aggregationEpochs?: Types.InputMaybe<Types.Scalars['Int']>;
}>;
export type TeamsStatisticsQuery = { __typename?: 'Query', teamsStatistics?: { __typename?: 'TeamsStatisticsConnection', edges: Array<{ __typename?: 'TeamStatisticsEdge', node: { __typename?: 'TeamStatistics', teamId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number, gamesPlayed: Array<string> } }> } | null };
export const TeamsStatisticsDocument = gql`
query TeamsStatistics($teamId: ID, $aggregationEpochs: Int) {
teamsStatistics(teamId: $teamId, aggregationEpochs: $aggregationEpochs) {
edges {
node {
teamId
totalQuantumVolume
totalQuantumRewards
totalGamesPlayed
gamesPlayed
}
}
}
}
`;
/**
* __useTeamsStatisticsQuery__
*
* To run a query within a React component, call `useTeamsStatisticsQuery` and pass it any options that fit your needs.
* When your component renders, `useTeamsStatisticsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useTeamsStatisticsQuery({
* variables: {
* teamId: // value for 'teamId'
* aggregationEpochs: // value for 'aggregationEpochs'
* },
* });
*/
export function useTeamsStatisticsQuery(baseOptions?: Apollo.QueryHookOptions<TeamsStatisticsQuery, TeamsStatisticsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<TeamsStatisticsQuery, TeamsStatisticsQueryVariables>(TeamsStatisticsDocument, options);
}
export function useTeamsStatisticsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<TeamsStatisticsQuery, TeamsStatisticsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<TeamsStatisticsQuery, TeamsStatisticsQueryVariables>(TeamsStatisticsDocument, options);
}
export type TeamsStatisticsQueryHookResult = ReturnType<typeof useTeamsStatisticsQuery>;
export type TeamsStatisticsLazyQueryHookResult = ReturnType<typeof useTeamsStatisticsLazyQuery>;
export type TeamsStatisticsQueryResult = Apollo.QueryResult<TeamsStatisticsQuery, TeamsStatisticsQueryVariables>;
+48
View File
@@ -0,0 +1,48 @@
import compact from 'lodash/compact';
import { useActiveRewardsQuery } from '../../components/rewards-container/__generated__/Rewards';
import { isActiveReward } from '../../components/rewards-container/active-rewards';
import {
EntityScope,
IndividualScope,
type TransferNode,
} from '@vegaprotocol/types';
const isScopedToTeams = (node: TransferNode) =>
node.transfer.kind.__typename === 'RecurringTransfer' &&
// scoped to teams
(node.transfer.kind.dispatchStrategy?.entityScope ===
EntityScope.ENTITY_SCOPE_TEAMS ||
// or to individuals
(node.transfer.kind.dispatchStrategy?.entityScope ===
EntityScope.ENTITY_SCOPE_INDIVIDUALS &&
// but they have to be in a team
node.transfer.kind.dispatchStrategy.individualScope ===
IndividualScope.INDIVIDUAL_SCOPE_IN_TEAM));
export const useGames = ({
currentEpoch,
onlyActive,
}: {
currentEpoch: number;
onlyActive: boolean;
}) => {
const { data, loading, error } = useActiveRewardsQuery({
variables: {
isReward: true,
},
fetchPolicy: 'cache-and-network',
});
const games = compact(data?.transfersConnection?.edges?.map((n) => n?.node))
.map((n) => n as TransferNode)
.filter((node) => {
const active = onlyActive ? isActiveReward(node, currentEpoch) : true;
return active && isScopedToTeams(node);
});
return {
data: games,
loading,
error,
};
};
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useMemo } from 'react';
import { titlefy } from '@vegaprotocol/utils';
import { usePageTitleStore } from '../../stores';
export const usePageTitle = (title: string | string[]) => {
const { updateTitle } = usePageTitleStore((store) => ({
updateTitle: store.updateTitle,
}));
const memotitle = useMemo(
() => titlefy(Array.isArray(title) ? title : [title]),
[title]
);
useEffect(() => {
updateTitle(memotitle);
}, [updateTitle, memotitle]);
};
@@ -0,0 +1,33 @@
import {
useSimpleTransaction,
type Options,
type CreateReferralSet,
type UpdateReferralSet,
} from '@vegaprotocol/wallet';
import { useStakeAvailable } from './use-stake-available';
/**
* Manages state for creating a referral set or team
*/
export const useReferralSetTransaction = (opts?: Options) => {
const { stakeAvailable, requiredStake, isEligible } = useStakeAvailable();
const { status, result, error, send } = useSimpleTransaction({
onSuccess: opts?.onSuccess,
onError: opts?.onError,
});
const onSubmit = (tx: CreateReferralSet | UpdateReferralSet) => {
send(tx);
};
return {
err: error ? error : null,
code: result ? result.id : null,
status,
stakeAvailable,
requiredStake,
onSubmit,
isEligible,
};
};
+112
View File
@@ -0,0 +1,112 @@
import compact from 'lodash/compact';
import orderBy from 'lodash/orderBy';
import {
useTeamQuery,
type TeamFieldsFragment,
type TeamStatsFieldsFragment,
type TeamRefereeFieldsFragment,
type TeamEntityFragment,
type TeamMemberStatsFieldsFragment,
} from './__generated__/Team';
import { DEFAULT_AGGREGATION_EPOCHS } from './use-teams';
export type Team = TeamFieldsFragment;
export type TeamStats = TeamStatsFieldsFragment;
export type Member = TeamRefereeFieldsFragment & {
isCreator: boolean;
totalGamesPlayed: number;
totalQuantumVolume: string;
totalQuantumRewards: string;
};
export type TeamEntity = TeamEntityFragment;
export type TeamGame = ReturnType<typeof useTeam>['games'][number];
export type MemberStats = TeamMemberStatsFieldsFragment;
export const useTeam = (teamId?: string, partyId?: string) => {
const queryResult = useTeamQuery({
variables: {
teamId: teamId || '',
partyId,
aggregationEpochs: DEFAULT_AGGREGATION_EPOCHS,
},
skip: !teamId,
fetchPolicy: 'cache-and-network',
});
const { data } = queryResult;
const teamEdge = data?.teams?.edges.find((e) => e.node.teamId === teamId);
const team = teamEdge?.node;
const partyTeam = data?.partyTeams?.edges?.length
? data.partyTeams.edges[0].node
: undefined;
const teamStatsEdge = data?.teamsStatistics?.edges.find(
(e) => e.node.teamId === teamId
);
const memberStats = data?.teamMembersStatistics?.edges.length
? data.teamMembersStatistics.edges.map((e) => e.node)
: [];
const members: Member[] = data?.teamReferees?.edges.length
? data.teamReferees.edges
.filter((e) => e.node.teamId === teamId)
.map((e) => {
const member = e.node;
const stats = memberStats.find((m) => m.partyId === member.referee);
return {
...member,
isCreator: false,
totalQuantumVolume: stats ? stats.totalQuantumVolume : '0',
totalQuantumRewards: stats ? stats.totalQuantumRewards : '0',
totalGamesPlayed: stats ? stats.totalGamesPlayed : 0,
};
})
: [];
if (team) {
const ownerStats = memberStats.find((m) => m.partyId === team.referrer);
members.unshift({
teamId: team.teamId,
referee: team.referrer,
joinedAt: team?.createdAt,
joinedAtEpoch: team?.createdAtEpoch,
isCreator: true,
totalQuantumVolume: ownerStats ? ownerStats.totalQuantumVolume : '0',
totalQuantumRewards: ownerStats ? ownerStats.totalQuantumRewards : '0',
totalGamesPlayed: ownerStats ? ownerStats.totalGamesPlayed : 0,
});
}
// Find games where the current team participated in
const gamesWithTeam = compact(data?.games.edges).map((edge) => {
const team = edge.node.entities.find((e) => {
if (e.__typename !== 'TeamGameEntity') return false;
if (e.team.teamId !== teamId) return false;
return true;
});
if (!team) return null;
return {
id: edge.node.id,
epoch: edge.node.epoch,
numberOfParticipants: edge.node.numberOfParticipants,
entities: edge.node.entities,
team: team as TeamEntity, // TS can't infer that all the game entities are teams
};
});
const games = orderBy(compact(gamesWithTeam), 'epoch', 'desc');
return {
...queryResult,
stats: teamStatsEdge?.node,
team,
members,
games,
partyTeam,
};
};
+46
View File
@@ -0,0 +1,46 @@
import orderBy from 'lodash/orderBy';
import { useMemo } from 'react';
import { useTeamsQuery } from './__generated__/Teams';
import { useTeamsStatisticsQuery } from './__generated__/TeamsStatistics';
import compact from 'lodash/compact';
export const DEFAULT_AGGREGATION_EPOCHS = 10;
export const useTeams = (aggregationEpochs = DEFAULT_AGGREGATION_EPOCHS) => {
const {
data: teamsData,
loading: teamsLoading,
error: teamsError,
} = useTeamsQuery({
fetchPolicy: 'cache-and-network',
});
const {
data: statsData,
loading: statsLoading,
error: statsError,
} = useTeamsStatisticsQuery({
variables: {
aggregationEpochs,
},
fetchPolicy: 'cache-and-network',
});
const teams = compact(teamsData?.teams?.edges).map((e) => e.node);
const stats = compact(statsData?.teamsStatistics?.edges).map((e) => e.node);
const data = useMemo(() => {
const data = teams.map((t) => ({
...t,
...stats.find((s) => s.teamId === t.teamId),
}));
return orderBy(data, (d) => Number(d.totalQuantumRewards || 0), 'desc');
}, [teams, stats]);
return {
data,
loading: teamsLoading && statsLoading,
error: teamsError || statsError,
};
};
+14 -2
View File
@@ -16,7 +16,12 @@ export const Routes = {
REFERRALS: '/referrals',
REFERRALS_APPLY_CODE: '/referrals/apply-code',
REFERRALS_CREATE_CODE: '/referrals/create-code',
TEAMS: '/teams',
COMPETITIONS: '/competitions',
COMPETITIONS_TEAMS: '/competitions/teams',
COMPETITIONS_TEAM: '/competitions/teams/:teamId',
COMPETITIONS_CREATE_TEAM: '/competitions/teams/create',
COMPETITIONS_CREATE_TEAM_SOLO: '/competitions/teams/create?solo=true',
COMPETITIONS_UPDATE_TEAM: '/competitions/teams/:teamId/update',
FEES: '/fees',
REWARDS: '/rewards',
} as const;
@@ -41,7 +46,14 @@ export const Links: ConsoleLinks = {
REFERRALS: () => Routes.REFERRALS,
REFERRALS_APPLY_CODE: () => Routes.REFERRALS_APPLY_CODE,
REFERRALS_CREATE_CODE: () => Routes.REFERRALS_CREATE_CODE,
TEAMS: () => Routes.TEAMS,
COMPETITIONS: () => Routes.COMPETITIONS,
COMPETITIONS_TEAMS: () => Routes.COMPETITIONS_TEAMS,
COMPETITIONS_TEAM: (teamId: string) =>
Routes.COMPETITIONS_TEAM.replace(':teamId', teamId),
COMPETITIONS_CREATE_TEAM: () => Routes.COMPETITIONS_CREATE_TEAM,
COMPETITIONS_CREATE_TEAM_SOLO: () => Routes.COMPETITIONS_CREATE_TEAM_SOLO,
COMPETITIONS_UPDATE_TEAM: (teamId: string) =>
Routes.COMPETITIONS_UPDATE_TEAM.replace(':teamId', teamId),
FEES: () => Routes.FEES,
REWARDS: () => Routes.REWARDS,
};
+38 -7
View File
@@ -2,8 +2,8 @@ import type { RouteObject } from 'react-router-dom';
import { Navigate, useRoutes } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { Loader, Splash } from '@vegaprotocol/ui-toolkit';
import { LayoutWithSidebar } from '../components/layouts';
import { LayoutCentered } from '../components/layouts/layout-centered';
import { LayoutWithSidebar, LayoutCentered } from '../components/layouts';
import { LayoutWithSky } from '../components/layouts-inner';
import { Home } from '../client-pages/home';
import { Liquidity } from '../client-pages/liquidity';
import { MarketsPage } from '../client-pages/markets';
@@ -14,9 +14,7 @@ import { Withdraw } from '../client-pages/withdraw';
import { Transfer } from '../client-pages/transfer';
import { Fees } from '../client-pages/fees';
import { Rewards } from '../client-pages/rewards';
import { Teams } from '../client-pages/teams';
import { Routes as AppRoutes } from '../lib/links';
import { LayoutWithSky } from '../client-pages/referrals/layout';
import { Referrals } from '../client-pages/referrals/referrals';
import { ReferralStatistics } from '../client-pages/referrals/referral-statistics';
import { ApplyCodeFormContainer } from '../client-pages/referrals/apply-code-form';
@@ -30,6 +28,11 @@ import { PortfolioSidebar } from '../client-pages/portfolio/portfolio-sidebar';
import { LiquiditySidebar } from '../client-pages/liquidity/liquidity-sidebar';
import { MarketsSidebar } from '../client-pages/markets/markets-sidebar';
import { useT } from '../lib/use-t';
import { CompetitionsHome } from '../client-pages/competitions/competitions-home';
import { CompetitionsTeams } from '../client-pages/competitions/competitions-teams';
import { CompetitionsTeam } from '../client-pages/competitions/competitions-team';
import { CompetitionsCreateTeam } from '../client-pages/competitions/competitions-create-team';
import { CompetitionsUpdateTeam } from '../client-pages/competitions/competitions-update-team';
// These must remain dynamically imported as pennant cannot be compiled by nextjs due to ESM
// Using dynamic imports is a workaround for this until pennant is published as ESM
@@ -47,7 +50,7 @@ const NotFound = () => {
export const useRouterConfig = (): RouteObject[] => {
const featureFlags = useFeatureFlags((state) => state.flags);
return compact([
const routeConfig = compact([
{
index: true,
element: <Home />,
@@ -95,8 +98,34 @@ export const useRouterConfig = (): RouteObject[] => {
: undefined,
featureFlags.TEAM_COMPETITION
? {
path: AppRoutes.TEAMS,
element: <Teams />,
path: AppRoutes.COMPETITIONS,
element: <LayoutWithSidebar sidebar={<PortfolioSidebar />} />,
children: [
// pages with planets and stars
{
element: <LayoutWithSky />,
children: [
{ index: true, element: <CompetitionsHome /> },
{
path: AppRoutes.COMPETITIONS_TEAMS,
element: <CompetitionsTeams />,
},
],
},
// pages with blurred background
{
path: AppRoutes.COMPETITIONS_TEAM,
element: <CompetitionsTeam />,
},
{
path: AppRoutes.COMPETITIONS_CREATE_TEAM,
element: <CompetitionsCreateTeam />,
},
{
path: AppRoutes.COMPETITIONS_UPDATE_TEAM,
element: <CompetitionsUpdateTeam />,
},
],
}
: undefined,
{
@@ -189,6 +218,8 @@ export const useRouterConfig = (): RouteObject[] => {
element: <NotFound />,
},
]);
return routeConfig;
};
export const ClientRouter = () => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

@@ -1,15 +0,0 @@
import type { Account } from './accounts-data-provider';
import * as Schema from '@vegaprotocol/types';
interface Props {
accounts: Account[] | null;
marketId: string;
}
export const getMarketAccount = ({ accounts, marketId }: Props) =>
accounts?.find((account) => {
return (
account.market?.id === marketId &&
account.type === Schema.AccountType.ACCOUNT_TYPE_MARGIN
);
}) || null;
+1 -2
View File
@@ -6,8 +6,7 @@ export * from './accounts-manager';
export * from './breakdown-table';
export * from './use-account-balance';
export * from './get-settlement-account';
export * from './use-market-account-balance';
export * from './use-margin-account-balance';
export * from './__generated__/Margins';
export { MarginHealthChart } from './margin-health-chart';
export * from './margin-data-provider';
export * from './transfer-container';
@@ -83,3 +83,25 @@ export const marketMarginDataProvider = makeDerivedDataProvider<
(margin) => margin.market.id === marketId
) || null
);
export type MarginModeData = Pick<
MarginFieldsFragment,
'marginMode' | 'marginFactor'
>;
export const marginModeDataProvider = makeDerivedDataProvider<
MarginModeData,
never,
MarginsQueryVariables & { marketId: string }
>([marketMarginDataProvider], ([data], variables, previousData) =>
produce(previousData, (draft) => {
if (!data) {
return data;
}
const newData = {
marginMode: (data as MarginFieldsFragment).marginMode,
marginFactor: (data as MarginFieldsFragment).marginFactor,
};
return draft ? Object.assign(draft, newData) : newData;
})
);
@@ -1,253 +0,0 @@
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { Tooltip, ExternalLink } from '@vegaprotocol/ui-toolkit';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { marketMarginDataProvider } from './margin-data-provider';
import { useAssetsMapProvider } from '@vegaprotocol/assets';
import { useT, ns } from './use-t';
import { useAccountBalance } from './use-account-balance';
import { useMarketAccountBalance } from './use-market-account-balance';
import { Trans } from 'react-i18next';
const MarginHealthChartTooltipRow = ({
label,
value,
decimals,
href,
}: {
label: string;
value: string;
decimals: number;
href?: string;
}) => (
<>
<div
className="float-left clear-left"
key="label"
data-testid="margin-health-tooltip-label"
>
{href ? (
<ExternalLink href={href} target="_blank">
{label}
</ExternalLink>
) : (
label
)}
</div>
<div
className="float-right"
key="value"
data-testid="margin-health-tooltip-value"
>
{addDecimalsFormatNumber(value, decimals)}
</div>
</>
);
export const MarginHealthChartTooltip = ({
maintenanceLevel,
searchLevel,
initialLevel,
collateralReleaseLevel,
decimals,
marginAccountBalance,
}: {
maintenanceLevel: string;
searchLevel: string;
initialLevel: string;
collateralReleaseLevel: string;
decimals: number;
marginAccountBalance?: string;
}) => {
const t = useT();
const tooltipContent = [
<MarginHealthChartTooltipRow
key={'maintenance'}
label={t('maintenance level')}
href="https://docs.vega.xyz/testnet/concepts/trading-on-vega/positions-margin#margin-level-maintenance"
value={maintenanceLevel}
decimals={decimals}
/>,
<MarginHealthChartTooltipRow
key={'search'}
label={t('search level')}
href="https://docs.vega.xyz/testnet/concepts/trading-on-vega/positions-margin#margin-level-searching-for-collateral"
value={searchLevel}
decimals={decimals}
/>,
<MarginHealthChartTooltipRow
key={'initial'}
label={t('initial level')}
href="https://docs.vega.xyz/testnet/concepts/trading-on-vega/positions-margin#margin-level-initial"
value={initialLevel}
decimals={decimals}
/>,
<MarginHealthChartTooltipRow
key={'release'}
label={t('release level')}
href="https://docs.vega.xyz/testnet/concepts/trading-on-vega/positions-margin#margin-level-releasing-collateral"
value={collateralReleaseLevel}
decimals={decimals}
/>,
];
if (marginAccountBalance) {
const balance = (
<MarginHealthChartTooltipRow
key={'balance'}
label={t('balance')}
value={marginAccountBalance}
decimals={decimals}
/>
);
if (BigInt(marginAccountBalance) < BigInt(searchLevel)) {
tooltipContent.splice(1, 0, balance);
} else if (BigInt(marginAccountBalance) < BigInt(initialLevel)) {
tooltipContent.splice(2, 0, balance);
} else if (BigInt(marginAccountBalance) < BigInt(collateralReleaseLevel)) {
tooltipContent.splice(3, 0, balance);
} else {
tooltipContent.push(balance);
}
}
return (
<div className="overflow-hidden" data-testid="margin-health-tooltip">
{tooltipContent}
</div>
);
};
export const MarginHealthChart = ({
marketId,
assetId,
}: {
marketId: string;
assetId: string;
}) => {
const { data: assetsMap } = useAssetsMapProvider();
const { pubKey: partyId } = useVegaWallet();
const { data } = useDataProvider({
dataProvider: marketMarginDataProvider,
variables: { marketId, partyId: partyId ?? '' },
skip: !partyId,
});
const { accountBalance: rawGeneralAccountBalance } =
useAccountBalance(assetId);
const { accountBalance: rawMarginAccountBalance } =
useMarketAccountBalance(marketId);
const asset = assetsMap && assetsMap[assetId];
if (!data || !asset) {
return null;
}
const { decimals } = asset;
const collateralReleaseLevel = Number(data.collateralReleaseLevel);
const initialLevel = Number(data.initialLevel);
const maintenanceLevel = Number(data.maintenanceLevel);
const searchLevel = Number(data.searchLevel);
const marginAccountBalance = Number(rawMarginAccountBalance);
const generalAccountBalance = Number(rawGeneralAccountBalance);
const max = Math.max(
marginAccountBalance + generalAccountBalance,
collateralReleaseLevel
);
const red = maintenanceLevel / max;
const orange = (searchLevel - maintenanceLevel) / max;
const yellow = ((searchLevel + initialLevel) / 2 - searchLevel) / max;
const green = (collateralReleaseLevel - initialLevel) / max + yellow;
const balanceMarker = marginAccountBalance / max;
const tooltip = (
<MarginHealthChartTooltip
maintenanceLevel={data.maintenanceLevel}
searchLevel={data.searchLevel}
initialLevel={data.initialLevel}
collateralReleaseLevel={data.collateralReleaseLevel}
marginAccountBalance={rawMarginAccountBalance}
decimals={decimals}
/>
);
return (
<div data-testid="margin-health-chart">
<Trans
defaults="{{balance}} above <0>maintenance level</0>"
components={[
<ExternalLink href="https://docs.vega.xyz/testnet/concepts/trading-on-vega/positions-margin#margin-level-maintenance">
maintenance level
</ExternalLink>,
]}
values={{
balance: addDecimalsFormatNumber(
(
BigInt(marginAccountBalance) - BigInt(maintenanceLevel)
).toString(),
decimals
),
}}
ns={ns}
/>
<Tooltip description={tooltip}>
<div
data-testid="margin-health-chart-track"
className="relative bg-vega-green-650"
style={{
height: '6px',
marginBottom: '1px',
display: 'flex',
}}
>
<div
data-testid="margin-health-chart-red"
className="bg-vega-red-550"
style={{
height: '100%',
width: `${red * 100}%`,
}}
></div>
<div
data-testid="margin-health-chart-orange"
className="bg-vega-orange"
style={{
height: '100%',
width: `${orange * 100}%`,
}}
></div>
<div
data-testid="margin-health-chart-yellow"
className="bg-vega-yellow"
style={{
height: '100%',
width: `${yellow * 100}%`,
}}
></div>
<div
data-testid="margin-health-chart-green"
className="bg-vega-green-600"
style={{
height: '100%',
width: `${green * 100}%`,
}}
></div>
{balanceMarker > 0 && balanceMarker < 100 && (
<div
data-testid="margin-health-chart-balance"
className="absolute bg-vega-blue"
style={{
height: '8px',
width: '8px',
top: '-1px',
transform: 'translate(-4px, 0px)',
borderRadius: '50%',
border: '1px solid white',
backgroundColor: 'blue',
left: `${balanceMarker * 100}%`,
}}
></div>
)}
</div>
</Tooltip>
</div>
);
};
@@ -1,158 +0,0 @@
import {
MarginHealthChart,
MarginHealthChartTooltip,
} from './margin-health-chart';
import { act, render, screen } from '@testing-library/react';
import type { MarginFieldsFragment } from './__generated__/Margins';
import type { AssetFieldsFragment } from '@vegaprotocol/assets';
import { MarginMode } from '@vegaprotocol/types';
const asset: AssetFieldsFragment = {
id: 'assetId',
decimals: 2,
} as AssetFieldsFragment;
const margins: MarginFieldsFragment = {
asset: {
id: 'assetId',
},
collateralReleaseLevel: '1000',
initialLevel: '800',
searchLevel: '600',
maintenanceLevel: '400',
marginFactor: '',
marginMode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
orderMarginLevel: '',
market: {
id: 'marketId',
},
};
const mockGetMargins = jest.fn(() => margins);
const mockGetBalance = jest.fn(() => '0');
jest.mock('./margin-data-provider', () => ({}));
jest.mock('@vegaprotocol/assets', () => ({
useAssetsMapProvider: () => {
return {
data: {
assetId: asset,
},
};
},
}));
jest.mock('@vegaprotocol/wallet', () => ({
useVegaWallet: () => {
return {
pubKey: 'partyId',
};
},
}));
jest.mock('@vegaprotocol/data-provider', () => ({
useDataProvider: () => {
return {
data: mockGetMargins(),
};
},
}));
jest.mock('./use-account-balance', () => ({
useAccountBalance: () => {
return {
accountBalance: mockGetBalance(),
};
},
}));
jest.mock('./use-market-account-balance', () => ({
useMarketAccountBalance: () => {
return {
accountBalance: '700',
};
},
}));
describe('MarginHealthChart', () => {
it('should render correct values', async () => {
render(<MarginHealthChart marketId="marketId" assetId="assetId" />);
const chart = screen.getByTestId('margin-health-chart');
expect(chart).toHaveTextContent('3.00 above maintenance level');
const red = screen.getByTestId('margin-health-chart-red');
const orange = screen.getByTestId('margin-health-chart-orange');
const yellow = screen.getByTestId('margin-health-chart-yellow');
const green = screen.getByTestId('margin-health-chart-green');
const balance = screen.getByTestId('margin-health-chart-balance');
expect(parseInt(red.style.width)).toBe(40);
expect(parseInt(orange.style.width)).toBe(20);
expect(parseInt(yellow.style.width)).toBe(10);
expect(parseInt(green.style.width)).toBe(30);
expect(parseInt(balance.style.left)).toBe(70);
});
it('should use correct scale', async () => {
mockGetBalance.mockReturnValueOnce('1300');
await act(async () => {
render(<MarginHealthChart marketId="marketId" assetId="assetId" />);
});
await screen.findByTestId('margin-health-chart');
const red = screen.getByTestId('margin-health-chart-red');
expect(parseInt(red.style.width)).toBe(20);
});
});
describe('MarginHealthChartTooltip', () => {
it('renders correct values and labels', async () => {
await act(async () => {
render(
<MarginHealthChartTooltip
{...margins}
decimals={asset.decimals}
marginAccountBalance="500"
/>
);
});
const labels = await screen.findAllByTestId('margin-health-tooltip-label');
const expectedLabels = [
'maintenance level',
'balance',
'search level',
'initial level',
'release level',
];
labels.forEach((value, i) => {
expect(value).toHaveTextContent(expectedLabels[i]);
});
const values = await screen.findAllByTestId('margin-health-tooltip-value');
const expectedValues = ['4.00', '5.00', '6.00', '8.00', '10.00'];
values.forEach((value, i) => {
expect(value).toHaveTextContent(expectedValues[i]);
});
});
it('renders balance in correct place', async () => {
const { rerender } = render(
<MarginHealthChartTooltip
{...margins}
decimals={asset.decimals}
marginAccountBalance="700"
/>
);
let values = await screen.findAllByTestId('margin-health-tooltip-value');
expect(values[2]).toHaveTextContent('7.00');
rerender(
<MarginHealthChartTooltip
{...margins}
decimals={asset.decimals}
marginAccountBalance="900"
/>
);
values = await screen.findAllByTestId('margin-health-tooltip-value');
expect(values.length).toBe(5);
expect(values[3]).toHaveTextContent('9.00');
});
});
@@ -0,0 +1,68 @@
import { useCallback, useMemo, useState } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { accountsDataProvider } from './accounts-data-provider';
import type { Account } from './accounts-data-provider';
import { AccountType } from '@vegaprotocol/types';
export const useMarginAccountBalance = (marketId: string) => {
const { pubKey } = useVegaWallet();
const [marginAccountBalance, setMarginAccountBalance] = useState<string>('');
const [orderMarginAccountBalance, setOrderMarginAccountBalance] =
useState<string>('');
const [accountDecimals, setAccountDecimals] = useState<number | null>(null);
const update = useCallback(
({ data }: { data: Account[] | null }) => {
const marginAccount = data?.find((account) => {
return (
account.market?.id === marketId &&
account.type === AccountType.ACCOUNT_TYPE_MARGIN
);
});
const orderMarginAccount = data?.find((account) => {
return (
account.market?.id === marketId &&
account.type === AccountType.ACCOUNT_TYPE_ORDER_MARGIN
);
});
if (marginAccount?.balance) {
setMarginAccountBalance(marginAccount?.balance || '');
}
if (orderMarginAccount?.balance) {
setOrderMarginAccountBalance(orderMarginAccount?.balance || '');
}
const decimals =
orderMarginAccount?.asset.decimals || marginAccount?.asset.decimals;
if (decimals) {
setAccountDecimals(decimals);
}
return true;
},
[marketId]
);
const { loading, error } = useDataProvider({
dataProvider: accountsDataProvider,
variables: { partyId: pubKey || '' },
skip: !pubKey || !marketId,
update,
});
return useMemo(
() => ({
marginAccountBalance: pubKey ? marginAccountBalance : '',
orderMarginAccountBalance: pubKey ? orderMarginAccountBalance : '',
accountDecimals: pubKey ? accountDecimals : null,
loading,
error,
}),
[
marginAccountBalance,
orderMarginAccountBalance,
accountDecimals,
pubKey,
loading,
error,
]
);
};
@@ -1,41 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { accountsDataProvider } from './accounts-data-provider';
import type { Account } from './accounts-data-provider';
import { getMarketAccount } from './get-market-account';
export const useMarketAccountBalance = (marketId: string) => {
const { pubKey } = useVegaWallet();
const [accountBalance, setAccountBalance] = useState<string>('');
const [accountDecimals, setAccountDecimals] = useState<number | null>(null);
const update = useCallback(
({ data }: { data: Account[] | null }) => {
const account = getMarketAccount({ accounts: data, marketId });
if (account?.balance) {
setAccountBalance(account?.balance || '');
}
if (account?.asset.decimals) {
setAccountDecimals(account?.asset.decimals || null);
}
return true;
},
[marketId]
);
const { loading, error } = useDataProvider({
dataProvider: accountsDataProvider,
variables: { partyId: pubKey || '' },
skip: !pubKey || !marketId,
update,
});
return useMemo(
() => ({
accountBalance: pubKey ? accountBalance : '',
accountDecimals: pubKey ? accountDecimals : null,
loading,
error,
}),
[accountBalance, accountDecimals, pubKey, loading, error]
);
};
@@ -7,6 +7,7 @@ import { AssetsDocument, type AssetsQuery } from './__generated__/Assets';
import { AssetStatus } from '@vegaprotocol/types';
import { type Asset } from './asset-data-provider';
import { DENY_LIST } from './constants';
import { type AssetFieldsFragment } from './__generated__/Asset';
export interface BuiltinAssetSource {
__typename: 'BuiltinAsset';
@@ -89,3 +90,24 @@ export const useEnabledAssets = () => {
variables: undefined,
});
};
/** Wrapped ETH symbol */
const WETH = 'WETH';
type WETHDetails = Pick<AssetFieldsFragment, 'symbol' | 'decimals' | 'quantum'>;
/**
* Tries to find WETH asset configuration on Vega in order to provide its
* details, otherwise it returns hardcoded values.
*/
export const useWETH = (): WETHDetails => {
const { data } = useAssetsDataProvider();
if (data) {
const weth = data.find((a) => a.symbol.toUpperCase() === WETH);
if (weth) return weth;
}
return {
symbol: WETH,
decimals: 18,
quantum: '500000000000000', // 1 WETH ~= 2000 qUSD
};
};
@@ -1,47 +1,14 @@
import { useCallback, useState } from 'react';
import { getAsset, getQuoteName } from '@vegaprotocol/markets';
import { getAsset } from '@vegaprotocol/markets';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { useVegaWallet } from '@vegaprotocol/wallet';
import type { Market } from '@vegaprotocol/markets';
import type { EstimatePositionQuery } from '@vegaprotocol/positions';
import { AccountBreakdownDialog } from '@vegaprotocol/accounts';
import {
formatNumberPercentage,
formatRange,
formatValue,
} from '@vegaprotocol/utils';
import { marketMarginDataProvider } from '@vegaprotocol/accounts';
import { useDataProvider } from '@vegaprotocol/data-provider';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import * as Schema from '@vegaprotocol/types';
import {
MARGIN_DIFF_TOOLTIP_TEXT,
DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT,
TOTAL_MARGIN_AVAILABLE,
LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT,
EST_TOTAL_MARGIN_TOOLTIP_TEXT,
MARGIN_ACCOUNT_TOOLTIP_TEXT,
} from '../../constants';
import { formatNumberPercentage, formatValue } from '@vegaprotocol/utils';
import { useEstimateFees } from '../../hooks/use-estimate-fees';
import { KeyValue } from './key-value';
import {
Accordion,
AccordionChevron,
AccordionPanel,
Intent,
ExternalLink,
Pill,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames';
import { Intent, Pill } from '@vegaprotocol/ui-toolkit';
import BigNumber from 'bignumber.js';
import { FeesBreakdown } from '../fees-breakdown';
import { getTotalDiscountFactor, getDiscountedFee } from '../discounts';
import { useT, ns } from '../../use-t';
import { Trans } from 'react-i18next';
import { useT } from '../../use-t';
export const emptyValue = '-';
@@ -119,337 +86,3 @@ export const DealTicketFeeDetails = ({
/>
);
};
export interface DealTicketMarginDetailsProps {
generalAccountBalance?: string;
marginAccountBalance?: string;
market: Market;
onMarketClick?: (marketId: string, metaKey?: boolean) => void;
assetSymbol: string;
positionEstimate: EstimatePositionQuery['estimatePosition'];
side: Schema.Side;
}
export const DealTicketMarginDetails = ({
marginAccountBalance,
generalAccountBalance,
assetSymbol,
market,
onMarketClick,
positionEstimate,
side,
}: DealTicketMarginDetailsProps) => {
const t = useT();
const [breakdownDialog, setBreakdownDialog] = useState(false);
const { pubKey: partyId } = useVegaWallet();
const { data: currentMargins } = useDataProvider({
dataProvider: marketMarginDataProvider,
variables: { marketId: market.id, partyId: partyId || '' },
skip: !partyId,
});
const liquidationEstimate = positionEstimate?.liquidation;
const marginEstimate = positionEstimate?.margin;
const totalBalance =
BigInt(generalAccountBalance || '0') + BigInt(marginAccountBalance || '0');
const asset = getAsset(market);
const { decimals: assetDecimals, quantum } = asset;
let marginRequiredBestCase: string | undefined = undefined;
let marginRequiredWorstCase: string | undefined = undefined;
if (marginEstimate) {
if (currentMargins) {
marginRequiredBestCase = (
BigInt(marginEstimate.bestCase.initialLevel) -
BigInt(currentMargins.initialLevel)
).toString();
if (marginRequiredBestCase.startsWith('-')) {
marginRequiredBestCase = '0';
}
marginRequiredWorstCase = (
BigInt(marginEstimate.worstCase.initialLevel) -
BigInt(currentMargins.initialLevel)
).toString();
if (marginRequiredWorstCase.startsWith('-')) {
marginRequiredWorstCase = '0';
}
} else {
marginRequiredBestCase = marginEstimate.bestCase.initialLevel;
marginRequiredWorstCase = marginEstimate.worstCase.initialLevel;
}
}
const totalMarginAvailable = (
currentMargins
? totalBalance - BigInt(currentMargins.maintenanceLevel)
: totalBalance
).toString();
let deductionFromCollateral = null;
let projectedMargin = null;
if (marginAccountBalance) {
const deductionFromCollateralBestCase =
BigInt(marginEstimate?.bestCase.initialLevel ?? 0) -
BigInt(marginAccountBalance);
const deductionFromCollateralWorstCase =
BigInt(marginEstimate?.worstCase.initialLevel ?? 0) -
BigInt(marginAccountBalance);
deductionFromCollateral = (
<KeyValue
indent
label={t('Deduction from collateral')}
value={formatRange(
deductionFromCollateralBestCase > 0
? deductionFromCollateralBestCase.toString()
: '0',
deductionFromCollateralWorstCase > 0
? deductionFromCollateralWorstCase.toString()
: '0',
assetDecimals
)}
formattedValue={formatValue(
deductionFromCollateralWorstCase > 0
? deductionFromCollateralWorstCase.toString()
: '0',
assetDecimals,
quantum
)}
symbol={assetSymbol}
labelDescription={t(
'DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT',
DEDUCTION_FROM_COLLATERAL_TOOLTIP_TEXT,
{ assetSymbol }
)}
/>
);
projectedMargin = (
<KeyValue
label={t('Projected margin')}
value={formatRange(
marginEstimate?.bestCase.initialLevel,
marginEstimate?.worstCase.initialLevel,
assetDecimals
)}
formattedValue={formatValue(
marginEstimate?.worstCase.initialLevel,
assetDecimals,
quantum
)}
symbol={assetSymbol}
labelDescription={t(
'EST_TOTAL_MARGIN_TOOLTIP_TEXT',
EST_TOTAL_MARGIN_TOOLTIP_TEXT
)}
/>
);
}
let liquidationPriceEstimate = emptyValue;
let liquidationPriceEstimateRange = emptyValue;
if (liquidationEstimate) {
const liquidationEstimateBestCaseIncludingBuyOrders = BigInt(
liquidationEstimate.bestCase.including_buy_orders.replace(/\..*/, '')
);
const liquidationEstimateBestCaseIncludingSellOrders = BigInt(
liquidationEstimate.bestCase.including_sell_orders.replace(/\..*/, '')
);
const liquidationEstimateBestCase =
side === Schema.Side.SIDE_BUY
? liquidationEstimateBestCaseIncludingBuyOrders
: liquidationEstimateBestCaseIncludingSellOrders;
const liquidationEstimateWorstCaseIncludingBuyOrders = BigInt(
liquidationEstimate.worstCase.including_buy_orders.replace(/\..*/, '')
);
const liquidationEstimateWorstCaseIncludingSellOrders = BigInt(
liquidationEstimate.worstCase.including_sell_orders.replace(/\..*/, '')
);
const liquidationEstimateWorstCase =
side === Schema.Side.SIDE_BUY
? liquidationEstimateWorstCaseIncludingBuyOrders
: liquidationEstimateWorstCaseIncludingSellOrders;
liquidationPriceEstimate = formatValue(
liquidationEstimateWorstCase.toString(),
market.decimalPlaces,
undefined,
market.decimalPlaces
);
liquidationPriceEstimateRange = formatRange(
(liquidationEstimateBestCase < liquidationEstimateWorstCase
? liquidationEstimateBestCase
: liquidationEstimateWorstCase
).toString(),
(liquidationEstimateBestCase > liquidationEstimateWorstCase
? liquidationEstimateBestCase
: liquidationEstimateWorstCase
).toString(),
market.decimalPlaces,
undefined,
market.decimalPlaces
);
}
const onAccountBreakdownDialogClose = useCallback(
() => setBreakdownDialog(false),
[]
);
const quoteName = getQuoteName(market);
return (
<div className="flex flex-col w-full gap-2 pt-2">
<Accordion>
<AccordionPanel
itemId="margin"
trigger={
<AccordionPrimitive.Trigger
data-testid="accordion-toggle"
className={classNames(
'w-full',
'flex items-center gap-2 text-xs',
'group'
)}
>
<div
data-testid={`deal-ticket-fee-margin-required`}
key={'value-dropdown'}
className="flex items-center justify-between w-full gap-2"
>
<div className="flex items-center text-left gap-1">
<Tooltip
description={t(
'MARGIN_DIFF_TOOLTIP_TEXT',
MARGIN_DIFF_TOOLTIP_TEXT,
{ assetSymbol }
)}
>
<span className="text-muted">{t('Margin required')}</span>
</Tooltip>
<AccordionChevron size={10} />
</div>
<Tooltip
description={
formatRange(
marginRequiredBestCase,
marginRequiredWorstCase,
assetDecimals
) ?? '-'
}
>
<div className="font-mono text-right">
{formatValue(
marginRequiredWorstCase,
assetDecimals,
quantum
)}{' '}
{assetSymbol || ''}
</div>
</Tooltip>
</div>
</AccordionPrimitive.Trigger>
}
>
<div className="flex flex-col w-full gap-2">
<KeyValue
label={t('Total margin available')}
indent
value={formatValue(totalMarginAvailable, assetDecimals)}
formattedValue={formatValue(
totalMarginAvailable,
assetDecimals,
quantum
)}
symbol={assetSymbol}
labelDescription={t(
'TOTAL_MARGIN_AVAILABLE',
TOTAL_MARGIN_AVAILABLE,
{
generalAccountBalance: formatValue(
generalAccountBalance,
assetDecimals,
quantum
),
marginAccountBalance: formatValue(
marginAccountBalance,
assetDecimals,
quantum
),
marginMaintenance: formatValue(
currentMargins?.maintenanceLevel,
assetDecimals,
quantum
),
assetSymbol,
}
)}
/>
{deductionFromCollateral}
<KeyValue
label={t('Current margin allocation')}
indent
onClick={
generalAccountBalance
? () => setBreakdownDialog(true)
: undefined
}
value={formatValue(marginAccountBalance, assetDecimals)}
symbol={assetSymbol}
labelDescription={t(
'MARGIN_ACCOUNT_TOOLTIP_TEXT',
MARGIN_ACCOUNT_TOOLTIP_TEXT
)}
formattedValue={formatValue(
marginAccountBalance,
assetDecimals,
quantum
)}
/>
</div>
</AccordionPanel>
</Accordion>
{projectedMargin}
<KeyValue
label={t('Liquidation')}
value={liquidationPriceEstimateRange}
formattedValue={liquidationPriceEstimate}
symbol={quoteName}
labelDescription={
<>
<span>
{t(
'LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT',
LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT
)}
</span>{' '}
<span>
<Trans
defaults="For full details please see <0>liquidation price estimate documentation</0>."
components={[
<ExternalLink
href={
'https://github.com/vegaprotocol/specs/blob/master/non-protocol-specs/0012-NP-LIPE-liquidation-price-estimate.md'
}
>
liquidation price estimate documentation
</ExternalLink>,
]}
ns={ns}
/>
</span>
</>
}
/>
{partyId && (
<AccountBreakdownDialog
assetId={breakdownDialog ? asset.id : undefined}
partyId={partyId}
onMarketClick={onMarketClick}
onClose={onAccountBreakdownDialogClose}
/>
)}
</div>
);
};
@@ -26,12 +26,25 @@ import {
import classNames from 'classnames';
import { useT, ns } from '../../use-t';
import { Trans } from 'react-i18next';
import type { DealTicketMarginDetailsProps } from './deal-ticket-fee-details';
import type { Market } from '@vegaprotocol/markets';
import { emptyValue } from './deal-ticket-fee-details';
import type { EstimatePositionQuery } from '@vegaprotocol/positions';
export interface DealTicketMarginDetailsProps {
generalAccountBalance?: string;
marginAccountBalance?: string;
orderMarginAccountBalance?: string;
market: Market;
onMarketClick?: (marketId: string, metaKey?: boolean) => void;
assetSymbol: string;
positionEstimate: EstimatePositionQuery['estimatePosition'];
side: Schema.Side;
}
export const DealTicketMarginDetails = ({
marginAccountBalance,
generalAccountBalance,
orderMarginAccountBalance,
assetSymbol,
market,
onMarketClick,
@@ -46,33 +59,56 @@ export const DealTicketMarginDetails = ({
variables: { marketId: market.id, partyId: partyId || '' },
skip: !partyId,
});
const isInIsolatedMode =
positionEstimate?.margin.bestCase.marginMode ===
Schema.MarginMode.MARGIN_MODE_ISOLATED_MARGIN;
const liquidationEstimate = positionEstimate?.liquidation;
const marginEstimate = positionEstimate?.margin;
const totalMarginAccountBalance =
BigInt(marginAccountBalance || '0') +
BigInt(orderMarginAccountBalance || '0');
const totalBalance =
BigInt(generalAccountBalance || '0') + BigInt(marginAccountBalance || '0');
BigInt(generalAccountBalance || '0') + totalMarginAccountBalance;
const asset = getAsset(market);
const { decimals: assetDecimals, quantum } = asset;
let marginRequiredBestCase: string | undefined = undefined;
let marginRequiredWorstCase: string | undefined = undefined;
if (marginEstimate) {
const collateralIncreaseEstimateBestCase = BigInt(
positionEstimate?.collateralIncreaseEstimate.bestCase ?? '0'
);
const collateralIncreaseEstimateWorstCase = BigInt(
positionEstimate?.collateralIncreaseEstimate.worstCase ?? '0'
);
const marginEstimateBestCase = isInIsolatedMode
? totalMarginAccountBalance + collateralIncreaseEstimateBestCase
: BigInt(marginEstimate?.bestCase.initialLevel ?? 0);
const marginEstimateWorstCase = isInIsolatedMode
? totalMarginAccountBalance + collateralIncreaseEstimateWorstCase
: BigInt(marginEstimate?.worstCase.initialLevel ?? 0);
if (isInIsolatedMode) {
marginRequiredBestCase = collateralIncreaseEstimateBestCase.toString();
marginRequiredWorstCase = collateralIncreaseEstimateWorstCase.toString();
} else if (marginEstimate) {
if (currentMargins) {
const currentMargin = BigInt(currentMargins.initialLevel);
marginRequiredBestCase = (
BigInt(marginEstimate.bestCase.initialLevel) -
BigInt(currentMargins.initialLevel)
marginEstimateBestCase - currentMargin
).toString();
if (marginRequiredBestCase.startsWith('-')) {
marginRequiredBestCase = '0';
}
marginRequiredWorstCase = (
BigInt(marginEstimate.worstCase.initialLevel) -
BigInt(currentMargins.initialLevel)
marginEstimateWorstCase - currentMargin
).toString();
if (marginRequiredWorstCase.startsWith('-')) {
marginRequiredWorstCase = '0';
}
} else {
marginRequiredBestCase = marginEstimate.bestCase.initialLevel;
marginRequiredWorstCase = marginEstimate.worstCase.initialLevel;
marginRequiredBestCase = marginEstimateBestCase.toString();
marginRequiredWorstCase = marginEstimateWorstCase.toString();
}
}
@@ -84,14 +120,12 @@ export const DealTicketMarginDetails = ({
let deductionFromCollateral = null;
let projectedMargin = null;
if (marginAccountBalance) {
if (totalMarginAccountBalance) {
const deductionFromCollateralBestCase =
BigInt(marginEstimate?.bestCase.initialLevel ?? 0) -
BigInt(marginAccountBalance);
marginEstimateBestCase - totalMarginAccountBalance;
const deductionFromCollateralWorstCase =
BigInt(marginEstimate?.worstCase.initialLevel ?? 0) -
BigInt(marginAccountBalance);
marginEstimateWorstCase - totalMarginAccountBalance;
deductionFromCollateral = (
<KeyValue
@@ -125,12 +159,12 @@ export const DealTicketMarginDetails = ({
<KeyValue
label={t('Projected margin')}
value={formatRange(
marginEstimate?.bestCase.initialLevel,
marginEstimate?.worstCase.initialLevel,
marginEstimateBestCase.toString(),
marginEstimateWorstCase.toString(),
assetDecimals
)}
formattedValue={formatValue(
marginEstimate?.worstCase.initialLevel,
marginEstimateWorstCase.toString(),
assetDecimals,
quantum
)}
@@ -276,6 +310,11 @@ export const DealTicketMarginDetails = ({
assetDecimals,
quantum
),
orderMarginAccountBalance: formatValue(
orderMarginAccountBalance,
assetDecimals,
quantum
),
marginMaintenance: formatValue(
currentMargins?.maintenanceLevel,
assetDecimals,
@@ -294,14 +333,17 @@ export const DealTicketMarginDetails = ({
? () => setBreakdownDialog(true)
: undefined
}
value={formatValue(marginAccountBalance, assetDecimals)}
value={formatValue(
totalMarginAccountBalance.toString(),
assetDecimals
)}
symbol={assetSymbol}
labelDescription={t(
'MARGIN_ACCOUNT_TOOLTIP_TEXT',
MARGIN_ACCOUNT_TOOLTIP_TEXT
)}
formattedValue={formatValue(
marginAccountBalance,
totalMarginAccountBalance.toString(),
assetDecimals,
quantum
)}
@@ -58,8 +58,9 @@ import type {
} from '@vegaprotocol/markets';
import { MarginWarning } from '../deal-ticket-validation/margin-warning';
import {
useMarketAccountBalance,
useMarginAccountBalance,
useAccountBalance,
marginModeDataProvider,
} from '@vegaprotocol/accounts';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { type OrderFormValues } from '../../hooks';
@@ -166,9 +167,10 @@ export const DealTicket = ({
const asset = getAsset(market);
const {
accountBalance: marginAccountBalance,
orderMarginAccountBalance,
marginAccountBalance,
loading: loadingMarginAccountBalance,
} = useMarketAccountBalance(market.id);
} = useMarginAccountBalance(market.id);
const {
accountBalance: generalAccountBalance,
@@ -176,7 +178,9 @@ export const DealTicket = ({
} = useAccountBalance(asset.id);
const balance = (
BigInt(marginAccountBalance) + BigInt(generalAccountBalance)
BigInt(marginAccountBalance) +
BigInt(generalAccountBalance) +
BigInt(orderMarginAccountBalance)
).toString();
const { marketState, marketTradingMode } = marketData;
@@ -241,7 +245,19 @@ export const DealTicket = ({
variables: { partyId: pubKey || '', marketId: market.id },
skip: !pubKey,
});
const openVolume = useOpenVolume(pubKey, market.id) ?? '0';
const { data: margin } = useDataProvider({
dataProvider: marginModeDataProvider,
variables: { partyId: pubKey || '', marketId: market.id },
skip: !pubKey,
});
const { openVolume, averageEntryPrice } = useOpenVolume(
pubKey,
market.id
) || {
openVolume: '0',
averageEntryPrice: '0',
};
const orders = activeOrders
? activeOrders.map<Schema.OrderInfo>((order) => ({
isMarketOrder: order.type === Schema.OrderType.TYPE_MARKET,
@@ -259,21 +275,25 @@ export const DealTicket = ({
});
}
const positionEstimate = usePositionEstimate({
marketId: market.id,
openVolume,
orders,
marginAccountBalance: marginAccountBalance,
generalAccountBalance: generalAccountBalance,
orderMarginAccountBalance: '0', // TODO: Get real balance
marginMode: Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN, // TODO: unhardcode this and get users margin mode for the market
averageEntryPrice: marketPrice || '0', // TODO: This assumes the order will be entirely filled at the current market price
skip:
!normalizedOrder ||
const positionEstimate = usePositionEstimate(
{
marketId: market.id,
openVolume,
averageEntryPrice,
orders,
marginAccountBalance: marginAccountBalance || '0',
generalAccountBalance: generalAccountBalance || '0',
orderMarginAccountBalance: orderMarginAccountBalance || '0',
marginFactor: margin?.marginFactor || '1',
marginMode:
margin?.marginMode || Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN,
includeCollateralIncreaseInAvailableCollateral: true,
},
!normalizedOrder ||
(normalizedOrder.type !== Schema.OrderType.TYPE_MARKET &&
(!normalizedOrder.price || normalizedOrder.price === '0')) ||
normalizedOrder.size === '0',
});
normalizedOrder.size === '0'
);
const assetSymbol = getAsset(market).symbol;
@@ -319,7 +339,9 @@ export const DealTicket = ({
}
const hasNoBalance =
!BigInt(generalAccountBalance) && !BigInt(marginAccountBalance);
!BigInt(generalAccountBalance) &&
!BigInt(marginAccountBalance) &&
!BigInt(orderMarginAccountBalance);
if (
hasNoBalance &&
!(loadingMarginAccountBalance || loadingGeneralAccountBalance)
@@ -349,6 +371,7 @@ export const DealTicket = ({
marketTradingMode,
generalAccountBalance,
marginAccountBalance,
orderMarginAccountBalance,
loadingMarginAccountBalance,
loadingGeneralAccountBalance,
pubKey,
@@ -707,10 +730,16 @@ export const DealTicket = ({
asset={asset}
marketTradingMode={marketData.marketTradingMode}
balance={balance}
margin={
positionEstimate?.estimatePosition?.margin.bestCase.initialLevel ||
'0'
}
margin={(
BigInt(
positionEstimate?.estimatePosition?.margin.bestCase.initialLevel ||
'0'
) +
BigInt(
positionEstimate?.estimatePosition?.margin.bestCase
.orderMarginLevel || '0'
)
).toString()}
isReadOnly={isReadOnly}
pubKey={pubKey}
onDeposit={onDeposit}
@@ -743,6 +772,7 @@ export const DealTicket = ({
onMarketClick={onMarketClick}
assetSymbol={asset.symbol}
marginAccountBalance={marginAccountBalance}
orderMarginAccountBalance={orderMarginAccountBalance}
generalAccountBalance={generalAccountBalance}
positionEstimate={positionEstimate?.estimatePosition}
market={market}
@@ -768,8 +798,20 @@ interface SummaryMessageProps {
export const NoWalletWarning = ({
isReadOnly,
}: Pick<SummaryMessageProps, 'isReadOnly'>) => {
noWalletConnected,
}: Pick<SummaryMessageProps, 'isReadOnly'> & {
noWalletConnected?: boolean;
}) => {
const t = useT();
if (noWalletConnected) {
return (
<div className="mb-2">
<InputError testId="deal-ticket-error-message-summary">
{t('You need a Vega wallet to start trading on this market')}
</InputError>
</div>
);
}
if (isReadOnly) {
return (
<div className="mb-2">
@@ -1,9 +1,12 @@
import { useDataProvider } from '@vegaprotocol/data-provider';
import * as Schema from '@vegaprotocol/types';
import {
TradingButton as Button,
TradingInput as Input,
FormGroup,
LeverageSlider,
Notification,
Intent,
} from '@vegaprotocol/ui-toolkit';
import { MarginMode, useVegaWallet } from '@vegaprotocol/wallet';
import * as Types from '@vegaprotocol/types';
@@ -15,15 +18,151 @@ import { Dialog } from '@vegaprotocol/ui-toolkit';
import { useEffect, useState } from 'react';
import { useT } from '../../use-t';
import classnames from 'classnames';
import { marketMarginDataProvider } from '@vegaprotocol/accounts';
import { useMaxLeverage } from '@vegaprotocol/positions';
import {
marginModeDataProvider,
useAccountBalance,
useMarginAccountBalance,
} from '@vegaprotocol/accounts';
import { useMaxLeverage, useOpenVolume } from '@vegaprotocol/positions';
import { activeOrdersProvider } from '@vegaprotocol/orders';
import { usePositionEstimate } from '../../hooks/use-position-estimate';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { getAsset, useMarket } from '@vegaprotocol/markets';
import { NoWalletWarning } from './deal-ticket';
const defaultLeverage = 10;
export const MarginChange = ({
partyId,
marketId,
marginMode,
marginFactor,
}: {
partyId: string | null;
marketId: string;
marginMode: Types.MarginMode;
marginFactor: string;
}) => {
const t = useT();
const { data: market } = useMarket(marketId);
const asset = market && getAsset(market);
const {
marginAccountBalance,
orderMarginAccountBalance,
loading: marginAccountBalanceLoading,
} = useMarginAccountBalance(marketId);
const {
accountBalance: generalAccountBalance,
loading: generalAccountBalanceLoading,
} = useAccountBalance(asset?.id);
const { openVolume, averageEntryPrice } = useOpenVolume(
partyId,
marketId
) || {
openVolume: '0',
averageEntryPrice: '0',
};
const { data: activeOrders } = useDataProvider({
dataProvider: activeOrdersProvider,
variables: { partyId: partyId || '', marketId },
});
const orders = activeOrders
? activeOrders.map<Schema.OrderInfo>((order) => ({
isMarketOrder: order.type === Schema.OrderType.TYPE_MARKET,
price: order.price,
remaining: order.remaining,
side: order.side,
}))
: [];
const skip =
(!orders?.length && openVolume === '0') ||
marginAccountBalanceLoading ||
generalAccountBalanceLoading;
const estimateMargin = usePositionEstimate(
{
generalAccountBalance: generalAccountBalance || '0',
marginAccountBalance: marginAccountBalance || '0',
marginFactor,
marginMode,
averageEntryPrice,
openVolume,
marketId,
orderMarginAccountBalance: orderMarginAccountBalance || '0',
includeCollateralIncreaseInAvailableCollateral: true,
orders,
},
skip
);
if (
!asset ||
!estimateMargin?.estimatePosition?.collateralIncreaseEstimate.worstCase ||
estimateMargin.estimatePosition.collateralIncreaseEstimate.worstCase === '0'
) {
return null;
}
const collateralIncreaseEstimate = BigInt(
estimateMargin.estimatePosition.collateralIncreaseEstimate.worstCase
);
if (!collateralIncreaseEstimate) {
return null;
}
let positionWarning = '';
if (orders?.length && openVolume !== '0') {
positionWarning = t(
'youHaveOpenPositionAndOrders',
'You have an existing position and open orders on this market.',
{
count: orders.length,
}
);
} else if (!orders?.length) {
positionWarning = t('You have an existing position on this market.');
} else {
positionWarning = t(
'youHaveOpenOrders',
'You have open orders on this market.',
{
count: orders.length,
}
);
}
let marginChangeWarning = '';
const amount = addDecimalsFormatNumber(
collateralIncreaseEstimate.toString(),
asset?.decimals
);
const { symbol } = asset;
const interpolation = { amount, symbol };
if (marginMode === Schema.MarginMode.MARGIN_MODE_CROSS_MARGIN) {
marginChangeWarning = t(
'Changing the margin mode will move {{amount}} {{symbol}} from your general account to fund the position.',
interpolation
);
} else {
marginChangeWarning = t(
'Changing the margin mode and leverage will move {{amount}} {{symbol}} from your general account to fund the position.',
interpolation
);
}
return (
<div className="mb-2">
<Notification
intent={Intent.Warning}
message={
<>
<p>{positionWarning}</p>
<p>{marginChangeWarning}</p>
</>
}
/>
</div>
);
};
interface MarginDialogProps {
open: boolean;
onClose: () => void;
marketId: string;
partyId: string;
create: VegaTransactionStore['create'];
}
@@ -33,6 +172,7 @@ const CrossMarginModeDialog = ({
marketId,
create,
}: MarginDialogProps) => {
const { pubKey: partyId, isReadOnly } = useVegaWallet();
const t = useT();
return (
<Dialog
@@ -60,15 +200,24 @@ const CrossMarginModeDialog = ({
)}
</p>
</div>
<MarginChange
marketId={marketId}
partyId={partyId}
marginMode={Types.MarginMode.MARGIN_MODE_CROSS_MARGIN}
marginFactor="1"
/>
<NoWalletWarning noWalletConnected={!partyId} isReadOnly={isReadOnly} />
<Button
className="w-full"
onClick={() => {
create({
updateMarginMode: {
marketId,
mode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
});
partyId &&
!isReadOnly &&
create({
updateMarginMode: {
marketId,
mode: MarginMode.MARGIN_MODE_CROSS_MARGIN,
},
});
onClose();
}}
>
@@ -82,10 +231,10 @@ const IsolatedMarginModeDialog = ({
open,
onClose,
marketId,
partyId,
marginFactor,
create,
}: MarginDialogProps & { marginFactor: string }) => {
const { pubKey: partyId, isReadOnly } = useVegaWallet();
const [leverage, setLeverage] = useState(
Number((1 / Number(marginFactor)).toFixed(1))
);
@@ -129,13 +278,15 @@ const IsolatedMarginModeDialog = ({
</div>
<form
onSubmit={() => {
create({
updateMarginMode: {
marketId,
mode: MarginMode.MARGIN_MODE_ISOLATED_MARGIN,
marginFactor: `${1 / leverage}`,
},
});
partyId &&
!isReadOnly &&
create({
updateMarginMode: {
marketId,
mode: MarginMode.MARGIN_MODE_ISOLATED_MARGIN,
marginFactor: `${1 / leverage}`,
},
});
onClose();
}}
>
@@ -144,7 +295,7 @@ const IsolatedMarginModeDialog = ({
<LeverageSlider
max={max}
step={0.1}
value={[leverage]}
value={[leverage || 1]}
onValueChange={([value]) => setLeverage(value)}
/>
</div>
@@ -154,10 +305,17 @@ const IsolatedMarginModeDialog = ({
min={1}
max={max}
step={0.1}
value={leverage}
value={leverage || ''}
onChange={(e) => setLeverage(Number(e.target.value))}
/>
</FormGroup>
<MarginChange
marketId={marketId}
partyId={partyId}
marginMode={Types.MarginMode.MARGIN_MODE_ISOLATED_MARGIN}
marginFactor={`${1 / leverage}`}
/>
<NoWalletWarning noWalletConnected={!partyId} isReadOnly={isReadOnly} />
<Button className="w-full" type="submit">
{t('Confirm')}
</Button>
@@ -169,27 +327,21 @@ const IsolatedMarginModeDialog = ({
export const MarginModeSelector = ({ marketId }: { marketId: string }) => {
const t = useT();
const [dialog, setDialog] = useState<'cross' | 'isolated' | ''>();
const { pubKey: partyId, isReadOnly } = useVegaWallet();
const { pubKey: partyId } = useVegaWallet();
const { data: margin } = useDataProvider({
dataProvider: marketMarginDataProvider,
dataProvider: marginModeDataProvider,
variables: {
partyId: partyId || '',
marketId,
},
skip: !partyId,
});
useEffect(() => {
if (!partyId) {
setDialog('');
}
}, [partyId]);
const create = useVegaTransactionStore((state) => state.create);
const marginMode = margin?.marginMode;
const marginFactor =
margin?.marginFactor && margin?.marginFactor !== '0'
? margin?.marginFactor
: undefined;
const disabled = isReadOnly;
const onClose = () => setDialog(undefined);
const enabledModeClassName = 'bg-vega-clight-500 dark:bg-vega-cdark-500';
@@ -197,8 +349,8 @@ export const MarginModeSelector = ({ marketId }: { marketId: string }) => {
<>
<div className="mb-4 grid h-8 leading-8 font-alpha text-xs grid-cols-2">
<button
disabled={disabled}
onClick={() => partyId && setDialog('cross')}
type="button"
onClick={() => setDialog('cross')}
className={classnames('rounded', {
[enabledModeClassName]:
!marginMode ||
@@ -208,8 +360,8 @@ export const MarginModeSelector = ({ marketId }: { marketId: string }) => {
{t('Cross')}
</button>
<button
disabled={disabled}
onClick={() => partyId && setDialog('isolated')}
type="button"
onClick={() => setDialog('isolated')}
className={classnames('rounded', {
[enabledModeClassName]:
marginMode === Types.MarginMode.MARGIN_MODE_ISOLATED_MARGIN,
@@ -222,25 +374,23 @@ export const MarginModeSelector = ({ marketId }: { marketId: string }) => {
})}
</button>
</div>
{partyId && (
{
<CrossMarginModeDialog
partyId={partyId}
open={dialog === 'cross'}
onClose={onClose}
marketId={marketId}
create={create}
/>
)}
{partyId && (
}
{
<IsolatedMarginModeDialog
partyId={partyId}
open={dialog === 'isolated'}
onClose={onClose}
marketId={marketId}
create={create}
marginFactor={marginFactor || `${1 / defaultLeverage}`}
/>
)}
}
</>
);
};

Some files were not shown because too many files have changed in this diff Show More