From 6ecbc46f175490f4db8cb180da89f18aa23b33ed Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Wed, 17 Jan 2024 17:04:51 -0500 Subject: [PATCH] feat(trading): join team (#5629) --- .../competitions/competitions-create-team.tsx | 30 ++- .../competitions/competitions-team.tsx | 40 ++-- .../competitions/hooks/TeamReferees.graphql | 12 - .../hooks/__generated__/TeamReferees.ts | 55 ----- .../competitions/hooks/use-games.ts | 1 + .../competitions/hooks/use-team.ts | 19 +- .../competitions/hooks/use-teams.tsx | 5 +- .../competitions/join-team.spec.tsx | 88 +++++++ .../client-pages/competitions/join-team.tsx | 225 ++++++++++++++++++ .../referrals/apply-code-form.tsx | 166 ++++++------- .../referrals/create-code-form.tsx | 27 ++- .../referrals/hooks/use-referral.ts | 2 +- .../referrals/referral-statistics.spec.tsx | 146 +++++------- .../lib/hooks/use-create-referral-set.ts | 53 +---- libs/i18n/src/locales/en/trading.json | 14 +- libs/i18n/src/locales/en/wallet.json | 3 + libs/wallet/src/SimpleTransaction.graphql | 17 ++ .../src/__generated__/SimpleTransaction.ts | 57 +++++ libs/wallet/src/connectors/vega-connector.ts | 7 + libs/wallet/src/index.ts | 6 + libs/wallet/src/use-simple-transaction.ts | 114 +++++++++ 21 files changed, 749 insertions(+), 338 deletions(-) delete mode 100644 apps/trading/client-pages/competitions/hooks/TeamReferees.graphql delete mode 100644 apps/trading/client-pages/competitions/hooks/__generated__/TeamReferees.ts create mode 100644 apps/trading/client-pages/competitions/join-team.spec.tsx create mode 100644 apps/trading/client-pages/competitions/join-team.tsx create mode 100644 libs/wallet/src/SimpleTransaction.graphql create mode 100644 libs/wallet/src/__generated__/SimpleTransaction.ts create mode 100644 libs/wallet/src/use-simple-transaction.ts diff --git a/apps/trading/client-pages/competitions/competitions-create-team.tsx b/apps/trading/client-pages/competitions/competitions-create-team.tsx index 295fb3731..8475b3edb 100644 --- a/apps/trading/client-pages/competitions/competitions-create-team.tsx +++ b/apps/trading/client-pages/competitions/competitions-create-team.tsx @@ -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 (

{t('Team creation transaction successful')}

@@ -299,17 +300,30 @@ const CreateTeamForm = ({ )} {err &&

{err}

} - - {status === 'loading' ? t('Confirm in wallet...') : t('Create')} - + ); }; +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 ( + + {text} + + ); +}; + const parseAllowListText = (str: string) => { return str .split(',') diff --git a/apps/trading/client-pages/competitions/competitions-team.tsx b/apps/trading/client-pages/competitions/competitions-team.tsx index 22e1b12a8..8e2117bad 100644 --- a/apps/trading/client-pages/competitions/competitions-team.tsx +++ b/apps/trading/client-pages/competitions/competitions-team.tsx @@ -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 ( ); }; 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 = ({

{team.name}

- +
@@ -265,25 +272,6 @@ const RefereeLink = ({ pubkey }: { pubkey: string }) => { ); }; -const JoinButton = ({ joined }: { joined: boolean }) => { - const t = useT(); - - if (joined) { - return ( - - ); - } - - return ; -}; - const LatestResults = ({ games }: { games: TeamGame[] }) => { const t = useT(); const latestGames = games.slice(0, 5); diff --git a/apps/trading/client-pages/competitions/hooks/TeamReferees.graphql b/apps/trading/client-pages/competitions/hooks/TeamReferees.graphql deleted file mode 100644 index d08ae0f1c..000000000 --- a/apps/trading/client-pages/competitions/hooks/TeamReferees.graphql +++ /dev/null @@ -1,12 +0,0 @@ -query TeamReferees($teamId: ID!) { - teamReferees(teamId: $teamId) { - edges { - node { - teamId - referee - joinedAt - joinedAtEpoch - } - } - } -} diff --git a/apps/trading/client-pages/competitions/hooks/__generated__/TeamReferees.ts b/apps/trading/client-pages/competitions/hooks/__generated__/TeamReferees.ts deleted file mode 100644 index 2e01bcc30..000000000 --- a/apps/trading/client-pages/competitions/hooks/__generated__/TeamReferees.ts +++ /dev/null @@ -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) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(TeamRefereesDocument, options); - } -export function useTeamRefereesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(TeamRefereesDocument, options); - } -export type TeamRefereesQueryHookResult = ReturnType; -export type TeamRefereesLazyQueryHookResult = ReturnType; -export type TeamRefereesQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/trading/client-pages/competitions/hooks/use-games.ts b/apps/trading/client-pages/competitions/hooks/use-games.ts index 0b14fa9c0..1534a876a 100644 --- a/apps/trading/client-pages/competitions/hooks/use-games.ts +++ b/apps/trading/client-pages/competitions/hooks/use-games.ts @@ -19,6 +19,7 @@ export const useGames = ({ variables: { isReward: true, }, + fetchPolicy: 'cache-and-network', }); const games = compact(data?.transfersConnection?.edges?.map((n) => n?.node)) diff --git a/apps/trading/client-pages/competitions/hooks/use-team.ts b/apps/trading/client-pages/competitions/hooks/use-team.ts index 64d5c22a2..888bae5a6 100644 --- a/apps/trading/client-pages/competitions/hooks/use-team.ts +++ b/apps/trading/client-pages/competitions/hooks/use-team.ts @@ -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['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, }; }; diff --git a/apps/trading/client-pages/competitions/hooks/use-teams.tsx b/apps/trading/client-pages/competitions/hooks/use-teams.tsx index 6643a0d0b..10e05761b 100644 --- a/apps/trading/client-pages/competitions/hooks/use-teams.tsx +++ b/apps/trading/client-pages/competitions/hooks/use-teams.tsx @@ -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); diff --git a/apps/trading/client-pages/competitions/join-team.spec.tsx b/apps/trading/client-pages/competitions/join-team.spec.tsx new file mode 100644 index 000000000..9865a4c30 --- /dev/null +++ b/apps/trading/client-pages/competitions/join-team.spec.tsx @@ -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(); + 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( + + ); + + const button = screen.getByRole('button', { name: /Owner/ }); + expect(button).toBeDisabled(); + }); + + it('disables button if you created a team', async () => { + render(); + + 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(); + + const button = screen.getByRole('button', { name: /Joined/ }); + expect(button).toBeDisabled(); + }); + + it('enables switch team if party is in a different team', async () => { + render(); + + 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(); + + const button = screen.getByRole('button', { name: /Join team/ }); + expect(button).toBeEnabled(); + await userEvent.click(button); + expect(props.onJoin).toHaveBeenCalledWith('join'); + }); +}); diff --git a/apps/trading/client-pages/competitions/join-team.tsx b/apps/trading/client-pages/competitions/join-team.tsx new file mode 100644 index 000000000..e4e694cac --- /dev/null +++ b/apps/trading/client-pages/competitions/join-team.tsx @@ -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(); + + const joinTeam = () => { + send({ + joinTeam: { + id: team.teamId, + }, + }); + }; + + return ( + <> + + setConfirmDialog(undefined)} + > + {confirmDialog !== undefined && ( + setConfirmDialog(undefined)} + /> + )} + + + ); +}; + +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 ( + + + + ); + } + // 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 ( + + ); + } else { + // Not creator of the team, but still can't switch because + // creators cannot leave their own team + return ( + + + + ); + } + } + // Party is in a team, but not this one + else if (partyTeam && partyTeam.teamId !== team.teamId) { + return ( + + ); + } + // Joined. Current party is already in this team + else if (partyTeam && partyTeam.teamId === team.teamId) { + return ( + + ); + } + + return ( + + ); +}; + +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

{t('Confirm in wallet...')}

; + } + + if (status === 'pending') { + return

{t('Confirming transaction...')}

; + } + + if (status === 'confirmed') { + if (type === 'switch') { + return ( +

+ {t( + 'Team switch successful. You will switch team at the end of the epoch.' + )} +

+ ); + } + + return

{t('Team joined')}

; + } + + return ( +
+ {type === 'switch' && ( + <> +

{t('Switch team')}

+

+ {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, + } + )} +

+ + )} + {type === 'join' && ( + <> +

{t('Join team')}

+

+ {t('Are you sure you want to join team: {{team}}', { + team: team.name, + })} +

+ + )} +
+ + +
+
+ ); +}; diff --git a/apps/trading/client-pages/referrals/apply-code-form.tsx b/apps/trading/client-pages/referrals/apply-code-form.tsx index f35753e1d..a5e1663dd 100644 --- a/apps/trading/client-pages/referrals/apply-code-form.tsx +++ b/apps/trading/client-pages/referrals/apply-code-form.tsx @@ -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 ; }; +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(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({ + 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 (

@@ -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 }) => { - {status === 'no-funds' ? ( + {noFunds ? ( diff --git a/apps/trading/client-pages/referrals/create-code-form.tsx b/apps/trading/client-pages/referrals/create-code-form.tsx index 2faffbd15..ac455bab4 100644 --- a/apps/trading/client-pages/referrals/create-code-form.tsx +++ b/apps/trading/client-pages/referrals/create-code-form.tsx @@ -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 (
- {(status === 'idle' || status === 'loading' || status === 'error') && ( + {(status === 'idle' || + status === 'requested' || + status === 'pending' || + err) && ( <> {

@@ -184,7 +194,7 @@ const CreateCodeDialog = ({ } )} - {status === 'success' && code && ( + {status === 'confirmed' && code && (

@@ -233,14 +243,17 @@ const CreateCodeDialog = ({ return (

- {(status === 'idle' || status === 'loading' || status === 'error') && ( + {(status === 'idle' || + status === 'requested' || + status === 'pending' || + err) && (

{t( 'Generate a referral code to share with your friends and access the commission benefits of the current program.' )}

)} - {status === 'success' && code && ( + {status === 'confirmed' && code && (

diff --git a/apps/trading/client-pages/referrals/hooks/use-referral.ts b/apps/trading/client-pages/referrals/hooks/use-referral.ts index f8fce1adb..cc8c4bc8b 100644 --- a/apps/trading/client-pages/referrals/hooks/use-referral.ts +++ b/apps/trading/client-pages/referrals/hooks/use-referral.ts @@ -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; diff --git a/apps/trading/client-pages/referrals/referral-statistics.spec.tsx b/apps/trading/client-pages/referrals/referral-statistics.spec.tsx index 0a3d41a84..be2f08c33 100644 --- a/apps/trading/client-pages/referrals/referral-statistics.spec.tsx +++ b/apps/trading/client-pages/referrals/referral-statistics.spec.tsx @@ -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 = { }, }; -jest.mock('@vegaprotocol/wallet', () => { - return { - ...jest.requireActual('@vegaprotocol/wallet'), - useVegaWallet: () => { - const ctx: Partial = { - 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( - - - + + + + + ); + }; - 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( - - - - - - ); + 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( - - - - - - ); - + 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( - - - - - - ); + 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(); }); }); }); diff --git a/apps/trading/lib/hooks/use-create-referral-set.ts b/apps/trading/lib/hooks/use-create-referral-set.ts index 51d62ad49..cefaa41e1 100644 --- a/apps/trading/lib/hooks/use-create-referral-set.ts +++ b/apps/trading/lib/hooks/use-create-referral-set.ts @@ -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(null); - const [code, setCode] = useState(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, diff --git a/libs/i18n/src/locales/en/trading.json b/libs/i18n/src/locales/en/trading.json index ceb19ef09..e2428504f 100644 --- a/libs/i18n/src/locales/en/trading.json +++ b/libs/i18n/src/locales/en/trading.json @@ -7,6 +7,7 @@ "<0>No running Desktop App/CLI detected. Open your app now to connect or enter a <1>custom wallet location": "<0>No running Desktop App/CLI detected. Open your app now to connect or enter a <1>custom wallet location", "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": "By using the Vega Console, you acknowledge that you have read and understood the <0>Vega Console Disclaimer", + "Cancel": "Cancel", "Change (24h)": "Change (24h)", "Changes have been proposed for this market. <0>View proposals": "Changes have been proposed for this market. <0>View proposals", "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.", diff --git a/libs/i18n/src/locales/en/wallet.json b/libs/i18n/src/locales/en/wallet.json index 88c80193e..756c4334b 100644 --- a/libs/i18n/src/locales/en/wallet.json +++ b/libs/i18n/src/locales/en/wallet.json @@ -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" diff --git a/libs/wallet/src/SimpleTransaction.graphql b/libs/wallet/src/SimpleTransaction.graphql new file mode 100644 index 000000000..4ac49f2af --- /dev/null +++ b/libs/wallet/src/SimpleTransaction.graphql @@ -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 + } + } + } +} diff --git a/libs/wallet/src/__generated__/SimpleTransaction.ts b/libs/wallet/src/__generated__/SimpleTransaction.ts new file mode 100644 index 000000000..e7da4a563 --- /dev/null +++ b/libs/wallet/src/__generated__/SimpleTransaction.ts @@ -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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSubscription(SimpleTransactionDocument, options); + } +export type SimpleTransactionSubscriptionHookResult = ReturnType; +export type SimpleTransactionSubscriptionResult = Apollo.SubscriptionResult; \ No newline at end of file diff --git a/libs/wallet/src/connectors/vega-connector.ts b/libs/wallet/src/connectors/vega-connector.ts index b87c02e9a..1bc626005 100644 --- a/libs/wallet/src/connectors/vega-connector.ts +++ b/libs/wallet/src/connectors/vega-connector.ts @@ -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 = ( diff --git a/libs/wallet/src/index.ts b/libs/wallet/src/index.ts index fc7c958cf..08aaa3c98 100644 --- a/libs/wallet/src/index.ts +++ b/libs/wallet/src/index.ts @@ -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'; diff --git a/libs/wallet/src/use-simple-transaction.ts b/libs/wallet/src/use-simple-transaction.ts new file mode 100644 index 000000000..a1e48d066 --- /dev/null +++ b/libs/wallet/src/use-simple-transaction.ts @@ -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('idle'); + const [result, setResult] = useState(); + const [error, setError] = useState(); + + 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, + }; +};