diff --git a/apps/trading/.env b/apps/trading/.env index 565f54412..d1c361360 100644 --- a/apps/trading/.env +++ b/apps/trading/.env @@ -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 diff --git a/apps/trading/.env.devnet b/apps/trading/.env.devnet index 364481010..12da4f51d 100644 --- a/apps/trading/.env.devnet +++ b/apps/trading/.env.devnet @@ -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= diff --git a/apps/trading/.env.stagnet1 b/apps/trading/.env.stagnet1 index c30708ad7..8634b87af 100644 --- a/apps/trading/.env.stagnet1 +++ b/apps/trading/.env.stagnet1 @@ -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= diff --git a/apps/trading/.env.testnet b/apps/trading/.env.testnet index ad9347afe..595bfad9f 100644 --- a/apps/trading/.env.testnet +++ b/apps/trading/.env.testnet @@ -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 diff --git a/apps/trading/client-pages/competitions/competitions-create-team.tsx b/apps/trading/client-pages/competitions/competitions-create-team.tsx new file mode 100644 index 000000000..e8ead8e9e --- /dev/null +++ b/apps/trading/client-pages/competitions/competitions-create-team.tsx @@ -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 ( + + +
+ +

+ {t('Create a team')} +

+ {pubKey && !isReadOnly ? ( + + ) : ( + <> +

+ {t( + 'Create a team to participate in team based rewards as well as access the discount benefits of the current referral program.' + )} +

+ + {t('Connect wallet')} + + + )} +
+
+
+
+ ); +}; + +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 ( +
+

{t('Team creation transaction successful')}

+ {code && ( + <> +

+ Your team ID is:{' '} + {code} +

+ + {t('View team')} + + + )} +
+ ); + } + + if (!isEligible) { + return ( +
+ {requiredStake !== undefined && ( +

+ {t( + 'You need at least {{requiredStake}} VEGA staked to generate a referral code and participate in the referral program.', + { + requiredStake: addDecimalsFormatNumber( + requiredStake.toString(), + 18 + ), + } + )} +

+ )} + + {t('Stake some $VEGA now')} + +
+ ); + } + + return ( + + ); +}; diff --git a/apps/trading/client-pages/competitions/competitions-home.tsx b/apps/trading/client-pages/competitions/competitions-home.tsx new file mode 100644 index 000000000..bfe1ed1c7 --- /dev/null +++ b/apps/trading/client-pages/competitions/competitions-home.tsx @@ -0,0 +1,133 @@ +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({ + sortByField: ['totalQuantumRewards'], + order: 'desc', + }); + + return ( + + +

+ {t( + 'Be a team player! Participate in games and work together to rake in as much profit to win.' + )} +

+
+ + {/** Get started */} +

{t('Get started')}

+ + + { + e.preventDefault(); + navigate(Links.COMPETITIONS_CREATE_TEAM()); + }} + > + {t('Create a public team')} + + } + /> + { + e.preventDefault(); + navigate(Links.COMPETITIONS_CREATE_TEAM_SOLO()); + }} + > + {t('Create a private team')} + + } + /> + { + e.preventDefault(); + navigate(Links.COMPETITIONS_TEAMS()); + }} + > + {t('Choose a team')} + + } + /> + + + {/** List of available games */} +

{t('Games')}

+ + {gamesLoading ? ( + + ) : ( + + )} + + {/** The teams ranking */} +
+

{t('Leaderboard')}

+ + {t('View all teams')} + +
+ + {teamsLoading ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/apps/trading/client-pages/competitions/competitions-team.tsx b/apps/trading/client-pages/competitions/competitions-team.tsx new file mode 100644 index 000000000..fb03b71d3 --- /dev/null +++ b/apps/trading/client-pages/competitions/competitions-team.tsx @@ -0,0 +1,238 @@ +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 ( + + + + ); +}; + +const TeamPageContainer = ({ teamId }: { teamId: string | undefined }) => { + const t = useT(); + const { pubKey } = useVegaWallet(); + const { team, partyTeam, stats, members, games, loading, refetch } = useTeam( + teamId, + pubKey || undefined + ); + + if (loading) { + return ( + + + + ); + } + + if (!team) { + return ( + +

{t('Page not found')}

+
+ ); + } + + return ( + + ); +}; + +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 ( + +
+ +
+

+ {team.name} +

+ + +
+
+ +
+
+ setShowGames(true)} + data-testid="games-toggle" + > + {t('Games ({{count}})', { count: games ? games.length : 0 })} + + setShowGames(false)} + data-testid="members-toggle" + > + {t('Members ({{count}})', { + count: members ? members.length : 0, + })} + +
+ {showGames ? : } +
+
+ ); +}; + +const Games = ({ games }: { games?: TeamGame[] }) => { + const t = useT(); + + if (!games?.length) { + return

{t('No games')}

; + } + + return ( + ({ + 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

{t('No members')}

; + } + + const data = orderBy( + members.map((m) => ({ + referee: , + joinedAt: getDateTimeFormat().format(new Date(m.joinedAt)), + joinedAtEpoch: Number(m.joinedAtEpoch), + })), + 'joinedAtEpoch', + 'desc' + ); + + return ( +
+ ); +}; + +const RefereeLink = ({ pubkey }: { pubkey: string }) => { + const linkCreator = useLinks(DApp.Explorer); + const link = linkCreator(EXPLORER_PARTIES.replace(':id', pubkey)); + + return ( + + {truncateMiddle(pubkey)} + + ); +}; + +const ToggleButton = ({ + active, + ...props +}: ButtonHTMLAttributes & { active: boolean }) => { + 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/competitions/team-form.tsx b/apps/trading/client-pages/competitions/team-form.tsx new file mode 100644 index 000000000..93368b0bf --- /dev/null +++ b/apps/trading/client-pages/competitions/team-form.tsx @@ -0,0 +1,254 @@ +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, + 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, + isSolo, + onSubmit, + defaultValues, +}: { + type: TransactionType; + status: ReturnType['status']; + err: ReturnType['err']; + isSolo: boolean; + onSubmit: ReturnType['onSubmit']; + defaultValues?: FormFields; +}) => { + const t = useT(); + + const { + register, + handleSubmit, + control, + watch, + formState: { errors }, + } = useForm({ + defaultValues: { + private: isSolo, + ...defaultValues, + }, + }); + + const isPrivate = watch('private'); + + const sendTransaction = (fields: FormFields) => { + onSubmit(prepareTransaction(type, fields)); + }; + + return ( +
+ + + + {errors.name?.message && ( + + {errors.name.message} + + )} + + + + {errors.url?.message && ( + + {errors.url.message} + + )} + + + + {errors.avatarUrl?.message && ( + + {errors.avatarUrl.message} + + )} + + + { + return ( + { + field.onChange(value); + }} + disabled={isSolo} + /> + ); + }} + /> + + {isPrivate && ( + +