From 754e66f2ea935b9039ec5880d91d2ef8b1741e5e Mon Sep 17 00:00:00 2001 From: asiaznik Date: Mon, 15 Jan 2024 15:58:16 +0100 Subject: [PATCH] chore(trading): competitions home page feat(trading): competitions --- .../competitions/competitions-home.tsx | 140 +++++++++++ .../competitions/competitions-teams.tsx | 230 ++++++++++++++++++ .../competitions/hooks/TeamReferees.graphql | 12 + .../competitions/hooks/Teams.graphql | 16 ++ .../hooks/TeamsStatistics.graphql | 13 + .../hooks/__generated__/TeamReferees.ts | 55 +++++ .../competitions/hooks/__generated__/Teams.ts | 61 +++++ .../hooks/__generated__/TeamsStatistics.ts | 58 +++++ .../competitions/hooks/use-games.ts | 37 +++ .../competitions/hooks/use-teams.tsx | 69 ++++++ .../competitions/competitions-cta.tsx | 49 ++++ .../competitions/competitions-header.tsx | 30 +++ .../competitions/competitions-leaderboard.tsx | 74 ++++++ .../competitions/games-container.tsx | 39 +++ .../competitions/graphics/dude-badge.tsx | 40 +++ .../competitions/graphics/dude-with-flag.tsx | 50 ++++ .../components/competitions/graphics/rank.tsx | 110 +++++++++ apps/trading/components/navbar/navbar.tsx | 7 + apps/trading/components/table/table.tsx | 22 +- apps/trading/lib/links.ts | 9 + apps/trading/pages/client-router.tsx | 19 +- 21 files changed, 1133 insertions(+), 7 deletions(-) create mode 100644 apps/trading/client-pages/competitions/competitions-home.tsx create mode 100644 apps/trading/client-pages/competitions/competitions-teams.tsx create mode 100644 apps/trading/client-pages/competitions/hooks/TeamReferees.graphql create mode 100644 apps/trading/client-pages/competitions/hooks/Teams.graphql create mode 100644 apps/trading/client-pages/competitions/hooks/TeamsStatistics.graphql create mode 100644 apps/trading/client-pages/competitions/hooks/__generated__/TeamReferees.ts create mode 100644 apps/trading/client-pages/competitions/hooks/__generated__/Teams.ts create mode 100644 apps/trading/client-pages/competitions/hooks/__generated__/TeamsStatistics.ts create mode 100644 apps/trading/client-pages/competitions/hooks/use-games.ts create mode 100644 apps/trading/client-pages/competitions/hooks/use-teams.tsx create mode 100644 apps/trading/components/competitions/competitions-cta.tsx create mode 100644 apps/trading/components/competitions/competitions-header.tsx create mode 100644 apps/trading/components/competitions/competitions-leaderboard.tsx create mode 100644 apps/trading/components/competitions/games-container.tsx create mode 100644 apps/trading/components/competitions/graphics/dude-badge.tsx create mode 100644 apps/trading/components/competitions/graphics/dude-with-flag.tsx create mode 100644 apps/trading/components/competitions/graphics/rank.tsx 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..9a1e05cac --- /dev/null +++ b/apps/trading/client-pages/competitions/competitions-home.tsx @@ -0,0 +1,140 @@ +import { useEffect } from 'react'; +import { titlefy } from '@vegaprotocol/utils'; +import { usePageTitleStore } from '../../stores'; +import { useT } from '../../lib/use-t'; +import { ErrorBoundary } from '@sentry/react'; +import { CompetitionsHeader } from '../../components/competitions/competitions-landing-banner'; +import { Intent, Loader, TradingButton } from '@vegaprotocol/ui-toolkit'; + +import { useGames } from './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 './hooks/use-teams'; +import take from 'lodash/take'; + +export const CompetitionsHome = () => { + const t = useT(); + const navigate = useNavigate(); + + 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', + }); + + const { updateTitle } = usePageTitleStore((store) => ({ + updateTitle: store.updateTitle, + })); + useEffect(() => { + updateTitle(titlefy([t('Competitions')])); + }, [updateTitle, t]); + + 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()); + }} + > + {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-teams.tsx b/apps/trading/client-pages/competitions/competitions-teams.tsx new file mode 100644 index 000000000..904e011ac --- /dev/null +++ b/apps/trading/client-pages/competitions/competitions-teams.tsx @@ -0,0 +1,230 @@ +import { ErrorBoundary } from '@sentry/react'; +import { CompetitionsHeader } from '../../components/competitions/competitions-header'; +import { usePageTitleStore } from '../../stores'; +import { useEffect, useRef, useState } from 'react'; +import { useT } from '../../lib/use-t'; +import { titlefy } from '@vegaprotocol/utils'; +import { useTeams } from './hooks/use-teams'; +import { CompetitionsLeaderboard } from '../../components/competitions/competitions-leaderboard'; +import { + Input, + Loader, + VegaIcon, + VegaIconNames, +} from '@vegaprotocol/ui-toolkit'; + +export const CompetitionsTeams = () => { + const t = useT(); + const { updateTitle } = usePageTitleStore((store) => ({ + updateTitle: store.updateTitle, + })); + useEffect(() => { + updateTitle(titlefy([t('Competitions'), t('Teams')])); + }, [updateTitle, t]); + + const { data: teamsData, loading: teamsLoading } = useTeams({ + sortByField: ['totalQuantumRewards'], + order: 'desc', + }); + + const inputRef = useRef(null); + + // const teamsData = [ + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'cat lovers 2000', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'dog lovers 2000', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'we like vega', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'pure gold', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'diamond hands', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'to the moon', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'beyond cats', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'cat lovers 2000', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'cat lovers 2000', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'cat lovers 2000', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // { + // referrer: '12345678909876543212345678765432345676543234567', + // avatarUrl: 'http://placekitten.com/g/200/300', + // teamUrl: 'https://vega.xyz', + // closed: true, + // teamId: '123', + // name: 'cat lovers 2000', + // totalQuantumRewards: '1234567890', + // totalGamesPlayed: 12, + // totalQuantumVolume: '1234567890', + // gamesPlayed: [], + // createdAt: 123, + // createdAtEpoch: 123, + // }, + // ]; + + const [filter, setFilter] = useState(undefined); + + return ( + + +

{t('Choose a team to get involved')}

x +
+ +
+
+ + + + { + const value = inputRef.current?.value; + if (value != filter) setFilter(value); + }} + /> +
+
+
+ {teamsLoading ? ( + + ) : ( + { + if (filter && filter.length > 0) { + const re = new RegExp(filter, 'i'); + return re.test(td.name); + } + return true; + })} + /> + )} +
+
+ ); +}; diff --git a/apps/trading/client-pages/competitions/hooks/TeamReferees.graphql b/apps/trading/client-pages/competitions/hooks/TeamReferees.graphql new file mode 100644 index 000000000..d08ae0f1c --- /dev/null +++ b/apps/trading/client-pages/competitions/hooks/TeamReferees.graphql @@ -0,0 +1,12 @@ +query TeamReferees($teamId: ID!) { + teamReferees(teamId: $teamId) { + edges { + node { + teamId + referee + joinedAt + joinedAtEpoch + } + } + } +} diff --git a/apps/trading/client-pages/competitions/hooks/Teams.graphql b/apps/trading/client-pages/competitions/hooks/Teams.graphql new file mode 100644 index 000000000..be308f3bf --- /dev/null +++ b/apps/trading/client-pages/competitions/hooks/Teams.graphql @@ -0,0 +1,16 @@ +query Teams($teamId: ID, $partyId: ID) { + teams(teamId: $teamId, partyId: $partyId) { + edges { + node { + teamId + referrer + name + teamUrl + avatarUrl + createdAt + createdAtEpoch + closed + } + } + } +} diff --git a/apps/trading/client-pages/competitions/hooks/TeamsStatistics.graphql b/apps/trading/client-pages/competitions/hooks/TeamsStatistics.graphql new file mode 100644 index 000000000..94808899a --- /dev/null +++ b/apps/trading/client-pages/competitions/hooks/TeamsStatistics.graphql @@ -0,0 +1,13 @@ +query TeamsStatistics($teamId: ID, $aggregationEpochs: Int) { + teamsStatistics(teamId: $teamId, aggregationEpochs: $aggregationEpochs) { + edges { + node { + teamId + totalQuantumVolume + totalQuantumRewards + totalGamesPlayed + gamesPlayed + } + } + } +} diff --git a/apps/trading/client-pages/competitions/hooks/__generated__/TeamReferees.ts b/apps/trading/client-pages/competitions/hooks/__generated__/TeamReferees.ts new file mode 100644 index 000000000..2e01bcc30 --- /dev/null +++ b/apps/trading/client-pages/competitions/hooks/__generated__/TeamReferees.ts @@ -0,0 +1,55 @@ +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/__generated__/Teams.ts b/apps/trading/client-pages/competitions/hooks/__generated__/Teams.ts new file mode 100644 index 000000000..08b2f1432 --- /dev/null +++ b/apps/trading/client-pages/competitions/hooks/__generated__/Teams.ts @@ -0,0 +1,61 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type TeamsQueryVariables = Types.Exact<{ + teamId?: Types.InputMaybe; + partyId?: Types.InputMaybe; +}>; + + +export type TeamsQuery = { __typename?: 'Query', teams?: { __typename?: 'TeamConnection', edges: Array<{ __typename?: 'TeamEdge', node: { __typename?: 'Team', teamId: string, referrer: string, name: string, teamUrl: string, avatarUrl: string, createdAt: any, createdAtEpoch: number, closed: boolean } }> } | null }; + + +export const TeamsDocument = gql` + query Teams($teamId: ID, $partyId: ID) { + teams(teamId: $teamId, partyId: $partyId) { + edges { + node { + teamId + referrer + name + teamUrl + avatarUrl + createdAt + createdAtEpoch + closed + } + } + } +} + `; + +/** + * __useTeamsQuery__ + * + * To run a query within a React component, call `useTeamsQuery` and pass it any options that fit your needs. + * When your component renders, `useTeamsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useTeamsQuery({ + * variables: { + * teamId: // value for 'teamId' + * partyId: // value for 'partyId' + * }, + * }); + */ +export function useTeamsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(TeamsDocument, options); + } +export function useTeamsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(TeamsDocument, options); + } +export type TeamsQueryHookResult = ReturnType; +export type TeamsLazyQueryHookResult = ReturnType; +export type TeamsQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/trading/client-pages/competitions/hooks/__generated__/TeamsStatistics.ts b/apps/trading/client-pages/competitions/hooks/__generated__/TeamsStatistics.ts new file mode 100644 index 000000000..36c126a6c --- /dev/null +++ b/apps/trading/client-pages/competitions/hooks/__generated__/TeamsStatistics.ts @@ -0,0 +1,58 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type TeamsStatisticsQueryVariables = Types.Exact<{ + teamId?: Types.InputMaybe; + aggregationEpochs?: Types.InputMaybe; +}>; + + +export type TeamsStatisticsQuery = { __typename?: 'Query', teamsStatistics?: { __typename?: 'TeamsStatisticsConnection', edges: Array<{ __typename?: 'TeamStatisticsEdge', node: { __typename?: 'TeamStatistics', teamId: string, totalQuantumVolume: string, totalQuantumRewards: string, totalGamesPlayed: number, gamesPlayed: Array } }> } | null }; + + +export const TeamsStatisticsDocument = gql` + query TeamsStatistics($teamId: ID, $aggregationEpochs: Int) { + teamsStatistics(teamId: $teamId, aggregationEpochs: $aggregationEpochs) { + edges { + node { + teamId + totalQuantumVolume + totalQuantumRewards + totalGamesPlayed + gamesPlayed + } + } + } +} + `; + +/** + * __useTeamsStatisticsQuery__ + * + * To run a query within a React component, call `useTeamsStatisticsQuery` and pass it any options that fit your needs. + * When your component renders, `useTeamsStatisticsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useTeamsStatisticsQuery({ + * variables: { + * teamId: // value for 'teamId' + * aggregationEpochs: // value for 'aggregationEpochs' + * }, + * }); + */ +export function useTeamsStatisticsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(TeamsStatisticsDocument, options); + } +export function useTeamsStatisticsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(TeamsStatisticsDocument, options); + } +export type TeamsStatisticsQueryHookResult = ReturnType; +export type TeamsStatisticsLazyQueryHookResult = ReturnType; +export type TeamsStatisticsQueryResult = 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 new file mode 100644 index 000000000..0b14fa9c0 --- /dev/null +++ b/apps/trading/client-pages/competitions/hooks/use-games.ts @@ -0,0 +1,37 @@ +import compact from 'lodash/compact'; +import { useActiveRewardsQuery } from '../../../components/rewards-container/__generated__/Rewards'; +import { isActiveReward } from '../../../components/rewards-container/active-rewards'; +import { EntityScope, type TransferNode } from '@vegaprotocol/types'; + +const isScopedToTeams = (node: TransferNode) => + node.transfer.kind.__typename === 'RecurringTransfer' && + node.transfer.kind.dispatchStrategy?.entityScope === + EntityScope.ENTITY_SCOPE_TEAMS; + +export const useGames = ({ + currentEpoch, + onlyActive, +}: { + currentEpoch: number; + onlyActive: boolean; +}) => { + const { data, loading, error } = useActiveRewardsQuery({ + variables: { + isReward: true, + }, + }); + + const games = compact(data?.transfersConnection?.edges?.map((n) => n?.node)) + .map((n) => n as TransferNode) + .filter((node) => { + const recurring = node.transfer.kind.__typename !== 'RecurringTransfer'; + const active = onlyActive ? isActiveReward(node, currentEpoch) : true; + return active && recurring && isScopedToTeams(node); + }); + + return { + data: games, + loading, + error, + }; +}; diff --git a/apps/trading/client-pages/competitions/hooks/use-teams.tsx b/apps/trading/client-pages/competitions/hooks/use-teams.tsx new file mode 100644 index 000000000..6643a0d0b --- /dev/null +++ b/apps/trading/client-pages/competitions/hooks/use-teams.tsx @@ -0,0 +1,69 @@ +import { useMemo } from 'react'; +import { type TeamsQuery, useTeamsQuery } from './__generated__/Teams'; +import { + type TeamsStatisticsQuery, + useTeamsStatisticsQuery, +} from './__generated__/TeamsStatistics'; +import compact from 'lodash/compact'; +import sortBy from 'lodash/sortBy'; +import { type ArrayElement } from 'type-fest/source/internal'; + +type SortableField = keyof Omit< + ArrayElement['edges']>['node'] & + ArrayElement< + NonNullable['edges'] + >['node'], + '__typename' +>; + +type UseTeamsArgs = { + aggregationEpochs?: number; + sortByField?: SortableField[]; + order?: 'asc' | 'desc'; +}; + +const DEFAULT_AGGREGATION_EPOCHS = 10; + +export const useTeams = ({ + aggregationEpochs = DEFAULT_AGGREGATION_EPOCHS, + sortByField = ['createdAtEpoch'], + order = 'asc', +}: UseTeamsArgs) => { + const { + data: teamsData, + loading: teamsLoading, + error: teamsError, + } = useTeamsQuery(); + + const { + data: statsData, + loading: statsLoading, + error: statsError, + } = useTeamsStatisticsQuery({ + variables: { + aggregationEpochs, + }, + }); + + const teams = compact(teamsData?.teams?.edges).map((e) => e.node); + const stats = compact(statsData?.teamsStatistics?.edges).map((e) => e.node); + + const data = useMemo(() => { + const data = teams.map((t) => ({ + ...t, + ...stats.find((s) => s.teamId === t.teamId), + })); + + const sorted = sortBy(data, sortByField); + if (order === 'desc') { + return sorted.reverse(); + } + return sorted; + }, [teams, sortByField, order, stats]); + + return { + data, + loading: teamsLoading && statsLoading, + error: teamsError || statsError, + }; +}; diff --git a/apps/trading/components/competitions/competitions-cta.tsx b/apps/trading/components/competitions/competitions-cta.tsx new file mode 100644 index 000000000..0318c1719 --- /dev/null +++ b/apps/trading/components/competitions/competitions-cta.tsx @@ -0,0 +1,49 @@ +import classNames from 'classnames'; +import { type ComponentProps, type ReactElement, type ReactNode } from 'react'; +import { DudeBadge } from './graphics/dude-badge'; + +export const BORDER_COLOR = 'border-vega-clight-500 dark:border-vega-cdark-500'; +export const GRADIENT = + 'bg-gradient-to-b from-vega-clight-800 dark:from-vega-cdark-800 to-transparent'; + +export const CompetitionsActionsContainer = ({ + children, +}: { + children: + | ReactElement + | Iterable>; +}) => ( +
+ {children} +
+); + +export const CompetitionsAction = ({ + variant, + title, + description, + actionElement, + children, +}: { + variant: ComponentProps['variant']; + title: string; + description?: string; + actionElement: ReactNode; + children?: ReactNode; +}) => { + return ( +
+ +

{title}

+ {description &&

{description}

} + {actionElement} +
+ ); +}; diff --git a/apps/trading/components/competitions/competitions-header.tsx b/apps/trading/components/competitions/competitions-header.tsx new file mode 100644 index 000000000..91dcb78c2 --- /dev/null +++ b/apps/trading/components/competitions/competitions-header.tsx @@ -0,0 +1,30 @@ +import classNames from 'classnames'; +import { AnimatedDudeWithWire } from '../../client-pages/referrals/graphics/dude'; +import { type ReactNode } from 'react'; + +export const CompetitionsHeader = ({ + title, + children, +}: { + title: string; + children?: ReactNode; +}) => { + return ( +
+
+
+ +
+
+

+ {title} +

+ {children} +
+
+
+ ); +}; diff --git a/apps/trading/components/competitions/competitions-leaderboard.tsx b/apps/trading/components/competitions/competitions-leaderboard.tsx new file mode 100644 index 000000000..40734842c --- /dev/null +++ b/apps/trading/components/competitions/competitions-leaderboard.tsx @@ -0,0 +1,74 @@ +import { Link } from 'react-router-dom'; +import { Splash } from '@vegaprotocol/ui-toolkit'; +import { getNumberFormat } from '@vegaprotocol/utils'; +import { type useTeams } from '../../client-pages/competitions/hooks/use-teams'; +import { useT } from '../../lib/use-t'; +import { Table } from '../table'; +import { Rank } from './graphics/rank'; +import { Links } from '../../lib/links'; + +export const CompetitionsLeaderboard = ({ + data, +}: { + data: ReturnType['data']; +}) => { + const t = useT(); + + const num = (n?: number | string) => + !n ? '-' : getNumberFormat(0).format(Number(n)); + + if (!data || data.length === 0) { + return {t('Could not find any teams')}; + } + + return ( + { + // leaderboard place or medal + let rank: number | React.ReactNode = i + 1; + if (rank === 1) rank = ; + if (rank === 2) rank = ; + if (rank === 3) rank = ; + + // avatar TODO: Generated avatar if none provided + const avatar = td.avatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {td.name} + ) : null; + + return { + rank, + avatar, + team: ( + + {td.name} + + ), + earned: num(td.totalQuantumRewards), + games: num(td.totalGamesPlayed), + members: 0, + status: td.closed ? t('Closed') : t('Open'), + volume: num(td.totalQuantumVolume), + }; + })} + >
+ ); +}; diff --git a/apps/trading/components/competitions/games-container.tsx b/apps/trading/components/competitions/games-container.tsx new file mode 100644 index 000000000..e675821f0 --- /dev/null +++ b/apps/trading/components/competitions/games-container.tsx @@ -0,0 +1,39 @@ +import { type TransferNode } from '@vegaprotocol/types'; +import { ActiveRewardCard } from '../rewards-container/active-rewards'; +import { useT } from '../../lib/use-t'; +import { Splash } from '@vegaprotocol/ui-toolkit'; + +export const GamesContainer = ({ + data, + currentEpoch, +}: { + data: TransferNode[]; + currentEpoch: number; +}) => { + const t = useT(); + if (!data || data.length === 0) { + return {t('There are currently no games available.')}; + } + return ( +
+ {data.map((game, i) => { + // TODO: Remove `kind` prop from ActiveRewardCard + const { transfer } = game; + if ( + transfer.kind.__typename !== 'RecurringTransfer' || + !transfer.kind.dispatchStrategy?.dispatchMetric + ) { + return null; + } + return ( + + ); + })} +
+ ); +}; diff --git a/apps/trading/components/competitions/graphics/dude-badge.tsx b/apps/trading/components/competitions/graphics/dude-badge.tsx new file mode 100644 index 000000000..082ebdced --- /dev/null +++ b/apps/trading/components/competitions/graphics/dude-badge.tsx @@ -0,0 +1,40 @@ +import classNames from 'classnames'; +import { DudeWithFlag } from './dude-with-flag'; + +/** + * Pre-defined badge gradients + */ + +export const BADGE_GRADIENT_VARIANT_A = + 'bg-gradient-to-r from-vega-blue-500 via-vega-purple-500 to-vega-pink-500'; +export const BADGE_GRADIENT_VARIANT_B = + 'bg-gradient-to-r from-vega-purple-500 via-vega-green-500 to-vega-blue-500'; +export const BADGE_GRADIENT_VARIANT_C = + 'bg-gradient-to-r from-vega-blue-500 via-vega-purple-500 to-vega-green-500'; + +/** Badge */ + +export const DudeBadge = ({ + variant, + className, +}: { + variant: 'A' | 'B' | 'C' | undefined; + className?: classNames.Argument; +}) => { + return ( +
+ +
+ ); +}; diff --git a/apps/trading/components/competitions/graphics/dude-with-flag.tsx b/apps/trading/components/competitions/graphics/dude-with-flag.tsx new file mode 100644 index 000000000..5d02043d9 --- /dev/null +++ b/apps/trading/components/competitions/graphics/dude-with-flag.tsx @@ -0,0 +1,50 @@ +import { theme } from '@vegaprotocol/tailwindcss-config'; + +type DudeWithFlagProps = { + flagColor?: string; + withStar?: boolean; + className?: string; +}; + +const DEFAULT_FLAG_COLOR = theme.colors.vega.green[500]; + +export const DudeWithFlag = ({ + flagColor = DEFAULT_FLAG_COLOR, + withStar = true, + className, +}: DudeWithFlagProps) => { + return ( + + {withStar && ( + <> + + + + + + )} + + + + + + ); +}; diff --git a/apps/trading/components/competitions/graphics/rank.tsx b/apps/trading/components/competitions/graphics/rank.tsx new file mode 100644 index 000000000..7ac53858c --- /dev/null +++ b/apps/trading/components/competitions/graphics/rank.tsx @@ -0,0 +1,110 @@ +import { useThemeSwitcher } from '@vegaprotocol/react-helpers'; +import classNames from 'classnames'; + +export const Rank = ({ + variant, + className, +}: { + variant?: 'gold' | 'silver' | 'bronze'; + className?: classNames.Argument; +}) => { + const { theme } = useThemeSwitcher(); + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/apps/trading/components/navbar/navbar.tsx b/apps/trading/components/navbar/navbar.tsx index ac6690841..7950732f8 100644 --- a/apps/trading/components/navbar/navbar.tsx +++ b/apps/trading/components/navbar/navbar.tsx @@ -202,6 +202,13 @@ const NavbarMenu = ({ onClick }: { onClick: () => void }) => { {t('Portfolio')} + {featureFlags.TEAM_COMPETITION && ( + + + {t('Competitions')} + + + )} {featureFlags.REFERRALS && ( diff --git a/apps/trading/components/table/table.tsx b/apps/trading/components/table/table.tsx index 6c726701f..0d333c03a 100644 --- a/apps/trading/components/table/table.tsx +++ b/apps/trading/components/table/table.tsx @@ -15,11 +15,17 @@ type TableColumnDefinition = { testId?: string; }; +type DataEntry = { + [key: TableColumnDefinition['name']]: ReactNode; + className?: string; +}; + type TableProps = { columns: TableColumnDefinition[]; - data: Record[]; + data: DataEntry[]; noHeader?: boolean; noCollapse?: boolean; + onRowClick?: (index: number) => void; }; const INNER_BORDER_STYLE = `border-b ${BORDER_COLOR}`; @@ -35,6 +41,7 @@ export const Table = forwardRef< noHeader = false, noCollapse = false, className, + onRowClick, ...props }, ref @@ -81,12 +88,17 @@ export const Table = forwardRef< > {!noHeader && header} - {data.map((d, i) => ( + {data.map((dataEntry, i) => ( { + if (onRowClick) { + onRowClick(i); + } + }} > {columns.map(({ name, displayName, className, testId }, j) => ( )} - {d[name]} + + {dataEntry[name]} + ))} diff --git a/apps/trading/lib/links.ts b/apps/trading/lib/links.ts index a479ab643..a9e950276 100644 --- a/apps/trading/lib/links.ts +++ b/apps/trading/lib/links.ts @@ -17,6 +17,10 @@ export const Routes = { REFERRALS_APPLY_CODE: '/referrals/apply-code', REFERRALS_CREATE_CODE: '/referrals/create-code', TEAM: '/competitions/team/:teamId', + COMPETITIONS: '/competitions', + COMPETITIONS_TEAMS: '/competitions/teams', + COMPETITIONS_CREATE_TEAM: '/competitions/teams/create', + COMPETITIONS_TEAM: '/competitions/teams/:teamId', FEES: '/fees', REWARDS: '/rewards', } as const; @@ -42,6 +46,11 @@ export const Links: ConsoleLinks = { REFERRALS_APPLY_CODE: () => Routes.REFERRALS_APPLY_CODE, REFERRALS_CREATE_CODE: () => Routes.REFERRALS_CREATE_CODE, TEAM: (teamId: string) => trimEnd(Routes.TEAM.replace(':teamId', teamId)), + COMPETITIONS: () => Routes.COMPETITIONS, + COMPETITIONS_TEAMS: () => Routes.COMPETITIONS_TEAMS, + COMPETITIONS_CREATE_TEAM: () => Routes.COMPETITIONS_CREATE_TEAM, + COMPETITIONS_TEAM: (teamId: string) => + Routes.COMPETITIONS_TEAM.replace(':teamId', teamId), FEES: () => Routes.FEES, REWARDS: () => Routes.REWARDS, }; diff --git a/apps/trading/pages/client-router.tsx b/apps/trading/pages/client-router.tsx index 57876ddec..8e942cb98 100644 --- a/apps/trading/pages/client-router.tsx +++ b/apps/trading/pages/client-router.tsx @@ -14,7 +14,6 @@ import { Withdraw } from '../client-pages/withdraw'; import { Transfer } from '../client-pages/transfer'; import { Fees } from '../client-pages/fees'; import { Rewards } from '../client-pages/rewards'; -import { Team } from '../client-pages/team'; import { Routes as AppRoutes } from '../lib/links'; import { LayoutWithSky } from '../client-pages/referrals/layout'; import { Referrals } from '../client-pages/referrals/referrals'; @@ -30,6 +29,8 @@ import { PortfolioSidebar } from '../client-pages/portfolio/portfolio-sidebar'; import { LiquiditySidebar } from '../client-pages/liquidity/liquidity-sidebar'; import { MarketsSidebar } from '../client-pages/markets/markets-sidebar'; import { useT } from '../lib/use-t'; +import { CompetitionsHome } from '../client-pages/competitions/competitions-home'; +import { CompetitionsTeams } from '../client-pages/competitions/competitions-teams'; // These must remain dynamically imported as pennant cannot be compiled by nextjs due to ESM // Using dynamic imports is a workaround for this until pennant is published as ESM @@ -95,8 +96,20 @@ export const useRouterConfig = (): RouteObject[] => { : undefined, featureFlags.TEAM_COMPETITION ? { - path: AppRoutes.TEAM, - element: , + path: AppRoutes.COMPETITIONS, + element: } />, + children: [ + { + element: , + children: [ + { index: true, element: }, + { + path: AppRoutes.COMPETITIONS_TEAMS, + element: , + }, + ], + }, + ], } : undefined, {