feat(trading): join team (#5629)

This commit is contained in:
Matthew Russell
2024-01-19 16:36:34 -05:00
parent f82262c8b3
commit 6ecbc46f17
21 changed files with 749 additions and 338 deletions
@@ -13,6 +13,7 @@ import {
import {
useVegaWallet,
type CreateReferralSet,
type Status,
useVegaWalletDialogStore,
} from '@vegaprotocol/wallet';
import {
@@ -97,7 +98,7 @@ const CreateTeamFormContainer = ({ isSolo }: { isSolo: boolean }) => {
},
});
if (status === 'success') {
if (status === 'confirmed') {
return (
<div className="flex flex-col items-start gap-2">
<p className="text-sm">{t('Team creation transaction successful')}</p>
@@ -299,17 +300,30 @@ const CreateTeamForm = ({
</TradingFormGroup>
)}
{err && <p className="text-danger text-xs mb-4 capitalize">{err}</p>}
<TradingButton
type="submit"
intent={Intent.Info}
disabled={status === 'loading'}
>
{status === 'loading' ? t('Confirm in wallet...') : t('Create')}
</TradingButton>
<SubmitButton status={status} />
</form>
);
};
const SubmitButton = ({ status }: { status: Status }) => {
const t = useT();
const disabled = status === 'pending' || status === 'requested';
let text = t('Create');
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(',')
@@ -3,8 +3,6 @@ import { Link, useParams } from 'react-router-dom';
import orderBy from 'lodash/orderBy';
import countBy from 'lodash/countBy';
import {
TradingButton as Button,
Intent,
Pill,
VegaIcon,
VegaIconNames,
@@ -28,6 +26,8 @@ import BigNumber from 'bignumber.js';
import { TeamAvatar } from '../../components/competitions/team-avatar';
import { usePageTitle } from '../../lib/hooks/use-page-title';
import { ErrorBoundary } from '../../components/error-boundary';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { JoinTeam } from './join-team';
export const CompetitionsTeam = () => {
const t = useT();
@@ -42,7 +42,11 @@ export const CompetitionsTeam = () => {
const TeamPageContainer = ({ teamId }: { teamId: string | undefined }) => {
const t = useT();
const { team, stats, partyInTeam, members, games, loading } = useTeam(teamId);
const { pubKey } = useVegaWallet();
const { team, partyTeam, stats, members, games, loading, refetch } = useTeam(
teamId,
pubKey || undefined
);
if (loading) {
return (
@@ -63,26 +67,29 @@ const TeamPageContainer = ({ teamId }: { teamId: string | undefined }) => {
return (
<TeamPage
team={team}
partyTeam={partyTeam}
stats={stats}
partyInTeam={partyInTeam}
members={members}
games={games}
refetch={refetch}
/>
);
};
const TeamPage = ({
team,
partyTeam,
stats,
partyInTeam,
members,
games,
refetch,
}: {
team: TeamType;
partyTeam?: TeamType;
stats?: TeamStats;
partyInTeam: boolean;
members?: Member[];
games?: TeamGame[];
refetch: () => void;
}) => {
const t = useT();
const [showGames, setShowGames] = useState(true);
@@ -99,7 +106,7 @@ const TeamPage = ({
<h1 className="calt text-2xl lg:text-3xl xl:text-5xl">
{team.name}
</h1>
<JoinButton joined={partyInTeam} />
<JoinTeam team={team} partyTeam={partyTeam} refetch={refetch} />
</div>
</header>
<StatSection>
@@ -265,25 +272,6 @@ const RefereeLink = ({ pubkey }: { pubkey: string }) => {
);
};
const JoinButton = ({ joined }: { joined: boolean }) => {
const t = useT();
if (joined) {
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 intent={Intent.Primary}>{t('Join this team')}</Button>;
};
const LatestResults = ({ games }: { games: TeamGame[] }) => {
const t = useT();
const latestGames = games.slice(0, 5);
@@ -1,12 +0,0 @@
query TeamReferees($teamId: ID!) {
teamReferees(teamId: $teamId) {
edges {
node {
teamId
referee
joinedAt
joinedAtEpoch
}
}
}
}
@@ -1,55 +0,0 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type TeamRefereesQueryVariables = Types.Exact<{
teamId: Types.Scalars['ID'];
}>;
export type TeamRefereesQuery = { __typename?: 'Query', teamReferees?: { __typename?: 'TeamRefereeConnection', edges: Array<{ __typename?: 'TeamRefereeEdge', node: { __typename?: 'TeamReferee', teamId: string, referee: string, joinedAt: any, joinedAtEpoch: number } }> } | null };
export const TeamRefereesDocument = gql`
query TeamReferees($teamId: ID!) {
teamReferees(teamId: $teamId) {
edges {
node {
teamId
referee
joinedAt
joinedAtEpoch
}
}
}
}
`;
/**
* __useTeamRefereesQuery__
*
* To run a query within a React component, call `useTeamRefereesQuery` and pass it any options that fit your needs.
* When your component renders, `useTeamRefereesQuery` 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 } = useTeamRefereesQuery({
* variables: {
* teamId: // value for 'teamId'
* },
* });
*/
export function useTeamRefereesQuery(baseOptions: Apollo.QueryHookOptions<TeamRefereesQuery, TeamRefereesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<TeamRefereesQuery, TeamRefereesQueryVariables>(TeamRefereesDocument, options);
}
export function useTeamRefereesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<TeamRefereesQuery, TeamRefereesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<TeamRefereesQuery, TeamRefereesQueryVariables>(TeamRefereesDocument, options);
}
export type TeamRefereesQueryHookResult = ReturnType<typeof useTeamRefereesQuery>;
export type TeamRefereesLazyQueryHookResult = ReturnType<typeof useTeamRefereesLazyQuery>;
export type TeamRefereesQueryResult = Apollo.QueryResult<TeamRefereesQuery, TeamRefereesQueryVariables>;
@@ -19,6 +19,7 @@ export const useGames = ({
variables: {
isReward: true,
},
fetchPolicy: 'cache-and-network',
});
const games = compact(data?.transfersConnection?.edges?.map((n) => n?.node))
@@ -7,7 +7,6 @@ import {
type TeamRefereeFieldsFragment,
type TeamEntityFragment,
} from './__generated__/Team';
import { useVegaWallet } from '@vegaprotocol/wallet';
export type Team = TeamFieldsFragment;
export type TeamStats = TeamStatsFieldsFragment;
@@ -15,16 +14,18 @@ export type Member = TeamRefereeFieldsFragment;
export type TeamEntity = TeamEntityFragment;
export type TeamGame = ReturnType<typeof useTeam>['games'][number];
export const useTeam = (teamId?: string) => {
const { pubKey } = useVegaWallet();
const { data, loading, error } = useTeamQuery({
variables: { teamId: teamId || '', partyId: pubKey },
export const useTeam = (teamId?: string, partyId?: string) => {
const { data, loading, error, refetch } = useTeamQuery({
variables: { teamId: teamId || '', partyId },
skip: !teamId,
fetchPolicy: 'cache-and-network',
});
const teamEdge = data?.teams?.edges.find((e) => e.node.teamId === teamId);
const partyTeamEdge = data?.partyTeams?.edges[0];
const partyTeam = data?.partyTeams?.edges?.length
? data.partyTeams.edges[0].node
: undefined;
const teamStatsEdge = data?.teamsStatistics?.edges.find(
(e) => e.node.teamId === teamId
);
@@ -49,16 +50,18 @@ export const useTeam = (teamId?: string) => {
team: team as TeamEntity, // TS can't infer that all the game entities are teams
};
});
const games = orderBy(compact(gamesWithTeam), 'epoch', 'desc');
return {
data,
loading,
error,
refetch,
stats: teamStatsEdge?.node,
team: teamEdge?.node,
members,
games,
partyInTeam: Boolean(partyTeamEdge),
partyTeam,
};
};
@@ -33,7 +33,9 @@ export const useTeams = ({
data: teamsData,
loading: teamsLoading,
error: teamsError,
} = useTeamsQuery();
} = useTeamsQuery({
fetchPolicy: 'cache-and-network',
});
const {
data: statsData,
@@ -43,6 +45,7 @@ export const useTeams = ({
variables: {
aggregationEpochs,
},
fetchPolicy: 'cache-and-network',
});
const teams = compact(teamsData?.teams?.edges).map((e) => e.node);
@@ -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 './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,225 @@
import {
TradingButton as Button,
Dialog,
Intent,
Tooltip,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import {
useSimpleTransaction,
useVegaWallet,
type Status,
} from '@vegaprotocol/wallet';
import { useT } from '../../lib/use-t';
import { type Team } from './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 { send, status } = useSimpleTransaction({
onSuccess: refetch,
});
const [confirmDialog, setConfirmDialog] = useState<JoinType>();
const joinTeam = () => {
send({
joinTeam: {
id: team.teamId,
},
});
};
return (
<>
<JoinButton
team={team}
partyTeam={partyTeam}
pubKey={pubKey}
isReadOnly={isReadOnly}
onJoin={setConfirmDialog}
/>
<Dialog
open={confirmDialog !== undefined}
onChange={() => setConfirmDialog(undefined)}
>
{confirmDialog !== undefined && (
<DialogContent
type={confirmDialog}
status={status}
team={team}
partyTeam={partyTeam}
onConfirm={joinTeam}
onCancel={() => setConfirmDialog(undefined)}
/>
)}
</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="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,
status,
team,
partyTeam,
onConfirm,
onCancel,
}: {
type: JoinType;
status: Status;
team: Team;
partyTeam?: Team;
onConfirm: () => void;
onCancel: () => void;
}) => {
const t = useT();
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={onConfirm} intent={Intent.Success}>
{t('Confirm')}
</Button>
<Button onClick={onCancel} intent={Intent.Danger}>
{t('Cancel')}
</Button>
</div>
</div>
);
};
@@ -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 { useCallback } from 'react';
import { RainbowButton } from '../../components/rainbow-button';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
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()} />
@@ -105,21 +105,28 @@ const CreateCodeDialog = ({
const { details: programDetails } = useReferralProgram();
const getButtonProps = () => {
if (status === 'idle' || status === 'error') {
if (status === 'idle') {
return {
children: t('Generate code'),
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,
@@ -173,7 +180,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>
@@ -184,7 +194,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">
@@ -233,14 +243,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">
@@ -14,7 +14,7 @@ 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,59 +1,28 @@
import {
determineId,
useVegaWallet,
useSimpleTransaction,
type CreateReferralSet,
type Options,
} from '@vegaprotocol/wallet';
import { useState } from 'react';
import { useStakeAvailable } from './use-stake-available';
/**
* Manages state for creating a referral set or team
*/
export const useCreateReferralSet = (opts?: {
onSuccess?: (code: string) => void;
onError?: (error: string) => void;
}) => {
const { pubKey, isReadOnly, sendTx } = useVegaWallet();
const [err, setErr] = useState<string | null>(null);
const [code, setCode] = useState<string | null>(null);
const [status, setStatus] = useState<
'idle' | 'loading' | 'success' | 'error'
>('idle');
export const useCreateReferralSet = (opts?: Options) => {
const { stakeAvailable, requiredStake, isEligible } = useStakeAvailable();
const { status, result, error, send } = useSimpleTransaction({
onSuccess: opts?.onSuccess,
onError: opts?.onError,
});
const onSubmit = (tx: CreateReferralSet) => {
if (isReadOnly || !pubKey) {
setErr('Not connected');
} else {
setErr(null);
setStatus('loading');
setCode(null);
sendTx(pubKey, tx)
.then((res) => {
if (!res) {
throw new Error(`Invalid response: ${JSON.stringify(res)}`);
}
const code = determineId(res.signature);
setCode(code);
setStatus('success');
opts?.onSuccess && opts.onSuccess(code);
})
.catch((err) => {
if (err.message.includes('user rejected')) {
setStatus('idle');
return;
}
setStatus('error');
setErr(err.message);
opts?.onError && opts.onError(err.message);
});
}
send(tx);
};
return {
err,
code,
err: error ? error : null,
code: result ? result.id : null,
status,
stakeAvailable,
requiredStake,
+13 -1
View File
@@ -7,6 +7,7 @@
"<0>No running Desktop App/CLI detected. Open your app now to connect or enter a</0> <1>custom wallet location</1>": "<0>No running Desktop App/CLI detected. Open your app now to connect or enter a</0> <1>custom wallet location</1>",
"A percentage of commission earned by the referrer": "A percentage of commission earned by the referrer",
"A successor to this market has been proposed": "A successor to this market has been proposed",
"As a team creator, you cannot switch teams": "As a team creator, you cannot switch teams",
"About the referral program": "About the referral program",
"Active": "Active",
"Activity Streak": "Activity Streak",
@@ -16,6 +17,7 @@
"Anonymous": "Anonymous",
"Anyone with the referral link can apply it to their key(s) of choice via an on chain transaction": "Anyone with the referral link can apply it to their key(s) of choice via an on chain transaction",
"Assessed over": "Assessed over",
"Are you sure you want to join team: {{team}}": "Are you sure you want to join team: {{team}}",
"Asset (1)": "Asset (1)",
"Assets": "Assets",
"Available to withdraw this epoch": "Available to withdraw this epoch",
@@ -27,6 +29,7 @@
"Best offer": "Best offer",
"Browse": "Browse",
"By using the Vega Console, you acknowledge that you have read and understood the <0>Vega Console Disclaimer</0>": "By using the Vega Console, you acknowledge that you have read and understood the <0>Vega Console Disclaimer</0>",
"Cancel": "Cancel",
"Change (24h)": "Change (24h)",
"Changes have been proposed for this market. <0>View proposals</0>": "Changes have been proposed for this market. <0>View proposals</0>",
"Chart": "Chart",
@@ -39,9 +42,12 @@
"Code must be be valid hex": "Code must be be valid hex",
"Collateral": "Collateral",
"Conduct your own due diligence and consult your financial advisor before making any investment decisions.": "Conduct your own due diligence and consult your financial advisor before making any investment decisions.",
"Confrim": "Confrim",
"Confirm in wallet...": "Confirm in wallet...",
"Confirming transaction...": "Confirming transaction...",
"Connect": "Connect",
"Connect wallet": "Connect wallet",
"Connect your wallet to join the team": "Connect your wallet to join the team",
"Connected node": "Connected node",
"Console": "Console",
"Continue sharing data": "Continue sharing data",
@@ -141,7 +147,7 @@
"Infrastructure": "Infrastructure",
"Interval: {{interval}}": "Interval: {{interval}}",
"Invite friends and earn rewards from the trading fees they pay. Stake those rewards to earn multipliers on future rewards.": "Invite friends and earn rewards from the trading fees they pay. Stake those rewards to earn multipliers on future rewards.",
"Join this team": "Join this team",
"Join team": "Join team",
"Joined": "Joined",
"Joined at": "Joined at",
"Joined epoch": "Joined epoch",
@@ -213,6 +219,7 @@
"Order": "Order",
"Orderbook": "Orderbook",
"Orders": "Orders",
"Owner": "Owner",
"PRNT": "PRNT",
"Page not found": "Page not found",
"Parent of a market": "Parent of a market",
@@ -293,10 +300,15 @@
"Successors to this market have been proposed": "Successors to this market have been proposed",
"Supplied stake": "Supplied stake",
"Suspended due to price or liquidity monitoring trigger": "Suspended due to price or liquidity monitoring trigger",
"Switch team": "Switch team",
"Switching team will move you from '{{fromTeam}}' to '{{toTeam}}' at the end of the epoch. Are you sure?": "Switching team will move you from '{{fromTeam}}' to '{{toTeam}}' at the end of the epoch. Are you sure?",
"Target stake": "Target stake",
"Team": "Team",
"Team name": "Team name",
"Team creation transaction successful": "Team creation transaction successful",
"Team joined": "Team joined",
"Team switch successful. You will switch team at the end of the epoch.": "Team switch successful. You will switch team at the end of the epoch.",
"The amount of fees paid to liquidity providers across the whole market during the last epoch {{epoch}}.": "The amount of fees paid to liquidity providers across the whole market during the last epoch {{epoch}}.",
"The commission is taken from the infrastructure fee, maker fee, and liquidity provider fee, not from the referee": "The commission is taken from the infrastructure fee, maker fee, and liquidity provider fee, not from the referee",
"The external time weighted average price (TWAP) received from the data source defined in the data sourcing specification.": "The external time weighted average price (TWAP) received from the data source defined in the data sourcing specification.",
+3
View File
@@ -48,6 +48,8 @@
"Supported browsers": "Supported browsers",
"The user rejected the wallet connection": "The user rejected the wallet connection",
"To complete your wallet connection, set your wallet network in your app to \"{{appChainId}}\".": "To complete your wallet connection, set your wallet network in your app to \"{{appChainId}}\".",
"Transaction could not be sent": "Transaction could not be sent",
"Transaction was not successful": "Transaction was not successful",
"Try again": "Try again",
"Understand the risk": "Understand the risk",
"Use the Desktop App/CLI": "Use the Desktop App/CLI",
@@ -57,6 +59,7 @@
"Verifying chain": "Verifying chain",
"View as party": "View as party",
"VIEW AS VEGA USER": "VIEW AS VEGA USER",
"Wallet rejected transaction": "Wallet rejected transaction",
"Wrong Network": "Wrong Network",
"Wrong network": "Wrong network",
"your browser": "your browser"
+17
View File
@@ -0,0 +1,17 @@
fragment SimpleTransactionFields on TransactionResult {
partyId
hash
status
error
}
subscription SimpleTransaction($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [TransactionResult]) {
type
event {
... on TransactionResult {
...SimpleTransactionFields
}
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type SimpleTransactionFieldsFragment = { __typename?: 'TransactionResult', partyId: string, hash: string, status: boolean, error?: string | null };
export type SimpleTransactionSubscriptionVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
}>;
export type SimpleTransactionSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', type: Types.BusEventType, event: { __typename?: 'Deposit' } | { __typename?: 'TimeUpdate' } | { __typename?: 'TransactionResult', partyId: string, hash: string, status: boolean, error?: string | null } | { __typename?: 'Withdrawal' } }> | null };
export const SimpleTransactionFieldsFragmentDoc = gql`
fragment SimpleTransactionFields on TransactionResult {
partyId
hash
status
error
}
`;
export const SimpleTransactionDocument = gql`
subscription SimpleTransaction($partyId: ID!) {
busEvents(partyId: $partyId, batchSize: 0, types: [TransactionResult]) {
type
event {
... on TransactionResult {
...SimpleTransactionFields
}
}
}
}
${SimpleTransactionFieldsFragmentDoc}`;
/**
* __useSimpleTransactionSubscription__
*
* To run a query within a React component, call `useSimpleTransactionSubscription` and pass it any options that fit your needs.
* When your component renders, `useSimpleTransactionSubscription` 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 subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useSimpleTransactionSubscription({
* variables: {
* partyId: // value for 'partyId'
* },
* });
*/
export function useSimpleTransactionSubscription(baseOptions: Apollo.SubscriptionHookOptions<SimpleTransactionSubscription, SimpleTransactionSubscriptionVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSubscription<SimpleTransactionSubscription, SimpleTransactionSubscriptionVariables>(SimpleTransactionDocument, options);
}
export type SimpleTransactionSubscriptionHookResult = ReturnType<typeof useSimpleTransactionSubscription>;
export type SimpleTransactionSubscriptionResult = Apollo.SubscriptionResult<SimpleTransactionSubscription>;
@@ -437,6 +437,12 @@ export type ApplyReferralCode = {
};
};
export type JoinTeam = {
joinTeam: {
id: string;
};
};
export type CreateReferralSet = {
createReferralSet: {
isTeam: boolean;
@@ -465,6 +471,7 @@ export type Transaction =
| TransferBody
| LiquidityProvisionSubmission
| ApplyReferralCode
| JoinTeam
| CreateReferralSet;
export const isWithdrawTransaction = (
+6
View File
@@ -7,3 +7,9 @@ export * from './provider';
export * from './connect-dialog';
export * from './utils';
export * from './storage';
export {
useSimpleTransaction,
type Status,
type Result,
type Options,
} from './use-simple-transaction';
+114
View File
@@ -0,0 +1,114 @@
import { useState } from 'react';
import { useVegaWallet } from './use-vega-wallet';
import { type Transaction } from './connectors';
import {
useSimpleTransactionSubscription,
type SimpleTransactionFieldsFragment,
} from './__generated__/SimpleTransaction';
import { useT } from './use-t';
import { determineId } from './utils';
export type Status = 'idle' | 'requested' | 'pending' | 'confirmed';
export type Result = {
txHash: string;
signature: string;
id: string;
};
export type Options = {
onSuccess?: (result: Result) => void;
onError?: (msg: string) => void;
};
export const useSimpleTransaction = (opts?: Options) => {
const t = useT();
const { pubKey, isReadOnly, sendTx } = useVegaWallet();
const [status, setStatus] = useState<Status>('idle');
const [result, setResult] = useState<Result>();
const [error, setError] = useState<string>();
const send = async (tx: Transaction) => {
if (!pubKey) {
throw new Error('no pubKey');
}
if (isReadOnly) {
throw new Error('cant submit in read only mode');
}
setStatus('requested');
try {
const res = await sendTx(pubKey, tx);
if (!res) {
throw new Error(t('Transaction could not be sent'));
}
setStatus('pending');
setResult({
txHash: res?.transactionHash.toLowerCase(),
signature: res.signature,
id: determineId(res.signature),
});
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('user rejected')) {
setStatus('idle');
} else {
setError(err.message);
opts?.onError?.(err.message);
}
} else {
const msg = t('Wallet rejected transaction');
setError(msg);
opts?.onError?.(msg);
}
}
};
useSimpleTransactionSubscription({
variables: { partyId: pubKey || '' },
skip: !pubKey || !result,
fetchPolicy: 'no-cache',
onData: ({ data }) => {
if (!result) {
throw new Error('simple transaction query started before result');
}
const e = data.data?.busEvents?.find((event) => {
if (
event.event.__typename === 'TransactionResult' &&
event.event.hash.toLowerCase() === result?.txHash
) {
return true;
}
return false;
});
if (!e) return;
// Force type narrowing
const event = e.event as SimpleTransactionFieldsFragment;
if (event.status && !event.error) {
setStatus('confirmed');
opts?.onSuccess?.(result);
} else {
const msg = event?.error || t('Transaction was not successful');
setError(msg);
opts?.onError?.(msg);
}
},
});
return {
result,
error,
status,
send,
};
};